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.
@@ -16,10 +16,42 @@
16
16
 
17
17
  import TurndownService from 'turndown';
18
18
  import { gfm } from 'turndown-plugin-gfm';
19
+ import { load } from 'cheerio';
19
20
  import { stripHiddenHtml } from './hiddenContent.js';
20
21
 
21
22
  let _td = null;
22
23
 
24
+ /**
25
+ * turndown-plugin-gfm renders a table as a pipe table only when its first row
26
+ * is entirely <th>. A corner cell written as an empty <td> — WestJet's fee
27
+ * table opens `<td> </td><th>1st Bag</th><th>2nd Bag</th>` — fails that test,
28
+ * so the whole table fell to the layout-table rule and flattened to text
29
+ * lines with the columns lost (R20, 2026-09-07). Promote empty corner cells
30
+ * in an otherwise all-<th> first row so the table renders as a table.
31
+ * @param {string} html
32
+ * @returns {string}
33
+ */
34
+ export function promoteCornerHeaderCells(html) {
35
+ if (!/<th[\s>]/i.test(html)) return html;
36
+ try {
37
+ const $ = load(html);
38
+ let changed = false;
39
+ $('table').each((_, table) => {
40
+ const firstRow = $(table).find('tr').first();
41
+ if (!firstRow.length || firstRow.closest('table')[0] !== table) return;
42
+ const cells = firstRow.children('th, td');
43
+ const tds = cells.filter('td');
44
+ if (cells.filter('th').length === 0 || tds.length === 0) return;
45
+ if (tds.toArray().some((td) => $(td).text().trim() !== '')) return;
46
+ tds.each((__, td) => { td.name = 'th'; });
47
+ changed = true;
48
+ });
49
+ return changed ? $.html() : html;
50
+ } catch {
51
+ return html;
52
+ }
53
+ }
54
+
23
55
  // Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
24
56
  // whether it converts a table or keeps it as raw HTML.
