1import { Actor } from 'apify';
2import { Dataset, createCheerioRouter, log } from 'crawlee';
3import {
4 buildArchiveRequest,
5 buildInitialRequests,
6 classifyUrl,
7 extractPreload,
8 findPublicationObject,
9 isLikelyNonPublicationHost,
10 stripHtml,
11 wordCount,
12} from './utils.js';
13import {
14 state,
15 getCount,
16 incrementCount,
17 setPublicationInfo,
18 getPublicationInfo,
19 isSeenPub,
20 markSeenPub,
21 getDiscoveredCount,
22 incrementDiscovered,
23 markPubSaved,
24 markPostEnqueued,
25 markPostSavedRecord,
26 reserveItemSlots,
27 isCapReached,
28 isTypeExhausted,
29 markTypeExhausted,
30 addItems,
31 getTotalItems,
32} from './state.js';
33
34export const router = createCheerioRouter();
35
36
37
38
39
40
41
42let lastStatusCount = 0;
43async function saveRecords(records) {
44 if (!records.length) return;
45
46 if (state.config.isPayPerEvent) {
47
48
49
50
51 const byType = new Map();
52 for (const row of records) {
53 if (isTypeExhausted(row.type)) continue;
54 if (!byType.has(row.type)) byType.set(row.type, []);
55 byType.get(row.type).push(row);
56 }
57 for (const [eventName, items] of byType) {
58 const result = await Actor.pushData(items, eventName);
59
60
61 const delivered = result?.eventChargeLimitReached
62 ? (result?.chargedCount ?? 0)
63 : items.length;
64 if (delivered > 0) addItems(delivered);
65 if (result?.eventChargeLimitReached) {
66 markTypeExhausted(eventName);
67 log.warning(`Budget for '${eventName}' results exhausted — no further ${eventName} records will be scraped.`);
68 }
69 }
70 } else {
71 const allowed = reserveItemSlots(records.length);
72 if (allowed < records.length) {
73 log.warning(`Maximum paid dataset items reached — dropped ${records.length - allowed} record(s).`);
74 }
75 const rows = records.slice(0, allowed);
76 if (!rows.length) return;
77 await Dataset.pushData(rows);
78 }
79
80 const total = getTotalItems();
81 if (total - lastStatusCount >= 50) {
82 lastStatusCount = total;
83 await Actor.setStatusMessage(`Saved ${total} items so far...`).catch(() => {});
84 }
85}
86
87
88
89
90function crawlBudgetExhausted() {
91 const primaryType = state.config.mode === 'posts' ? 'post' : 'publication';
92 return isCapReached() || isTypeExhausted(primaryType);
93}
94
95let stopRequested = false;
96async function stopIfCapReached(crawler) {
97 if (!crawlBudgetExhausted() || stopRequested) return;
98 stopRequested = true;
99 log.warning('Paid results budget reached — stopping the crawl.');
100 if (typeof crawler.stop === 'function') crawler.stop();
101}
102
103function parseJsonBody(body) {
104 if (!body) return null;
105 if (typeof body === 'object' && !Buffer.isBuffer(body)) return body;
106 try {
107 return JSON.parse(body.toString());
108 } catch {
109 return null;
110 }
111}
112
113function commentsRequest(origin, publication, post) {
114 return {
115 url: `${origin}/api/v1/post/${post.id}/comments?token=&all_comments=true&sort=best_first`,
116 userData: {
117 label: 'COMMENTS',
118 origin,
119 publication,
120 postId: post.id,
121 postSlug: post.slug,
122 postTitle: post.title,
123 },
124 };
125}
126
127
128function clean(s) {
129 return typeof s === 'string' ? s.trim() : s ?? null;
130}
131
132
133function parseCount(value) {
134 if (typeof value === 'number') return value;
135 if (typeof value === 'string') {
136 const n = Number(value.replace(/[,\s]/g, ''));
137 return Number.isFinite(n) ? n : null;
138 }
139 return null;
140}
141
142function shapePost(raw, ctx = {}) {
143 const canonical = raw.canonical_url || (raw.slug && ctx.origin ? `${ctx.origin}/p/${raw.slug}` : null);
144 const bodyHtml = raw.body_html ?? null;
145 const bodyText = bodyHtml ? stripHtml(bodyHtml) : (raw.truncated_body_text ?? raw.description ?? '');
146 const pubKey = ctx.publication ?? raw.publication?.subdomain ?? null;
147 const cachedPub = pubKey ? getPublicationInfo(pubKey) : {};
148 const publicationName = clean(raw.publication?.name ?? raw.publicationName ?? cachedPub.name ?? null);
149 return {
150 type: 'post',
151 id: raw.id,
152 title: clean(raw.title),
153 subtitle: clean(raw.subtitle),
154 slug: raw.slug,
155 url: canonical,
156 publication: pubKey,
157 publicationName,
158 publishedAt: raw.post_date ?? raw.published_at ?? null,
159 audience: raw.audience ?? null,
160 postType: raw.type ?? null,
161 coverImage: raw.cover_image ?? null,
162 description: raw.description ?? null,
163 truncatedBodyText: raw.truncated_body_text ?? null,
164 bodyHtml,
165 bodyText: bodyText || null,
166 wordcount: wordCount(bodyText),
167 reactionCount: raw.reaction_count ?? raw.reactions?.heart ?? null,
168 commentCount: raw.comment_count ?? null,
169 restacks: raw.restacks ?? null,
170 isPaid: raw.audience ? raw.audience !== 'everyone' : null,
171 author: clean(raw.publishedBylines?.[0]?.name
172 ?? raw.publishedBylines?.[0]?.handle
173 ?? raw.author?.name
174 ?? null),
175 authors: (raw.publishedBylines ?? []).map((b) => ({
176 id: b.id,
177 name: b.name,
178 handle: b.handle,
179 photoUrl: b.photo_url ?? null,
180 bio: b.bio ?? null,
181 })),
182 postTags: (raw.postTags ?? []).map((t) => t.name ?? t),
183 scrapedAt: new Date().toISOString(),
184 };
185}
186
187function shapePublication(raw, ctx = {}) {
188 const pub = raw.publication ?? raw;
189 return {
190 type: 'publication',
191 id: pub.id,
192 name: clean(pub.name),
193 subdomain: pub.subdomain ?? ctx.publication ?? null,
194 customDomain: pub.custom_domain ?? null,
195 url: pub.custom_domain ? `https://${pub.custom_domain}` : (pub.subdomain ? `https://${pub.subdomain}.substack.com` : ctx.origin),
196 heroText: pub.hero_text ?? null,
197 description: pub.description ?? pub.about ?? null,
198 logoUrl: pub.logo_url ?? null,
199 copyright: pub.copyright ?? null,
200 language: pub.language ?? null,
201 authorId: pub.author_id ?? null,
202 foundingPlanName: pub.founding_plan_name ?? null,
203 paidSubscribers: pub.paid_subscribers_visible ? pub.paid_subscribers : null,
204 totalSubscribers: pub.subscriber_count_number ?? pub.subscriber_count ?? null,
205 subscriberCountString: pub.subscriber_count_string ?? null,
206 categoryName: pub.base_category_name ?? null,
207 createdAt: pub.created_at ?? null,
208 scrapedAt: new Date().toISOString(),
209 };
210}
211
212router.addHandler('PUBLICATION_HTML', async ({ request, $, body, crawler }) => {
213 const ctx = request.userData;
214 if (typeof $ !== 'function') {
215 throw new Error(`Non-HTML response on publication page ${request.url} — retrying`);
216 }
217 const html = body?.toString() ?? '';
218
219
220
221
222 let effectiveOrigin = ctx.origin;
223 let effectiveHost = null;
224 try {
225 const loaded = new URL(request.loadedUrl ?? request.url);
226 effectiveOrigin = loaded.origin;
227 effectiveHost = loaded.hostname.toLowerCase();
228 } catch { }
229
230
231
232
233 const preload = extractPreload(html);
234 const pubObj = findPublicationObject(preload);
235
236 if (!preload) {
237 if (ctx.fromDiscovery) {
238 log.info(`Skipping ${request.url} — no Substack preload, likely not a Substack publication`);
239 return;
240 }
241 throw new Error(`No Substack preload data on ${request.url} — blocked or not a Substack publication`);
242 }
243
244 markSeenPub(ctx.publication, pubObj?.subdomain, pubObj?.custom_domain, effectiveHost);
245
246 let shaped;
247 if (pubObj) {
248 shaped = shapePublication({ publication: pubObj }, ctx);
249 } else {
250 if (ctx.fromDiscovery) {
251 log.info(`Skipping ${request.url} — no publication object in preload`);
252 return;
253 }
254 const get = (sel, attr = 'content') => {
255 const v = $(sel).attr(attr);
256 return v ? v.trim() : null;
257 };
258 const cleanPubName = (s) => {
259 if (!s) return null;
260 const parts = s
261 .split(/\s*[|\-–—]\s*/)
262 .map((x) => x.trim())
263 .filter(Boolean)
264 .filter((x) => x.toLowerCase() !== 'substack');
265 return parts[0] ?? null;
266 };
267 const metaName = cleanPubName(get('meta[property="og:title"]'))
268 || cleanPubName(get('meta[name="twitter:title"]'))
269 || cleanPubName(($('title').text() ?? '').trim())
270 || cleanPubName(get('meta[property="og:site_name"]'))
271 || null;
272 shaped = {
273 type: 'publication',
274 id: null,
275 name: metaName,
276 subdomain: ctx.publication ?? null,
277 customDomain: ctx.origin?.replace(/^https?:\/\//, '') ?? null,
278 url: ctx.origin,
279 description: get('meta[property="og:description"]') || get('meta[name="description"]') || null,
280 heroText: null,
281 logoUrl: get('meta[property="og:image"]') || null,
282 copyright: null,
283 language: null,
284 authorId: null,
285 foundingPlanName: null,
286 paidSubscribers: null,
287 totalSubscribers: null,
288 subscriberCountString: null,
289 categoryName: null,
290 createdAt: null,
291 scrapedAt: new Date().toISOString(),
292 };
293 }
294
295 if (ctx.publication && shaped.name) {
296 setPublicationInfo(ctx.publication, {
297 name: shaped.name,
298 description: shaped.description,
299 logoUrl: shaped.logoUrl,
300 });
301 }
302
303 if (pubObj) {
304
305
306 await crawler.addRequests([{
307 url: `${effectiveOrigin}/about`,
308 userData: {
309 label: 'PUBLICATION_ABOUT',
310 origin: effectiveOrigin,
311 publication: ctx.publication,
312 shaped,
313 },
314 }]);
315 } else if (markPubSaved(shaped.subdomain ?? ctx.publication)) {
316 await saveRecords([shaped]);
317 log.info(`Saved publication: ${shaped.name || ctx.publication} (from meta tags)`);
318 }
319
320 if (crawlBudgetExhausted()) {
321 await stopIfCapReached(crawler);
322 return;
323 }
324
325 if (ctx.followWithArchive) {
326 await crawler.addRequests([buildArchiveRequest({
327 origin: effectiveOrigin,
328 publication: ctx.publication,
329 searchQuery: ctx.searchQuery,
330 })]);
331 }
332
333 if (ctx.harvestRecommendations) {
334 const limit = state.config.maxPublicationsToDiscover ?? 25;
335 if (getDiscoveredCount() < limit) {
336 await crawler.addRequests([{
337 url: `${effectiveOrigin}/recommendations`,
338 userData: {
339 label: 'RECOMMENDATIONS_HTML',
340 origin: effectiveOrigin,
341 publication: ctx.publication,
342 },
343 }]);
344 }
345 }
346});
347
348
349
350router.addHandler('PUBLICATION_ABOUT', async ({ request, body, crawler }) => {
351 const { publication, shaped } = request.userData;
352 const preload = extractPreload(body?.toString() ?? '');
353 if (!preload) {
354 throw new Error(`No Substack preload data on ${request.url} — retrying`);
355 }
356
357 const aboutPub = findPublicationObject(preload) ?? {};
358 const enriched = {
359 ...shaped,
360 description: shaped.description ?? clean(aboutPub.hero_text) ?? null,
361 heroText: shaped.heroText ?? clean(aboutPub.hero_text) ?? null,
362 totalSubscribers: shaped.totalSubscribers
363 ?? parseCount(aboutPub.freeSubscriberCount ?? preload.pub?.freeSubscriberCount),
364 subscriberCountString: shaped.subscriberCountString
365 ?? clean(preload.subscriberCountDetails)
366 ?? null,
367 categoryName: shaped.categoryName ?? clean(aboutPub.base_category_name) ?? null,
368 };
369
370 if (markPubSaved(enriched.subdomain ?? publication)) {
371 await saveRecords([enriched]);
372 log.info(`Saved publication: ${enriched.name || publication}${enriched.totalSubscribers ? ` (${enriched.totalSubscribers} subscribers)` : ''}`);
373 }
374 await stopIfCapReached(crawler);
375});
376
377router.addHandler('ARCHIVE', async ({ request, json, body, crawler }) => {
378 const config = state.config;
379 const payload = json ?? parseJsonBody(body);
380 if (!Array.isArray(payload)) {
381
382
383 throw new Error(`Archive endpoint returned non-JSON for ${request.url} — retrying`);
384 }
385 if (payload.length === 0) return;
386
387 if (crawlBudgetExhausted()) {
388 await stopIfCapReached(crawler);
389 return;
390 }
391
392 const { origin, publication, offset, searchQuery } = request.userData;
393 const countKey = `posts:${publication}`;
394 const limit = config.maxPostsPerPublication;
395
396 const toEnqueue = [];
397 const toStoreInline = [];
398 let limitReached = false;
399
400 for (const raw of payload) {
401 if (config.onlyFreePosts && raw.audience && raw.audience !== 'everyone') continue;
402 if (limit != null && getCount(countKey) >= limit) {
403 limitReached = true;
404 break;
405 }
406
407 if (!markPostEnqueued(publication, raw.id)) continue;
408 incrementCount(countKey);
409
410 if (config.includeContent) {
411 toEnqueue.push({
412 url: `${origin}/api/v1/posts/${raw.slug}`,
413 userData: {
414 label: 'POST_JSON',
415 origin,
416 publication,
417 slug: raw.slug,
418 archiveFallback: raw,
419 },
420 });
421 } else if (markPostSavedRecord(publication, raw.id)) {
422 toStoreInline.push(shapePost(raw, { origin, publication }));
423 }
424
425 if (config.includeComments && raw.comment_count !== 0 && !isTypeExhausted('comment')) {
426 toEnqueue.push(commentsRequest(origin, publication, raw));
427 }
428 }
429
430 if (toStoreInline.length) await saveRecords(toStoreInline);
431 if (toEnqueue.length) await crawler.addRequests(toEnqueue);
432
433
434
435 const hasMore = !limitReached
436 && (limit == null || getCount(countKey) < limit)
437 && !isCapReached();
438 if (hasMore) {
439 const nextOffset = offset + payload.length;
440 await crawler.addRequests([buildArchiveRequest({ origin, publication, offset: nextOffset, searchQuery })]);
441 }
442});
443
444router.addHandler('POST_JSON', async ({ request, json, body, crawler }) => {
445 const payload = json ?? parseJsonBody(body);
446 if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
447
448
449 throw new Error(`Post endpoint returned non-JSON for ${request.url} — retrying`);
450 }
451 const { origin, publication } = request.userData;
452
453
454
455 if (payload.error || payload.id == null) {
456 await salvageFailedRequest(request);
457 log.warning(`Not a post (${payload.error ?? 'no id'}): ${request.url} — skipped`);
458 return;
459 }
460
461 if (markPostSavedRecord(publication, payload.id)) {
462 await saveRecords([shapePost(payload, { origin, publication })]);
463 }
464
465
466
467 if (state.config.includeComments && payload.id && payload.comment_count !== 0 && !isTypeExhausted('comment')) {
468 await crawler.addRequests([commentsRequest(origin, publication, payload)]);
469 }
470
471 await stopIfCapReached(crawler);
472});
473
474router.addHandler('COMMENTS', async ({ request, json, body, crawler }) => {
475 if (isTypeExhausted('comment')) return;
476 const payload = json ?? parseJsonBody(body);
477 if (!payload || typeof payload !== 'object') {
478 throw new Error(`Comments endpoint returned non-JSON for ${request.url} — retrying`);
479 }
480 const comments = payload.comments ?? payload.children ?? [];
481 if (!comments.length) return;
482
483 const flat = [];
484 const walk = (nodes, depth = 0, parentId = null) => {
485 for (const node of nodes) {
486 flat.push({
487 type: 'comment',
488 postId: request.userData.postId,
489 postSlug: request.userData.postSlug,
490 postTitle: request.userData.postTitle,
491 publication: request.userData.publication,
492 id: node.id,
493 parentId,
494 depth,
495 body: node.body,
496 authorName: node.name,
497 authorHandle: node.handle,
498 authorId: node.user_id,
499 publishedAt: node.date,
500 reactionCount: node.reactions?.heart ?? node.reaction_count ?? 0,
501 scrapedAt: new Date().toISOString(),
502 });
503 if (node.children && node.children.length) walk(node.children, depth + 1, node.id);
504 }
505 };
506 walk(comments);
507 if (flat.length) await saveRecords(flat);
508 await stopIfCapReached(crawler);
509});
510
511router.addHandler('AUTHOR_JSON', async ({ request, json, body, crawler }) => {
512 const payload = json ?? parseJsonBody(body);
513 if (!payload || typeof payload !== 'object') {
514 throw new Error(`Profile endpoint returned non-JSON for ${request.url} — retrying`);
515 }
516 if (payload.error || payload.id == null) {
517 log.warning(`Not an author profile (${payload.error ?? 'no id'}): ${request.url} — skipped`);
518 return;
519 }
520 const handle = payload.handle ?? request.userData.handle ?? null;
521 await saveRecords([{
522 type: 'author',
523 id: payload.id,
524 name: payload.name,
525 handle,
526 bio: payload.bio ?? null,
527 photoUrl: payload.photo_url ?? null,
528 profileUrl: handle ? `https://substack.com/@${handle}` : null,
529 subscriptions: (payload.subscriptions ?? []).map((s) => ({
530 publicationId: s.publication?.id,
531 publicationName: s.publication?.name,
532 subdomain: s.publication?.subdomain,
533 customDomain: s.publication?.custom_domain,
534 })),
535 publications: (payload.publicationUsers ?? []).map((p) => ({
536 publicationId: p.publication?.id,
537 publicationName: p.publication?.name,
538 subdomain: p.publication?.subdomain,
539 role: p.role,
540 })),
541 scrapedAt: new Date().toISOString(),
542 }]);
543 await stopIfCapReached(crawler);
544});
545
546
547
548router.addHandler('AUTHOR_HTML', async ({ request, $, body, crawler }) => {
549 const html = body?.toString() ?? '';
550 const preload = extractPreload(html);
551 let handle = preload?.profile?.handle ?? preload?.user?.handle ?? null;
552 if (!handle && typeof $ === 'function') {
553 const ogUrl = $('meta[property="og:url"]').attr('content') ?? '';
554 handle = ogUrl.match(/substack\.com\/@([\w.-]+)/)?.[1] ?? null;
555 }
556 if (!handle) {
557 throw new Error(`Could not resolve author handle from ${request.url}`);
558 }
559 await crawler.addRequests([{
560 url: `https://substack.com/api/v1/user/${handle}/public_profile`,
561 userData: { label: 'AUTHOR_JSON', origin: 'https://substack.com', handle },
562 }]);
563});
564
565
566router.addHandler('RESOLVE', async ({ request, crawler }) => {
567 const finalUrl = request.loadedUrl ?? request.url;
568 const classified = classifyUrl(finalUrl);
569 if (!classified || classified.kind === 'redirect') {
570 log.warning(`Could not resolve ${request.url} (landed on ${finalUrl})`);
571 return;
572 }
573 if (classified.kind === 'publication') markSeenPub(classified.publication);
574 await crawler.addRequests(buildInitialRequests(classified, state.config.mode, {
575 searchQuery: state.config.searchQuery,
576 }));
577});
578
579router.addHandler('RECOMMENDATIONS_HTML', async ({ request, $, crawler }) => {
580 const config = state.config;
581 const limit = config.maxPublicationsToDiscover ?? 25;
582 if (typeof $ !== 'function') {
583 throw new Error(`Non-HTML response on ${request.url} — retrying`);
584 }
585 const sourceOrigin = request.userData.origin;
586 const sourcePub = request.userData.publication;
587 const found = new Map();
588
589 $('a[href]').each((_i, el) => {
590 if (getDiscoveredCount() + found.size >= limit) return false;
591 const href = $(el).attr('href');
592 if (!href) return;
593 let abs;
594 try { abs = new URL(href, request.url); } catch { return; }
595 if (abs.origin === sourceOrigin) return;
596 if (isLikelyNonPublicationHost(abs.hostname)) return;
597
598
599 const classified = classifyUrl(abs.href);
600 if (!classified || classified.kind !== 'publication') return;
601 const key = classified.publication;
602 if (!key || isSeenPub(key) || found.has(key)) return;
603 found.set(key, classified);
604 });
605
606 if (!found.size) {
607 log.info(`No new publications on ${sourcePub}/recommendations (discovered: ${getDiscoveredCount()}/${limit})`);
608 return;
609 }
610
611 const toEnqueue = [];
612 for (const [key, classified] of found) {
613
614
615 markSeenPub(key);
616 incrementDiscovered();
617 toEnqueue.push({
618 url: classified.origin,
619 userData: {
620 label: 'PUBLICATION_HTML',
621 origin: classified.origin,
622 publication: classified.publication,
623 followWithArchive: config.mode === 'posts',
624 harvestRecommendations: getDiscoveredCount() < limit,
625 fromDiscovery: true,
626 searchQuery: config.searchQuery,
627 },
628 });
629 }
630 await crawler.addRequests(toEnqueue);
631 log.info(`Discovery: harvested ${toEnqueue.length} publications from ${sourcePub} (total: ${getDiscoveredCount()}/${limit})`);
632});
633
634router.addDefaultHandler(async ({ request }) => {
635 log.warning(`No handler for ${request.url} (label=${request.label ?? 'none'})`);
636});
637
638
639
640
641export async function salvageFailedRequest(request) {
642 const { label, archiveFallback, origin, publication, shaped } = request.userData ?? {};
643 if (label === 'POST_JSON' && archiveFallback) {
644 if (!markPostSavedRecord(publication, archiveFallback.id)) return;
645 const salvaged = shapePost(archiveFallback, { origin, publication });
646 salvaged.note = 'full content fetch failed; archive metadata only';
647 await saveRecords([salvaged]);
648 log.info(`Salvaged archive metadata for failed post ${request.url}`);
649 return;
650 }
651
652 if (label === 'PUBLICATION_ABOUT' && shaped) {
653 if (!markPubSaved(shaped.subdomain ?? publication)) return;
654 await saveRecords([shaped]);
655 log.info(`Saved publication ${shaped.name || publication} without /about enrichment`);
656 }
657}