crawlforge-mcp-server 6.22.3 → 6.23.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 +10 -1
- package/package.json +1 -1
- package/server.js +14 -7
- package/src/core/ActionExecutor.js +117 -12
- package/src/core/AgentOrchestrator.js +71 -14
- package/src/core/AuthManager.js +7 -1
- package/src/core/ChangeTracker.js +52 -4
- package/src/core/LLMsTxtAnalyzer.js +21 -9
- package/src/core/LocalizationManager.js +20 -4
- package/src/core/ResearchOrchestrator.js +8 -1
- package/src/core/StealthBrowserManager.js +10 -5
- package/src/core/analysis/ContentAnalyzer.js +157 -29
- package/src/core/browser/guardedProxy.js +286 -0
- package/src/core/browser/snapshot.js +22 -0
- package/src/core/crawlers/BFSCrawler.js +10 -4
- package/src/core/processing/BrowserProcessor.js +5 -3
- package/src/core/processing/ContentProcessor.js +2 -1
- package/src/core/workers/worker.js +2 -0
- package/src/schemas/toolOutputSchemas.js +3 -2
- package/src/server/fallbackHints.js +5 -1
- package/src/server/inlineThreshold.js +15 -9
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
- package/src/tools/agent/sourceChecks.js +103 -0
- package/src/tools/basic/extractMetadata.js +9 -4
- package/src/tools/basic/extractText.js +6 -4
- package/src/tools/basic/fetchUrl.js +3 -0
- package/src/tools/basic/scrapeStructured.js +66 -21
- package/src/tools/crawl/mapSite.js +18 -3
- package/src/tools/extract/extractStructured.js +134 -1
- package/src/tools/extract/processDocument.js +48 -5
- package/src/tools/extract/summarizeContent.js +123 -5
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
- package/src/tools/result/readResult.js +79 -10
- package/src/tools/scrape/_mainContent.js +97 -6
- package/src/tools/search/adapters/dataforseoSearch.js +4 -1
- package/src/tools/search/searchWeb.js +46 -15
- package/src/tools/search/serpRank.js +1 -1
- package/src/tools/tracking/trackChanges/index.js +1 -0
- package/src/tools/tracking/trackChanges/schema.js +2 -1
- package/src/utils/htmlToMarkdown.js +77 -1
- package/src/utils/localFilePolicy.js +65 -0
- package/src/utils/provenance.js +102 -1
- package/src/utils/ssrfGuard.js +4 -3
- package/src/utils/ssrfProtection.js +3 -0
|
@@ -13,6 +13,7 @@ import { flattenText } from 'crawlforge-extractors';
|
|
|
13
13
|
import ExtractContentTool from '../extract/extractContent.js';
|
|
14
14
|
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
15
15
|
import { elementText } from '../../utils/elementText.js';
|
|
16
|
+
import { parseSelectorSpec } from '../basic/scrapeStructured.js';
|
|
16
17
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
17
18
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
18
19
|
import { resolveStealthEngine, redactProxyEntry, assertProxyEngineAllowed } from '../../core/StealthBrowserManager.js';
|
|
@@ -779,7 +780,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
779
780
|
}
|
|
780
781
|
|
|
781
782
|
if (params.extractionOptions?.selectors) {
|
|
782
|
-
state.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
|
|
783
|
+
state.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors, stateData.formValues);
|
|
783
784
|
}
|
|
784
785
|
|
|
785
786
|
states.push(state);
|
|
@@ -827,16 +828,21 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
827
828
|
$('script, style, noscript, template').remove();
|
|
828
829
|
extractResult.content = extractResult.content || {};
|
|
829
830
|
if (params.extractionOptions?.selectors) {
|
|
830
|
-
extractResult.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
|
|
831
|
+
extractResult.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors, chainResult.finalFormValues);
|
|
831
832
|
}
|
|
832
833
|
// Readability picks a "main" block and drops form controls; on a small
|
|
833
834
|
// post-action page (a demo form, a results panel) that leaves the
|
|
834
835
|
// footer alone, or a page minus the very buttons the actions produced.
|
|
835
836
|
// A short readable result means it found nothing substantial, so hand
|
|
836
|
-
// back the whole (equally short) body text instead.
|
|
837
|
+
// back the whole (equally short) body text instead. So does a page
|
|
838
|
+
// whose Readability article was not its main content (a landing or
|
|
839
|
+
// list page): the whole page, as browser_session read returns it,
|
|
840
|
+
// rather than the boilerplate-removal guess — that read kept all 10
|
|
841
|
+
// of quotes.toscrape.com's quotes where this text lost 3 (2026-10-20).
|
|
837
842
|
const readable = extractResult.content.text || '';
|
|
838
843
|
const bodyText = flattenText($);
|
|
839
|
-
|
|
844
|
+
const notMainContent = extractResult.extractionMethod && extractResult.extractionMethod !== 'readability';
|
|
845
|
+
if ((readable.length < 300 || notMainContent) && bodyText.length > readable.length) {
|
|
840
846
|
extractResult.content.text = bodyText;
|
|
841
847
|
extractResult.content.textSource = 'body';
|
|
842
848
|
}
|
|
@@ -940,10 +946,29 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
940
946
|
return links;
|
|
941
947
|
}
|
|
942
948
|
|
|
943
|
-
|
|
949
|
+
/**
|
|
950
|
+
* Run `extractionOptions.selectors` over a captured page. A selector may end
|
|
951
|
+
* in `@attr` to read that attribute instead of the text, as in
|
|
952
|
+
* scrape_structured. `@value` on an input, textarea or select reads what the
|
|
953
|
+
* control holds now — typed or selected — from `formValues` (captured from
|
|
954
|
+
* the live page, in document order); every other `@attr` reads the
|
|
955
|
+
* attribute as the page's HTML has it.
|
|
956
|
+
*/
|
|
957
|
+
extractWithSelectors($, selectors, formValues = []) {
|
|
944
958
|
const extracted = {};
|
|
959
|
+
const controls = $('input, textarea, select').toArray();
|
|
945
960
|
|
|
946
|
-
|
|
961
|
+
const read = (el, attribute) => {
|
|
962
|
+
if (!attribute) return elementText($, el);
|
|
963
|
+
if (attribute === 'value') {
|
|
964
|
+
const live = formValues[controls.indexOf(el)];
|
|
965
|
+
if (live?.tag === el.name) return live.value;
|
|
966
|
+
}
|
|
967
|
+
return $(el).attr(attribute) ?? null;
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
for (const [key, raw] of Object.entries(selectors)) {
|
|
971
|
+
const { selector, attribute } = parseSelectorSpec(raw);
|
|
947
972
|
try {
|
|
948
973
|
const elements = $(selector);
|
|
949
974
|
|
|
@@ -953,12 +978,13 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
953
978
|
if (elements.length === 0) {
|
|
954
979
|
extracted[key] = null;
|
|
955
980
|
} else if (elements.length === 1) {
|
|
956
|
-
extracted[key] =
|
|
981
|
+
extracted[key] = read(elements.get(0), attribute);
|
|
957
982
|
} else {
|
|
958
|
-
|
|
983
|
+
// Not .map().get(), which drops the nulls an absent attribute gives.
|
|
984
|
+
extracted[key] = elements.toArray().map((el) => read(el, attribute));
|
|
959
985
|
}
|
|
960
986
|
} catch (error) {
|
|
961
|
-
extracted[key] = { error: `Invalid selector: ${
|
|
987
|
+
extracted[key] = { error: `Invalid selector: ${raw}` };
|
|
962
988
|
}
|
|
963
989
|
}
|
|
964
990
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sourceChecks.js — deterministic checks on which sources the `agent` trusts
|
|
3
|
+
* and which dates it reports (live sweep 2026-10-20, item 2.5).
|
|
4
|
+
*
|
|
5
|
+
* "Latest stable Rust version and its release date" was first searched as the
|
|
6
|
+
* bare word "Rust": the game's site (rust.facepunch.com, a root titled "Rust")
|
|
7
|
+
* passed as the entity's official site, was promoted to the live page, and the
|
|
8
|
+
* run came back degraded:true blaming it. The answer gave 1.99.1 "as of 16
|
|
9
|
+
* October 2026": endoflife.date's "Last updated on" date, not the 15 October
|
|
10
|
+
* release date in its table.
|
|
11
|
+
*
|
|
12
|
+
* Imports nothing, so AgentOrchestrator can read it without a cycle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
|
|
16
|
+
export function siteLabel(hostname) {
|
|
17
|
+
const labels = hostname.toLowerCase().split('.');
|
|
18
|
+
const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
|
|
19
|
+
return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function labelOf(url) {
|
|
23
|
+
try { return siteLabel(new URL(url).hostname); } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The origins a search for the whole question also returns (same site label,
|
|
28
|
+
* any subdomain). A bare entity name is ambiguous — "Rust" is a language, a
|
|
29
|
+
* game and a film — and the site that owns the bare name need not be the one
|
|
30
|
+
* the question is about. A site the question's own search does not surface is
|
|
31
|
+
* not promoted as its live page. When that search returned nothing there is
|
|
32
|
+
* nothing to check against, and every origin passes.
|
|
33
|
+
*
|
|
34
|
+
* @param {Iterable<string>} origins candidate origins from the entity query
|
|
35
|
+
* @param {string[]} questionUrls result URLs of the other queries
|
|
36
|
+
* @returns {Set<string>}
|
|
37
|
+
*/
|
|
38
|
+
export function corroboratedOrigins(origins, questionUrls) {
|
|
39
|
+
const all = new Set(origins);
|
|
40
|
+
const labels = new Set((questionUrls || []).map(labelOf).filter(Boolean));
|
|
41
|
+
if (labels.size === 0) return all;
|
|
42
|
+
return new Set([...all].filter(origin => labels.has(labelOf(origin))));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
46
|
+
const MONTH = '(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\\.?';
|
|
47
|
+
const DATE_RE = new RegExp(
|
|
48
|
+
`\\b(\\d{4})-(\\d{2})-(\\d{2})\\b` +
|
|
49
|
+
`|\\b${MONTH}\\s+(\\d{1,2})(?:st|nd|rd|th)?,?\\s+(\\d{4})\\b` +
|
|
50
|
+
`|\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+${MONTH}\\s+(\\d{4})\\b`,
|
|
51
|
+
'gi'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** Every date in a text, written any of three ways, keyed as YYYY-MM-DD. */
|
|
55
|
+
function datesIn(text) {
|
|
56
|
+
const found = [];
|
|
57
|
+
for (const m of String(text || '').matchAll(DATE_RE)) {
|
|
58
|
+
let y, mo, d;
|
|
59
|
+
if (m[1]) [y, mo, d] = [m[1], Number(m[2]), Number(m[3])];
|
|
60
|
+
else if (m[4]) [y, mo, d] = [m[6], MONTHS.indexOf(m[4].toLowerCase()) + 1, Number(m[5])];
|
|
61
|
+
else [y, mo, d] = [m[9], MONTHS.indexOf(m[8].toLowerCase()) + 1, Number(m[7])];
|
|
62
|
+
found.push({ raw: m[0], key: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`, index: m.index });
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A date written right after one of these is when a page changed, not when the thing happened. */
|
|
68
|
+
const UPDATE_LABEL = /\b(?:updated|modified|edited|revised|retrieved|accessed|crawled)\b[^\d\n]{0,20}$/i;
|
|
69
|
+
|
|
70
|
+
/** A task that asks when something was updated wants exactly those dates. */
|
|
71
|
+
const ASKS_FOR_UPDATES = /\b(?:updated?|modified|last (?:changed|edited))\b/i;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Dates the answer states that its sources give only as a page's
|
|
75
|
+
* "last updated" / "modified" date, never as anything else. Each comes with
|
|
76
|
+
* the first source that labels it so, so a warning names a page the run used.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} answer
|
|
79
|
+
* @param {{url: string, text?: string}[]} evidence
|
|
80
|
+
* @param {string} prompt
|
|
81
|
+
* @returns {{date: string, url: string}[]}
|
|
82
|
+
*/
|
|
83
|
+
export function updateLabelledDates(answer, evidence, prompt) {
|
|
84
|
+
if (!answer || ASKS_FOR_UPDATES.test(prompt || '')) return [];
|
|
85
|
+
const byKey = new Map();
|
|
86
|
+
for (const e of evidence || []) {
|
|
87
|
+
const text = e.text || '';
|
|
88
|
+
for (const { key, index } of datesIn(text)) {
|
|
89
|
+
const lineStart = text.lastIndexOf('\n', index - 1) + 1;
|
|
90
|
+
const updated = UPDATE_LABEL.test(text.slice(Math.max(lineStart, index - 40), index));
|
|
91
|
+
const seen = byKey.get(key) || { other: false, url: null };
|
|
92
|
+
if (updated) seen.url = seen.url || e.url;
|
|
93
|
+
else seen.other = true;
|
|
94
|
+
byKey.set(key, seen);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const flagged = [];
|
|
98
|
+
for (const { raw, key } of datesIn(answer.replace(/https?:\/\/\S+/g, ' '))) {
|
|
99
|
+
const seen = byKey.get(key);
|
|
100
|
+
if (seen?.url && !seen.other && !flagged.some(f => f.key === key)) flagged.push({ key, date: raw, url: seen.url });
|
|
101
|
+
}
|
|
102
|
+
return flagged.map(({ date, url }) => ({ date, url }));
|
|
103
|
+
}
|
|
@@ -88,9 +88,10 @@ export async function extractMetadataHandler({ url, user_agent, respect_robots,
|
|
|
88
88
|
$('h1').first().text().trim() ||
|
|
89
89
|
'';
|
|
90
90
|
|
|
91
|
-
const description =
|
|
91
|
+
const description = (
|
|
92
92
|
$('meta[name="description"]').attr('content') ||
|
|
93
|
-
$('meta[property="og:description"]').attr('content') || ''
|
|
93
|
+
$('meta[property="og:description"]').attr('content') || ''
|
|
94
|
+
).trim();
|
|
94
95
|
const keywords = $('meta[name="keywords"]').attr('content') || '';
|
|
95
96
|
const canonical = $('link[rel="canonical"]').attr('href') || '';
|
|
96
97
|
|
|
@@ -112,9 +113,13 @@ export async function extractMetadataHandler({ url, user_agent, respect_robots,
|
|
|
112
113
|
const author = $('meta[name="author"]').attr('content') || '';
|
|
113
114
|
const robots = $('meta[name="robots"]').attr('content') || '';
|
|
114
115
|
const viewport = $('meta[name="viewport"]').attr('content') || '';
|
|
115
|
-
|
|
116
|
+
// Only the charset token: the http-equiv form used to come back whole,
|
|
117
|
+
// "text/html; charset=utf-8" (live sweep 2026-10-20).
|
|
118
|
+
const charset = (
|
|
116
119
|
$('meta[charset]').attr('charset') ||
|
|
117
|
-
$('meta[http-equiv="Content-Type"]').attr('content') || '';
|
|
120
|
+
($('meta[http-equiv="Content-Type"]').attr('content') || '').match(/charset\s*=\s*["']?([^\s;"']+)/i)?.[1] ||
|
|
121
|
+
''
|
|
122
|
+
).trim().toUpperCase();
|
|
118
123
|
|
|
119
124
|
const jsonLd = parseJsonLd($);
|
|
120
125
|
const microdata = parseMicrodata($);
|
|
@@ -14,7 +14,7 @@ import { flattenText, gridTables, absoluteUrls } from 'crawlforge-extractors';
|
|
|
14
14
|
import { JSDOM } from 'jsdom';
|
|
15
15
|
import { Readability } from '@mozilla/readability';
|
|
16
16
|
import { fetchLadder, ladderErrorResult, isJsonType } from '../../utils/fetchLadder.js';
|
|
17
|
-
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
17
|
+
import { htmlToMarkdown, normalizeCodeBlocks } from '../../utils/htmlToMarkdown.js';
|
|
18
18
|
import { tooLargeForReadability } from '../scrape/_mainContent.js';
|
|
19
19
|
|
|
20
20
|
// Block-level elements whose boundaries should become paragraph breaks
|
|
@@ -67,11 +67,13 @@ export function extractBlockText($) {
|
|
|
67
67
|
* @returns {string}
|
|
68
68
|
*/
|
|
69
69
|
export function readabilityToMarkdown(html, pageUrl) {
|
|
70
|
-
|
|
70
|
+
// Code-block languages live in classes Readability strips (2.2).
|
|
71
|
+
const codeHtml = normalizeCodeBlocks(html);
|
|
72
|
+
let articleHtml = codeHtml;
|
|
71
73
|
// A page too large for Readability is converted whole (R25).
|
|
72
|
-
if (!tooLargeForReadability(
|
|
74
|
+
if (!tooLargeForReadability(codeHtml)) {
|
|
73
75
|
try {
|
|
74
|
-
const dom = new JSDOM(
|
|
76
|
+
const dom = new JSDOM(codeHtml, { url: pageUrl });
|
|
75
77
|
const reader = new Readability(dom.window.document);
|
|
76
78
|
const article = reader.parse();
|
|
77
79
|
if (article) articleHtml = article.content;
|
|
@@ -36,6 +36,9 @@ export async function fetchUrlHandler({ url, headers, timeout, user_agent, respe
|
|
|
36
36
|
contentType: response.headers.get('content-type') || 'unknown',
|
|
37
37
|
size: body.length,
|
|
38
38
|
url: response.url,
|
|
39
|
+
// Whether a redirect was followed, and the URL the body came from.
|
|
40
|
+
redirected: response.redirected === true,
|
|
41
|
+
final_url: response.url || url,
|
|
39
42
|
// Time the target took to respond and deliver the body, in ms.
|
|
40
43
|
// Excludes the per-host throttle, so it can back a latency check.
|
|
41
44
|
responseTime: response._responseTime
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* fix elements_found to report real per-field DOM match counts.
|
|
6
6
|
* 3.2: Optional row_selector returns row-aligned records instead of the
|
|
7
7
|
* default parallel arrays, which are matched independently per field.
|
|
8
|
+
* 6.4: A field may be an object {selector, multiple}; multiple:true returns
|
|
9
|
+
* every match as an array (inside a row it otherwise keeps only the first).
|
|
10
|
+
* @href and @src values are resolved to absolute URLs against the page URL.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { load } from 'cheerio';
|
|
@@ -25,7 +28,7 @@ const ATTR_SUFFIX_RE = /@[A-Za-z_:][\w:.-]*$/;
|
|
|
25
28
|
* @param {string} raw
|
|
26
29
|
* @returns {{ selector: string, attribute: string|null }}
|
|
27
30
|
*/
|
|
28
|
-
function parseSelectorSpec(raw) {
|
|
31
|
+
export function parseSelectorSpec(raw) {
|
|
29
32
|
const match = ATTR_SUFFIX_RE.exec(raw);
|
|
30
33
|
// Only treat it as an attribute suffix if it isn't inside brackets/quotes,
|
|
31
34
|
// i.e. the selector portion before it has balanced [ ] and quotes.
|
|
@@ -41,6 +44,42 @@ function parseSelectorSpec(raw) {
|
|
|
41
44
|
return { selector: raw, attribute: null };
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A field is either a selector string ("css" or "css@attr") or an object
|
|
49
|
+
* { selector: "css@attr", multiple?: boolean }.
|
|
50
|
+
* @param {string|{ selector: string, multiple?: boolean }} field
|
|
51
|
+
* @returns {{ raw: string, selector: string, attribute: string|null, multiple: boolean }}
|
|
52
|
+
*/
|
|
53
|
+
export function parseFieldSpec(field) {
|
|
54
|
+
const raw = typeof field === 'string' ? field : field?.selector;
|
|
55
|
+
if (typeof raw !== 'string' || raw === '') {
|
|
56
|
+
throw new Error('a field must be a selector string or an object with a selector string');
|
|
57
|
+
}
|
|
58
|
+
return { raw, ...parseSelectorSpec(raw), multiple: typeof field === 'object' && field.multiple === true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Attributes holding a URL, resolved against the page URL so a relative
|
|
62
|
+
// "/item?id=1" comes back usable.
|
|
63
|
+
const URL_ATTRIBUTES = new Set(['href', 'src']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The value of one matched element: its text, or the named attribute (null
|
|
67
|
+
* when absent), with href/src made absolute against `baseUrl`.
|
|
68
|
+
*/
|
|
69
|
+
function extractValue($, el, attribute, baseUrl) {
|
|
70
|
+
if (!attribute) return elementText($, el);
|
|
71
|
+
const value = $(el).attr(attribute);
|
|
72
|
+
if (value == null) return null;
|
|
73
|
+
if (URL_ATTRIBUTES.has(attribute.toLowerCase()) && baseUrl) {
|
|
74
|
+
try {
|
|
75
|
+
return new URL(value.trim(), baseUrl).href;
|
|
76
|
+
} catch {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
/**
|
|
45
84
|
* Row-aligned extraction: every field is matched *inside* each row element, so
|
|
46
85
|
* record N of the output is row N of the page. A field a row lacks is null
|
|
@@ -48,37 +87,46 @@ function parseSelectorSpec(raw) {
|
|
|
48
87
|
* parallel-array output does — there each field is matched independently across
|
|
49
88
|
* the whole document and the arrays are not row-aligned.
|
|
50
89
|
*
|
|
90
|
+
* A field with multiple:true is an array of every match inside the row ([] when
|
|
91
|
+
* the row has none); otherwise it is the first match.
|
|
92
|
+
*
|
|
51
93
|
* @param {import('cheerio').CheerioAPI} $
|
|
52
|
-
* @param {{ row_selector: string, selectors: Record<string, string>, max_results?: number }} params
|
|
94
|
+
* @param {{ row_selector: string, selectors: Record<string, string|object>, max_results?: number, baseUrl?: string }} params
|
|
53
95
|
* @returns {{ records: object[], rowsFound: number, matchCounts: Record<string, number> }}
|
|
54
96
|
*/
|
|
55
|
-
function extractRows($, { row_selector, selectors, max_results }) {
|
|
97
|
+
function extractRows($, { row_selector, selectors, max_results, baseUrl }) {
|
|
56
98
|
const allRows = $(row_selector);
|
|
57
99
|
// In row mode max_results caps rows, not per-field matches.
|
|
58
100
|
const rows = (max_results != null && max_results > 0)
|
|
59
101
|
? allRows.slice(0, max_results)
|
|
60
102
|
: allRows;
|
|
61
103
|
|
|
62
|
-
const specs = Object.entries(selectors).map(([field,
|
|
104
|
+
const specs = Object.entries(selectors).map(([field, spec]) => [field, spec]);
|
|
63
105
|
const matchCounts = Object.fromEntries(specs.map(([field]) => [field, 0]));
|
|
64
106
|
|
|
65
107
|
const records = rows.toArray().map((rowEl) => {
|
|
66
108
|
const $row = $(rowEl);
|
|
67
109
|
const record = {};
|
|
68
|
-
for (const [field,
|
|
110
|
+
for (const [field, spec] of specs) {
|
|
69
111
|
try {
|
|
112
|
+
const { selector, attribute, multiple } = parseFieldSpec(spec);
|
|
70
113
|
// A field selector may name the row element itself (row "tr.athing",
|
|
71
114
|
// field "tr@id"); .find() searches descendants only.
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
115
|
+
const matches = $row.is(selector) ? $row : $row.find(selector);
|
|
116
|
+
if (multiple) {
|
|
117
|
+
record[field] = matches.toArray().map((el) => extractValue($, el, attribute, baseUrl));
|
|
118
|
+
if (matches.length > 0) matchCounts[field] += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (matches.length === 0) {
|
|
74
122
|
record[field] = null;
|
|
75
123
|
continue;
|
|
76
124
|
}
|
|
77
|
-
record[field] =
|
|
125
|
+
record[field] = extractValue($, matches.get(0), attribute, baseUrl);
|
|
78
126
|
matchCounts[field] += 1;
|
|
79
127
|
} catch (selectorError) {
|
|
80
128
|
record[field] = {
|
|
81
|
-
error: `Invalid selector: ${
|
|
129
|
+
error: `Invalid selector: ${typeof spec === 'string' ? spec : spec?.selector}`,
|
|
82
130
|
message: selectorError.message
|
|
83
131
|
};
|
|
84
132
|
}
|
|
@@ -90,7 +138,7 @@ function extractRows($, { row_selector, selectors, max_results }) {
|
|
|
90
138
|
}
|
|
91
139
|
|
|
92
140
|
/**
|
|
93
|
-
* @param {{ url: string, selectors: Record<string, string>, row_selector?: string,
|
|
141
|
+
* @param {{ url: string, selectors: Record<string, string|{ selector: string, multiple?: boolean }>, row_selector?: string,
|
|
94
142
|
* max_results?: number, user_agent?: string, respect_robots?: boolean }} params
|
|
95
143
|
*/
|
|
96
144
|
export async function scrapeStructuredHandler({ url, selectors, row_selector, max_results, user_agent, respect_robots }) {
|
|
@@ -108,7 +156,7 @@ export async function scrapeStructuredHandler({ url, selectors, row_selector, ma
|
|
|
108
156
|
const $ = load(html);
|
|
109
157
|
|
|
110
158
|
if (row_selector) {
|
|
111
|
-
const { records, rowsFound, matchCounts } = extractRows($, { row_selector, selectors, max_results });
|
|
159
|
+
const { records, rowsFound, matchCounts } = extractRows($, { row_selector, selectors, max_results, baseUrl: response.url || url });
|
|
112
160
|
return {
|
|
113
161
|
content: [{
|
|
114
162
|
type: 'text',
|
|
@@ -127,29 +175,26 @@ export async function scrapeStructuredHandler({ url, selectors, row_selector, ma
|
|
|
127
175
|
const results = {};
|
|
128
176
|
const matchCounts = {};
|
|
129
177
|
|
|
178
|
+
const baseUrl = response.url || url;
|
|
130
179
|
for (const [fieldName, rawSelector] of Object.entries(selectors)) {
|
|
131
180
|
try {
|
|
132
|
-
const { selector, attribute } =
|
|
181
|
+
const { selector, attribute, multiple } = parseFieldSpec(rawSelector);
|
|
133
182
|
let elements = $(selector);
|
|
134
183
|
const domCount = elements.length;
|
|
135
184
|
matchCounts[fieldName] = domCount;
|
|
136
185
|
|
|
137
186
|
if (domCount === 0) {
|
|
138
|
-
results[fieldName] = null;
|
|
187
|
+
results[fieldName] = multiple ? [] : null;
|
|
139
188
|
} else {
|
|
140
189
|
// Apply max_results cap if specified
|
|
141
190
|
if (max_results != null && max_results > 0 && domCount > max_results) {
|
|
142
191
|
elements = elements.slice(0, max_results);
|
|
143
192
|
}
|
|
144
193
|
|
|
145
|
-
const extract = (el) =>
|
|
146
|
-
if (attribute) {
|
|
147
|
-
return $(el).attr(attribute) ?? null;
|
|
148
|
-
}
|
|
149
|
-
return elementText($, el);
|
|
150
|
-
};
|
|
194
|
+
const extract = (el) => extractValue($, el, attribute, baseUrl);
|
|
151
195
|
|
|
152
|
-
|
|
196
|
+
// multiple:true always answers with an array, even for one match.
|
|
197
|
+
if (elements.length === 1 && !multiple) {
|
|
153
198
|
results[fieldName] = extract(elements.get(0));
|
|
154
199
|
} else {
|
|
155
200
|
// cheerio's .map().get() drops null/undefined results, which would
|
|
@@ -160,7 +205,7 @@ export async function scrapeStructuredHandler({ url, selectors, row_selector, ma
|
|
|
160
205
|
}
|
|
161
206
|
} catch (selectorError) {
|
|
162
207
|
results[fieldName] = {
|
|
163
|
-
error: `Invalid selector: ${rawSelector}`,
|
|
208
|
+
error: `Invalid selector: ${typeof rawSelector === 'string' ? rawSelector : rawSelector?.selector}`,
|
|
164
209
|
message: selectorError.message
|
|
165
210
|
};
|
|
166
211
|
matchCounts[fieldName] = 0;
|
|
@@ -48,6 +48,21 @@ export function searchScore(url, search) {
|
|
|
48
48
|
return terms.filter((t) => haystack.includes(t)).length;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* normalizeUrl, plus a directory index folded into its directory: `/` and
|
|
53
|
+
* `/index.html` were listed as two URLs (live sweep 2026-10-20). Kept here,
|
|
54
|
+
* not in the shared normalizer, so other tools' URL keys do not change.
|
|
55
|
+
* @param {string} url
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
function normalizeMapUrl(url) {
|
|
59
|
+
const normalized = normalizeUrl(url);
|
|
60
|
+
const u = new URL(normalized);
|
|
61
|
+
if (!/\/index\.html?$/i.test(u.pathname)) return normalized;
|
|
62
|
+
u.pathname = u.pathname.replace(/index\.html?$/i, '');
|
|
63
|
+
return normalizeUrl(u.href);
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
// A gate refusal is the answer to the request, not a page we failed to read.
|
|
52
67
|
const REFUSAL_CODES = new Set(['ROBOTS_DISALLOWED', 'HOST_BLOCKED', 'USE_REDDIT_SEARCH', 'SSRF_BLOCKED']);
|
|
53
68
|
|
|
@@ -159,7 +174,7 @@ export class MapSiteTool {
|
|
|
159
174
|
// Try to fetch sitemap first
|
|
160
175
|
if (validated.include_sitemap) {
|
|
161
176
|
const sitemapUrls = await this.fetchSitemapUrls(baseUrl, domainFilter, poolLimit, scopePath);
|
|
162
|
-
sitemapUrls.forEach(url => urls.add(
|
|
177
|
+
sitemapUrls.forEach(url => urls.add(normalizeMapUrl(url)));
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
// Fetch and parse the main page for additional URLs. A start page that
|
|
@@ -170,11 +185,11 @@ export class MapSiteTool {
|
|
|
170
185
|
// The start page is a page of the site too: a one-page site with no
|
|
171
186
|
// sitemap used to map to 0 URLs (R25 1.2).
|
|
172
187
|
if (!domainFilter || domainFilter.isAllowed(validated.url).allowed) {
|
|
173
|
-
urls.add(
|
|
188
|
+
urls.add(normalizeMapUrl(validated.url));
|
|
174
189
|
}
|
|
175
190
|
pageUrls.forEach(url => {
|
|
176
191
|
if (urls.size < poolLimit) {
|
|
177
|
-
urls.add(
|
|
192
|
+
urls.add(normalizeMapUrl(url));
|
|
178
193
|
}
|
|
179
194
|
});
|
|
180
195
|
} catch (error) {
|
|
@@ -154,6 +154,88 @@ export function verifyFieldShapes(data, schema) {
|
|
|
154
154
|
return { data: out, unverified };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The value of the label/value row whose label names the field: a two-cell
|
|
159
|
+
* <tr> (`<th>UPC</th><td>…</td>`) or a <dt>/<dd> pair. Every word of the
|
|
160
|
+
* field name must be in the label; among several matches the label with the
|
|
161
|
+
* fewest extra words wins. The CSS fallback only looked at class, id, itemprop
|
|
162
|
+
* and meta, so a product-information table never answered anything:
|
|
163
|
+
* books.toscrape's UPC came back missing (live sweep 2026-10-20, 2.3).
|
|
164
|
+
*
|
|
165
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
166
|
+
* @param {string} key - schema field name
|
|
167
|
+
* @returns {string|null}
|
|
168
|
+
*/
|
|
169
|
+
function labelledValue($, key) {
|
|
170
|
+
const want = keyWords(key);
|
|
171
|
+
if (want.length === 0) return null;
|
|
172
|
+
let best = null;
|
|
173
|
+
const consider = (label, value) => {
|
|
174
|
+
const words = keyWords(label);
|
|
175
|
+
if (!value || !want.every((w) => words.includes(w))) return;
|
|
176
|
+
const extra = words.length - want.length;
|
|
177
|
+
if (!best || extra < best.extra) best = { value, extra };
|
|
178
|
+
};
|
|
179
|
+
$('tr').each((_, tr) => {
|
|
180
|
+
const cells = $(tr).children('th, td');
|
|
181
|
+
if (cells.length === 2) consider(cells.first().text(), cells.last().text().trim());
|
|
182
|
+
});
|
|
183
|
+
$('dt').each((_, dt) => consider($(dt).text(), $(dt).next('dd').text().trim()));
|
|
184
|
+
return best ? best.value : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Field-name words a page's own wording leaves out: "in_stock_count". */
|
|
188
|
+
const COUNT_WORDS = new Set(['count', 'number', 'num', 'total', 'qty', 'quantity']);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* For a numeric field: the text after the field's phrase on the first page
|
|
192
|
+
* line that has it with a number after it. `in_stock_count` reads
|
|
193
|
+
* "In stock (22 available)" as " (22 available)", which the caller coerces to
|
|
194
|
+
* 22. Only the text after the phrase counts, so a number earlier on the line
|
|
195
|
+
* is never taken.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} text - flattened page text, one line per block
|
|
198
|
+
* @param {string} key
|
|
199
|
+
* @returns {string|null}
|
|
200
|
+
*/
|
|
201
|
+
function textAfterPhrase(text, key) {
|
|
202
|
+
const words = keyWords(key).filter((w) => !COUNT_WORDS.has(w));
|
|
203
|
+
if (words.length === 0) return null;
|
|
204
|
+
const phrase = new RegExp(`\\b${words.join('[\\s_-]*')}\\b`, 'i');
|
|
205
|
+
for (const line of text.split('\n')) {
|
|
206
|
+
const match = phrase.exec(line);
|
|
207
|
+
if (!match) continue;
|
|
208
|
+
const rest = line.slice(match.index + match[0].length);
|
|
209
|
+
if (/\d/.test(rest)) return rest;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const RATING_WORDS = { one: 1, two: 2, three: 3, four: 4, five: 5 };
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A star rating carried only in a class token: books.toscrape renders three
|
|
218
|
+
* stars as `<p class="star-rating Three">` with five identical icons inside,
|
|
219
|
+
* so no text on the page says 3. For a field named for a rating or stars, the
|
|
220
|
+
* first class token on a star/rating element that spells a number one to five
|
|
221
|
+
* is that number. Decision (2.3): it is returned as a number for a
|
|
222
|
+
* number/integer field and as its digit string ("3") otherwise.
|
|
223
|
+
*
|
|
224
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
225
|
+
* @param {string} key
|
|
226
|
+
* @returns {number|null}
|
|
227
|
+
*/
|
|
228
|
+
function classRating($, key) {
|
|
229
|
+
if (!keyWords(key).some((w) => w === 'rating' || w === 'stars' || w === 'star')) return null;
|
|
230
|
+
for (const el of $('[class*="star"], [class*="rating"]').toArray()) {
|
|
231
|
+
for (const token of ($(el).attr('class') || '').split(/\s+/)) {
|
|
232
|
+
const n = RATING_WORDS[token.toLowerCase()];
|
|
233
|
+
if (n) return n;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
157
239
|
/** Lowercased, whitespace-collapsed text for a containment check. */
|
|
158
240
|
function squash(text) {
|
|
159
241
|
return text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
@@ -502,10 +584,30 @@ export class ExtractStructuredTool {
|
|
|
502
584
|
const failedRequired = extractionResult.valid !== true
|
|
503
585
|
&& (missingRequired.length > 0 || invalidRequired.length > 0);
|
|
504
586
|
|
|
587
|
+
// Every requested field is in `data`. A field no method filled used to
|
|
588
|
+
// be left out with no null and no note, so a caller could not tell
|
|
589
|
+
// "not on the page" from "not looked for" (live sweep 2026-10-20, 2.3).
|
|
590
|
+
// An empty array is an answer (no items), not a missing value.
|
|
591
|
+
const data = { ...(extractionResult.data || {}) };
|
|
592
|
+
const missingFields = [];
|
|
593
|
+
if (!Array.isArray(extractionResult.data)) {
|
|
594
|
+
for (const key of Object.keys(schema.properties || {})) {
|
|
595
|
+
const value = data[key];
|
|
596
|
+
if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
|
|
597
|
+
data[key] = null;
|
|
598
|
+
missingFields.push(key);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (missingFields.length > 0) {
|
|
603
|
+
extractionNotes.push(`Not found on the page, returned as null: ${missingFields.join(', ')}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
505
606
|
return {
|
|
506
607
|
success: !failedRequired,
|
|
507
608
|
url,
|
|
508
|
-
data
|
|
609
|
+
data,
|
|
610
|
+
missing_fields: missingFields,
|
|
509
611
|
extraction_method: extractionMethod,
|
|
510
612
|
// The provider and model that produced an LLM extraction (R24).
|
|
511
613
|
...(extractionMethod === 'llm' && extractionResult.model
|
|
@@ -556,6 +658,7 @@ export class ExtractStructuredTool {
|
|
|
556
658
|
const properties = schema.properties || {};
|
|
557
659
|
const extracted = {};
|
|
558
660
|
let fieldsFound = 0;
|
|
661
|
+
let pageText = null; // flattened lazily, only for a numeric field that needs it
|
|
559
662
|
|
|
560
663
|
for (const [key, fieldSchema] of Object.entries(properties)) {
|
|
561
664
|
// Schema keys become CSS selector fragments below (class/id/data-attr).
|
|
@@ -647,6 +750,15 @@ export class ExtractStructuredTool {
|
|
|
647
750
|
}
|
|
648
751
|
}
|
|
649
752
|
|
|
753
|
+
// A labelled table or definition-list row: <th>UPC</th><td>…</td>.
|
|
754
|
+
if (!(key in extracted)) {
|
|
755
|
+
const labelled = labelledValue($, key);
|
|
756
|
+
if (labelled) {
|
|
757
|
+
extracted[key] = this._coerceValue(labelled, fieldSchema);
|
|
758
|
+
fieldsFound++;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
650
762
|
// Last resort: semantic element selectors for well-known field names
|
|
651
763
|
// (e.g. title -> <h1>/<title>) so common fields resolve without hints.
|
|
652
764
|
if (!(key in extracted)) {
|
|
@@ -673,6 +785,27 @@ export class ExtractStructuredTool {
|
|
|
673
785
|
}
|
|
674
786
|
}
|
|
675
787
|
}
|
|
788
|
+
|
|
789
|
+
// A rating encoded only as a class token (`star-rating Three`).
|
|
790
|
+
if (!(key in extracted)) {
|
|
791
|
+
const stars = classRating($, key);
|
|
792
|
+
if (stars !== null) {
|
|
793
|
+
const numeric = fieldSchema.type === 'number' || fieldSchema.type === 'integer';
|
|
794
|
+
extracted[key] = numeric ? stars : String(stars);
|
|
795
|
+
fieldsFound++;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// A numeric field stated in running text: "In stock (22 available)".
|
|
800
|
+
if (!(key in extracted) && (fieldSchema.type === 'number' || fieldSchema.type === 'integer')) {
|
|
801
|
+
if (pageText === null) pageText = flattenBodyText($);
|
|
802
|
+
const after = textAfterPhrase(pageText, key);
|
|
803
|
+
const value = after === null ? null : this._coerceValue(after, fieldSchema);
|
|
804
|
+
if (typeof value === 'number') {
|
|
805
|
+
extracted[key] = value;
|
|
806
|
+
fieldsFound++;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
676
809
|
} catch (_fieldError) {
|
|
677
810
|
// Invalid selector for this key — skip the field, keep going.
|
|
678
811
|
continue;
|