crawlforge-mcp-server 6.3.1 → 6.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,44 @@ import { CRAWLFORGE_USER_AGENT } from '../../utils/fetchIdentity.js';
10
10
  import { preflightFetch } from '../../utils/robotsGate.js';
11
11
  import { pageTitle } from '../../utils/pageTitle.js';
12
12
 
13
+ /**
14
+ * The path prefix a seed URL asks for: nps.gov/yell/ means the Yellowstone
15
+ * subtree, not the first 200 URLs of the park service's site-wide sitemap
16
+ * (R21, 2026-09-09: map_site returned Abraham Lincoln Birthplace pages for it).
17
+ * A page URL scopes to its directory; the site root scopes to nothing.
18
+ * @param {string} url
19
+ * @returns {string|null} e.g. "/yell/", or null for the root
20
+ */
21
+ export function scopePathOf(url) {
22
+ try {
23
+ const { pathname } = new URL(url);
24
+ const dir = pathname.endsWith('/') ? pathname : pathname.slice(0, pathname.lastIndexOf('/') + 1);
25
+ return dir && dir !== '/' ? dir : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * How many of a search's terms a URL's own path and query contain — the
33
+ * relevance signal the generic ranker lacks when every candidate is a bare
34
+ * URL (it scored 0.19 for all 200 nps.gov URLs of a "fees" search, none of
35
+ * which mentioned fees).
36
+ * @param {string} url
37
+ * @param {string} search
38
+ * @returns {number}
39
+ */
40
+ export function searchScore(url, search) {
41
+ const terms = [...new Set(String(search || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t.length >= 2))];
42
+ if (terms.length === 0) return 0;
43
+ let haystack = url.toLowerCase();
44
+ try {
45
+ const { pathname, search: query } = new URL(url);
46
+ haystack = decodeURIComponent(pathname + query).toLowerCase();
47
+ } catch { /* keep the raw url */ }
48
+ return terms.filter((t) => haystack.includes(t)).length;
49
+ }
50
+
13
51
  // Lazy singleton — avoids creating a CacheManager timer per request
14
52
  let _ranker = null;
15
53
  function getRanker() {
@@ -107,22 +145,47 @@ export class MapSiteTool {
107
145
  }
108
146
  }
109
147
 
148
+ // A seed with a path asks for that subtree, and a search needs a pool
149
+ // wider than max_urls to rank — otherwise the cut falls before the
150
+ // relevant URLs ever enter (the sitemap head is alphabetical).
151
+ const scopePath = scopePathOf(validated.url);
152
+ const widen = scopePath || validated.search;
153
+ const poolLimit = widen ? Math.min(10000, Math.max(validated.max_urls * 10, 2000)) : validated.max_urls;
154
+ const warnings = [];
155
+
110
156
  // Try to fetch sitemap first
111
157
  if (validated.include_sitemap) {
112
- const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, validated.max_urls);
158
+ const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, poolLimit, scopePath);
113
159
  sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
114
160
  }
115
161
 
116
162
  // Fetch and parse the main page for additional URLs
117
163
  const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