25
57
  function isHeadingRow(tr) {
@@ -112,7 +144,7 @@ export function htmlToMarkdown(html, options = {}) {
112
144
  const visible = options.keepHiddenContent
113
145
  ? html
114
146
  : stripHiddenHtml(html, { css: options.css });
115
- return getTurndown().turndown(visible).trim();
147
+ return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
116
148
  } catch {
117
149
  // Fallback: strip tags, return plain text
118
150
  return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
@@ -0,0 +1,123 @@
1
+ /**
2
+ * redditHosts — reddit.com is never fetched.
3
+ *
4
+ * reddit.com refuses every non-browser client (403, and the stealth browsers
5
+ * too — the block is IP/TLS-reputation based), so a scrape or fetch_url
6
+ * against it always fails and leaves the caller guessing. Reddit is served by
7
+ * reddit_search, which reads the same posts and comments from the Arctic
8
+ * Shift community archive (PullPush second). A reddit.com target is therefore
9
+ * refused before any network work, with the reddit_search call that gets the
10
+ * same data spelled out — derived from the URL where the URL says enough.
11
+ *
12
+ * Runs inside the pre-fetch gate (robotsGate.js), so every fetching tool and
13
+ * both browser paths get it without knowing. Not overridable: respect_robots
14
+ * is about robots.txt, and fetching reddit.com fails whatever the caller sends.
15
+ *
16
+ * Mirrors the website's `src/lib/tools/reddit-hosts.ts` — same rule, same
17
+ * message — so a reddit.com URL is answered identically on both surfaces.
18
+ */
19
+
20
+ export class UseRedditSearchError extends Error {
21
+ constructor(url) {
22
+ const call = redditSearchCallFor(url);
23
+ const nextStep = Object.keys(call).length
24
+ ? `reddit_search(${JSON.stringify(call)})`
25
+ : 'reddit_search with a query, subreddit or author — or mode "thread" with a post\'s link_id';
26
+ super(
27
+ `${hostOf(url)} is not fetched: reddit.com refuses every non-browser client, stealth browsers included, ` +
28
+ `so this call would fail. Reddit is served by reddit_search (5 credits), which reads the same posts ` +
29
+ `and comments from the Arctic Shift community archive. Next step: ${nextStep}`
30
+ );
31
+ this.name = 'UseRedditSearchError';
32
+ this.code = 'USE_REDDIT_SEARCH';
33
+ this.url = url;
34
+ this.redditSearchCall = call;
35
+ }
36
+ }
37
+
38
+ /** The hostname of a URL, lowercased, or null if it will not parse. */
39
+ function hostOf(url) {
40
+ try {
41
+ return new URL(url).hostname.toLowerCase().replace(/\.$/, '');
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * True for reddit.com and its subdomains (www, old, new, np, sh …) and for the
49
+ * bare redd.it short-link host. The media hosts (i.redd.it, v.redd.it,
50
+ * preview.redd.it) serve files to any client and are left alone.
51
+ * @param {string} url
52
+ */
53
+ export function isRedditUrl(url) {
54
+ const host = hostOf(url);
55
+ if (!host) return false;
56
+ return host === 'reddit.com' || host.endsWith('.reddit.com') || host === 'redd.it';
57
+ }
58
+
59
+ /**
60
+ * The reddit_search call that answers a reddit.com URL, or {} when the URL
61
+ * names nothing reddit_search can be pointed at (the front page, a wiki, a
62
+ * settings page).
63
+ *
64
+ * /r/{sub}/comments/{id}/…, /comments/{id}, /gallery/{id}, redd.it/{id}
65
+ * → mode "thread", link_id
66
+ * /r/{sub}/search?q=… → query scoped to the subreddit
67
+ * /search?q=… → an unscoped query
68
+ * /r/{sub}[/new|/top|…] → the subreddit's posts
69
+ * /user/{name} or /u/{name}[/comments] → the author's posts (or comments)
70
+ * ?type=comment → mode "comments"
71
+ * @param {string} url
72
+ * @returns {{ mode?: string, query?: string, subreddit?: string, author?: string, link_id?: string }}
73
+ */
74
+ export function redditSearchCallFor(url) {
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(url);
78
+ } catch {
79
+ return {};
80
+ }
81
+ const segments = parsed.pathname
82
+ .split('/')
83
+ .filter(Boolean)
84
+ .map((segment) => {
85
+ try {
86
+ return decodeURIComponent(segment);
87
+ } catch {
88
+ return segment;
89
+ }
90
+ });
91
+
92
+ if (parsed.hostname.toLowerCase() === 'redd.it') {
93
+ return segments[0] ? { mode: 'thread', link_id: segments[0] } : {};
94
+ }
95
+
96
+ // A post's id follows "comments" or "gallery" wherever it sits in the path;
97
+ // a permalink to one comment still reads as its thread.
98
+ const idAt = segments.findIndex((segment) => segment === 'comments' || segment === 'gallery');
99
+ if (idAt !== -1 && segments[idAt + 1]) {
100
+ return { mode: 'thread', link_id: segments[idAt + 1] };
101
+ }
102
+
103
+ const call = {};
104
+ if (segments[0] === 'r' && segments[1]) call.subreddit = segments[1];
105
+ if ((segments[0] === 'user' || segments[0] === 'u') && segments[1]) {
106
+ call.author = segments[1];
107
+ if (segments[2] === 'comments') call.mode = 'comments';
108
+ }
109
+ const query = parsed.searchParams.get('q')?.trim();
110
+ if (query) call.query = query;
111
+ if (parsed.searchParams.get('type') === 'comment') call.mode = 'comments';
112
+ return call;
113
+ }
114
+
115
+ /**
116
+ * Throw UseRedditSearchError for a reddit.com target. Call before any network
117
+ * work — the point is that reddit.com never gets a request, not even for its
118
+ * robots.txt.
119
+ * @param {string} url
120
+ */
121
+ export function assertNotRedditUrl(url) {
122
+ if (isRedditUrl(url)) throw new UseRedditSearchError(url);
123
+ }
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { RobotsChecker } from './robotsChecker.js';
21
21
  import { assertHostAllowed } from './hostBlocklist.js';
22
+ import { assertNotRedditUrl } from './redditHosts.js';
22
23
  import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
23
24
  import { throttleHost } from './hostRateLimiter.js';
24
25
  import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
@@ -75,8 +76,13 @@ export async function robotsPreflight(url, options = {}) {
75
76
  // costs the caller nothing: we refused, we fetched nothing.
76
77
  try {
77
78
  assertHostAllowed(url);
79
+ // reddit.com refuses every non-browser client, so it is never fetched and
80
+ // the caller is pointed at reddit_search instead. Also not overridable.
81
+ assertNotRedditUrl(url);
78
82
  } catch (error) {
79
- if (error?.code === 'HOST_BLOCKED') markPreflightRefusal('HOST_BLOCKED');
83
+ if (error?.code === 'HOST_BLOCKED' || error?.code === 'USE_REDDIT_SEARCH') {
84
+ markPreflightRefusal(error.code);
85
+ }
80
86
  throw error;
81
87
  }
82
88
 
@@ -178,11 +184,29 @@ export async function preflightFetch(url, options = {}) {
178
184
  return {
179
185
  headers: outboundHeaders(decision.userAgent, signature),
180
186
  userAgent: decision.userAgent,
181
- warnings: decision.warnings,
187
+ warnings: [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)],
182
188
  overridden: decision.overridden
183
189
  };
184
190
  }
185
191
 
192
+ /**
193
+ * Name a long Crawl-delay, so a slow multi-page call reads as compliance
194
+ * rather than a stall: eff.org asks every agent for 30 s, and a 10-page
195
+ * llms.txt run took 13 minutes with nothing in the response saying why (R21,
196
+ * 2026-09-09).
197
+ * @param {string} url
198
+ * @param {number} crawlDelayMs
199
+ * @returns {string[]}
200
+ */
201
+ function crawlDelayWarning(url, crawlDelayMs) {
202
+ if (!(crawlDelayMs >= 5000)) return [];
203
+ let host = url;
204
+ try { host = new URL(url).host; } catch { /* keep the raw url */ }
205
+ return [
206
+ `robots.txt on ${host} asks for a ${Math.round(crawlDelayMs / 1000)} s crawl delay; requests to it are spaced by that much, so a multi-page call takes about that long per page.`
207
+ ];
208
+ }
209
+
186
210
  /**
187
211
  * The gate for browser paths. Same decision as {@link preflightFetch}, minus
188
212
  * the identity and signature headers — those belong on an HTTP fetch, not on a
@@ -213,7 +237,7 @@ export async function browserPreflight(url, options = {}) {
213
237
  }
214
238
 
215
239
  await throttleHost(url, { crawlDelayMs: decision.crawlDelayMs });
216
- return decision.warnings;
240
+ return [...decision.warnings, ...crawlDelayWarning(url, decision.crawlDelayMs)];
217
241
  }
218
242
 
219
243
  /** Test/diagnostic hook: drop every cached robots.txt. */
@@ -10,6 +10,23 @@ import { noteRetryAfter } from './hostRateLimiter.js';
10
10
 
11
11
  const gunzip = promisify(zlib.gunzip);
12
12
 
13
+ /**
14
+ * A sitemap <loc> resolved against the sitemap's own URL and normalized;
15
+ * null when it is empty or not a URL. The protocol wants absolute locs, but
16
+ * boeing.com's 1,878-entry sitemap is written with relative paths ("/",
17
+ * "/commercial"): `normalizeUrl("/")` threw out of the entry loop, the whole
18
+ * sitemap read as empty, and map_site fell back to crawling links — 75 URLs
19
+ * (R20, 2026-09-07). One bad entry must not discard the rest either.
20
+ */
21
+ function resolveLoc(loc, base) {
22
+ if (!loc) return null;
23
+ try {
24
+ return normalizeUrl(new URL(loc, base).href);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
13
30
  export class SitemapParser {
14
31
  constructor(options = {}) {
15
32
  const {
@@ -121,11 +138,11 @@ export class SitemapParser {
121
138
  // Parse sitemap index entries
122
139
  $('sitemap').each((_, element) => {
123
140
  const $sitemap = $(element);
124
- const loc = $sitemap.find('loc').text().trim();
125
-
141
+ const loc = resolveLoc($sitemap.find('loc').text().trim(), indexUrl);
142
+
126
143
  if (loc) {
127
144
  const sitemap = {
128
- url: normalizeUrl(loc),
145
+ url: loc,
129
146
  lastmod: $sitemap.find('lastmod').text().trim() || null
130
147
  };
131
148
  sitemaps.push(sitemap);
@@ -347,7 +364,9 @@ export class SitemapParser {
347
364
  const cacheKey = this.cache?.generateKey(url, { depth: currentDepth });
348
365
  if (this.cache && cacheKey) {
349
366
  const cached = await this.cache.get(cacheKey);
350
- if (cached) {
367
+ // An empty cached parse is a failure that got remembered (see the
368
+ // write side below); re-parse rather than serve it for an hour.
369
+ if (cached && (cached.urls?.length > 0 || cached.sitemaps?.length > 0)) {
351
370
  this.stats.cacheHits++;
352
371
  return cached;
353
372
  }
@@ -378,8 +397,12 @@ export class SitemapParser {
378
397
  }
379
398
  }
380
399
 
381
- // Cache the result
382
- if (this.cache && cacheKey) {
400
+ // Cache the result — but never an empty one. A parse that yielded no
401
+ // URL and no child sitemap is far more likely a failure than a fact
402
+ // (the relative-<loc> throw above sat in the disk cache for an hour
403
+ // and map_site kept answering 75 for boeing.com after the parser was
404
+ // fixed, R20 2026-09-07).
405
+ if (this.cache && cacheKey && (result.urls.length > 0 || result.sitemaps.length > 0)) {
383
406
  await this.cache.set(cacheKey, result);
384
407
  }
385
408
 
@@ -469,11 +492,11 @@ export class SitemapParser {
469
492
  // Parse standard URLs
470
493
  $('url').each((_, element) => {
471
494
  const $url = $(element);
472
- const loc = $url.find('loc').text().trim();
473
-
495
+ const loc = resolveLoc($url.find('loc').text().trim(), url);
496
+
474
497
  if (loc && result.urls.length < this.maxUrlsPerSitemap) {
475
498
  const urlData = {
476
- loc: normalizeUrl(loc),
499
+ loc,
477
500
  lastmod: $url.find('lastmod').text().trim() || null,
478
501
  changefreq: $url.find('changefreq').text().trim() || null,
479
502
  priority: $url.find('priority').text().trim() || null
@@ -565,10 +588,10 @@ export class SitemapParser {
565
588
 
566
589
  $('sitemap').each((_, element) => {
567
590
  const $sitemap = $(element);
568
- const loc = $sitemap.find('loc').text().trim();
569
-
591
+ const loc = resolveLoc($sitemap.find('loc').text().trim(), url);
592
+
570
593
  if (loc) {
571
- result.sitemaps.push(normalizeUrl(loc));
594
+ result.sitemaps.push(loc);
572
595
  }
573
596
  });
574
597