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.
Files changed (53) hide show
  1. package/README.md +10 -1
  2. package/package.json +1 -1
  3. package/server.js +14 -7
  4. package/src/core/ActionExecutor.js +117 -12
  5. package/src/core/AgentOrchestrator.js +71 -14
  6. package/src/core/AuthManager.js +7 -1
  7. package/src/core/ChangeTracker.js +52 -4
  8. package/src/core/LLMsTxtAnalyzer.js +21 -9
  9. package/src/core/LocalizationManager.js +20 -4
  10. package/src/core/ResearchOrchestrator.js +8 -1
  11. package/src/core/StealthBrowserManager.js +10 -5
  12. package/src/core/analysis/ContentAnalyzer.js +157 -29
  13. package/src/core/browser/guardedProxy.js +286 -0
  14. package/src/core/browser/snapshot.js +22 -0
  15. package/src/core/crawlers/BFSCrawler.js +10 -4
  16. package/src/core/processing/BrowserProcessor.js +5 -3
  17. package/src/core/processing/ContentProcessor.js +2 -1
  18. package/src/core/workers/worker.js +2 -0
  19. package/src/schemas/toolOutputSchemas.js +3 -2
  20. package/src/server/fallbackHints.js +5 -1
  21. package/src/server/inlineThreshold.js +15 -9
  22. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  25. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  26. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  31. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  32. package/src/tools/agent/sourceChecks.js +103 -0
  33. package/src/tools/basic/extractMetadata.js +9 -4
  34. package/src/tools/basic/extractText.js +6 -4
  35. package/src/tools/basic/fetchUrl.js +3 -0
  36. package/src/tools/basic/scrapeStructured.js +66 -21
  37. package/src/tools/crawl/mapSite.js +18 -3
  38. package/src/tools/extract/extractStructured.js +134 -1
  39. package/src/tools/extract/processDocument.js +48 -5
  40. package/src/tools/extract/summarizeContent.js +123 -5
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  42. package/src/tools/result/readResult.js +79 -10
  43. package/src/tools/scrape/_mainContent.js +97 -6
  44. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  45. package/src/tools/search/searchWeb.js +46 -15
  46. package/src/tools/search/serpRank.js +1 -1
  47. package/src/tools/tracking/trackChanges/index.js +1 -0
  48. package/src/tools/tracking/trackChanges/schema.js +2 -1
  49. package/src/utils/htmlToMarkdown.js +77 -1
  50. package/src/utils/localFilePolicy.js +65 -0
  51. package/src/utils/provenance.js +102 -1
  52. package/src/utils/ssrfGuard.js +4 -3
  53. 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
- if (readable.length < 300 && bodyText.length > readable.length) {
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
- extractWithSelectors($, selectors) {
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
- for (const [key, selector] of Object.entries(selectors)) {
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] = elementText($, elements.get(0));
981
+ extracted[key] = read(elements.get(0), attribute);
957
982
  } else {
958
- extracted[key] = elements.map((_, el) => elementText($, el)).get();
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: ${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
- const charset =
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
- let articleHtml = html;
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(html)) {
74
+ if (!tooLargeForReadability(codeHtml)) {
73
75
  try {
74
- const dom = new JSDOM(html, { url: pageUrl });
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, raw]) => [field, raw, parseSelectorSpec(raw)]);
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, raw, { selector, attribute }] of specs) {
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 el = $row.is(selector) ? $row : $row.find(selector).first();
73
- if (el.length === 0) {
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] = attribute ? (el.attr(attribute) ?? null) : elementText($, el.get(0));
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: ${raw}`,
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 } = parseSelectorSpec(rawSelector);
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
- if (elements.length === 1) {
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(normalizeUrl(url)));
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(normalizeUrl(validated.url));
188
+ urls.add(normalizeMapUrl(validated.url));
174
189
  }
175
190
  pageUrls.forEach(url => {
176
191
  if (urls.size < poolLimit) {
177
- urls.add(normalizeUrl(url));
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: extractionResult.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;