118
164
  pageUrls.forEach(url => {
119
- if (urls.size < validated.max_urls) {
165
+ if (urls.size < poolLimit) {
120
166
  urls.add(normalizeUrl(url));
121
167
  }
122
168
  });
123
169
 
170
+ let pool = Array.from(urls);
171
+ if (scopePath) {
172
+ const inScope = pool.filter((u) => { try { return new URL(u).pathname.startsWith(scopePath); } catch { return false; } });
173
+ if (inScope.length > 0) {
174
+ pool = inScope;
175
+ } else {
176
+ warnings.push(`No URL under ${scopePath} was found in the sitemap or on the page; the whole site is listed instead.`);
177
+ }
178
+ }
179
+ if (validated.search) {
180
+ // Stable: relevance first, discovery order among equals.
181
+ pool = pool
182
+ .map((url, i) => ({ url, i, score: searchScore(url, validated.search) }))
183
+ .sort((a, b) => b.score - a.score || a.i - b.i)
184
+ .map((x) => x.url);
185
+ }
186
+
124
187
  // Convert to array and limit
125
- const urlArray = Array.from(urls).slice(0, validated.max_urls);
188
+ const urlArray = pool.slice(0, validated.max_urls);
126
189
 
127
190
  // Fetch metadata if requested
128
191
  if (validated.include_metadata) {
@@ -142,7 +205,9 @@ export class MapSiteTool {
142
205
  site_map: this.generateSiteMap(urlArray),
143
206
  statistics: this.generateStatistics(urlArray),
144
207
  domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
145
- filter_stats: domainFilter ? domainFilter.getStats() : null
208
+ filter_stats: domainFilter ? domainFilter.getStats() : null,
209
+ ...(scopePath ? { scope: scopePath } : {}),
210
+ ...(warnings.length ? { warnings } : {})
146
211
  };
147
212
 
148
213
  // Optional: rank URLs by relevance to a search string
@@ -157,7 +222,11 @@ export class MapSiteTool {
157
222
  return { link: url, title, snippet: '' };
158
223
  });
159
224
  const ranked = await getRanker().rankResults(rankerInput, validated.search);
160
- result.ranked_urls = ranked.map(r => ({ url: r.link, score: r.finalScore ?? 0 }));
225
+ // The ranker's score is flat across bare URLs; the path-term count
226
+ // is what separates /yell/planyourvisit/fees.htm from the rest.
227
+ result.ranked_urls = ranked
228
+ .map(r => ({ url: r.link, score: Number(((r.finalScore ?? 0) + searchScore(r.link, validated.search)).toFixed(3)) }))
229
+ .sort((a, b) => b.score - a.score);
161
230
  } catch {
162
231
  // ranking is best-effort; don't fail the whole call
163
232
  result.ranked_urls = urlArray.map(u => ({ url: u, score: 0 }));
@@ -193,7 +262,11 @@ export class MapSiteTool {
193
262
  });
194
263
  }
195
264
 
