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
package/README.md
CHANGED
|
@@ -276,6 +276,12 @@ export CRAWLFORGE_STEALTH_PROXIES="http://user:pass@gw.provider.net:8080"
|
|
|
276
276
|
|
|
277
277
|
# Optional: turn off the clearance jar (on by default) — see "Stealth engines and proxies"
|
|
278
278
|
export CRAWLFORGE_CLEARANCE_JAR="off"
|
|
279
|
+
|
|
280
|
+
# Optional: also refuse private networks (RFC1918, ULA, CGNAT, ...) — see "Security & Approvals"
|
|
281
|
+
export SSRF_STRICT="true"
|
|
282
|
+
|
|
283
|
+
# Optional: the only directories process_document may read local files from (":"-separated, ";" on Windows)
|
|
284
|
+
export CRAWLFORGE_ALLOWED_DIRS="$HOME/Documents:$HOME/Downloads"
|
|
279
285
|
```
|
|
280
286
|
|
|
281
287
|
### MCP Spec Features
|
|
@@ -380,7 +386,10 @@ Once configured, use these tools in your AI assistant:
|
|
|
380
386
|
|
|
381
387
|
### Security & Approvals
|
|
382
388
|
|
|
383
|
-
- **SSRF enforcement**: Every scraped URL is validated before the request is sent
|
|
389
|
+
- **SSRF enforcement**: Every scraped URL is validated before the request is sent, and every connection (each redirect hop included, capped at 5) is checked against the address it actually connects to — http/https only, cloud metadata hostnames (GCP, Azure) refused. Two levels:
|
|
390
|
+
- **Default (Stage 1)** blocks loopback (`127.0.0.0/8`, `::1`), the unspecified addresses (`0.0.0.0/8`, `::`), link-local and cloud metadata (`169.254.0.0/16` incl. `169.254.169.254`, `fe80::/10`). It **allows private networks** (RFC1918 `10/8`, `172.16/12`, `192.168/16`, IPv6 ULA): run on your own machine, the server acts for you, and reading a router page, a NAS or an intranet docs site on your LAN is a legitimate use that the stricter level would break.
|
|
391
|
+
- **`SSRF_STRICT=true`** adds every private and reserved range: RFC1918, ULA (`fc00::/7`), CGNAT (`100.64/10`), multicast, reserved, IPv4-compatible (`::/96`) and NAT64 (`64:ff9b::/96`) IPv6. Set it if an agent that reads untrusted pages (and could be prompt-injected into fetching a LAN address) should not reach your local network, or whenever the server is shared.
|
|
392
|
+
- **Local files** (`process_document` with `sourceType` `file` or `pdf_file`; refused outright when the server is served to a network): a path is judged by its realpath, so a symlink counts as its target. Only `.pdf`, `.docx`, `.txt`, `.md`, `.html` and `.htm` files are read, and nothing inside a dot-directory (`~/.ssh`, `~/.aws`, `~/.config`, `~/.crawlforge`, ...). `CRAWLFORGE_ALLOWED_DIRS` (directories separated by `:`, `;` on Windows) narrows reads to those directories instead; inside them the dot-directory rule is lifted, the extension rule is not.
|
|
384
393
|
- **Backend endpoint guard** (v3.0.18): The server's own calls to CrawlForge.dev use a separate fail-closed allow-list (`{crawlforge.dev, www.crawlforge.dev, api.crawlforge.dev}`, HTTPS required). Setting `CRAWLFORGE_API_URL` to an arbitrary host is blocked at parse time.
|
|
385
394
|
- **Action allowlist**: `scrape_with_actions` accepts only 11 action types (`snapshot`, `wait`, `click`, `type`, `press`, `scroll`, `screenshot`, `executeJavaScript`, `select`, `hover`, `navigate`). No download, file-write, or arbitrary cross-page navigation primitives exist — `navigate` goes through the same SSRF and robots.txt gate as the initial URL.
|
|
386
395
|
- **JavaScript gate**: The `executeJavaScript` action throws by default. Set `ALLOW_JAVASCRIPT_EXECUTION=true` at deploy time to enable (not recommended in production).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.23.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
package/server.js
CHANGED
|
@@ -113,7 +113,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
|
|
|
113
113
|
// Create the server
|
|
114
114
|
const server = new McpServer({
|
|
115
115
|
name: "crawlforge",
|
|
116
|
-
version: "6.
|
|
116
|
+
version: "6.23.0",
|
|
117
117
|
description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
118
118
|
homepage: "https://www.crawlforge.dev",
|
|
119
119
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -427,7 +427,7 @@ const refusedByGate = (result) => result?.success === false && preflightRefusal(
|
|
|
427
427
|
|
|
428
428
|
// Tool: fetch_url
|
|
429
429
|
registerToolIfEnabled("fetch_url", {
|
|
430
|
-
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
430
|
+
description: "Use this for a raw HTTP body - JSON, XML, plain text, an API response - or for the status code, headers and response time. Returns the body unprocessed. Not for HTML you intend to read: scrape returns markdown from one fetch, so fetch_url followed by extract_* is a double fetch. Not for JS-rendered or bot-protected pages (scrape, then stealth_mode). Supports custom headers (e.g. auth tokens) and a timeout; the result reports redirected and final_url. Cost: 1 credit. Example: fetch_url({url: \"https://api.example.com/v1/items\", timeout: 15000})",
|
|
431
431
|
annotations: { title: "Fetch URL", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
432
432
|
inputSchema: {
|
|
433
433
|
url: z.string().url().describe("The URL to fetch content from"),
|
|
@@ -504,7 +504,10 @@ registerToolIfEnabled("scrape_structured", {
|
|
|
504
504
|
annotations: { title: "Scrape Structured Data", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
505
505
|
inputSchema: {
|
|
506
506
|
url: z.string().url().describe("The URL to scrape"),
|
|
507
|
-
selectors: z.record(z.
|
|
507
|
+
selectors: z.record(z.union([
|
|
508
|
+
z.string(),
|
|
509
|
+
z.object({ selector: z.string(), multiple: z.boolean().optional() })
|
|
510
|
+
])).describe("CSS selectors mapping field names to selectors. Append @attr to extract an attribute instead of text (e.g. \"a.link@href\", \"img@src\"); @href and @src values are resolved to absolute URLs. A field may also be {selector, multiple: true} to return every match as an array - inside a row_selector row a field otherwise keeps only the first match"),
|
|
508
511
|
row_selector: z.string().optional().describe("CSS selector for the repeating row/container element. When set, each field in selectors is matched inside each row and data is an array of row-aligned records ({field: value|null}) instead of parallel arrays"),
|
|
509
512
|
max_results: z.number().int().min(1).optional().describe("Maximum number of matches to return per field when a selector matches multiple elements, or the maximum number of rows when row_selector is set"),
|
|
510
513
|
...COMPLIANCE_PARAMS
|
|
@@ -754,7 +757,7 @@ registerToolIfEnabled("extract_content", {
|
|
|
754
757
|
|
|
755
758
|
// Tool: process_document
|
|
756
759
|
registerToolIfEnabled("process_document", {
|
|
757
|
-
description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count; for a PDF, pagesRead names the pages read (maxPages defaults to 100). Not for ordinary web pages (scrape), though an HTML URL is accepted. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
760
|
+
description: "Use this to extract text from a PDF or DOCX URL or file - research papers, contracts, reports. The body decides how it is read: a PDF or Word document served under sourceType \"url\" still reaches its parser, and a body this tool cannot read (an image, an archive) is refused by name. Returns structured sections, metadata, and word count; for a PDF, pagesRead names the pages read (maxPages defaults to 100). Not for ordinary web pages (scrape), though an HTML URL is accepted. A local file (sourceType file/pdf_file) must be .pdf, .docx, .txt, .md or .html and outside dot-directories; CRAWLFORGE_ALLOWED_DIRS limits reads to the listed directories. Cost: 2 credits. Example: process_document({source: \"https://example.com/report.pdf\", sourceType: \"pdf_url\"})",
|
|
758
761
|
annotations: { title: "Process Document", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
759
762
|
inputSchema: {
|
|
760
763
|
source: z.string().describe("Document source - URL or file path"),
|
|
@@ -1060,7 +1063,7 @@ registerToolIfEnabled("scrape_with_actions", {
|
|
|
1060
1063
|
includeMetadata: z.boolean().default(true),
|
|
1061
1064
|
includeLinks: z.boolean().default(true),
|
|
1062
1065
|
includeImages: z.boolean().default(true)
|
|
1063
|
-
}).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response."),
|
|
1066
|
+
}).optional().describe("Content extraction options. selectors results are returned as content.json.extracted, so include \"json\" in formats when passing selectors — without it the extraction is not part of the response. Append @attr to a selector to read an attribute instead of the text (\"a.next@href\"); @value on an input, textarea or select reads what it holds after the actions (typed or selected), and any other @attr reads the attribute as written in the page's HTML."),
|
|
1064
1067
|
continueOnActionError: z.boolean().default(false).describe("Continue executing actions if one fails"),
|
|
1065
1068
|
maxRetries: z.number().min(0).max(3).default(0).describe("Whole-chain retries on failure (0-3). A retry re-navigates to the starting URL and replays every action; each attempt is reported under attempts[]."),
|
|
1066
1069
|
screenshotOnError: z.boolean().default(true).describe("Capture screenshot when an error occurs"),
|
|
@@ -1294,7 +1297,7 @@ registerToolIfEnabled("agent", {
|
|
|
1294
1297
|
|
|
1295
1298
|
// Tool: track_changes
|
|
1296
1299
|
registerToolIfEnabled("track_changes", {
|
|
1297
|
-
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1300
|
+
description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). A compare always uses the baseline's granularity/customSelectors/excludeSelectors; different values are reported in ignoredOptions. Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
|
|
1298
1301
|
annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
1299
1302
|
// The tool module owns the schema (G5); this is the same shape it validates with.
|
|
1300
1303
|
inputSchema: {
|
|
@@ -1523,7 +1526,11 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1523
1526
|
};
|
|
1524
1527
|
if (verdict.blocked) result.blocked = verdict.blocked;
|
|
1525
1528
|
if (verdict.error) result.error = verdict.error;
|
|
1526
|
-
|
|
1529
|
+
// The requested wait_for and the render grace the browser added on top
|
|
1530
|
+
// are separate numbers; reporting only the grace made wait_for look
|
|
1531
|
+
// unapplied (wait_for:5000 showed as 4 ms, live sweep 2026-10-20).
|
|
1532
|
+
if (scraped.waitedMs > 0) result.waited_ms = scraped.waitedMs;
|
|
1533
|
+
if (scraped.gracedMs > 0) result.render_grace_ms = scraped.gracedMs;
|
|
1527
1534
|
|
|
1528
1535
|
// Screenshots follow the same crawlforge://screenshot/{id} pattern as
|
|
1529
1536
|
// scrape and scrape_with_actions, so the base64 never bloats the result.
|
|
@@ -9,7 +9,7 @@ import { EventEmitter } from 'events';
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { assertUrlAllowed, assertNavigationAllowed } from '../utils/ssrfGuard.js';
|
|
11
11
|
import { browserPreflight, redirectGate, pageMoveGate, gateRefusalCode, paceBrowserAction } from '../utils/robotsGate.js';
|
|
12
|
-
import { isRef, resolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
12
|
+
import { isRef, resolveRef, reresolveRef, captureSnapshot } from './browser/snapshot.js';
|
|
13
13
|
import { settlePage } from './browser/settle.js';
|
|
14
14
|
import { handleConsent } from './browser/consent.js';
|
|
15
15
|
import { stealthDocumentVerdict } from '../utils/stealthVerdict.js';
|
|
@@ -55,6 +55,27 @@ const withoutHash = (url) => url.split('#')[0];
|
|
|
55
55
|
* A failed action's message, naming its selector. Playwright names it only in
|
|
56
56
|
* the "Call log:", which is stripped before the model reads the error (R25 3.2).
|
|
57
57
|
*/
|
|
58
|
+
/**
|
|
59
|
+
* The live `.value` of every input, textarea and select, in document order.
|
|
60
|
+
*
|
|
61
|
+
* page.content() serializes attributes, and typing changes the property, not
|
|
62
|
+
* the attribute: `input[type=number]@value` came back null after typing "42"
|
|
63
|
+
* (live sweep 2026-10-20, 3.4). The serialized HTML's controls are matched to
|
|
64
|
+
* these by position (ScrapeWithActionsTool.extractWithSelectors).
|
|
65
|
+
* @param {Page} page - Playwright page
|
|
66
|
+
* @returns {Promise<Array<{tag: string, value: string}>>} [] when the page cannot answer
|
|
67
|
+
*/
|
|
68
|
+
async function readFormValues(page) {
|
|
69
|
+
try {
|
|
70
|
+
return await page.evaluate(() => Array.from(
|
|
71
|
+
document.querySelectorAll('input, textarea, select'),
|
|
72
|
+
(el) => ({ tag: el.localName, value: el.value })
|
|
73
|
+
));
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
function actionErrorMessage(action, error) {
|
|
59
80
|
const firstLine = String(error.message).split('\n')[0];
|
|
60
81
|
return action.selector && !firstLine.includes(action.selector)
|
|
@@ -363,6 +384,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
363
384
|
if (finalState) {
|
|
364
385
|
executionContext.finalHtml = finalState.html;
|
|
365
386
|
executionContext.finalUrl = finalState.url;
|
|
387
|
+
executionContext.finalFormValues = finalState.formValues;
|
|
366
388
|
}
|
|
367
389
|
|
|
368
390
|
this.stats.successfulChains++;
|
|
@@ -433,6 +455,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
433
455
|
...executionContext,
|
|
434
456
|
page: undefined, // Don't store page in history
|
|
435
457
|
finalHtml: undefined,
|
|
458
|
+
finalFormValues: undefined, // what was typed, passwords included
|
|
436
459
|
screenshots: undefined,
|
|
437
460
|
screenshotCount: executionContext.screenshots.length,
|
|
438
461
|
capturedStates: undefined,
|
|
@@ -459,6 +482,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
459
482
|
url,
|
|
460
483
|
finalUrl: executionContext.finalUrl || url,
|
|
461
484
|
finalHtml: executionContext.finalHtml,
|
|
485
|
+
finalFormValues: executionContext.finalFormValues || [],
|
|
462
486
|
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
463
487
|
navigations: executionContext.navigations || [],
|
|
464
488
|
consent: executionContext.consent,
|
|
@@ -579,6 +603,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
579
603
|
afterActionId: actionResult.id,
|
|
580
604
|
url: captured.url,
|
|
581
605
|
html: captured.html,
|
|
606
|
+
formValues: captured.formValues,
|
|
582
607
|
timestamp: Date.now()
|
|
583
608
|
});
|
|
584
609
|
}
|
|
@@ -780,18 +805,19 @@ export class ActionExecutor extends EventEmitter {
|
|
|
780
805
|
}
|
|
781
806
|
|
|
782
807
|
/**
|
|
783
|
-
* The page's URL and HTML, read natively (no
|
|
784
|
-
* works regardless of ALLOW_JAVASCRIPT_EXECUTION)
|
|
785
|
-
* read yields null and a warning;
|
|
808
|
+
* The page's URL and HTML, read natively (no caller-supplied JS, so it
|
|
809
|
+
* works regardless of ALLOW_JAVASCRIPT_EXECUTION), and the live value of
|
|
810
|
+
* each form control. A page that cannot be read yields null and a warning;
|
|
811
|
+
* a page the gate refuses throws.
|
|
786
812
|
* @param {Page} page - Playwright page
|
|
787
813
|
* @param {Object} [browserOptions] - Browser options (`respectRobots` override)
|
|
788
814
|
* @param {string} what - names the capture in the warning
|
|
789
|
-
* @returns {Promise<{ url: string, html: string }|null>}
|
|
815
|
+
* @returns {Promise<{ url: string, html: string, formValues: Array<{tag: string, value: string}> }|null>}
|
|
790
816
|
*/
|
|
791
817
|
async captureState(page, browserOptions, what) {
|
|
792
818
|
return await this.readGated(page, browserOptions, async () => {
|
|
793
819
|
try {
|
|
794
|
-
return { html: await page.content(), url: page.url() };
|
|
820
|
+
return { html: await page.content(), url: page.url(), formValues: await readFormValues(page) };
|
|
795
821
|
} catch (captureErr) {
|
|
796
822
|
this.log('warn', 'Failed to capture ' + what + ': ' + captureErr.message);
|
|
797
823
|
return null;
|
|
@@ -879,7 +905,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
879
905
|
// "Action timeout".
|
|
880
906
|
const backstopMs = timeout + ACTION_TIMEOUT_GRACE_MS;
|
|
881
907
|
let backstopTimer;
|
|
882
|
-
const executionPromise = this.
|
|
908
|
+
const executionPromise = this.executeActionWithRefRetry(page, action, executionContext);
|
|
883
909
|
const timeoutPromise = new Promise((_, reject) => {
|
|
884
910
|
backstopTimer = setTimeout(
|
|
885
911
|
() => reject(new Error(
|
|
@@ -1058,6 +1084,42 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1058
1084
|
return await settlePage(page, { timeout: remaining });
|
|
1059
1085
|
}
|
|
1060
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* Run the action; if its ref names an element the page has since replaced,
|
|
1089
|
+
* re-find the element by the ref's role and name once and run it again.
|
|
1090
|
+
*
|
|
1091
|
+
* Checked up front as well as on the error, because a detached ref does not
|
|
1092
|
+
* always fail fast: Playwright reports "Element is not attached to the DOM"
|
|
1093
|
+
* when the swap lands mid-action, but waits out the whole action timeout for
|
|
1094
|
+
* a ref that was already detached when the action began.
|
|
1095
|
+
* @param {Page} page - Playwright page
|
|
1096
|
+
* @param {Object} action - Action configuration
|
|
1097
|
+
* @param {Object} [executionContext] - Execution context
|
|
1098
|
+
* @returns {Promise<any>} Action result
|
|
1099
|
+
*/
|
|
1100
|
+
async executeActionWithRefRetry(page, action, executionContext) {
|
|
1101
|
+
const ref = [action.selector, action.toElement].find(isRef);
|
|
1102
|
+
let reresolved = false;
|
|
1103
|
+
if (ref) {
|
|
1104
|
+
let matches = 1;
|
|
1105
|
+
try {
|
|
1106
|
+
matches = await this.elementLocator(page, ref).count();
|
|
1107
|
+
} catch (_) {
|
|
1108
|
+
// A stale or unknown ref: the action itself reports it.
|
|
1109
|
+
}
|
|
1110
|
+
if (matches === 0) reresolved = await reresolveRef(page, ref);
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
return await this.executeActionByType(page, action, executionContext);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
if (!ref || reresolved || !/not attached to the DOM/i.test(error?.message) ||
|
|
1116
|
+
!(await reresolveRef(page, ref))) {
|
|
1117
|
+
throw error;
|
|
1118
|
+
}
|
|
1119
|
+
return await this.executeActionByType(page, action, executionContext);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1061
1123
|
/**
|
|
1062
1124
|
* Execute action based on its type
|
|
1063
1125
|
* @param {Page} page - Playwright page
|
|
@@ -1442,14 +1504,22 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1442
1504
|
const startedAt = Date.now();
|
|
1443
1505
|
const timeout = this.actionTimeout(action);
|
|
1444
1506
|
|
|
1507
|
+
// Both checks run before the page is touched, so a target refused here
|
|
1508
|
+
// leaves the page where it was.
|
|
1445
1509
|
await assertUrlAllowed(action.url, { resolveDns: true });
|
|
1446
1510
|
const gateWarnings = await this.assertRobotsAllowed(action.url, executionContext?.browserOptions);
|
|
1447
1511
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1512
|
+
const previousUrl = page.url();
|
|
1513
|
+
try {
|
|
1514
|
+
await this.navigateToUrl(page, action.url, {
|
|
1515
|
+
waitUntil: action.waitUntil,
|
|
1516
|
+
timeout,
|
|
1517
|
+
browserOptions: executionContext?.browserOptions
|
|
1518
|
+
});
|
|
1519
|
+
} catch (error) {
|
|
1520
|
+
await this.restoreAfterRefusal(page, previousUrl, error, executionContext?.browserOptions);
|
|
1521
|
+
throw error;
|
|
1522
|
+
}
|
|
1453
1523
|
page.__crawlforgeGateWarnings = gateWarnings;
|
|
1454
1524
|
|
|
1455
1525
|
// Consent handling gets at most what is left of the action's deadline, so
|
|
@@ -1477,6 +1547,41 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1477
1547
|
};
|
|
1478
1548
|
}
|
|
1479
1549
|
|
|
1550
|
+
/**
|
|
1551
|
+
* Put the page back after a navigation refused partway.
|
|
1552
|
+
*
|
|
1553
|
+
* A target refused up front never leaves the page. A redirect hop to a
|
|
1554
|
+
* blocked address is refused only once the browser asks for it — by the
|
|
1555
|
+
* guarded proxy — and by then the page is gone: blanked by
|
|
1556
|
+
* assertNavigationAllowed, or on the browser's error page when the proxy
|
|
1557
|
+
* refused an https tunnel. The browser cannot undo a committed navigation, so
|
|
1558
|
+
* the page that was left is loaded again (through the same gate). That keeps
|
|
1559
|
+
* a session on its page and its cookies, but not on state that lived only in
|
|
1560
|
+
* the page, and the error says which of the two happened (live sweep
|
|
1561
|
+
* 2026-10-20, 3.3).
|
|
1562
|
+
* @param {Page} page - Playwright page
|
|
1563
|
+
* @param {string} previousUrl - where the page was before the navigation
|
|
1564
|
+
* @param {Error} error - the navigation's failure; its message is extended
|
|
1565
|
+
* @param {Object} [browserOptions] - Browser options (`respectRobots` for the reload's gate)
|
|
1566
|
+
* @returns {Promise<void>}
|
|
1567
|
+
*/
|
|
1568
|
+
async restoreAfterRefusal(page, previousUrl, error, browserOptions) {
|
|
1569
|
+
const refused = gateRefusalCode(error) || /ERR_TUNNEL_CONNECTION_FAILED/.test(error?.message);
|
|
1570
|
+
if (!refused || !/^https?:\/\//i.test(previousUrl)) return;
|
|
1571
|
+
// A refused tunnel's error page commits after goto() has already rejected;
|
|
1572
|
+
// let it land first, or it interrupts the reload.
|
|
1573
|
+
if (page.url() === previousUrl) {
|
|
1574
|
+
await page.waitForURL((url) => url.href !== previousUrl, { timeout: 2000 }).catch(() => {});
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
await this.navigateToUrl(page, previousUrl, { browserOptions });
|
|
1578
|
+
error.message += ` The page was returned to ${previousUrl} by loading it again; ` +
|
|
1579
|
+
'state held only in the page (unsaved form input, script state) was lost.';
|
|
1580
|
+
} catch (_) {
|
|
1581
|
+
error.message += ` The page could not be returned to ${previousUrl} and is now ${page.url()}.`;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1480
1585
|
/**
|
|
1481
1586
|
* What the navigation that just finished reached: the page, or a wall. The
|
|
1482
1587
|
* same verdict the chain's final document gets, run on this document with
|
|
@@ -27,6 +27,7 @@ import { fenceUntrusted } from '../utils/untrustedContent.js';
|
|
|
27
27
|
import { stealthDocumentVerdict as documentVerdict } from '../utils/stealthVerdict.js';
|
|
28
28
|
import { aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
29
|
import { AGENT_MAX_ESCALATIONS } from '../tools/agent/escalation.js';
|
|
30
|
+
import { siteLabel, corroboratedOrigins, updateLabelledDates } from '../tools/agent/sourceChecks.js';
|
|
30
31
|
|
|
31
32
|
const DEFAULT_WALL_CLOCK_MS = 120_000;
|
|
32
33
|
const DEFAULT_MAX_STEPS = 5;
|
|
@@ -280,13 +281,6 @@ export function isCurrentStateTask(prompt) {
|
|
|
280
281
|
return CURRENT_STATE_RE.test(prompt || '');
|
|
281
282
|
}
|
|
282
283
|
|
|
283
|
-
/** The registrable label of a hostname: "python" for www.python.org, "bbc" for bbc.co.uk. */
|
|
284
|
-
function siteLabel(hostname) {
|
|
285
|
-
const labels = hostname.toLowerCase().split('.');
|
|
286
|
-
const ccSecondLevel = labels.length >= 3 && labels.at(-1).length === 2 && labels.at(-2).length <= 3;
|
|
287
|
-
return labels[Math.max(labels.length - (ccSecondLevel ? 3 : 2), 0)];
|
|
288
|
-
}
|
|
289
|
-
|
|
290
284
|
/**
|
|
291
285
|
* True when a search result is the entity's own site: its domain is the
|
|
292
286
|
* entity's name (python.org for "Python"), or it is a site root whose title
|
|
@@ -580,15 +574,22 @@ export class AgentOrchestrator {
|
|
|
580
574
|
// live front page, which rarely states the fact asked for: "what does
|
|
581
575
|
// Southwest charge for a first checked bag" fetched southwest.com's home,
|
|
582
576
|
// booking and careers pages and answered that the fee is not stated
|
|
583
|
-
// (R20, 2026-09-07).
|
|
584
|
-
//
|
|
585
|
-
//
|
|
577
|
+
// (R20, 2026-09-07). Add the task's own words as a second query so the
|
|
578
|
+
// page that states the fact is in the queue too. A one-word name is also
|
|
579
|
+
// ambiguous: "Latest stable Rust version and its release date" searched
|
|
580
|
+
// "Rust" first and got the game and the film (live sweep, 2026-10-20).
|
|
581
|
+
// Then the task's words, its noun phrase intact, are searched first. The
|
|
582
|
+
// entity query is searched either way: the domain vote and live-root
|
|
583
|
+
// promotion below read its results.
|
|
584
|
+
const entityQuery = searchQueries[0];
|
|
585
|
+
const oneWordEntity = !/\s/.test(entityQuery.trim());
|
|
586
586
|
if (currentState && searchQueries.length === 1) {
|
|
587
587
|
try {
|
|
588
588
|
const { compactSearchTopic, clampSearchQuery } = await import('./ResearchOrchestrator.js');
|
|
589
589
|
const factQuery = clampSearchQuery(compactSearchTopic(prompt));
|
|
590
|
-
if (factQuery && factQuery.toLowerCase() !==
|
|
591
|
-
searchQueries.
|
|
590
|
+
if (factQuery && factQuery.toLowerCase() !== entityQuery.toLowerCase()) {
|
|
591
|
+
if (oneWordEntity) searchQueries.unshift(factQuery);
|
|
592
|
+
else searchQueries.push(factQuery);
|
|
592
593
|
}
|
|
593
594
|
} catch { /* the entity query alone is the pre-R20 behaviour */ }
|
|
594
595
|
}
|
|
@@ -656,7 +657,6 @@ export class AgentOrchestrator {
|
|
|
656
657
|
const official = new Set();
|
|
657
658
|
// Only the bare entity query votes: the fact query added above returns
|
|
658
659
|
// guides and news sites, which must not outvote the official domain.
|
|
659
|
-
const entityQuery = searchQueries[0];
|
|
660
660
|
for (const s of searchResults) {
|
|
661
661
|
if (s.query !== entityQuery) continue;
|
|
662
662
|
try {
|
|
@@ -665,6 +665,22 @@ export class AgentOrchestrator {
|
|
|
665
665
|
if (isOfficialResult(entityQuery, s.url, s.title)) official.add(origin);
|
|
666
666
|
} catch { /* unparsable result url */ }
|
|
667
667
|
}
|
|
668
|
+
// For a one-word name, a site the task's own search does not surface is
|
|
669
|
+
// a namesake, not the subject: rust.facepunch.com, a root titled "Rust",
|
|
670
|
+
// passed as official for the Rust language and was blamed in the
|
|
671
|
+
// degraded reason (live sweep, 2026-10-20).
|
|
672
|
+
const onTopic = oneWordEntity
|
|
673
|
+
? corroboratedOrigins(originCounts.keys(), searchResults.filter(s => s.query !== entityQuery).map(s => s.url))
|
|
674
|
+
: new Set(originCounts.keys());
|
|
675
|
+
for (const origin of official) if (!onTopic.has(origin)) official.delete(origin);
|
|
676
|
+
// The namesakes' pages are not fetched either: the game's site is not a
|
|
677
|
+
// source for the language's release.
|
|
678
|
+
for (let i = urlQueue.length - 1; i >= 0; i--) {
|
|
679
|
+
try {
|
|
680
|
+
if (!namedUrls.has(urlQueue[i]) && originCounts.has(new URL(urlQueue[i]).origin) &&
|
|
681
|
+
!onTopic.has(new URL(urlQueue[i]).origin)) urlQueue.splice(i, 1);
|
|
682
|
+
} catch { /* unparsable url stays */ }
|
|
683
|
+
}
|
|
668
684
|
// The entity's own site wins over any vote count; only when no result
|
|
669
685
|
// is official does the vote decide, and a tied vote decides nothing —
|
|
670
686
|
// with one result per origin it would just promote the top-ranked one.
|
|
@@ -672,6 +688,7 @@ export class AgentOrchestrator {
|
|
|
672
688
|
let bestCount = 0;
|
|
673
689
|
let tied = false;
|
|
674
690
|
for (const [origin, count] of originCounts) {
|
|
691
|
+
if (!onTopic.has(origin)) continue;
|
|
675
692
|
if (official.size > 0 && !official.has(origin)) continue;
|
|
676
693
|
// Strict > keeps the earliest-seen (top-ranked) origin on ties.
|
|
677
694
|
if (count > bestCount) { bestOrigin = origin; bestCount = count; tied = false; }
|
|
@@ -896,6 +913,7 @@ export class AgentOrchestrator {
|
|
|
896
913
|
let unverified = [];
|
|
897
914
|
let unverifiedLinks = [];
|
|
898
915
|
let forumOnlyVersions = [];
|
|
916
|
+
let updateDates = [];
|
|
899
917
|
// Distinct from `degraded`: a run can be degraded because its version
|
|
900
918
|
// claims are unsupported and still have had its provenance checked.
|
|
901
919
|
let synthesisFailed = false;
|
|
@@ -926,6 +944,7 @@ export class AgentOrchestrator {
|
|
|
926
944
|
`- Answer ONLY from the provided sources; do not use outside knowledge.\n` +
|
|
927
945
|
`- Read the sources carefully before concluding anything is missing from them.\n` +
|
|
928
946
|
`- Cite the exact URL(s) you used: a source URL, or the URL an item has under "Links on this page".\n` +
|
|
947
|
+
`- For a date, use the one the sources give for the event itself (e.g. "released", "release date"), never a page's "last updated" or "modified" date, and quote the sentence or table row it came from.\n` +
|
|
929
948
|
`- If, after careful reading, the sources genuinely do not contain the answer, say so explicitly.\n` +
|
|
930
949
|
`- NEVER invent or guess a URL; cite only URLs that appear in the sources above.\n\n` +
|
|
931
950
|
`Provide a clear, concise answer.`;
|
|
@@ -933,6 +952,29 @@ export class AgentOrchestrator {
|
|
|
933
952
|
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
934
953
|
answer = text;
|
|
935
954
|
|
|
955
|
+
// A page's "Last updated on 16 October 2026" is not the release date
|
|
956
|
+
// its table gives (15 Oct 2026), and the provenance check below passes
|
|
957
|
+
// it: the string is on the page (live sweep, 2026-10-20). A date the
|
|
958
|
+
// sources state only as an update date gets one corrective rewrite
|
|
959
|
+
// before the provenance check; what survives it is flagged below.
|
|
960
|
+
updateDates = updateLabelledDates(answer, evidence, prompt);
|
|
961
|
+
if (updateDates.length > 0 && remaining() >= REWRITE_MIN_REMAINING_MS) {
|
|
962
|
+
const dateRetryPrompt =
|
|
963
|
+
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
964
|
+
`It gives ${updateDates.map(d => `"${d.date}"`).join(', ')}, which the sources state only as the date a page was last updated or modified. ` +
|
|
965
|
+
`Rewrite the answer using the date the sources give for the event itself (e.g. "released", "release date", the date in the row of the version asked about), ` +
|
|
966
|
+
`and quote the sentence or table row it came from. If the sources give no such date, say so.`;
|
|
967
|
+
try {
|
|
968
|
+
const { text: rewritten } = await this._getSamplingClient().complete(dateRetryPrompt, { maxTokens: 1024, timeoutMs: remaining() });
|
|
969
|
+
if (rewritten && rewritten.trim()) {
|
|
970
|
+
answer = rewritten;
|
|
971
|
+
updateDates = updateLabelledDates(answer, evidence, prompt);
|
|
972
|
+
}
|
|
973
|
+
} catch {
|
|
974
|
+
// Keep the first answer; it is flagged below.
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
936
978
|
// A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
|
|
937
979
|
// published January 18, 2024" — none of it on any fetched page (R17,
|
|
938
980
|
// 2026-09-04). Versions, dates and counts in the answer must appear in
|
|
@@ -1012,6 +1054,20 @@ export class AgentOrchestrator {
|
|
|
1012
1054
|
}
|
|
1013
1055
|
}
|
|
1014
1056
|
|
|
1057
|
+
// A provenance or version rewrite above may have changed the dates.
|
|
1058
|
+
updateDates = updateLabelledDates(answer, evidence, prompt);
|
|
1059
|
+
if (updateDates.length > 0) {
|
|
1060
|
+
answer +=
|
|
1061
|
+
`\n\nDate warning: ${updateDates.map(d => `"${d.date}"`).join(', ')} ` +
|
|
1062
|
+
`${updateDates.length === 1 ? 'is' : 'are'} the date a source page was last updated, not the date of the event asked about.`;
|
|
1063
|
+
if (!degraded) {
|
|
1064
|
+
degraded = true;
|
|
1065
|
+
degradedReason =
|
|
1066
|
+
`The answer gives a page-update date as the date asked for: ` +
|
|
1067
|
+
updateDates.map(d => `"${d.date}" is labelled as an update date on ${d.url}`).join('; ') + '.';
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1015
1071
|
// A promoted live page is only worth its +1000 if the answer came from
|
|
1016
1072
|
// it. R25 (2026-10-19) answered "not stated" over a promoted Wikipedia
|
|
1017
1073
|
// Main Page, and the HN question from thehackernews.com, both with
|
|
@@ -1069,7 +1125,8 @@ export class AgentOrchestrator {
|
|
|
1069
1125
|
checked: !synthesisFailed,
|
|
1070
1126
|
unverified,
|
|
1071
1127
|
...(unverifiedLinks.length > 0 ? { unverified_urls: unverifiedLinks } : {}),
|
|
1072
|
-
...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
|
|
1128
|
+
...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {}),
|
|
1129
|
+
...(updateDates.length > 0 ? { update_dates: updateDates } : {})
|
|
1073
1130
|
}
|
|
1074
1131
|
};
|
|
1075
1132
|
}
|
package/src/core/AuthManager.js
CHANGED
|
@@ -769,7 +769,7 @@ class AuthManager {
|
|
|
769
769
|
: 'Browser operation. configure/get_stats/cleanup cost 1 credit each.';
|
|
770
770
|
break;
|
|
771
771
|
case 'browser_session':
|
|
772
|
-
note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call
|
|
772
|
+
note = `Priced per operation: open 3, read 2, snapshot/act/screenshot/close/list 1. This call's list price is ${projected}.`;
|
|
773
773
|
break;
|
|
774
774
|
case 'serp_rank':
|
|
775
775
|
note = projected === 0
|
|
@@ -811,6 +811,12 @@ class AuthManager {
|
|
|
811
811
|
note += ` redact_pii mode:"model" adds ${REDACT_PII_MODEL_CREDITS} once for the PERSON/LOCATION pass; the charge drops back when no LLM route answered.`;
|
|
812
812
|
}
|
|
813
813
|
|
|
814
|
+
// Creator mode bills nothing, so withAuth reports _cost.projected and
|
|
815
|
+
// actual as 0; the note must not read as a charge next to them (6.10).
|
|
816
|
+
if (this.isCreatorMode()) {
|
|
817
|
+
note += ` This install is not billed (creator mode): the figures above are list prices, and _cost.projected and actual are 0.`;
|
|
818
|
+
}
|
|
819
|
+
|
|
814
820
|
return { projected, note };
|
|
815
821
|
}
|
|
816
822
|
|
|
@@ -28,6 +28,7 @@ const ChangeTrackingSchema = z.object({
|
|
|
28
28
|
trackLinks: z.boolean().default(true),
|
|
29
29
|
ignoreWhitespace: z.boolean().default(true),
|
|
30
30
|
ignoreCase: z.boolean().default(false),
|
|
31
|
+
includeMarkupChanges: z.boolean().default(false),
|
|
31
32
|
customSelectors: z.array(z.string()).optional(),
|
|
32
33
|
excludeSelectors: z.array(z.string()).optional().default([
|
|
33
34
|
'script', 'style', 'noscript', '.advertisement', '.ad'
|
|
@@ -344,10 +345,12 @@ export class ChangeTracker extends EventEmitter {
|
|
|
344
345
|
const currentAnalysis = await this.analyzeContent(currentContent, baseline.options);
|
|
345
346
|
|
|
346
347
|
// Perform comprehensive change detection
|
|
348
|
+
// includeMarkupChanges only shapes the response, so unlike the analysis
|
|
349
|
+
// options it may be asked for at compare time.
|
|
347
350
|
const changeAnalysis = await this.detectChanges(
|
|
348
351
|
baseline.analysis,
|
|
349
352
|
currentAnalysis,
|
|
350
|
-
baseline.options
|
|
353
|
+
{ ...baseline.options, includeMarkupChanges: options.includeMarkupChanges ?? baseline.options.includeMarkupChanges }
|
|
351
354
|
);
|
|
352
355
|
|
|
353
356
|
// Calculate change significance
|
|
@@ -410,7 +413,16 @@ export class ChangeTracker extends EventEmitter {
|
|
|
410
413
|
metrics: changeRecord.metrics,
|
|
411
414
|
recommendations: this.generateChangeRecommendations(changeRecord),
|
|
412
415
|
...(alerts.length ? { alerts } : {}),
|
|
416
|
+
// ignoredOptions is the structured form of the warning: each entry
|
|
417
|
+
// names what the caller asked for and what the baseline applied, so
|
|
418
|
+
// a compare at granularity "text" against a "section" baseline says
|
|
419
|
+
// so in a field, not only in prose (6.12).
|
|
413
420
|
...(ignoredOptions.length ? {
|
|
421
|
+
ignoredOptions: ignoredOptions.map(option => ({
|
|
422
|
+
option,
|
|
423
|
+
requested: options[option],
|
|
424
|
+
applied: baseline.options[option] ?? null
|
|
425
|
+
})),
|
|
414
426
|
warnings: [
|
|
415
427
|
`${ignoredOptions.join(', ')} passed to this compare ${ignoredOptions.length === 1 ? 'was' : 'were'} ignored — ` +
|
|
416
428
|
`the baseline's options are applied to both sides of the diff. Recreate the baseline to change them.`
|
|
@@ -615,13 +627,23 @@ export class ChangeTracker extends EventEmitter {
|
|
|
615
627
|
changes.modifiedElements.push(...elementChanges.modified);
|
|
616
628
|
}
|
|
617
629
|
|
|
618
|
-
// Detect text changes
|
|
630
|
+
// Detect text changes. Diffed over the visible text, not the raw HTML,
|
|
631
|
+
// so a class rename or icon markup no longer reads as a text change
|
|
632
|
+
// (live sweep 4.1). The raw markup diff is kept apart in markupChanges
|
|
633
|
+
// and only computed when the caller asks for it.
|
|
619
634
|
if (options.trackText) {
|
|
620
635
|
changes.textChanges = await this.detectTextChanges(
|
|
621
|
-
baseline.originalContent,
|
|
622
|
-
current.originalContent,
|
|
636
|
+
this.visibleText(baseline.originalContent),
|
|
637
|
+
this.visibleText(current.originalContent),
|
|
623
638
|
options
|
|
624
639
|
);
|
|
640
|
+
if (options.includeMarkupChanges) {
|
|
641
|
+
changes.markupChanges = await this.detectTextChanges(
|
|
642
|
+
baseline.originalContent,
|
|
643
|
+
current.originalContent,
|
|
644
|
+
options
|
|
645
|
+
);
|
|
646
|
+
}
|
|
625
647
|
}
|
|
626
648
|
|
|
627
649
|
// Detect monetary value changes. Scored by magnitude rather than by how
|
|
@@ -1038,6 +1060,32 @@ export class ChangeTracker extends EventEmitter {
|
|
|
1038
1060
|
return changes;
|
|
1039
1061
|
}
|
|
1040
1062
|
|
|
1063
|
+
/**
|
|
1064
|
+
* The text a reader sees: text nodes in document order, one per line, with
|
|
1065
|
+
* script, style, noscript and template content dropped. Input with no
|
|
1066
|
+
* markup (a plain-text content param) is returned unchanged.
|
|
1067
|
+
* @param {string} content - HTML or plain text
|
|
1068
|
+
* @returns {string} - Visible text
|
|
1069
|
+
*/
|
|
1070
|
+
visibleText(content = '') {
|
|
1071
|
+
if (!/<[a-z!/]/i.test(content)) return content;
|
|
1072
|
+
const $ = load(content);
|
|
1073
|
+
$('script, style, noscript, template').remove();
|
|
1074
|
+
const parts = [];
|
|
1075
|
+
const walk = nodes => {
|
|
1076
|
+
for (const node of nodes) {
|
|
1077
|
+
if (node.type === 'text') {
|
|
1078
|
+
const text = node.data.trim();
|
|
1079
|
+
if (text) parts.push(text);
|
|
1080
|
+
} else if (node.children) {
|
|
1081
|
+
walk(node.children);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
walk($.root().contents().toArray());
|
|
1086
|
+
return parts.join('\n');
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1041
1089
|
async detectTextChanges(baselineContent, currentContent, options = {}) {
|
|
1042
1090
|
const textChanges = [];
|
|
1043
1091
|
|