crawlforge-mcp-server 6.17.1 → 6.18.1
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 +2 -2
- package/package.json +2 -2
- package/server.js +18 -9
- package/src/core/ActionExecutor.js +57 -1
- package/src/core/AgentOrchestrator.js +58 -3
- package/src/core/AuthManager.js +12 -0
- package/src/core/crawlers/BFSCrawler.js +7 -0
- package/src/core/queue/QueueManager.js +3 -0
- package/src/schemas/toolOutputSchemas.js +6 -4
- package/src/server/fallbackHints.js +4 -4
- package/src/server/inlineThreshold.js +76 -10
- package/src/skills/agent-skills/crawlforge-batch-automation/references/actions.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +2 -2
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +4 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +16 -2
- package/src/tools/advanced/BrowserSessionTool.js +13 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +14 -3
- package/src/tools/advanced/batchScrape/worker.js +8 -0
- package/src/tools/advanced/scrapeWithActions/recorder.js +2 -0
- package/src/tools/basic/_targetFailure.js +54 -0
- package/src/tools/basic/extractLinks.js +62 -42
- package/src/tools/basic/extractText.js +79 -14
- package/src/tools/crawl/crawlDeep.js +16 -5
- package/src/tools/crawl/mapSite.js +49 -41
- package/src/tools/extract/_fetchAndParse.js +15 -34
- package/src/tools/scrape/unifiedScrape.js +14 -2
- package/src/tools/tracking/trackChanges/index.js +33 -6
- package/src/tools/tracking/trackChanges/schema.js +2 -2
- package/src/utils/fetchLadder.js +243 -0
|
@@ -184,6 +184,18 @@ function withJsResult(result) {
|
|
|
184
184
|
return { ...result, jsResult: result.result.result };
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* A screenshot action's image travels once, in `screenshots[]`, which the
|
|
189
|
+
* server publishes as crawlforge://screenshot/{actionId} — the action's id.
|
|
190
|
+
* Its copy in the action result is replaced by that URI, as scrape_with_actions
|
|
191
|
+
* does: left in, the same base64 came back inline beside the resource (R24).
|
|
192
|
+
*/
|
|
193
|
+
function withoutScreenshotData(result) {
|
|
194
|
+
if (result?.type !== 'screenshot' || typeof result.result?.data !== 'string') return result;
|
|
195
|
+
const { data, ...rest } = result.result;
|
|
196
|
+
return { ...result, result: { ...rest, resourceUri: `crawlforge://screenshot/${result.id}` } };
|
|
197
|
+
}
|
|
198
|
+
|
|
187
199
|
/**
|
|
188
200
|
* The gate's warnings — a respect_robots override, a crawl-delay note — are
|
|
189
201
|
* what the shared parameter description promises the caller gets back
|
|
@@ -517,7 +529,7 @@ export class BrowserSessionTool {
|
|
|
517
529
|
: {}),
|
|
518
530
|
...gateWarningFields(session.page),
|
|
519
531
|
error: result.error,
|
|
520
|
-
actionResults: result.results.map(withJsResult),
|
|
532
|
+
actionResults: result.results.map(withJsResult).map(withoutScreenshotData),
|
|
521
533
|
screenshots: result.screenshots,
|
|
522
534
|
...(result.capturedStates.length > 0 ? { capturedStates: result.capturedStates } : {}),
|
|
523
535
|
stats: result.stats
|
|
@@ -593,7 +593,10 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
593
593
|
actionResults,
|
|
594
594
|
attempt: chainResult.attempt ?? attempts.length,
|
|
595
595
|
attempts,
|
|
596
|
-
|
|
596
|
+
// The chain that was run — the caller's actions plus the fills, waits
|
|
597
|
+
// and submit formAutoFill adds — so it is the denominator of the three
|
|
598
|
+
// counts below; counting only the caller's own read "1 of 11".
|
|
599
|
+
totalActions: actionChain.length,
|
|
597
600
|
successfulActions: actionResults.filter(r => r.success).length,
|
|
598
601
|
failedActions: actionResults.filter(r => !r.success).length,
|
|
599
602
|
actionsExecuted: actionResults.length, // Total executed (for validation)
|
|
@@ -635,10 +638,18 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
635
638
|
if (Array.isArray(formAutoFill.fields)) {
|
|
636
639
|
// Structured shape: { fields: [{selector, value, type, waitAfter}], submitSelector, waitAfterSubmit }
|
|
637
640
|
for (const field of formAutoFill.fields) {
|
|
641
|
+
// Each field type is filled the way that control is: typing into a
|
|
642
|
+
// radio, a checkbox or a <select> "succeeds" and changes nothing, so
|
|
643
|
+
// the form was posted without them and nothing failed (R24). `check`
|
|
644
|
+
// also carries the types nothing can fill ("file"), which it refuses.
|
|
645
|
+
const fill = field.type === 'text'
|
|
646
|
+
? { type: 'type', text: field.value }
|
|
647
|
+
: field.type === 'select'
|
|
648
|
+
? { type: 'select', value: field.value }
|
|
649
|
+
: { type: 'check', value: field.value, fieldType: field.type };
|
|
638
650
|
fillActions.push({
|
|
639
|
-
|
|
651
|
+
...fill,
|
|
640
652
|
selector: field.selector,
|
|
641
|
-
text: field.value,
|
|
642
653
|
description: `Auto-fill field: ${field.selector}`,
|
|
643
654
|
continueOnError: true,
|
|
644
655
|
retries: 1
|
|
@@ -14,6 +14,7 @@ import { fetchResigned } from '../../../utils/resignedFetch.js';
|
|
|
14
14
|
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
15
15
|
import { elementText } from '../../../utils/elementText.js';
|
|
16
16
|
import { pageTitle } from '../../../utils/pageTitle.js';
|
|
17
|
+
import { classifyContentType, unsupportedContentTypeError } from '../../extract/_fetchAndParse.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
|
|
@@ -41,6 +42,13 @@ export async function fetchUrl(url, options = {}) {
|
|
|
41
42
|
if (response.status === 429 || response.status === 503) {
|
|
42
43
|
noteRetryAfter(url, response.headers?.get?.('retry-after'));
|
|
43
44
|
}
|
|
45
|
+
// A PDF decoded as text came back success:true with "%PDF-1.4 …" as the
|
|
46
|
+
// page (R24 1.5). Refused before the body is read, with the wording
|
|
47
|
+
// `scrape` gives the same URL; an error status keeps its own message.
|
|
48
|
+
const contentType = response.headers?.get?.('content-type') || null;
|
|
49
|
+
if (response.ok && classifyContentType(contentType) === 'binary') {
|
|
50
|
+
throw unsupportedContentTypeError(contentType);
|
|
51
|
+
}
|
|
44
52
|
// crawlforge-extractors' readBody: same size cap, but decoded with the
|
|
45
53
|
// body's real charset — the local reader this replaces decoded everything
|
|
46
54
|
// as UTF-8, which turned lua.org's ISO-8859-1 "português" into "portugu�s".
|
|
@@ -168,6 +168,8 @@ export function buildRecordedEntry(action, timestampMsSinceStart) {
|
|
|
168
168
|
if (action.duration !== undefined) entry.duration = action.duration;
|
|
169
169
|
if (action.url !== undefined) entry.url = action.url;
|
|
170
170
|
if (action.value !== undefined) entry.value = action.value;
|
|
171
|
+
// check (a formAutoFill checkbox/radio field) does not replay without it
|
|
172
|
+
if (action.fieldType !== undefined) entry.fieldType = action.fieldType;
|
|
171
173
|
if (action.direction !== undefined) entry.direction = action.direction;
|
|
172
174
|
if (action.distance !== undefined) entry.distance = action.distance;
|
|
173
175
|
// scroll: absolute scroll-to coordinates
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a fetched document fails extract_links / extract_text, with the same
|
|
3
|
+
* verdict and wording as the REST routes (fix plan Phase E1).
|
|
4
|
+
*
|
|
5
|
+
* A named vendor's wall fails on any status: a challenge arrives as a 403 or
|
|
6
|
+
* as a 200 with the wall in the body. Any other non-2xx fails with its
|
|
7
|
+
* status. An empty shell or an error placeholder on a 2xx is not raised here;
|
|
8
|
+
* extraction proceeds as before.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { load } from 'cheerio';
|
|
12
|
+
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
13
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
14
|
+
|
|
15
|
+
// Response bodies a verdict can be read from; anything else is a binary. A
|
|
16
|
+
// missing Content-Type is read, as _fetchAndParse.js treats it as HTML.
|
|
17
|
+
const TEXTUAL_BODY = /^(?:text\/(?:html|plain)|application\/xhtml\+xml)\b/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The plain fetch's verdict on a document, or null for a body a verdict
|
|
21
|
+
* cannot be read from (a binary, JSON). The fetch ladder reads it to decide
|
|
22
|
+
* whether escalation could help (src/utils/fetchLadder.js).
|
|
23
|
+
* @param {Response & { _body: string }} response - from fetchWithTimeout
|
|
24
|
+
* @param {string} url - the requested URL
|
|
25
|
+
*/
|
|
26
|
+
export function plainVerdict(response, url) {
|
|
27
|
+
const contentType = response.headers?.get?.('content-type');
|
|
28
|
+
if (contentType && !TEXTUAL_BODY.test(contentType)) return null;
|
|
29
|
+
// A parse of its own, scripts and styles removed, so the handler's
|
|
30
|
+
// document stays intact.
|
|
31
|
+
const $ = load(response._body ?? '');
|
|
32
|
+
$('script, style, noscript').remove();
|
|
33
|
+
return stealthDocumentVerdict(
|
|
34
|
+
{
|
|
35
|
+
url: response.url || url,
|
|
36
|
+
status: response.status,
|
|
37
|
+
title: pageTitle($),
|
|
38
|
+
text: $('body').text().replace(/\s+/g, ' ').trim(),
|
|
39
|
+
html: response._body
|
|
40
|
+
},
|
|
41
|
+
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {Response & { _body: string }} response - from fetchWithTimeout
|
|
47
|
+
* @param {string} url - the requested URL
|
|
48
|
+
* @param {object|null} [verdict] - plainVerdict's, when the caller already has it
|
|
49
|
+
* @returns {string|null} the error message, or null when the document is usable
|
|
50
|
+
*/
|
|
51
|
+
export function targetFailure(response, url, verdict = plainVerdict(response, url)) {
|
|
52
|
+
if (verdict?.blocked) return `Target answered HTTP ${response.status}: ${verdict.error}`;
|
|
53
|
+
return response.ok ? null : `Target answered HTTP ${response.status}`;
|
|
54
|
+
}
|
|
@@ -1,76 +1,84 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* extract_links — Extract all links from a webpage with optional filtering.
|
|
3
3
|
* Extracted from server.js inline handler.
|
|
4
|
+
* E3: Link records come from crawlforge-extractors' extractLinkRecords, the
|
|
5
|
+
* reader the REST route uses, so both surfaces return the same links.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { load } from 'cheerio';
|
|
7
|
-
import {
|
|
9
|
+
import { extractLinkRecords } from 'crawlforge-extractors';
|
|
10
|
+
import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* @param {{ url: string, filter_external?: boolean, base_url?: string,
|
|
11
|
-
* user_agent?: string, respect_robots?: boolean
|
|
14
|
+
* user_agent?: string, respect_robots?: boolean,
|
|
15
|
+
* escalate?: boolean, escalate_engine?: string }} params
|
|
16
|
+
* @param {Function} [escalateFetch] the stealth escalation stage
|
|
12
17
|
*/
|
|
13
|
-
|
|
18
|
+
async function extractLinks({ url, filter_external, base_url, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
14
19
|
try {
|
|
15
|
-
const
|
|
20
|
+
const ladder = await fetchLadder(url, {
|
|
21
|
+
tool: 'extract_links',
|
|
16
22
|
userAgent: user_agent,
|
|
17
23
|
respectRobots: respect_robots,
|
|
18
|
-
|
|
24
|
+
escalate: escalate === true,
|
|
25
|
+
escalateEngine: escalate_engine,
|
|
26
|
+
escalateFetch
|
|
19
27
|
});
|
|
20
|
-
if (
|
|
21
|
-
|
|
28
|
+
if (ladder.html === undefined) return ladderErrorResult('extract_links', 'Failed to extract links: ', ladder);
|
|
29
|
+
|
|
30
|
+
// A JSON body carries no <a href>.
|
|
31
|
+
if (isJsonType(ladder.type)) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{
|
|
34
|
+
type: 'text',
|
|
35
|
+
text: JSON.stringify({
|
|
36
|
+
links: [],
|
|
37
|
+
total_count: 0,
|
|
38
|
+
internal_count: 0,
|
|
39
|
+
external_count: 0,
|
|
40
|
+
other_count: 0,
|
|
41
|
+
base_url: base_url || ladder.url,
|
|
42
|
+
...ladder.fields,
|
|
43
|
+
warnings: [...ladder.warnings, 'the target returned application/json; it has no HTML links']
|
|
44
|
+
}, null, 2)
|
|
45
|
+
}]
|
|
46
|
+
};
|
|
22
47
|
}
|
|
23
48
|
|
|
24
|
-
const html =
|
|
49
|
+
const html = ladder.html;
|
|
25
50
|
const $ = load(html);
|
|
26
51
|
|
|
27
|
-
const finalUrl =
|
|
28
|
-
const pageUrl = new URL(finalUrl);
|
|
52
|
+
const finalUrl = ladder.url;
|
|
29
53
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
54
|
+
// Reported base: an explicit base_url, else <base href> resolved against
|
|
55
|
+
// the page, else the page URL (extractLinkRecords resolves the same way).
|
|
32
56
|
let docBase = finalUrl;
|
|
33
57
|
const baseHref = $('base[href]').first().attr('href');
|
|
34
58
|
if (baseHref) {
|
|
35
59
|
try { docBase = new URL(baseHref, finalUrl).toString(); } catch { /* ignore invalid <base href> */ }
|
|
36
60
|
}
|
|
37
|
-
|
|
38
61
|
const baseUrl = base_url || docBase;
|
|
39
|
-
const links = [];
|
|
40
|
-
|
|
41
|
-
$('a[href]').each((_, element) => {
|
|
42
|
-
const href = $(element).attr('href');
|
|
43
|
-
const text = $(element).text().trim();
|
|
44
|
-
|
|
45
|
-
// A javascript: pseudo-link ("Cookie Settings") is a button, not a
|
|
46
|
-
// link; it was counted as an external link on boeing.com (R20).
|
|
47
|
-
if (!href || /^\s*javascript:/i.test(href)) return;
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
links.push({ href: absoluteUrl, text, is_external: isExternal, original_href: href });
|
|
56
|
-
} catch {
|
|
57
|
-
// skip invalid URLs
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const uniqueLinks = links.filter((link, index, arr) =>
|
|
62
|
-
arr.findIndex(l => l.href === link.href) === index
|
|
63
|
-
);
|
|
63
|
+
// Records are deduplicated on the URL without fragment or trailing slash;
|
|
64
|
+
// mailto:/tel:/javascript: links are type "other". filter_external drops
|
|
65
|
+
// only the internal ones.
|
|
66
|
+
const records = extractLinkRecords($, { pageUrl: finalUrl, baseUrl: base_url });
|
|
67
|
+
const links = filter_external ? records.filter(l => l.type !== 'internal') : records;
|
|
68
|
+
const count = (type) => links.filter(l => l.type === type).length;
|
|
64
69
|
|
|
65
70
|
return {
|
|
66
71
|
content: [{
|
|
67
72
|
type: 'text',
|
|
68
73
|
text: JSON.stringify({
|
|
69
|
-
links
|
|
70
|
-
total_count:
|
|
71
|
-
internal_count:
|
|
72
|
-
external_count:
|
|
73
|
-
|
|
74
|
+
links,
|
|
75
|
+
total_count: links.length,
|
|
76
|
+
internal_count: count('internal'),
|
|
77
|
+
external_count: count('external'),
|
|
78
|
+
other_count: count('other'),
|
|
79
|
+
base_url: baseUrl,
|
|
80
|
+
...ladder.fields,
|
|
81
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
74
82
|
}, null, 2)
|
|
75
83
|
}]
|
|
76
84
|
};
|
|
@@ -81,3 +89,15 @@ export async function extractLinksHandler({ url, filter_external, base_url, user
|
|
|
81
89
|
};
|
|
82
90
|
}
|
|
83
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
|
|
95
|
+
* the stealth escalation stage (server.js `stealthEscalation`); without it
|
|
96
|
+
* an escalation that would run is reported as unavailable
|
|
97
|
+
*/
|
|
98
|
+
export function createExtractLinksHandler({ escalateFetch } = {}) {
|
|
99
|
+
return (params) => extractLinks(params, escalateFetch);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The handler with no escalation stage wired (tests; server.js wires its own). */
|
|
103
|
+
export const extractLinksHandler = createExtractLinksHandler();
|
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
* Extracted from server.js inline handler.
|
|
4
4
|
* D3.1: Added output_format:"markdown" option backed by Turndown.
|
|
5
5
|
* B1: Preserve block structure for text mode; use Readability + GFM for markdown mode.
|
|
6
|
+
* E3: Text mode reads through crawlforge-extractors' flattenText, as the REST
|
|
7
|
+
* route does; `selector` and `max_length` take the REST route's meaning.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { load } from 'cheerio';
|
|
11
|
+
import { flattenText } from 'crawlforge-extractors';
|
|
9
12
|
import { JSDOM } from 'jsdom';
|
|
10
13
|
import { Readability } from '@mozilla/readability';
|
|
11
|
-
import {
|
|
14
|
+
import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
|
|
12
15
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
13
16
|
|
|
14
17
|
// Block-level elements whose boundaries should become paragraph breaks
|
|
@@ -73,22 +76,61 @@ export function readabilityToMarkdown(html, pageUrl) {
|
|
|
73
76
|
return htmlToMarkdown(articleHtml);
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Cut text to max_length characters and mark the cut with '...' (the REST rule).
|
|
81
|
+
* @param {string} text
|
|
82
|
+
* @param {number} [maxLength]
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function truncate(text, maxLength) {
|
|
86
|
+
return maxLength && text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
/**
|
|
77
90
|
* @param {{ url: string, remove_scripts?: boolean, remove_styles?: boolean,
|
|
78
|
-
* output_format?: "text"|"markdown",
|
|
91
|
+
* output_format?: "text"|"markdown", selector?: string, max_length?: number,
|
|
92
|
+
* user_agent?: string, respect_robots?: boolean,
|
|
93
|
+
* escalate?: boolean, escalate_engine?: string }} params
|
|
94
|
+
* @param {Function} [escalateFetch] the stealth escalation stage
|
|
79
95
|
*/
|
|
80
|
-
|
|
96
|
+
async function extractText({ url, remove_scripts, remove_styles, output_format, selector, max_length, user_agent, respect_robots, escalate, escalate_engine }, escalateFetch) {
|
|
81
97
|
try {
|
|
82
|
-
const
|
|
98
|
+
const ladder = await fetchLadder(url, {
|
|
99
|
+
tool: 'extract_text',
|
|
83
100
|
userAgent: user_agent,
|
|
84
101
|
respectRobots: respect_robots,
|
|
85
|
-
|
|
102
|
+
escalate: escalate === true,
|
|
103
|
+
escalateEngine: escalate_engine,
|
|
104
|
+
escalateFetch
|
|
86
105
|
});
|
|
87
|
-
if (
|
|
88
|
-
|
|
106
|
+
if (ladder.html === undefined) return ladderErrorResult('extract_text', 'Failed to extract text: ', ladder);
|
|
107
|
+
|
|
108
|
+
const extras = {
|
|
109
|
+
...ladder.fields,
|
|
110
|
+
...(ladder.warnings.length > 0 ? { warnings: ladder.warnings } : {})
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// A JSON body has no markup to strip: it is returned as it came.
|
|
114
|
+
if (isJsonType(ladder.type)) {
|
|
115
|
+
const body = truncate(ladder.html, max_length);
|
|
116
|
+
const format = output_format === 'markdown' ? 'markdown' : 'text';
|
|
117
|
+
return {
|
|
118
|
+
content: [{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: JSON.stringify({
|
|
121
|
+
url: ladder.url,
|
|
122
|
+
[format]: body,
|
|
123
|
+
output_format: format,
|
|
124
|
+
word_count: body.split(/\s+/).filter(w => w.length > 0).length,
|
|
125
|
+
char_count: body.length,
|
|
126
|
+
...ladder.fields,
|
|
127
|
+
warnings: [...ladder.warnings, 'the target returned application/json; its body is returned as text']
|
|
128
|
+
}, null, 2)
|
|
129
|
+
}]
|
|
130
|
+
};
|
|
89
131
|
}
|
|
90
132
|
|
|
91
|
-
const html =
|
|
133
|
+
const html = ladder.html;
|
|
92
134
|
const $ = load(html);
|
|
93
135
|
|
|
94
136
|
if (remove_scripts !== false) $('script').remove();
|
|
@@ -101,21 +143,32 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
101
143
|
// enabled never render noscript content, so always strip it.
|
|
102
144
|
$('noscript').remove();
|
|
103
145
|
|
|
104
|
-
|
|
146
|
+
// A selector names the content, so the boilerplate strip is skipped.
|
|
147
|
+
let $target;
|
|
148
|
+
if (selector) {
|
|
149
|
+
$target = $(selector);
|
|
150
|
+
if ($target.length === 0) throw new Error(`No elements found for selector: ${selector}`);
|
|
151
|
+
} else {
|
|
152
|
+
$('nav, header, footer, aside, .advertisement, .ad, .sidebar').remove();
|
|
153
|
+
}
|
|
105
154
|
|
|
106
155
|
const result = {
|
|
107
|
-
url:
|
|
156
|
+
url: ladder.url
|
|
108
157
|
};
|
|
109
158
|
|
|
110
159
|
if (output_format === 'markdown') {
|
|
111
|
-
// Run Readability first to get main content, then convert to GFM markdown
|
|
112
|
-
|
|
160
|
+
// Run Readability first to get main content, then convert to GFM markdown;
|
|
161
|
+
// a selector's matches are converted as they are.
|
|
162
|
+
const markdown = $target
|
|
163
|
+
? htmlToMarkdown($target.toArray().map(el => $.html(el)).join('\n'))
|
|
164
|
+
: readabilityToMarkdown(html, ladder.url);
|
|
165
|
+
result.markdown = truncate(markdown, max_length);
|
|
113
166
|
result.output_format = 'markdown';
|
|
114
167
|
const plainText = result.markdown.replace(/[#*`_\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
|
115
168
|
result.word_count = plainText.split(/\s+/).filter(w => w.length > 0).length;
|
|
116
169
|
result.char_count = plainText.length;
|
|
117
170
|
} else {
|
|
118
|
-
const text =
|
|
171
|
+
const text = truncate(flattenText($, $target), max_length);
|
|
119
172
|
result.text = text;
|
|
120
173
|
result.output_format = 'text';
|
|
121
174
|
result.word_count = text.split(/\s+/).filter(w => w.length > 0).length;
|
|
@@ -125,7 +178,7 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
125
178
|
return {
|
|
126
179
|
content: [{
|
|
127
180
|
type: 'text',
|
|
128
|
-
text: JSON.stringify(result, null, 2)
|
|
181
|
+
text: JSON.stringify({ ...result, ...extras }, null, 2)
|
|
129
182
|
}]
|
|
130
183
|
};
|
|
131
184
|
} catch (error) {
|
|
@@ -135,3 +188,15 @@ export async function extractTextHandler({ url, remove_scripts, remove_styles, o
|
|
|
135
188
|
};
|
|
136
189
|
}
|
|
137
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {{ escalateFetch?: (args: { url: string, engine: string, respectRobots?: boolean }) => Promise<object> }} [deps]
|
|
194
|
+
* the stealth escalation stage (server.js `stealthEscalation`); without it
|
|
195
|
+
* an escalation that would run is reported as unavailable
|
|
196
|
+
*/
|
|
197
|
+
export function createExtractTextHandler({ escalateFetch } = {}) {
|
|
198
|
+
return (params) => extractText(params, escalateFetch);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The handler with no escalation stage wired (tests; server.js wires its own). */
|
|
202
|
+
export const extractTextHandler = createExtractTextHandler();
|
|
@@ -261,19 +261,30 @@ export class CrawlDeepTool {
|
|
|
261
261
|
});
|
|
262
262
|
const duration = Date.now() - startTime;
|
|
263
263
|
|
|
264
|
+
// The counters, each defined once: a URL the crawl tried either
|
|
265
|
+
// produced a page (results) or an error (errors), so
|
|
266
|
+
// pages_attempted = pages_crawled + error_count. pages_crawled used to
|
|
267
|
+
// count attempts, which read "3 crawled, 1 found, 2 errors" (R24).
|
|
268
|
+
const succeeded = results.results.length;
|
|
269
|
+
const failed = results.errors.length;
|
|
270
|
+
|
|
264
271
|
// Process and format results
|
|
265
272
|
const response = {
|
|
266
273
|
url: validated.url,
|
|
267
274
|
crawl_depth: effectiveMaxDepth,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
pages_attempted: succeeded + failed,
|
|
276
|
+
pages_crawled: succeeded,
|
|
277
|
+
pages_found: succeeded,
|
|
278
|
+
error_count: failed,
|
|
271
279
|
duration_ms: duration,
|
|
272
|
-
|
|
280
|
+
// A seed refused before any I/O finishes in 0 ms; x / 0 is Infinity,
|
|
281
|
+
// which the output schema rejects as "expected number".
|
|
282
|
+
pages_per_second: succeeded / (Math.max(duration, 1) / 1000),
|
|
273
283
|
results: this.formatResults(results.results, validated.extract_content, validated.content_max_length),
|
|
274
284
|
errors: results.errors,
|
|
275
285
|
stats: results.stats,
|
|
276
|
-
|
|
286
|
+
// Pages only: a URL that failed is in errors[], not in the site's structure.
|
|
287
|
+
site_structure: this.analyzeSiteStructure(results.results.map(page => page.url), results.results),
|
|
277
288
|
domain_filter_config: domainFilter ? domainFilter.exportConfig() : null,
|
|
278
289
|
link_analysis: results.linkAnalysis,
|
|
279
290
|
session: sessionContext
|
|
@@ -48,6 +48,9 @@ export function searchScore(url, search) {
|
|
|
48
48
|
return terms.filter((t) => haystack.includes(t)).length;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// A gate refusal is the answer to the request, not a page we failed to read.
|
|
52
|
+
const REFUSAL_CODES = new Set(['ROBOTS_DISALLOWED', 'HOST_BLOCKED', 'USE_REDDIT_SEARCH', 'SSRF_BLOCKED']);
|
|
53
|
+
|
|
51
54
|
// Lazy singleton — avoids creating a CacheManager timer per request
|
|
52
55
|
let _ranker = null;
|
|
53
56
|
function getRanker() {
|
|
@@ -159,13 +162,24 @@ export class MapSiteTool {
|
|
|
159
162
|
sitemapUrls.forEach(url => urls.add(normalizeUrl(url)));
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
// Fetch and parse the main page for additional URLs
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
// Fetch and parse the main page for additional URLs. A start page that
|
|
166
|
+
// cannot be read is an error when the sitemap gave nothing either: an
|
|
167
|
+
// unresolvable host used to return a success with total_urls: 0 (R24).
|
|
168
|
+
try {
|
|
169
|
+
const pageUrls = await this.fetchPageUrls(validated.url, domainFilter, identity);
|
|
170
|
+
pageUrls.forEach(url => {
|
|
171
|
+
if (urls.size < poolLimit) {
|
|
172
|
+
urls.add(normalizeUrl(url));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (REFUSAL_CODES.has(error.code) || error.message.startsWith('SSRF Protection:')) throw error;
|
|
177
|
+
const reason = error.cause?.message || error.message;
|
|
178
|
+
if (urls.size === 0) {
|
|
179
|
+
throw new Error(`${validated.url} could not be read (${reason}) and no sitemap was found`);
|
|
167
180
|
}
|
|
168
|
-
|
|
181
|
+
warnings.push(`The start page could not be read (${reason}); the URLs come from the sitemap only.`);
|
|
182
|
+
}
|
|
169
183
|
|
|
170
184
|
let pool = Array.from(urls);
|
|
171
185
|
if (scopePath) {
|
|
@@ -304,46 +318,40 @@ export class MapSiteTool {
|
|
|
304
318
|
}
|
|
305
319
|
|
|
306
320
|
async fetchPageUrls(url, domainFilter = null, identity = {}) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
321
|
+
// Throws when the page cannot be read — a gate or SSRF refusal, a network
|
|
322
|
+
// failure, a non-2xx status. execute() decides which of those end the call.
|
|
323
|
+
const response = await this.fetchWithTimeout(url, identity);
|
|
324
|
+
if (!response.ok) {
|
|
325
|
+
throw new Error(`HTTP ${response.status}`);
|
|
326
|
+
}
|
|
312
327
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
328
|
+
const html = await response.text();
|
|
329
|
+
const $ = load(html);
|
|
330
|
+
const urls = new Set();
|
|
331
|
+
const baseUrl = getBaseUrl(url);
|
|
332
|
+
|
|
333
|
+
// Extract all links
|
|
334
|
+
$('a[href]').each((_, element) => {
|
|
335
|
+
const href = $(element).attr('href');
|
|
336
|
+
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {
|
|
337
|
+
try {
|
|
338
|
+
const absoluteUrl = new URL(href, url);
|
|
339
|
+
// Only include URLs from the same domain
|
|
340
|
+
if (absoluteUrl.origin === new URL(baseUrl).origin) {
|
|
341
|
+
const urlString = absoluteUrl.toString();
|
|
342
|
+
|
|
343
|
+
// Apply domain filter if provided
|
|
344
|
+
if (!domainFilter || domainFilter.isAllowed(urlString).allowed) {
|
|
345
|
+
urls.add(urlString);
|
|
332
346
|
}
|
|
333
|
-
} catch {
|
|
334
|
-
// Invalid URL, skip
|
|
335
347
|
}
|
|
348
|
+
} catch {
|
|
349
|
+
// Invalid URL, skip
|
|
336
350
|
}
|
|
337
|
-
}
|
|
351
|
+
}
|
|
352
|
+
});
|
|
338
353
|
|
|
339
|
-
|
|
340
|
-
} catch (error) {
|
|
341
|
-
// A gate refusal is the answer to the request, not a page we failed to
|
|
342
|
-
// read: surface it instead of returning an emptier map than the caller
|
|
343
|
-
// would notice.
|
|
344
|
-
if (error.code === 'ROBOTS_DISALLOWED' || error.code === 'HOST_BLOCKED' || error.code === 'USE_REDDIT_SEARCH') throw error;
|
|
345
|
-
return [];
|
|
346
|
-
}
|
|
354
|
+
return Array.from(urls);
|
|
347
355
|
}
|
|
348
356
|
|
|
349
357
|
async fetchMetadata(urls, metadataMap, identity = {}) {
|