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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/server.js +10 -5
- package/src/core/ActionExecutor.js +9 -1
- package/src/core/AgentOrchestrator.js +22 -1
- package/src/core/ChangeTracker.js +25 -8
- package/src/core/crawlers/BFSCrawler.js +21 -2
- package/src/core/llm/LLMManager.js +86 -6
- package/src/core/processing/PDFProcessor.js +3 -1
- package/src/server/fallbackHints.js +3 -0
- package/src/server/inlineThreshold.js +34 -1
- package/src/server/requestContext.js +1 -1
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +2 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +21 -0
- package/src/tools/basic/extractLinks.js +3 -1
- package/src/tools/crawl/mapSite.js +81 -7
- package/src/tools/extract/extractEmbeddedState.js +18 -2
- package/src/tools/extract/extractStructured.js +4 -0
- package/src/tools/extract/processDocument.js +94 -1
- package/src/tools/scrape/_brandingExtractor.js +23 -5
- package/src/tools/scrape/_mainContent.js +17 -1
- package/src/tools/scrape/unifiedScrape.js +8 -1
- package/src/tools/search/redditSearch.js +36 -18
- package/src/utils/hiddenContent.js +67 -2
- package/src/utils/htmlToMarkdown.js +33 -1
- package/src/utils/redditHosts.js +123 -0
- package/src/utils/robotsGate.js +27 -3
- package/src/utils/sitemapParser.js +35 -12
|
@@ -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
|
+
}
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -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'
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
594
|
+
result.sitemaps.push(loc);
|
|
572
595
|
}
|
|
573
596
|
});
|
|
574
597
|
|