crawlforge-mcp-server 6.22.2 → 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 (54) 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 +133 -23
  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 +29 -3
  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/research/claimFilters.js +20 -1
  19. package/src/core/workers/worker.js +2 -0
  20. package/src/schemas/toolOutputSchemas.js +3 -2
  21. package/src/server/fallbackHints.js +5 -1
  22. package/src/server/inlineThreshold.js +19 -11
  23. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  26. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  31. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  32. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  33. package/src/tools/agent/sourceChecks.js +103 -0
  34. package/src/tools/basic/extractMetadata.js +9 -4
  35. package/src/tools/basic/extractText.js +6 -4
  36. package/src/tools/basic/fetchUrl.js +3 -0
  37. package/src/tools/basic/scrapeStructured.js +66 -21
  38. package/src/tools/crawl/mapSite.js +18 -3
  39. package/src/tools/extract/extractStructured.js +134 -1
  40. package/src/tools/extract/processDocument.js +48 -5
  41. package/src/tools/extract/summarizeContent.js +123 -5
  42. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  43. package/src/tools/result/readResult.js +79 -10
  44. package/src/tools/scrape/_mainContent.js +97 -6
  45. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  46. package/src/tools/search/searchWeb.js +46 -15
  47. package/src/tools/search/serpRank.js +1 -1
  48. package/src/tools/tracking/trackChanges/index.js +1 -0
  49. package/src/tools/tracking/trackChanges/schema.js +2 -1
  50. package/src/utils/htmlToMarkdown.js +77 -1
  51. package/src/utils/localFilePolicy.js +65 -0
  52. package/src/utils/provenance.js +102 -1
  53. package/src/utils/ssrfGuard.js +4 -3
  54. package/src/utils/ssrfProtection.js +3 -0
@@ -2,7 +2,7 @@
2
2
  name: crawlforge-web-scraping
3
3
  description: "Scrapes web pages and returns clean Markdown, HTML, plain text, links, or metadata using CrawlForge's scrape, fetch_url, extract_content, extract_text, extract_links, extract_metadata, map_site, and crawl_deep tools. Use when the user wants to scrape a URL, fetch a page, get the markdown or text of a website, extract links or metadata, read an article, map a site's URLs, generate a sitemap, or crawl a whole website or documentation site. Prefer the unified scrape tool to get multiple formats in one call; use map_site to discover URLs and crawl_deep to walk an entire site."
4
4
  metadata:
5
- version: 6.22.2
5
+ version: 6.23.0
6
6
  source: crawlforge-mcp-server
7
7
  ---
8
8
 
@@ -122,7 +122,7 @@ Reads a result a tool returned with `truncated: true` and a `result_handle`
122
122
  | `length` | number | — | `slice`: characters (default 10,000); `lines`: lines (default 200, at most 5,000). |
123
123
  | `query` | string | — | `search`: case-insensitive literal substring. |
124
124
  | `max_matches` | number | `20` | `search`: 1–100. |
125
- | `path` | string | — | `json_path`: path into the stored result object (or the parsed body when the stored text is JSON, e.g. a `fetch_url` body). |
125
+ | `path` | string | — | `json_path`: path from the root of the stored result; a `fetch_url` body is under `body.` (parsed when it holds JSON), and a key containing a dot goes in brackets, e.g. `files["llms.txt"]`. |
126
126
  | `max_inline_chars` | number | `40000` | 1,000–10,000,000. |
127
127
 
128
128
  Every response carries `handle`, `tool`, `operation`, `view` (`text` or `json`),
@@ -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) {