196
- async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity) {
265
+ async fetchSitemapUrls(baseUrl, domainFilter = null, maxUrls = Infinity, scopePath = null) {
266
+ const inScope = (url) => {
267
+ if (!scopePath) return true;
268
+ try { return new URL(url).pathname.startsWith(scopePath); } catch { return false; }
269
+ };
197
270
  // Discover sitemaps via robots.txt and common paths, then parse with full
198
271
  // SitemapParser support (sitemap-index recursion, gzip, CDATA/entities).
199
272
  const discovered = await this.sitemapParser.discoverSitemaps(baseUrl, {
@@ -212,6 +285,7 @@ export class MapSiteTool {
212
285
  if (parsed.success) {
213
286
  for (const entry of parsed.urls) {
214
287
  const url = entry.loc || entry;
288
+ if (!inScope(url)) continue;
215
289
  if (!domainFilter || domainFilter.isAllowed(url).allowed) {
216
290
  urls.add(url);
217
291
  }
@@ -267,7 +341,7 @@ export class MapSiteTool {
267
341
  // A gate refusal is the answer to the request, not a page we failed to
268
342
  // read: surface it instead of returning an emptier map than the caller
269
343
  // would notice.
270
- if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED') throw error;
344
+ if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
271
345
  return [];
272
346
  }
273
347
  }
@@ -41,7 +41,23 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
41
41
  );
42
42
  }
43
43
 
44
- const data = path ? selectJsonPath(state.data, path) : state.data;
44
+ // A path is naturally written against the payload ("props.pageProps"),
45
+ // not against this tool's envelope ("next_data.props.pageProps"). When
46
+ // the page carries exactly one payload and the path's root is not one of
47
+ // the envelope keys, read it inside that payload and say so (R21,
48
+ // 2026-09-09: four Next.js pages in a row failed on the bare path).
49
+ let effectivePath = path;
50
+ if (path && state.found.length === 1) {
51
+ const root = path.split(/[.[]/)[0];
52
+ if (root && !(root in state.data)) {
53
+ effectivePath = `${state.found[0].name}.${path}`;
54
+ warnings.push(
55
+ `path "${path}" was read as "${effectivePath}": "${state.found[0].name}" is the only payload on this page, so the path is resolved inside it.`
56
+ );
57
+ }
58
+ }
59
+
60
+ const data = effectivePath ? selectJsonPath(state.data, effectivePath) : state.data;
45
61
  const bytes = Buffer.byteLength(JSON.stringify(data) ?? '');
46
62
 
47
63
  if (!path && bytes > LARGE_RESULT_BYTES) {
@@ -57,7 +73,7 @@ export async function extractEmbeddedStateHandler({ url, path, user_agent, respe
57
73
  text: JSON.stringify({
58
74
  url: finalUrl,
59
75
  found: state.found,
60
- path: path || null,
76
+ path: effectivePath || null,
61
77
  bytes,
62
78
  data,
63
79
  warnings
@@ -258,6 +258,9 @@ export class ExtractStructuredTool {
258
258
  if (result?.method === 'llm') {
259
259
  extractionResult = result;
260
260
  extractionMethod = 'llm';
261
+ // A salvaged cut-off response: the rows are real, the caller must
262
+ // know the list is not the whole table (R21, 2026-09-09).
263
+ if (result.warning) warnings.push(result.warning);
261
264
  } else {
262
265
  llmErrorMessage = result?.error || 'LLM did not return usable JSON';
263
266
  }
@@ -433,6 +436,7 @@ export class ExtractStructuredTool {
433
436
  },
434
437
  extractionNotes,
435
438
  provenance,
439
+ ...(extractionResult.partial ? { partial: true } : {}),
436
440
  ...(warnings?.length ? { warnings } : {})
437
441
  };
438
442
 
@@ -118,6 +118,57 @@ const ProcessDocumentResult = z.object({
118
118
  error: z.string().optional()
119
119
  });
120
120
 
121
+ /**
122
+ * What a fetched body is, from its Content-Type, magic bytes and URL:
123
+ * 'pdf', 'docx', 'binary' (something this tool cannot read) or 'html'
124
+ * (HTML, XML, JSON and plain text all go through the page pipeline).
125
+ * @param {string} contentType
126
+ * @param {Buffer} buffer
127
+ * @param {string} [url]
128
+ * @returns {'pdf'|'docx'|'binary'|'html'}
129
+ */
130
+ export function sniffDocumentKind(contentType = '', buffer, url = '') {
131
+ const type = String(contentType).split(';')[0].trim().toLowerCase();
132
+ const head = buffer.subarray(0, 8).toString('latin1');
133
+ let path = String(url).toLowerCase();
134
+ try { path = new URL(url).pathname.toLowerCase(); } catch { /* keep the raw string */ }
135
+
136
+ if (type === 'application/pdf' || head.startsWith('%PDF')) return 'pdf';
137
+
138
+ const zip = head.startsWith('PK\u0003\u0004');
139
+ if (
140
+ type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
141
+ (zip && (path.endsWith('.docx') || buffer.includes('word/document.xml')))
142
+ ) return 'docx';
143
+
144
+ if (zip) return 'binary';
145
+ if (/^(image|audio|video|font)\//.test(type)) return 'binary';
146
+ if (/^application\/(x-)?(zip|gzip|tar|7z|rar|msword|vnd\.)/.test(type)) return 'binary';
147
+ const textual = type.startsWith('text/') || /html|xml|json|javascript/.test(type);
148
+ if (!textual && buffer.subarray(0, 512).includes(0)) return 'binary';
149
+ return 'html';
150
+ }
151
+
152
+ /**
153
+ * Decode a text body by its Content-Type charset, else the <meta charset>
154
+ * it declares, else UTF-8.
155
+ * @param {Buffer} buffer
156
+ * @param {string} contentType
157
+ * @returns {string}
158
+ */
159
+ export function decodeTextBody(buffer, contentType = '') {
160
+ let charset = (/charset=["']?([\w-]+)/i.exec(contentType) || [])[1];
161
+ if (!charset) {
162
+ const head = buffer.subarray(0, 4096).toString('latin1');
163
+ charset = (/<meta[^>]+charset=["']?([\w-]+)/i.exec(head) || [])[1];
164
+ }
165
+ try {
166
+ return new TextDecoder(charset || 'utf-8').decode(buffer);
167
+ } catch {
168
+ return buffer.toString('utf8');
169
+ }
170
+ }
171
+
121
172
  export class ProcessDocumentTool {
122
173
  constructor() {
123
174
  this.pdfProcessor = new PDFProcessor();
@@ -340,7 +391,30 @@ export class ProcessDocumentTool {
340
391
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
341
392
  }
342
393
 
343
- html = await response.text();
394
+ // What came back decides how it is read, not the sourceType the caller
395
+ // guessed: a .docx fetched as 'url' was run through the HTML pipeline
396
+ // and returned its ZIP bytes as page text, success:true (R21,
397
+ // 2026-09-09). PDFs reach their parser the same way.
398
+ const contentType = response.headers.get('content-type') || '';
399
+ const buffer = Buffer.from(await response.arrayBuffer());
400
+ const kind = sniffDocumentKind(contentType, buffer, source);
401
+ if (kind === 'pdf') {
402
+ result.documentType = 'pdf';
403
+ await this.processPDFDocument(result, buffer, 'pdf_buffer', options, identity);
404
+ return;
405
+ }
406
+ if (kind === 'docx') {
407
+ result.documentType = 'docx';
408
+ await this.processDocxBuffer(result, buffer, options);
409
+ return;
410
+ }
411
+ if (kind === 'binary') {
412
+ throw new Error(
413
+ `${contentType.split(';')[0] || 'This'} content is not a document this tool reads: ` +
414
+ 'process_document reads PDF, DOCX, HTML and plain text.'
415
+ );
416
+ }
417
+ html = decodeTextBody(buffer, contentType);
344
418
  pageTitle = this.extractTitleFromHTML(html);
345
419
  }
346
420
 
@@ -349,6 +423,25 @@ export class ProcessDocumentTool {
349
423
  await this.processFetchedHtml(result, html, source, options);
350
424
  }
351
425
 
426
+ /**
427
+ * Read a Word document: its text (and markdown when asked) via mammoth.
428
+ * @param {Object} result - Result object to populate
429
+ * @param {Buffer} buffer - the .docx bytes
430
+ * @param {Object} options - Processing options
431
+ */
432
+ async processDocxBuffer(result, buffer, options) {
433
+ const mammoth = (await import('mammoth')).default;
434
+ const raw = await mammoth.extractRawText({ buffer });
435
+ result.content = { text: (raw.value || '').trim() };
436
+ if (options.outputFormat === 'markdown') {
437
+ const md = await mammoth.convertToMarkdown({ buffer });
438
+ result.content.markdown = md.value || '';
439
+ }
440
+ result.title = null;
441
+ const notes = (raw.messages || []).map((m) => m.message).filter(Boolean);
442
+ if (notes.length > 0) result.warnings = [...(result.warnings || []), ...notes.slice(0, 5)];
443
+ }
444
+
352
445
  /**
353
446
  * Process a local non-PDF file (sourceType 'file'): read it from disk and
354
447
  * run it through the same content-processing pipeline used for web pages.
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { safeFetch } from '../../utils/ssrfGuard.js';
18
+ import { mediaAppliesToScreen } from '../../utils/hiddenContent.js';
18
19
 
19
20
  const GENERIC_FAMILIES = new Set([
20
21
  'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
@@ -123,19 +124,36 @@ export async function collectCssSources($, pageUrl, opts) {
123
124
  let inlineStyleEls = 0;
124
125
  let cssText = '';
125
126
 
127
+ // Print (and other non-screen) stylesheets never paint a screen render, so
128
+ // neither branding nor the hidden-content pass may read them. `media:
129
+ // 'unconditional-screen'` also drops sheets gated on a media query.
130
+ const unconditional = opts.media === 'unconditional-screen';
131
+ const applies = (el) => mediaAppliesToScreen($(el).attr('media'), { unconditional });
132
+
126
133
  $('style').each((_, el) => {
134
+ if (!applies(el)) return;
127
135
  const t = $(el).html();
128
136
  if (t) { cssText += '\n' + t; styleBlocks++; }
129
137
  });
130
138
 
131
- $('[style]').each((_, el) => {
132
- const t = $(el).attr('style');
133
- if (t) { cssText += '\n*{' + t + '}'; inlineStyleEls++; }
134
- });
139
+ // Inline style="" attributes are folded in as universal rules so branding
140
+ // can mine their colours and fonts. They are NOT visibility rules: one
141
+ // element carrying style="display:none" became `*{display:none}`, and the
142
+ // hidden-content pass then deleted every element small enough to pass its
143
+ // bulk guard — irs.gov's tax-bracket page came back as its header (R21,
144
+ // 2026-09-09). That pass reads inline styles per element itself, so it
145
+ // asks for `inlineStyles: false`.
146
+ if (opts.inlineStyles !== false) {
147
+ $('[style]').each((_, el) => {
148
+ const t = $(el).attr('style');
149
+ if (t) { cssText += '\n*{' + t + '}'; inlineStyleEls++; }
150
+ });
151
+ }
135
152
 
136
153
  if (opts.fetchLinkedCss) {
137
154
  const hrefs = [];
138
155
  $('link[rel~="stylesheet"][href]').each((_, el) => {
156
+ if (!applies(el)) return;
139
157
  const href = $(el).attr('href');
140
158
  if (href) hrefs.push(resolveUrl(href, pageUrl));
141
159
  });
@@ -404,7 +422,7 @@ function extractTokens(cssText, cssVariables) {
404
422
  * Extract the full branding object from a loaded cheerio $.
405
423
  * @param {import('cheerio').CheerioAPI} $
406
424
  * @param {string} pageUrl
407
- * @param {{ fetchLinkedCss?: boolean, maxStylesheets?: number, perFileTimeoutMs?: number, timeoutMs?: number, overallTimeoutMs?: number, stylesheetConcurrency?: number }} [opts]
425
+ * @param {{ fetchLinkedCss?: boolean, maxStylesheets?: number, perFileTimeoutMs?: number, timeoutMs?: number, overallTimeoutMs?: number, stylesheetConcurrency?: number, media?: 'screen'|'unconditional-screen', inlineStyles?: boolean }} [opts]
408
426
  * @returns {Promise<object>}
409
427
  */
410
428
  export async function extractBranding($, pageUrl, opts = {}) {
@@ -172,6 +172,22 @@ function isDataTable(table) {
172
172
  return rows >= 10 || columns > 4;
173
173
  }
174
174
 
175
+ /**
176
+ * A table whose author marked header cells carries data whatever its size —
177
+ * layout tables do not use <th>. The size test above misses every small fee
178
+ * or spec table: WestJet's checked-bag fees are 6 rows × 3 columns inside a
179
+ * `com-tabs` component (Readability's `negative` regex matches `com-`), and
180
+ * the page came back reading "fees are as follows:" with nothing following
181
+ * (R20, 2026-09-07).
182
+ * @param {HTMLTableElement} table
183
+ * @returns {boolean}
184
+ */
185
+ function isHeadedTable(table) {
186
+ if (table.rows.length < 2) return false;
187
+ if (!table.querySelector('th')) return false;
188
+ return Array.from(table.rows).some((row) => row.cells.length >= 2);
189
+ }
190
+
175
191
  /**
176
192
  * A data table's text with its structure kept: one line per row, cells joined
177
193
  * by " | ". A bare `textContent` runs every cell together
@@ -216,7 +232,7 @@ export function recoverDroppedTables(html, url, keptText = '') {
216
232
  // A nested table travels with its parent; re-attaching it separately
217
233
  // would duplicate it.
218
234
  .filter((table) => !table.parentElement?.closest('table'))
219
- .filter(isDataTable)
235
+ .filter((table) => isDataTable(table) || isHeadedTable(table))
220
236
  .filter((table) => {
221
237
  const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
222
238
  return signature.length > 0 && !kept.includes(signature);
@@ -542,7 +542,14 @@ export class UnifiedScrapeTool {
542
542
  // Themes split visibility rules across many component sheets — the
543
543
  // rule hiding Shopify's sold-out badge sits at index 12 of 38 on a
544
544
  // stock Dawn storefront, so a cap of 10 silently misses it.
545
- maxStylesheets: 20
545
+ maxStylesheets: 20,
546
+ // Only sheets a screen render applies unconditionally: a
547
+ // media="print" sheet hid every screen element of irs.gov's
548
+ // tax-bracket page and left the print logo (R21, 2026-09-09).
549
+ media: 'unconditional-screen',
550
+ // stripHiddenFromDom reads style="" per element; folded into
551
+ // universal rules they hid the whole page (R21).
552
+ inlineStyles: false
546
553
  });
547
554
  css = collected.cssText || '';
548
555
  }
@@ -15,11 +15,14 @@
15
15
  * cross-subreddit full-text search, but has known post-2023 archive gaps
16
16
  * and recurring outages.
17
17
  *
18
- * Routing: scoped searches go to Arctic Shift. An unscoped keyword search
19
- * finds posts through a site-restricted web search and then reads them — or
20
- * searches their comments in the archive by id. PullPush stopped serving
21
- * automated clients in August 2026 and is used only on explicit request.
22
- * Both archives are free and need no credentials.
18
+ * Routing: Arctic Shift first, PullPush second. A scoped search queries
19
+ * Arctic Shift and, if that fails, PullPush. An unscoped keyword search finds
20
+ * posts through a site-restricted web search and reads them or searches
21
+ * their comments in Arctic Shift by id, then falls back to PullPush's own
22
+ * full-text search. Thread mode is Arctic Shift only. PullPush has refused
23
+ * automated clients since August 2026, so the fallback usually reports that
24
+ * refusal; it stays second for whenever it answers again. Both archives are
25
+ * free and need no credentials.
23
26
  *
24
27
  * Optional official-API path: if the user sets REDDIT_CLIENT_ID and
25
28
  * REDDIT_CLIENT_SECRET (their own Reddit app), posts/thread requests can read
@@ -101,9 +104,8 @@ export class RedditSearchTool {
101
104
  // Lazily constructed on first official-path use; overridable for tests.
102
105
  this._officialAdapter = options.officialAdapter || null;
103
106
 
104
- // Web discovery serves the one shape no archive can: a keyword search
105
- // across all of Reddit. Arctic Shift requires a subreddit or author scope,
106
- // and PullPush stopped serving automated clients in August 2026.
107
+ // Web discovery serves the one shape Arctic Shift cannot: a keyword search
108
+ // across all of Reddit, which it requires a subreddit or author scope for.
107
109
  this.searchAdapter = options.searchAdapter || null;
108
110
  this.searchApiKey = options.searchApiKey || null;
109
111
  this.searchApiBaseUrl = options.searchApiBaseUrl || null;
@@ -179,13 +181,16 @@ export class RedditSearchTool {
179
181
  order = ['pullpush'];
180
182
  } else {
181
183
  // auto: prefer the user's own official API (live, authoritative) when it
182
- // can serve this request, then fall back to the community archives.
183
- // PullPush is no longer tried automatically: every request now returns
184
- // 429 "This website does not provide free scraping resources for agents",
185
- // or a Cloudflare 403 challenge. It stays available on explicit request.
184
+ // can serve this request, then the community archives Arctic Shift
185
+ // first, PullPush second (owner's call, 2026-09-10). PullPush has refused
186
+ // automated clients since August 2026 — 429 "This website does not
187
+ // provide free scraping resources for agents", or a Cloudflare 403
188
+ // challenge — so the fallback is one un-retried request that usually
189
+ // reports that refusal after the real Arctic Shift error, and answers
190
+ // when PullPush is serving again. Thread mode has no second source.
186
191
  const archives = v.mode === 'thread' ? ['arctic_shift']
187
- : arcticPossible ? ['arctic_shift']
188
- : discoveryPossible ? ['web_discovery']
192
+ : arcticPossible ? ['arctic_shift', 'pullpush']
193
+ : discoveryPossible ? ['web_discovery', 'pullpush']
189
194
  : [];
190
195
  order = officialPossible ? ['reddit_api', ...archives] : archives;
191
196
  }
@@ -252,7 +257,7 @@ export class RedditSearchTool {
252
257
  ? 'Reddit-wide comment search: posts were found with a site-restricted web search, then each post\'s comments were searched for the keywords in the Arctic Shift archive, in post relevance order.'
253
258
  : 'Reddit-wide keyword search: posts were found with a site-restricted web search, then read from the Arctic Shift archive by ID.',
254
259
  'Results are ordered by web-search relevance, not by score or date.',
255
- 'Arctic Shift cannot keyword-search across all of Reddit, and PullPush no longer serves automated clients — scope the search to a subreddit or author to query the archive directly.',
260
+ 'Arctic Shift cannot keyword-search across all of Reddit — scope the search to a subreddit or author to query the archive directly.',
256
261
  ];
257
262
  if (v.after || v.before) {
258
263
  // Silently dropping a date filter would return results the caller
@@ -373,7 +378,18 @@ export class RedditSearchTool {
373
378
  // caller the 422. Thread mode returned above, so both remaining modes
374
379
  // (posts, comments) narrow.
375
380
  const narrowable = Boolean(v.query) && !v.after;
376
- if (!error.retryable || !narrowable) throw error;
381
+ if (!error.retryable || !narrowable) {
382
+ // The caller's window is respected, so the 422 is theirs to act on —
383
+ // but the generic hint ("add subreddit or author") is useless on a
384
+ // search that is already scoped (R20: r/aviation "737 MAX" after=30d).
385
+ if (error.retryable && v.query && v.after) {
386
+ throw new Error(
387
+ `${error.message} — Arctic Shift timed out searching your after=${v.after} window. ` +
388
+ 'Pass a narrower after (7d, 3d, 1d), drop the query to list the newest posts in the scope, or read one post with mode:"thread" and link_id.'
389
+ );
390
+ }
391
+ throw error;
392
+ }
377
393
  let lastError = error;
378
394
  for (const window of ['7d', '3d', '1d']) {
379
395
  await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs));
@@ -478,12 +494,14 @@ export class RedditSearchTool {
478
494
  if (response.status === 429) {
479
495
  const reset = response.headers?.get?.('x-ratelimit-reset');
480
496
  // PullPush's 429 body states its actual policy ("does not provide free
481
- // scraping resources for agents...") — pass that through verbatim.
497
+ // scraping resources for agents...") — pass that through verbatim. That
498
+ // policy refusal is not a transient throttle: retrying it only spends the
499
+ // delay, which matters now that PullPush is the automatic second source.
482
500
  let detail = '';
483
501
  try { detail = (await response.json())?.error ?? ''; } catch { /* no body */ }
484
502
  throw Object.assign(
485
503
  new Error(`rate limited (429)${reset ? `, retry in ${reset}s` : ''}${detail ? ` — ${detail}` : ''}`),
486
- { retryable: true },
504
+ { retryable: !/does not provide free scraping/i.test(detail) },
487
505
  );
488
506
  }
489
507
  if (!response.ok) {
@@ -83,6 +83,38 @@ function stripConditionalBlocks(css) {
83
83
  return out;
84
84
  }
85
85
 
86
+ /**
87
+ * Split a selector list on its top-level commas only. A naive split cut
88
+ * irs.gov's `.callout:has(> .a, .b, p, ul, h2, h3, table)` into bare `p`,
89
+ * `ul`, `h2`, `h3` and `table` rules carrying its `display:none !important`,
90
+ * and the stripper then deleted the article body (R21, 2026-09-09).
91
+ * @param {string} list
92
+ * @returns {string[]}
93
+ */
94
+ export function splitSelectorList(list) {
95
+ const out = [];
96
+ let depth = 0;
97
+ let quote = null;
98
+ let start = 0;
99
+ for (let i = 0; i < list.length; i++) {
100
+ const ch = list[i];
101
+ if (quote) {
102
+ if (ch === '\\') i++;
103
+ else if (ch === quote) quote = null;
104
+ continue;
105
+ }
106
+ if (ch === '"' || ch === "'") quote = ch;
107
+ else if (ch === '(' || ch === '[') depth++;
108
+ else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
109
+ else if (ch === ',' && depth === 0) {
110
+ out.push(list.slice(start, i));
111
+ start = i + 1;
112
+ }
113
+ }
114
+ out.push(list.slice(start));
115
+ return out;
116
+ }
117
+
86
118
  /** True when cheerio cannot meaningfully evaluate the selector. */
87
119
  function isUnsupportedSelector(selector) {
88
120
  return (
@@ -91,7 +123,7 @@ function isUnsupportedSelector(selector) {
91
123
  // case and these rules would hide content every real visitor sees.
92
124
  /(^|[\s.#\[])no-js(\b|[.\[])/.test(selector) ||
93
125
  selector.includes('::') ||
94
- /:(hover|focus|focus-within|focus-visible|active|target|checked|disabled|placeholder|before|after|root|host|where|is|not\()/i.test(selector) ||
126
+ /:(hover|focus|focus-within|focus-visible|active|target|checked|disabled|placeholder|before|after|root|host|where|is|not\(|has\()/i.test(selector) ||
95
127
  selector.includes('@') ||
96
128
  selector.length === 0
97
129
  );
@@ -199,7 +231,7 @@ export function collectVisibilitySelectors(css) {
199
231
 
200
232
  const important = /!\s*important/i.test(body);
201
233
 
202
- for (const raw of selectorList.split(',')) {
234
+ for (const raw of splitSelectorList(selectorList)) {
203
235
  const selector = raw.trim();
204
236
  if (isUnsupportedSelector(selector) || NEVER_REMOVE.has(selector)) continue;
205
237
  const entry = { selector, spec: specificity(selector), order, important };
@@ -249,12 +281,45 @@ function renderedSize($, el) {
249
281
  export function inlineStyleText($) {
250
282
  const parts = [];
251
283
  $('style').each((_, el) => {
284
+ // A <style media="print"> block is only consulted when printing.
285
+ if (!mediaAppliesToScreen($(el).attr('media'), { unconditional: true })) return;
252
286
  const text = $(el).html();
253
287
  if (text) parts.push(text);
254
288
  });
255
289
  return parts.join('\n');
256
290
  }
257
291
 
292
+ /**
293
+ * Whether a stylesheet's `media` attribute applies to a screen render.
294
+ *
295
+ * `<link rel="stylesheet" media="print">` is consulted only when printing.
296
+ * irs.gov's tax-bracket page ships one that hides every screen element and
297
+ * shows a print-only logo; read as screen CSS it emptied the page to that logo
298
+ * (R21, 2026-09-09). Absent or empty, `all`, `screen` and `not print` apply.
299
+ * `print`, `speech` and the other non-screen types, and `not screen`/`not all`,
300
+ * do not. With `unconditional: true` a media QUERY (`screen and (max-width:
301
+ * 600px)`, a bare `(prefers-color-scheme: dark)`) does not apply either — the
302
+ * hidden-content pass ignores @media blocks for the same reason.
303
+ *
304
+ * @param {string|undefined} media - the attribute value
305
+ * @param {{ unconditional?: boolean }} [options]
306
+ * @returns {boolean}
307
+ */
308
+ export function mediaAppliesToScreen(media, { unconditional = false } = {}) {
309
+ const value = (media || '').trim().toLowerCase();
310
+ if (!value) return true;
311
+ return value.split(',').some((part) => {
312
+ const query = part.trim();
313
+ if (!query) return false;
314
+ if (unconditional && query.includes('(')) return false;
315
+ if (query.startsWith('(')) return true; // a bare feature query: type "all"
316
+ const m = query.match(/^(?:(not|only)\s+)?([a-z-]+)/);
317
+ if (!m) return true;
318
+ const screenType = m[2] === 'all' || m[2] === 'screen';
319
+ return m[1] === 'not' ? !screenType : screenType;
320
+ });
321
+ }
322
+
258
323
  /**
259
324
  * Remove browser-invisible content from a cheerio document, in place.
260
325
  *