crawlforge-mcp-server 5.6.0 → 5.6.2
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/CLAUDE.md +6 -11
- package/README.md +1 -1
- package/package.json +2 -2
- package/server.js +19 -3
- package/src/core/AgentOrchestrator.js +6 -0
- package/src/core/LocalizationManager.js +6 -2
- package/src/core/StealthBrowserManager.js +155 -92
- package/src/core/analysis/ContentAnalyzer.js +5 -2
- package/src/core/llm/LLMManager.js +14 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +6 -4
- package/src/tools/advanced/ScrapeWithActionsTool.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +6 -1
- package/src/tools/extract/extractStructured.js +38 -5
- package/src/tools/extract/extractWithLlm.js +15 -13
- package/src/tools/scrape/unifiedScrape.js +15 -13
- package/src/tools/search/adapters/redditOfficialApi.js +1 -1
- package/src/tools/search/redditSearch.js +2 -2
- package/src/utils/challengeDetection.js +68 -0
- package/src/utils/extractionFormat.js +52 -0
- package/src/utils/languageDetection.js +20 -0
- package/src/utils/provenance.js +38 -2
- package/src/utils/robotsGate.js +24 -1
package/CLAUDE.md
CHANGED
|
@@ -60,9 +60,9 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
|
|
|
60
60
|
|
|
61
61
|
## Project Overview
|
|
62
62
|
|
|
63
|
-
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing
|
|
63
|
+
CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 29 web scraping, crawling, and content processing tools (6 inline + 23 advanced).
|
|
64
64
|
|
|
65
|
-
**Current Version:** 5.
|
|
65
|
+
**Current Version:** 5.6.0
|
|
66
66
|
|
|
67
67
|
## Development Commands
|
|
68
68
|
|
|
@@ -75,11 +75,6 @@ npm run setup
|
|
|
75
75
|
# Or provide API key via environment:
|
|
76
76
|
export CRAWLFORGE_API_KEY="your_api_key_here"
|
|
77
77
|
|
|
78
|
-
# Creator Mode (for package maintainer only)
|
|
79
|
-
# Set your creator secret in .env file:
|
|
80
|
-
# CRAWLFORGE_CREATOR_SECRET=your-secret-uuid
|
|
81
|
-
# This enables unlimited access for development/testing
|
|
82
|
-
|
|
83
78
|
# Run the server (production)
|
|
84
79
|
npm start
|
|
85
80
|
|
|
@@ -172,13 +167,13 @@ Tools are organized in subdirectories by category:
|
|
|
172
167
|
- `tracking/` - trackChanges
|
|
173
168
|
- `llmstxt/` - generateLLMsTxt
|
|
174
169
|
|
|
175
|
-
### Available MCP Tools (
|
|
170
|
+
### Available MCP Tools (29 total)
|
|
176
171
|
|
|
177
|
-
**Basic Tools (server.js inline,
|
|
178
|
-
fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
|
|
172
|
+
**Basic Tools (server.js inline, 6):**
|
|
173
|
+
fetch_url, extract_text, extract_links, extract_metadata, extract_embedded_state, scrape_structured
|
|
179
174
|
|
|
180
175
|
**Advanced Tools (23):**
|
|
181
|
-
search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
176
|
+
search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, get_batch_results, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
|
|
182
177
|
|
|
183
178
|
**serp_rank (DataForSEO):**
|
|
184
179
|
- `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002 per 10 results of `depth`, so $0.004 at the default `depth:20` and $0.02 at `depth:100`), separate from CrawlForge credits. When unconfigured it returns `{ configured:false }` and charges **0** credits; when configured, **Cost: 5**. Never fabricates a rank.
|
package/README.md
CHANGED
|
@@ -192,7 +192,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
192
192
|
| `summarize_content` | 4 | Generate intelligent summaries |
|
|
193
193
|
| `crawl_deep` | 4 | Deep crawl entire websites |
|
|
194
194
|
| `search_web` | 5 | Search the web using Google Search API |
|
|
195
|
-
| `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this
|
|
195
|
+
| `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive (free, no Reddit credentials). A Reddit-wide search spends a web search to discover posts, so it is priced with `search_web` |
|
|
196
196
|
| `serp_rank` | 5 | Check where a domain ranks in Google's **real organic SERP** for a keyword (the position `search_web` can't give). Powered by DataForSEO (`DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD`, billed to your own DataForSEO account). Returns `{ configured:false }` and charges **0** credits until configured |
|
|
197
197
|
| `batch_scrape` | 5 | Process multiple URLs simultaneously |
|
|
198
198
|
| `scrape_with_actions` | 5 | Browser automation chains |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.2",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 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",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"cheerio": "^1.1.2",
|
|
115
115
|
"commander": "^14.0.3",
|
|
116
116
|
"compromise": "^14.14.4",
|
|
117
|
-
"crawlforge-extractors": "^1.6.
|
|
117
|
+
"crawlforge-extractors": "^1.6.2",
|
|
118
118
|
"diff": "^9.0.0",
|
|
119
119
|
"dotenv": "^17.2.1",
|
|
120
120
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
|
33
33
|
// Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
|
|
34
34
|
import * as cheerio from "cheerio";
|
|
35
35
|
import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
|
|
36
|
+
import { detectChallengePage } from "./src/utils/challengeDetection.js";
|
|
36
37
|
import { browserPreflight } from "./src/utils/robotsGate.js";
|
|
37
38
|
import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
|
|
38
39
|
import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
|
|
@@ -105,7 +106,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
105
106
|
// Create the server
|
|
106
107
|
const server = new McpServer({
|
|
107
108
|
name: "crawlforge",
|
|
108
|
-
version: "5.6.
|
|
109
|
+
version: "5.6.2",
|
|
109
110
|
description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, 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.",
|
|
110
111
|
homepage: "https://www.crawlforge.dev",
|
|
111
112
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1377,12 +1378,21 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1377
1378
|
stealthConfig
|
|
1378
1379
|
});
|
|
1379
1380
|
|
|
1381
|
+
// A bot-wall interstitial arrives as HTTP 200 with a title and prose
|
|
1382
|
+
// of its own; reported as a successful scrape it hid the block for
|
|
1383
|
+
// three rounds (R10 Q1 → R15). Name it — the content is still returned
|
|
1384
|
+
// so the caller can see what the site served.
|
|
1385
|
+
const challenge = detectChallengePage(scraped);
|
|
1380
1386
|
result = {
|
|
1381
|
-
success:
|
|
1387
|
+
success: !challenge,
|
|
1382
1388
|
url: scraped.url,
|
|
1383
1389
|
title: scraped.title,
|
|
1384
1390
|
content: stealthScrapeFormats(formats, scraped)
|
|
1385
1391
|
};
|
|
1392
|
+
if (challenge) {
|
|
1393
|
+
result.blocked = challenge;
|
|
1394
|
+
result.error = `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`;
|
|
1395
|
+
}
|
|
1386
1396
|
|
|
1387
1397
|
// Screenshots follow the same crawlforge://screenshot/{id} pattern as
|
|
1388
1398
|
// scrape and scrape_with_actions, so the base64 never bloats the result.
|
|
@@ -1452,6 +1462,12 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1452
1462
|
ok: response ? response.ok() : null,
|
|
1453
1463
|
title: await page.title().catch(() => null)
|
|
1454
1464
|
};
|
|
1465
|
+
const challenge = detectChallengePage({
|
|
1466
|
+
title: navigation.title || '',
|
|
1467
|
+
html: await page.content().catch(() => ''),
|
|
1468
|
+
text: await page.innerText('body').catch(() => '')
|
|
1469
|
+
});
|
|
1470
|
+
if (challenge) navigation.blocked = challenge;
|
|
1455
1471
|
}
|
|
1456
1472
|
} finally {
|
|
1457
1473
|
// No operation can ever reference this page again — keeping it open
|
|
@@ -1574,7 +1590,7 @@ registerToolIfEnabled("localization", {
|
|
|
1574
1590
|
break;
|
|
1575
1591
|
case 'generate_timezone_spoof':
|
|
1576
1592
|
result = {
|
|
1577
|
-
timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode),
|
|
1593
|
+
timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode, params.timezone),
|
|
1578
1594
|
countryCode: params.countryCode || localizationManager.getCurrentSettings().countryCode
|
|
1579
1595
|
};
|
|
1580
1596
|
break;
|
|
@@ -203,7 +203,13 @@ export class AgentOrchestrator {
|
|
|
203
203
|
const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
|
|
204
204
|
const lines = text.split('\n')
|
|
205
205
|
.map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
|
|
206
|
+
// A model that fences its list ("```\nq1\nq2\n```") or quotes each
|
|
207
|
+
// query leaks the fence and quote characters into the queries; the
|
|
208
|
+
// R14 Julia run spent two of its three searches on the literal "```".
|
|
209
|
+
.map(l => l.replace(/^```\w*\s*|\s*```$/g, '').replace(/^[`"']+|[`"']+$/g, '').trim())
|
|
206
210
|
.filter(Boolean)
|
|
211
|
+
// A line with no letter or digit left is punctuation, not a query.
|
|
212
|
+
.filter(l => /[\p{L}\p{N}]/u.test(l))
|
|
207
213
|
// Drop preamble/garbage lines ("Here are 3 concise web search queries:", …)
|
|
208
214
|
// so they never become search query #1 and poison the URL queue.
|
|
209
215
|
.filter(l =>
|
|
@@ -428,9 +428,13 @@ export class LocalizationManager extends EventEmitter {
|
|
|
428
428
|
* @param {string} countryCode - Target country code
|
|
429
429
|
* @returns {string} - JavaScript injection code
|
|
430
430
|
*/
|
|
431
|
-
async generateTimezoneSpoof(countryCode = null) {
|
|
431
|
+
async generateTimezoneSpoof(countryCode = null, timezone = null) {
|
|
432
432
|
const targetCountry = countryCode || this.currentSettings.countryCode;
|
|
433
|
-
|
|
433
|
+
let config = await this.getLocalizationConfig(targetCountry);
|
|
434
|
+
// An explicit IANA timezone wins over the country default: the tool
|
|
435
|
+
// accepted `timezone` and silently answered with the configured country's
|
|
436
|
+
// zone (Asia/Tokyo asked, Europe/Berlin returned — R14).
|
|
437
|
+
if (timezone) config = { ...config, timezone };
|
|
434
438
|
|
|
435
439
|
const timezoneOffset = this.getTimezoneOffset(config.timezone);
|
|
436
440
|
|
|
@@ -140,47 +140,50 @@ export class StealthBrowserManager {
|
|
|
140
140
|
}
|
|
141
141
|
};
|
|
142
142
|
|
|
143
|
-
//
|
|
143
|
+
// User agent pools. Chrome majors track the bundled Chromium (151 at
|
|
144
|
+
// playwright-core 1.62) and Firefox tracks the Camoufox build (135): a
|
|
145
|
+
// UA thirty majors behind the engine — the pool sat at 119–121 while
|
|
146
|
+
// Chrome 152 shipped — is a staleness tell for anything that reads
|
|
147
|
+
// sec-ch-ua (R15, 2026-09-04).
|
|
144
148
|
this.userAgentPools = {
|
|
145
149
|
chrome: {
|
|
146
150
|
windows: [
|
|
147
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
148
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
149
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
150
|
-
'Mozilla/5.0 (Windows NT
|
|
151
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
152
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
153
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
|
|
154
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
|
|
151
155
|
],
|
|
152
156
|
macos: [
|
|
153
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
154
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
155
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X
|
|
157
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
158
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
159
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
|
156
160
|
],
|
|
157
161
|
linux: [
|
|
158
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
159
|
-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
162
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
|
|
163
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
|
|
164
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
|
|
160
165
|
]
|
|
161
166
|
},
|
|
162
167
|
firefox: {
|
|
163
168
|
windows: [
|
|
164
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:
|
|
165
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
|
|
169
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0'
|
|
166
170
|
],
|
|
167
171
|
macos: [
|
|
168
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:
|
|
169
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0'
|
|
172
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0'
|
|
170
173
|
],
|
|
171
174
|
linux: [
|
|
172
|
-
'Mozilla/5.0 (X11; Linux x86_64; rv:
|
|
173
|
-
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:
|
|
175
|
+
'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0',
|
|
176
|
+
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0'
|
|
174
177
|
]
|
|
175
178
|
},
|
|
176
179
|
safari: {
|
|
177
180
|
macos: [
|
|
178
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/
|
|
179
|
-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/
|
|
181
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15',
|
|
182
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
|
|
180
183
|
]
|
|
181
184
|
}
|
|
182
185
|
};
|
|
183
|
-
|
|
186
|
+
|
|
184
187
|
// Operating system distributions for realistic user agent selection
|
|
185
188
|
this.osDistribution = {
|
|
186
189
|
windows: 0.75,
|
|
@@ -467,8 +470,10 @@ export class StealthBrowserManager {
|
|
|
467
470
|
height: fingerprint.screen.height
|
|
468
471
|
},
|
|
469
472
|
|
|
470
|
-
//
|
|
471
|
-
|
|
473
|
+
// bypassCSP is deliberately NOT set: ignoring a page's Content Security
|
|
474
|
+
// Policy is "invalid behavior for a normal browser" and rebrowser's
|
|
475
|
+
// bot detector flags it red (R15, 2026-09-04). Every init script here
|
|
476
|
+
// goes through addInitScript/evaluate, which need no CSP bypass.
|
|
472
477
|
javaScriptEnabled: true
|
|
473
478
|
};
|
|
474
479
|
|
|
@@ -616,6 +621,14 @@ export class StealthBrowserManager {
|
|
|
616
621
|
|
|
617
622
|
// Select browser based on distribution and OS compatibility
|
|
618
623
|
let availableBrowsers = { ...this.browserDistribution };
|
|
624
|
+
// The engine decides the pool. Chromium exposes navigator.vendor
|
|
625
|
+
// "Google Inc.", window.chrome and Chrome's PDF viewer plugins, so a
|
|
626
|
+
// Firefox or Safari User-Agent on it contradicts what the page can see —
|
|
627
|
+
// sannysoft printed a Firefox UA beside "Chrome: present" (R14). Camoufox
|
|
628
|
+
// presents its own Firefox identity and never draws from this pool.
|
|
629
|
+
if (config.engine !== 'camoufox') {
|
|
630
|
+
availableBrowsers = { chrome: 1 };
|
|
631
|
+
}
|
|
619
632
|
if (selectedOS === 'linux' && availableBrowsers.safari) {
|
|
620
633
|
delete availableBrowsers.safari;
|
|
621
634
|
// Redistribute safari's weight
|
|
@@ -696,10 +709,10 @@ export class StealthBrowserManager {
|
|
|
696
709
|
* @param {string} [userAgent] — the selected user agent string
|
|
697
710
|
*/
|
|
698
711
|
generateSecChUaHeader(userAgent = '') {
|
|
699
|
-
// Extract Chrome major version from the UA (e.g. "Chrome/
|
|
700
|
-
// Fall back to
|
|
712
|
+
// Extract Chrome major version from the UA (e.g. "Chrome/151.0.0.0" → "151").
|
|
713
|
+
// Fall back to the bundled Chromium major if the UA is not a Chrome UA.
|
|
701
714
|
const match = userAgent.match(/Chrome\/(\d+)/i);
|
|
702
|
-
const version = match ? match[1] : '
|
|
715
|
+
const version = match ? match[1] : '151';
|
|
703
716
|
|
|
704
717
|
const brands = [
|
|
705
718
|
{ brand: 'Not_A Brand', version: '8' },
|
|
@@ -1005,7 +1018,9 @@ export class StealthBrowserManager {
|
|
|
1005
1018
|
processor: selectedProcessor.name,
|
|
1006
1019
|
architecture: 'x86_64',
|
|
1007
1020
|
memory: Math.floor(Math.random() * 24) + 8, // 8-32 GB
|
|
1008
|
-
|
|
1021
|
+
// navigator.deviceMemory is capped at 8 in every Chromium build, so 16
|
|
1022
|
+
// or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL).
|
|
1023
|
+
deviceMemory: Math.random() < 0.7 ? 8 : 4,
|
|
1009
1024
|
platform: this.selectRealisticPlatform(selectedOS)
|
|
1010
1025
|
};
|
|
1011
1026
|
}
|
|
@@ -1215,29 +1230,84 @@ export class StealthBrowserManager {
|
|
|
1215
1230
|
})
|
|
1216
1231
|
});
|
|
1217
1232
|
|
|
1218
|
-
//
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1233
|
+
// Plugin list. New headless Chromium already reports the same five PDF
|
|
1234
|
+
// viewer entries as a real PluginArray, and replacing them with a plain
|
|
1235
|
+
// Array fails the "plugins is of type PluginArray" check every scanner
|
|
1236
|
+
// runs. Only an empty list — the legacy headless tell — is replaced.
|
|
1237
|
+
if (navigator.plugins.length === 0 && typeof PluginArray !== 'undefined') {
|
|
1238
|
+
// Built on the real prototypes so `instanceof PluginArray`, the
|
|
1239
|
+
// toStringTag and the DOM-style accessors all hold — a plain Array here
|
|
1240
|
+
// failed bot.sannysoft.com's "plugins is of type PluginArray" check.
|
|
1241
|
+
// The five entries are what every desktop Chrome reports.
|
|
1242
|
+
const mimeSpecs = [
|
|
1243
|
+
{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
|
|
1244
|
+
{ type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format' }
|
|
1245
|
+
];
|
|
1246
|
+
const makePlugin = (name) => {
|
|
1247
|
+
const plugin = Object.create(Plugin.prototype);
|
|
1248
|
+
const mimeTypes = mimeSpecs.map((spec) => {
|
|
1249
|
+
const mimeType = Object.create(MimeType.prototype);
|
|
1250
|
+
Object.defineProperties(mimeType, {
|
|
1251
|
+
type: { value: spec.type },
|
|
1252
|
+
suffixes: { value: spec.suffixes },
|
|
1253
|
+
description: { value: spec.description },
|
|
1254
|
+
enabledPlugin: { get: () => plugin }
|
|
1255
|
+
});
|
|
1256
|
+
return mimeType;
|
|
1257
|
+
});
|
|
1258
|
+
Object.defineProperties(plugin, {
|
|
1259
|
+
name: { value: name },
|
|
1260
|
+
filename: { value: 'internal-pdf-viewer' },
|
|
1261
|
+
description: { value: 'Portable Document Format' },
|
|
1262
|
+
length: { value: mimeTypes.length },
|
|
1263
|
+
item: { value: (index) => mimeTypes[index] || null },
|
|
1264
|
+
namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
|
|
1265
|
+
[Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
|
|
1266
|
+
});
|
|
1267
|
+
mimeTypes.forEach((mimeType, index) => {
|
|
1268
|
+
Object.defineProperty(plugin, index, { value: mimeType, enumerable: true });
|
|
1269
|
+
Object.defineProperty(plugin, mimeType.type, { value: mimeType });
|
|
1270
|
+
});
|
|
1271
|
+
return plugin;
|
|
1272
|
+
};
|
|
1273
|
+
const plugins = [
|
|
1274
|
+
'PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer',
|
|
1275
|
+
'Microsoft Edge PDF Viewer', 'WebKit built-in PDF'
|
|
1276
|
+
].map(makePlugin);
|
|
1277
|
+
const pluginArray = Object.create(PluginArray.prototype);
|
|
1278
|
+
Object.defineProperties(pluginArray, {
|
|
1279
|
+
length: { value: plugins.length },
|
|
1280
|
+
item: { value: (index) => plugins[index] || null },
|
|
1281
|
+
namedItem: { value: (name) => plugins.find((p) => p.name === name) || null },
|
|
1282
|
+
refresh: { value: () => {} },
|
|
1283
|
+
[Symbol.iterator]: { value: function* () { yield* plugins; } }
|
|
1284
|
+
});
|
|
1285
|
+
plugins.forEach((plugin, index) => {
|
|
1286
|
+
Object.defineProperty(pluginArray, index, { value: plugin, enumerable: true });
|
|
1287
|
+
Object.defineProperty(pluginArray, plugin.name, { value: plugin });
|
|
1288
|
+
});
|
|
1289
|
+
Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
|
|
1290
|
+
|
|
1291
|
+
// Real Chrome pairs those plugins with two navigator.mimeTypes entries
|
|
1292
|
+
// (application/pdf, text/pdf). Five plugins beside an empty mimeTypes
|
|
1293
|
+
// is its own tell — infosimples' detect-headless read "5 plugins /
|
|
1294
|
+
// 0 mime types" (R15, 2026-09-04).
|
|
1295
|
+
if (typeof MimeTypeArray !== 'undefined') {
|
|
1296
|
+
const mimeTypeArray = Object.create(MimeTypeArray.prototype);
|
|
1297
|
+
const mimeTypes = Array.from(plugins[0]);
|
|
1298
|
+
Object.defineProperties(mimeTypeArray, {
|
|
1299
|
+
length: { value: mimeTypes.length },
|
|
1300
|
+
item: { value: (index) => mimeTypes[index] || null },
|
|
1301
|
+
namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
|
|
1302
|
+
[Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
|
|
1303
|
+
});
|
|
1304
|
+
mimeTypes.forEach((mimeType, index) => {
|
|
1305
|
+
Object.defineProperty(mimeTypeArray, index, { value: mimeType, enumerable: true });
|
|
1306
|
+
Object.defineProperty(mimeTypeArray, mimeType.type, { value: mimeType });
|
|
1307
|
+
});
|
|
1308
|
+
Object.defineProperty(navigator, 'mimeTypes', { get: () => mimeTypeArray });
|
|
1239
1309
|
}
|
|
1240
|
-
}
|
|
1310
|
+
}
|
|
1241
1311
|
|
|
1242
1312
|
// Override languages with the fingerprint's own locale — a hardcoded
|
|
1243
1313
|
// en-US here contradicts navigator.language and Accept-Language whenever
|
|
@@ -1367,14 +1437,23 @@ export class StealthBrowserManager {
|
|
|
1367
1437
|
HTMLCanvasElement.prototype.getContext = function(contextType, contextAttributes) {
|
|
1368
1438
|
const ctx = getContext.call(this, contextType, contextAttributes);
|
|
1369
1439
|
|
|
1370
|
-
|
|
1440
|
+
// Headless Chromium without a GPU path answers getContext('webgl')
|
|
1441
|
+
// with null; wrapping it threw a TypeError where a real browser
|
|
1442
|
+
// returns null, which is itself a tell.
|
|
1443
|
+
if (ctx && (contextType === 'webgl' || contextType === 'experimental-webgl')) {
|
|
1371
1444
|
const originalGetParameter = ctx.getParameter;
|
|
1445
|
+
// Real Chrome answers VENDOR/RENDERER with "WebKit"/"WebKit WebGL";
|
|
1446
|
+
// the GPU identity lives behind WEBGL_debug_renderer_info as the
|
|
1447
|
+
// UNMASKED_VENDOR_WEBGL (37445) / UNMASKED_RENDERER_WEBGL (37446)
|
|
1448
|
+
// parameters. Those are what a scanner reads, so those are spoofed.
|
|
1449
|
+
const UNMASKED_VENDOR_WEBGL = 37445;
|
|
1450
|
+
const UNMASKED_RENDERER_WEBGL = 37446;
|
|
1372
1451
|
ctx.getParameter = function(parameter) {
|
|
1373
1452
|
// Spoof specific WebGL parameters
|
|
1374
|
-
if (parameter ===
|
|
1453
|
+
if (parameter === UNMASKED_RENDERER_WEBGL) {
|
|
1375
1454
|
return webglConfig.renderer;
|
|
1376
1455
|
}
|
|
1377
|
-
if (parameter ===
|
|
1456
|
+
if (parameter === UNMASKED_VENDOR_WEBGL) {
|
|
1378
1457
|
return webglConfig.vendor;
|
|
1379
1458
|
}
|
|
1380
1459
|
if (parameter === ctx.VERSION) {
|
|
@@ -1387,13 +1466,10 @@ export class StealthBrowserManager {
|
|
|
1387
1466
|
return originalGetParameter.call(this, parameter);
|
|
1388
1467
|
};
|
|
1389
1468
|
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
}
|
|
1395
|
-
return null;
|
|
1396
|
-
};
|
|
1469
|
+
// getExtension is left alone: returning null for an extension the
|
|
1470
|
+
// fingerprint's list did not name made WEBGL_debug_renderer_info
|
|
1471
|
+
// vanish, and a page that reads it threw a TypeError where a real
|
|
1472
|
+
// browser reports a GPU (sannysoft "WebGL Vendor: Error", R14).
|
|
1397
1473
|
}
|
|
1398
1474
|
|
|
1399
1475
|
return ctx;
|
|
@@ -1797,47 +1873,34 @@ export class StealthBrowserManager {
|
|
|
1797
1873
|
}
|
|
1798
1874
|
}
|
|
1799
1875
|
|
|
1876
|
+
/**
|
|
1877
|
+
* Whether a request is dropped before it leaves the browser. Only the
|
|
1878
|
+
* advanced level sheds assets, never by URL and never the document itself.
|
|
1879
|
+
* @param {string} resourceType - Playwright's request.resourceType()
|
|
1880
|
+
* @param {string} level - stealth level
|
|
1881
|
+
* @returns {boolean}
|
|
1882
|
+
*/
|
|
1883
|
+
static shouldAbortRequest(resourceType, level) {
|
|
1884
|
+
if (level !== 'advanced') return false;
|
|
1885
|
+
if (!['image', 'font', 'stylesheet'].includes(resourceType)) return false;
|
|
1886
|
+
// Allow some images/fonts to maintain realism
|
|
1887
|
+
return Math.random() >= 0.3;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1800
1890
|
/**
|
|
1801
1891
|
* Apply page-level stealth measures
|
|
1802
1892
|
*/
|
|
1803
1893
|
async applyPageStealthMeasures(page, config, fingerprint) {
|
|
1804
|
-
//
|
|
1894
|
+
// Resource routing. Nothing is aborted by URL any more: the old list
|
|
1895
|
+
// blocked challenges.cloudflare.com and every URL containing "selenium",
|
|
1896
|
+
// "webdriver" or "puppeteer" — the first meant a Cloudflare challenge
|
|
1897
|
+
// could never complete (the interstitial named the blocked host), the
|
|
1898
|
+
// second killed the top-level navigation to www.selenium.dev on every
|
|
1899
|
+
// stealth path (R15, 2026-09-04). A vendor script that never loads is a
|
|
1900
|
+
// louder tell than one that runs.
|
|
1805
1901
|
await page.route('**/*', route => {
|
|
1806
|
-
|
|
1807
|
-
const url = route.request().url();
|
|
1808
|
-
|
|
1809
|
-
// Block known bot detection resources
|
|
1810
|
-
const blockedDomains = [
|
|
1811
|
-
'botd.fpjs.io',
|
|
1812
|
-
'challenges.cloudflare.com',
|
|
1813
|
-
'datadome.co',
|
|
1814
|
-
'perimeterx.net',
|
|
1815
|
-
'distilnetworks.com'
|
|
1816
|
-
];
|
|
1817
|
-
|
|
1818
|
-
if (blockedDomains.some(domain => url.includes(domain))) {
|
|
1819
|
-
route.abort();
|
|
1820
|
-
return;
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
// Don't block detection-related resources that might be expected
|
|
1824
|
-
if (url.includes('webdriver') || url.includes('selenium') || url.includes('puppeteer')) {
|
|
1902
|
+
if (StealthBrowserManager.shouldAbortRequest(route.request().resourceType(), config.level)) {
|
|
1825
1903
|
route.abort();
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
|
|
1829
|
-
// Selective resource blocking based on level
|
|
1830
|
-
if (config.level === 'advanced') {
|
|
1831
|
-
if (['image', 'font', 'stylesheet'].includes(resourceType)) {
|
|
1832
|
-
// Allow some images/fonts to maintain realism
|
|
1833
|
-
if (Math.random() < 0.3) {
|
|
1834
|
-
route.continue();
|
|
1835
|
-
} else {
|
|
1836
|
-
route.abort();
|
|
1837
|
-
}
|
|
1838
|
-
} else {
|
|
1839
|
-
route.continue();
|
|
1840
|
-
}
|
|
1841
1904
|
} else {
|
|
1842
1905
|
route.continue();
|
|
1843
1906
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import {
|
|
7
7
|
LANGUAGE_NAMES,
|
|
8
8
|
cjkScriptCounts as sharedCjkScriptCounts,
|
|
9
|
-
detectLanguage as sharedDetectLanguage
|
|
9
|
+
detectLanguage as sharedDetectLanguage,
|
|
10
|
+
needsWordSegmentation
|
|
10
11
|
} from '../../utils/languageDetection.js';
|
|
11
12
|
import nlp from 'compromise';
|
|
12
13
|
import { z } from 'zod';
|
|
@@ -168,7 +169,9 @@ export class ContentAnalyzer {
|
|
|
168
169
|
* @returns {string[]} - Word tokens
|
|
169
170
|
*/
|
|
170
171
|
tokenizeWords(text) {
|
|
171
|
-
|
|
172
|
+
// CJK and the unspaced South-East Asian scripts (Thai, Lao, Khmer,
|
|
173
|
+
// Burmese) have no word spaces to split on.
|
|
174
|
+
return needsWordSegmentation(text)
|
|
172
175
|
? this.segmentWords(text)
|
|
173
176
|
: text.split(/\s+/).filter(w => w.length > 0);
|
|
174
177
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OpenAIProvider } from './OpenAIProvider.js';
|
|
2
|
+
import { extractionFormat } from '../../utils/extractionFormat.js';
|
|
2
3
|
import { AnthropicProvider } from './AnthropicProvider.js';
|
|
3
4
|
import { OllamaProvider } from './OllamaProvider.js';
|
|
4
5
|
import { Logger } from '../../utils/Logger.js';
|
|
@@ -919,7 +920,7 @@ Return the list and nothing else.`;
|
|
|
919
920
|
const schemaFields = Object.keys(schema.properties || {}).length;
|
|
920
921
|
const scaledTokens = Math.min(2000, Math.max(maxTokens, schemaFields * 100 + 500));
|
|
921
922
|
|
|
922
|
-
const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Do not include any explanation or markdown — only the raw JSON object.`;
|
|
923
|
+
const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Use null for any field the content does not state — never guess, infer, or fill a value from memory. Do not include any explanation or markdown — only the raw JSON object.`;
|
|
923
924
|
|
|
924
925
|
const schemaStr = JSON.stringify(schema, null, 2);
|
|
925
926
|
const guidance = userPrompt ? `\n\nExtraction guidance: ${userPrompt}` : '';
|
|
@@ -937,9 +938,11 @@ Extract the data and return valid JSON:`;
|
|
|
937
938
|
systemPrompt,
|
|
938
939
|
maxTokens: scaledTokens,
|
|
939
940
|
temperature: 0.1,
|
|
940
|
-
// Constrain the output to
|
|
941
|
-
//
|
|
942
|
-
|
|
941
|
+
// Constrain the output to the caller's shape with every field
|
|
942
|
+
// nullable, so a model shown content that does not state a field can
|
|
943
|
+
// answer null instead of being decoded into an invented string. Small
|
|
944
|
+
// local models otherwise wrap the JSON in prose and the parse throws.
|
|
945
|
+
format: extractionFormat(schema)
|
|
943
946
|
});
|
|
944
947
|
|
|
945
948
|
// Strip markdown code fences if present
|
|
@@ -972,7 +975,9 @@ Extract the data and return valid JSON:`;
|
|
|
972
975
|
const required = schema.required || [];
|
|
973
976
|
|
|
974
977
|
for (const field of required) {
|
|
975
|
-
|
|
978
|
+
// The decoder is told to answer null for a field the content never
|
|
979
|
+
// states, so a null here is "not filled in", the same as absent.
|
|
980
|
+
if (!(field in data) || data[field] === null) {
|
|
976
981
|
errors.push(`Missing required field: ${field}`);
|
|
977
982
|
}
|
|
978
983
|
}
|
|
@@ -980,6 +985,10 @@ Extract the data and return valid JSON:`;
|
|
|
980
985
|
for (const [key, fieldSchema] of Object.entries(properties)) {
|
|
981
986
|
if (key in data) {
|
|
982
987
|
const value = data[key];
|
|
988
|
+
// A null in a field the schema does not require is the honest answer
|
|
989
|
+
// for content that never states it, not a type error (typeof null is
|
|
990
|
+
// 'object', which used to read as "expected number, got object").
|
|
991
|
+
if (value === null) continue;
|
|
983
992
|
const expectedType = fieldSchema.type;
|
|
984
993
|
if (expectedType) {
|
|
985
994
|
const actualType = Array.isArray(value) ? 'array' : typeof value;
|
|
@@ -53,8 +53,8 @@ Returns titles, URLs, snippets. Supports `lang`, `site` (domain filter),
|
|
|
53
53
|
|
|
54
54
|
## reddit_search (cost: 5)
|
|
55
55
|
|
|
56
|
-
reddit.com 403-blocks direct scraping, so this
|
|
57
|
-
|
|
56
|
+
reddit.com 403-blocks direct scraping, so this reads the Arctic Shift community
|
|
57
|
+
archive instead (free, no Reddit credentials).
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
60
|
{
|
|
@@ -65,8 +65,10 @@ PullPush community archives instead (free, no Reddit credentials).
|
|
|
65
65
|
|
|
66
66
|
Modes: `posts` (default), `comments`, and `thread` (a post plus its nested
|
|
67
67
|
comment tree via `link_id`). Scope with `subreddit`/`author` for near-real-time
|
|
68
|
-
results (Arctic Shift); an unscoped `query` searches all of Reddit via
|
|
69
|
-
|
|
68
|
+
results (Arctic Shift); an unscoped `query` searches all of Reddit via web
|
|
69
|
+
discovery — a site-restricted web search finds posts, then they are read from the
|
|
70
|
+
archive by ID (max 10 per call, ordered by relevance, and `after`/`before` do not
|
|
71
|
+
apply on that path). `after`/`before` accept ISO dates,
|
|
70
72
|
epoch seconds, or offsets like `"7d"`. Scores of posts under ~36h old read 0/1
|
|
71
73
|
until the archive backfills. Space calls out rather than firing them in
|
|
72
74
|
parallel — the archives shed load under concurrent bursts.
|
|
@@ -618,6 +618,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
618
618
|
for (const stateData of capturedStates) {
|
|
619
619
|
try {
|
|
620
620
|
const $ = load(stateData.html);
|
|
621
|
+
// Never rendered as text — the same strip the batch worker does (R15).
|
|
622
|
+
$('script, style, noscript, template').remove();
|
|
621
623
|
|
|
622
624
|
const state = {
|
|
623
625
|
capturePoint: stateData.afterActionIndex + 1,
|
|
@@ -688,6 +690,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
688
690
|
// over the post-action DOM here, the one place every caller reads.
|
|
689
691
|
if (chainResult?.finalHtml && (params.extractionOptions?.selectors || params.formats?.includes('text'))) {
|
|
690
692
|
const $ = load(chainResult.finalHtml);
|
|
693
|
+
// Never rendered as text — the same strip the batch worker does (R15).
|
|
694
|
+
$('script, style, noscript, template').remove();
|
|
691
695
|
extractResult.content = extractResult.content || {};
|
|
692
696
|
if (params.extractionOptions?.selectors) {
|
|
693
697
|
extractResult.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
|
|
@@ -120,7 +120,12 @@ function extractStructuredData($, selectors) {
|
|
|
120
120
|
return extracted;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
function generateFormats($, html, formats) {
|
|
123
|
+
export function generateFormats($, html, formats) {
|
|
124
|
+
// Script, style, noscript and template bodies are never rendered text.
|
|
125
|
+
// Left in, the text format carried an entire theme-toggle script
|
|
126
|
+
// (erlang.org/downloads) and CSS rules (opennet.ru) — R15, 2026-09-04.
|
|
127
|
+
// `html` below is the raw document and stays untouched.
|
|
128
|
+
$('script, style, noscript, template').remove();
|
|
124
129
|
const content = {};
|
|
125
130
|
if (formats.includes('html')) content.html = html;
|
|
126
131
|
if (formats.includes('text')) content.text = $('body').text().replace(/\s+/g, ' ').trim();
|
|
@@ -54,6 +54,37 @@ function mainContentText($, html, url) {
|
|
|
54
54
|
return [title, flattenBodyText(load(mainHtml))].filter(Boolean).join('\n');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Characters of page text the model is shown; about 6k tokens. */
|
|
58
|
+
const SHOWN_TEXT_BUDGET = 24000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The text the model reads: main content first, then the whole page when both
|
|
62
|
+
* fit the budget.
|
|
63
|
+
*
|
|
64
|
+
* Main content alone is what keeps the Cloudflare blog's headline from being
|
|
65
|
+
* "Skip to content", but Readability also drops the page chrome, and that is
|
|
66
|
+
* where racket-lang.org announces "Racket version 9.3 is available". A model
|
|
67
|
+
* that never sees the answer fills a required field with the nearest heading,
|
|
68
|
+
* which the provenance guard cannot fault because it carries no digits, and
|
|
69
|
+
* the full-text retry below never fires because nothing was nulled. Showing
|
|
70
|
+
* the page text after the main content puts the banner in view while the
|
|
71
|
+
* article still leads. A page too long for both gets main content alone, up
|
|
72
|
+
* to the budget — the old 6000-character cap cut a 20k-character article to
|
|
73
|
+
* its first third.
|
|
74
|
+
*
|
|
75
|
+
* @param {import('cheerio').CheerioAPI} $
|
|
76
|
+
* @param {string} html
|
|
77
|
+
* @param {string} url
|
|
78
|
+
* @param {string} textContent - whole-page text from fetchAndParse
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function shownText($, html, url, textContent) {
|
|
82
|
+
const main = mainContentText($, html, url);
|
|
83
|
+
if (!main) return textContent;
|
|
84
|
+
if (main.length + textContent.length > SHOWN_TEXT_BUDGET) return main;
|
|
85
|
+
return `${main}\n\n${textContent}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
/**
|
|
58
89
|
* Missing, null, blank string or empty array — what "the extraction did not
|
|
59
90
|
* fill this field in" looks like across every extraction method.
|
|
@@ -147,6 +178,9 @@ export class ExtractStructuredTool {
|
|
|
147
178
|
tool: 'extract_structured'
|
|
148
179
|
});
|
|
149
180
|
|
|
181
|
+
// What the model reads — see shownText().
|
|
182
|
+
const shown = shownText($, html, url, textContent);
|
|
183
|
+
|
|
150
184
|
// Step 3: Try LLM extraction first
|
|
151
185
|
let extractionResult = null;
|
|
152
186
|
let extractionMethod = 'llm';
|
|
@@ -160,9 +194,9 @@ export class ExtractStructuredTool {
|
|
|
160
194
|
// local Ollama was never used.
|
|
161
195
|
llmAvailable = await llm.ready();
|
|
162
196
|
if (llmAvailable) {
|
|
163
|
-
const result = await llm.extractStructured(
|
|
197
|
+
const result = await llm.extractStructured(shown, schema, {
|
|
164
198
|
prompt: prompt || '',
|
|
165
|
-
maxContentLength:
|
|
199
|
+
maxContentLength: SHOWN_TEXT_BUDGET
|
|
166
200
|
});
|
|
167
201
|
// extractStructured swallows LLM failures and returns its keyword
|
|
168
202
|
// fallback. Only accept the result as an LLM extraction when it
|
|
@@ -246,13 +280,12 @@ export class ExtractStructuredTool {
|
|
|
246
280
|
// So when a REQUIRED field is nulled, retry once on the full page text,
|
|
247
281
|
// which is exactly what the guard will accept. Only a strictly better
|
|
248
282
|
// result is kept, so a retry can never make the answer worse.
|
|
249
|
-
|
|
250
|
-
if (guarded.missingRequired.length > 0 && shownText !== textContent && textContent) {
|
|
283
|
+
if (guarded.missingRequired.length > 0 && shown !== textContent && textContent) {
|
|
251
284
|
try {
|
|
252
285
|
const llm = this._ensureLLMManager(llmConfig || {});
|
|
253
286
|
const retry = await llm.extractStructured(textContent, schema, {
|
|
254
287
|
prompt: prompt || '',
|
|
255
|
-
maxContentLength:
|
|
288
|
+
maxContentLength: SHOWN_TEXT_BUDGET
|
|
256
289
|
});
|
|
257
290
|
if (retry?.method === 'llm') {
|
|
258
291
|
const retryGuarded = applyGuard(retry);
|
|
@@ -11,6 +11,7 @@ import { z } from 'zod';
|
|
|
11
11
|
import { fetchAndParse } from './_fetchAndParse.js';
|
|
12
12
|
import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
|
|
13
13
|
import { verifyNumericProvenance } from '../../utils/provenance.js';
|
|
14
|
+
import { extractionFormat } from '../../utils/extractionFormat.js';
|
|
14
15
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
15
16
|
// D1.3: SamplingClient for MCP sampling fallback (lazy — only imported if needed)
|
|
16
17
|
let _SamplingClient = null;
|
|
@@ -164,16 +165,12 @@ function extractBalancedJson(str) {
|
|
|
164
165
|
* as an object schema.
|
|
165
166
|
*/
|
|
166
167
|
function buildInputSchema(schema) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const [key, val] of Object.entries(schema || {})) {
|
|
174
|
-
properties[key] = (val && typeof val === 'object') ? val : { type: 'string' };
|
|
175
|
-
}
|
|
176
|
-
return { type: 'object', properties, additionalProperties: true };
|
|
168
|
+
// Every field nullable, no `required`: the decoder must be allowed to say
|
|
169
|
+
// "not stated" — a required string it cannot leave null becomes an
|
|
170
|
+
// invention (R14: `scrape` json on racket-lang.org answered "Racket 5.1.0"
|
|
171
|
+
// for a page that says 9.3). The caller's schema still drives validation.
|
|
172
|
+
const format = extractionFormat(schema);
|
|
173
|
+
return format === 'json' ? { type: 'object', properties: {}, additionalProperties: true } : format;
|
|
177
174
|
}
|
|
178
175
|
|
|
179
176
|
/**
|
|
@@ -189,7 +186,7 @@ function jsonSchemaToZod(schema) {
|
|
|
189
186
|
if (!isJsonSchema) {
|
|
190
187
|
const shape = {};
|
|
191
188
|
for (const [key, val] of Object.entries(schema)) {
|
|
192
|
-
shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).optional();
|
|
189
|
+
shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).nullable().optional();
|
|
193
190
|
}
|
|
194
191
|
return z.object(shape).passthrough();
|
|
195
192
|
}
|
|
@@ -206,7 +203,11 @@ function jsonSchemaToZod(schema) {
|
|
|
206
203
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
207
204
|
for (const [key, val] of Object.entries(schema.properties || {})) {
|
|
208
205
|
const field = jsonSchemaToZod(val);
|
|
209
|
-
|
|
206
|
+
// The model is told to answer null for a field the content never
|
|
207
|
+
// states, so null is the honest answer for a field the schema does
|
|
208
|
+
// not require — not a type violation. A required field stays strict:
|
|
209
|
+
// null there is exactly what the caller needs to hear about.
|
|
210
|
+
shape[key] = required.includes(key) ? field : field.nullable().optional();
|
|
210
211
|
}
|
|
211
212
|
return z.object(shape).passthrough();
|
|
212
213
|
}
|
|
@@ -561,7 +562,8 @@ export class ExtractWithLlm {
|
|
|
561
562
|
}
|
|
562
563
|
|
|
563
564
|
const systemMessage =
|
|
564
|
-
'You extract structured data from web content per the user\'s instructions. Return JSON only.'
|
|
565
|
+
'You extract structured data from web content per the user\'s instructions. Return JSON only. ' +
|
|
566
|
+
'Use null for any field the content does not state — never guess, infer, or fill a value from memory.';
|
|
565
567
|
|
|
566
568
|
const { userMessage, truncated: inputTruncated, original_length } = buildUserMessage(prompt, text, schema);
|
|
567
569
|
|
|
@@ -281,19 +281,21 @@ export class UnifiedScrapeTool {
|
|
|
281
281
|
if (fmt && typeof fmt === 'object' && fmt.type === 'json') {
|
|
282
282
|
try {
|
|
283
283
|
const extractWithLlm = await this._getExtractWithLlm();
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
284
|
+
// Script and template bodies are never rendered, but $('body').text()
|
|
285
|
+
// includes them — on a Shopify storefront that was 179KB of
|
|
286
|
+
// JavaScript, more than the page's real text, and it carried the
|
|
287
|
+
// very "Sold out" strings the strip had just removed from the DOM.
|
|
288
|
+
const { load } = await import('cheerio');
|
|
289
|
+
const $visible = load(html);
|
|
290
|
+
$visible('script, style, noscript, template').remove();
|
|
291
|
+
const pageText = $visible('body').text().replace(/\s+/g, ' ').trim();
|
|
292
|
+
// Main content first, then the whole page: Readability drops the page
|
|
293
|
+
// chrome, and racket-lang.org states its version there — shown the
|
|
294
|
+
// main content alone, the model answered "Racket" for a version the
|
|
295
|
+
// page gives as 9.3 (R14). The article still leads.
|
|
296
|
+
const text = onlyMainContent
|
|
297
|
+
? `${htmlToMarkdown(getMainHtml())}\n\n${pageText}`
|
|
298
|
+
: pageText;
|
|
297
299
|
const result = await extractWithLlm.execute({
|
|
298
300
|
content: text,
|
|
299
301
|
prompt: fmt.prompt || 'Extract structured data from this page content.',
|
|
@@ -133,7 +133,7 @@ export class RedditOfficialApiAdapter {
|
|
|
133
133
|
// The official API cannot honor an arbitrary date range (only coarse `t`
|
|
134
134
|
// buckets). Reject so `auto` mode falls back to the archives, which can.
|
|
135
135
|
if (v.after || v.before) {
|
|
136
|
-
throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift"
|
|
136
|
+
throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift" (scoped searches only — date filters need an archive)');
|
|
137
137
|
}
|
|
138
138
|
const sr = stripNamePrefix(subreddit);
|
|
139
139
|
const au = stripNamePrefix(author);
|
|
@@ -166,12 +166,12 @@ export class RedditSearchTool {
|
|
|
166
166
|
throw new Error('source:"reddit_api" needs Reddit app credentials — set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET (create a "script" app at https://www.reddit.com/prefs/apps)');
|
|
167
167
|
}
|
|
168
168
|
if (v.mode === 'comments') {
|
|
169
|
-
throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift"
|
|
169
|
+
throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift" (scoped) or "web_discovery" (unscoped), or read a whole thread with mode:"thread"');
|
|
170
170
|
}
|
|
171
171
|
order = ['reddit_api'];
|
|
172
172
|
} else if (v.source === 'arctic_shift') {
|
|
173
173
|
if (!arcticPossible) {
|
|
174
|
-
throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or
|
|
174
|
+
throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or drop source so auto routes the query to web_discovery');
|
|
175
175
|
}
|
|
176
176
|
order = ['arctic_shift'];
|
|
177
177
|
} else if (v.source === 'pullpush') {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* challengeDetection.js — recognise a bot-wall interstitial served as a page.
|
|
3
|
+
*
|
|
4
|
+
* Cloudflare, Amazon, DataDome, PerimeterX and Akamai all answer a blocked
|
|
5
|
+
* request with HTTP 200 and a page of their own: a title, some prose and a
|
|
6
|
+
* challenge script. Reported as a successful scrape, that page hides the
|
|
7
|
+
* block — producthunt.com came back "success:true, title: Just a moment..."
|
|
8
|
+
* for three regression rounds (R10 Q1 → R15, 2026-09-04). A title match is
|
|
9
|
+
* definitive; a script or form marker is definitive only on a short page,
|
|
10
|
+
* because a real page can legitimately embed a Turnstile widget.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SHORT_PAGE_CHARS = 4000;
|
|
14
|
+
|
|
15
|
+
const CHALLENGES = [
|
|
16
|
+
{
|
|
17
|
+
vendor: 'cloudflare',
|
|
18
|
+
title: /^just a moment/i,
|
|
19
|
+
markers: /challenges\.cloudflare\.com|cf-chl-|_cf_chl_opt|cf_chl_rc_|window\._cf_chl/i,
|
|
20
|
+
evidence: 'a Cloudflare challenge script'
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
vendor: 'amazon',
|
|
24
|
+
// The robot check is the only Amazon page whose form posts to validateCaptcha.
|
|
25
|
+
definitive: /action="[^"]*validateCaptcha/i,
|
|
26
|
+
evidence: 'the validateCaptcha form'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
vendor: 'datadome',
|
|
30
|
+
markers: /captcha-delivery\.com\/captcha|geo\.captcha-delivery\.com|dd\.captcha/i,
|
|
31
|
+
evidence: 'a DataDome captcha frame'
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
vendor: 'perimeterx',
|
|
35
|
+
markers: /px-captcha|_pxCaptcha|human-challenge/i,
|
|
36
|
+
evidence: 'a PerimeterX / HUMAN challenge element'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
vendor: 'akamai',
|
|
40
|
+
title: /^access denied/i,
|
|
41
|
+
markers: /errors\.edgesuite\.net/i,
|
|
42
|
+
evidence: 'an Akamai access-denied page'
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ title?: string, html?: string, text?: string }} page
|
|
48
|
+
* @returns {{ vendor: string, evidence: string } | null}
|
|
49
|
+
*/
|
|
50
|
+
export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
|
|
51
|
+
const cleanTitle = (title || '').trim();
|
|
52
|
+
const visible = (text || '').replace(/\s+/g, ' ').trim();
|
|
53
|
+
const shortPage = visible.length < SHORT_PAGE_CHARS;
|
|
54
|
+
for (const challenge of CHALLENGES) {
|
|
55
|
+
if (challenge.title && challenge.title.test(cleanTitle)) {
|
|
56
|
+
return { vendor: challenge.vendor, evidence: `title "${cleanTitle}"` };
|
|
57
|
+
}
|
|
58
|
+
if (challenge.definitive && challenge.definitive.test(html)) {
|
|
59
|
+
return { vendor: challenge.vendor, evidence: challenge.evidence };
|
|
60
|
+
}
|
|
61
|
+
if (shortPage && challenge.markers && challenge.markers.test(html)) {
|
|
62
|
+
return { vendor: challenge.vendor, evidence: `${challenge.evidence} on a ${visible.length}-character page` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default detectChallengePage;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The constrained-decoding shape for an LLM extraction.
|
|
3
|
+
*
|
|
4
|
+
* A caller's JSON Schema says which fields are REQUIRED of the result. Handed
|
|
5
|
+
* to the model verbatim as the output format, that word means something else:
|
|
6
|
+
* the decoder cannot emit null for a required string, so a model shown content
|
|
7
|
+
* that never states the field is forced to invent one. R14 caught it twice on
|
|
8
|
+
* the same day — racket-lang.org's version came back as the page title,
|
|
9
|
+
* rust-lang.org's as the word "stable" — while the same model on the same
|
|
10
|
+
* pages answered correctly the moment the fact was in front of it.
|
|
11
|
+
*
|
|
12
|
+
* So the format the model decodes against makes every property nullable and
|
|
13
|
+
* drops `required`. The caller's schema still drives validation and the
|
|
14
|
+
* provenance retry: a required field the model answered null is what those
|
|
15
|
+
* exist to catch, and a null is honest where a guess is not.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} schema - caller's JSON Schema (or a flat hint map)
|
|
18
|
+
* @returns {object|'json'} an object schema for the decoder, or 'json' when the
|
|
19
|
+
* input carries no properties to constrain
|
|
20
|
+
*/
|
|
21
|
+
export function extractionFormat(schema) {
|
|
22
|
+
if (!schema || typeof schema !== 'object') return 'json';
|
|
23
|
+
const source = schema.properties && typeof schema.properties === 'object'
|
|
24
|
+
? schema
|
|
25
|
+
: { type: 'object', properties: flatHintProperties(schema) };
|
|
26
|
+
if (!source.properties || Object.keys(source.properties).length === 0) return 'json';
|
|
27
|
+
|
|
28
|
+
const properties = {};
|
|
29
|
+
for (const [key, prop] of Object.entries(source.properties)) {
|
|
30
|
+
properties[key] = nullable(prop);
|
|
31
|
+
}
|
|
32
|
+
const { required: _required, ...rest } = source;
|
|
33
|
+
return { additionalProperties: true, ...rest, type: 'object', properties };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A flat `{ field: 'string' }` hint map as JSON-Schema properties. */
|
|
37
|
+
function flatHintProperties(hints) {
|
|
38
|
+
const properties = {};
|
|
39
|
+
for (const [key, val] of Object.entries(hints)) {
|
|
40
|
+
properties[key] = (val && typeof val === 'object') ? val : { type: 'string' };
|
|
41
|
+
}
|
|
42
|
+
return properties;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The property with `null` admitted to its type, when it names a single type. */
|
|
46
|
+
function nullable(prop) {
|
|
47
|
+
if (!prop || typeof prop !== 'object' || Array.isArray(prop)) return prop;
|
|
48
|
+
if (typeof prop.type !== 'string' || prop.type === 'null' || prop.type === 'object') return prop;
|
|
49
|
+
return { ...prop, type: [prop.type, 'null'] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default extractionFormat;
|
|
@@ -158,6 +158,26 @@ export function isCjkText(text) {
|
|
|
158
158
|
return cjkScriptCounts(text).share >= CJK_SCRIPT_THRESHOLD;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// Scripts written without spaces between words. Thai, Lao, Khmer and Burmese
|
|
162
|
+
// need the same dictionary segmentation CJK gets; on the whitespace split a
|
|
163
|
+
// 290-character Thai paragraph counted as 4 words (R15, 2026-09-04).
|
|
164
|
+
const UNSPACED_SCRIPT_RE = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/gu;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when the text needs Intl.Segmenter to find word boundaries — a CJK
|
|
168
|
+
* script, or one of the South-East Asian scripts that write no word spaces.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} text
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
export function needsWordSegmentation(text) {
|
|
174
|
+
if (isCjkText(text)) return true;
|
|
175
|
+
const letters = (text.match(/\p{L}/gu) || []).length;
|
|
176
|
+
if (letters === 0) return false;
|
|
177
|
+
const unspaced = (text.match(UNSPACED_SCRIPT_RE) || []).length;
|
|
178
|
+
return unspaced / letters >= CJK_SCRIPT_THRESHOLD;
|
|
179
|
+
}
|
|
180
|
+
|
|
161
181
|
/**
|
|
162
182
|
* Detect the language of a piece of text.
|
|
163
183
|
*
|
package/src/utils/provenance.js
CHANGED
|
@@ -228,6 +228,28 @@ function literalReadings(value) {
|
|
|
228
228
|
return [...spellings];
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** A version with two or more dot-separated numeric segments, inside prose. */
|
|
232
|
+
const EMBEDDED_VERSION = /(?<![\w.])v?\d+(?:\.\d+){2,}(?![\w.])/gi;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The spellings of every version-shaped token inside a prose string, or null
|
|
236
|
+
* when the value is not prose or carries none. "Racket 5.1.0" on a page that
|
|
237
|
+
* says 9.3 is the case that found this: the whole value is prose, so
|
|
238
|
+
* `literalReadings` left it alone, and the fabricated version rode through.
|
|
239
|
+
*
|
|
240
|
+
* @param {*} value
|
|
241
|
+
* @returns {string[][]|null} one spelling list per token
|
|
242
|
+
*/
|
|
243
|
+
function embeddedVersionReadings(value) {
|
|
244
|
+
if (typeof value !== 'string' || !/\s/.test(value.trim())) return null;
|
|
245
|
+
const tokens = value.match(EMBEDDED_VERSION);
|
|
246
|
+
if (!tokens) return null;
|
|
247
|
+
return tokens.map((token) => {
|
|
248
|
+
const lower = token.toLowerCase();
|
|
249
|
+
return [...new Set([lower, lower.replace(VERSION_PREFIX, '')])];
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
231
253
|
/**
|
|
232
254
|
* Replace every numeric value that is not present in the source with null.
|
|
233
255
|
*
|
|
@@ -286,8 +308,22 @@ export function verifyNumericProvenance(data, source) {
|
|
|
286
308
|
// Not a number, but possibly a version/date/SKU-shaped literal, which is
|
|
287
309
|
// the other thing a model invents when the page does not say.
|
|
288
310
|
const literals = literalReadings(node);
|
|
289
|
-
if (literals
|
|
290
|
-
|
|
311
|
+
if (literals !== null) {
|
|
312
|
+
if (inSource(literals)) {
|
|
313
|
+
verified++;
|
|
314
|
+
return node;
|
|
315
|
+
}
|
|
316
|
+
unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Prose that carries a version-shaped token ("Racket 5.1.0"): the words
|
|
321
|
+
// may be the model's own re-wording, but the token has exactly one
|
|
322
|
+
// spelling and is checked like a bare one. Decimals ("1.5 million") and
|
|
323
|
+
// bare integers stay prose — a model may legitimately re-word those.
|
|
324
|
+
const embedded = embeddedVersionReadings(node);
|
|
325
|
+
if (embedded === null) return node;
|
|
326
|
+
if (embedded.every((spellings) => inSource(spellings))) {
|
|
291
327
|
verified++;
|
|
292
328
|
return node;
|
|
293
329
|
}
|
package/src/utils/robotsGate.js
CHANGED
|
@@ -135,6 +135,29 @@ export async function robotsPreflight(url, options = {}) {
|
|
|
135
135
|
* warnings: string[], overridden: boolean }>}
|
|
136
136
|
* @throws {BlockedHostError|RobotsDisallowedError}
|
|
137
137
|
*/
|
|
138
|
+
/**
|
|
139
|
+
* The headers an HTTP page fetch carries: identity, the Web Bot Auth signature
|
|
140
|
+
* when one is configured, and an EMPTY Accept-Language.
|
|
141
|
+
*
|
|
142
|
+
* Node's fetch adds `Accept-Language: *` whenever the caller sets none. Amazon
|
|
143
|
+
* answers this identity with a captcha interstitial when ANY Accept-Language
|
|
144
|
+
* rides along — `*`, en-US, de-DE, a full browser list — and serves the page
|
|
145
|
+
* when the header is absent (R14, bisected header by header with curl, then
|
|
146
|
+
* reproduced with Node's fetch). fetch offers no way to leave the header out,
|
|
147
|
+
* but an empty value is sent as-is instead of `*`, and Amazon treats empty as
|
|
148
|
+
* absent. The identity stays honest; no language preference is claimed.
|
|
149
|
+
* @param {string} [userAgent]
|
|
150
|
+
* @param {Record<string,string>} [signature]
|
|
151
|
+
* @returns {Record<string,string>}
|
|
152
|
+
*/
|
|
153
|
+
export function outboundHeaders(userAgent, signature = {}) {
|
|
154
|
+
return {
|
|
155
|
+
...identityHeaders({ userAgent }),
|
|
156
|
+
'Accept-Language': '',
|
|
157
|
+
...signature
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
138
161
|
export async function preflightFetch(url, options = {}) {
|
|
139
162
|
const decision = await robotsPreflight(url, options);
|
|
140
163
|
if (!decision.allowed) {
|
|
@@ -153,7 +176,7 @@ export async function preflightFetch(url, options = {}) {
|
|
|
153
176
|
const signature = signRequestHeaders(url) || {};
|
|
154
177
|
|
|
155
178
|
return {
|
|
156
|
-
headers:
|
|
179
|
+
headers: outboundHeaders(decision.userAgent, signature),
|
|
157
180
|
userAgent: decision.userAgent,
|
|
158
181
|
warnings: decision.warnings,
|
|
159
182
|
overridden: decision.overridden
|