crawlforge-mcp-server 5.8.0 → 5.9.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 +1 -1
- package/package.json +1 -1
- package/server.js +20 -4
- package/src/core/AuthManager.js +9 -4
- package/src/schemas/toolOutputSchemas.js +5 -0
- package/src/server/fallbackHints.js +34 -9
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -0
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +5 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +5 -0
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +30 -1
- package/src/tools/scrape/escalation.js +62 -0
- package/src/tools/scrape/unifiedScrape.js +191 -58
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
|
|
|
179
179
|
| `list_ollama_models` | 1 | List the Ollama models installed locally (helps you pick a `model` for `extract_with_llm`) |
|
|
180
180
|
| `get_batch_results` | 1 | Retrieve paginated results for a `batch_scrape` job by `batchId` |
|
|
181
181
|
| `read_result` | 1 | Search, slice, read lines or a JSON path from a result a tool returned with `truncated: true` and a `result_handle` (kept 1 hour on your own machine) — never fetch the page again |
|
|
182
|
-
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings |
|
|
182
|
+
| `scrape` | 2 | **Unified single-fetch, multi-format extraction.** Pass a `formats` array (markdown/html/rawHtml/text/links/metadata/screenshot/json-schema, plus `{type:"highlights",query}` and `{type:"question",question}` for only the matching sentences, table rows and code blocks, verbatim with offsets into the markdown: +1 credit once per call, `mode:"model"` +3) plus `onlyMainContent`; one fetch serves every requested format with per-format partial-success warnings. `escalate:true` retries a blocked plain fetch once in the stealth browser and returns the page instead of the block (+5 as the projected ceiling; the charge drops back to 2 when the plain fetch succeeded and no escalation ran) |
|
|
183
183
|
| `scrape_structured` | 2 | Extract structured data with CSS selectors |
|
|
184
184
|
| `extract_embedded_state` | 2 | Read a page's embedded JavaScript state — `__NEXT_DATA__`, React Server Component payloads, Nuxt, Apollo, Redux, `<script type="application/json">` — with a `path` to scope the result. No LLM in the extraction path |
|
|
185
185
|
| `extract_content` | 2 | Enhanced content extraction |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 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
|
@@ -109,7 +109,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
109
109
|
// Create the server
|
|
110
110
|
const server = new McpServer({
|
|
111
111
|
name: "crawlforge",
|
|
112
|
-
version: "5.
|
|
112
|
+
version: "5.9.0",
|
|
113
113
|
description: "Production-ready MCP server with 30 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.",
|
|
114
114
|
homepage: "https://www.crawlforge.dev",
|
|
115
115
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -121,7 +121,7 @@ const server = new McpServer({
|
|
|
121
121
|
"- Read one page whose URL you have -> scrape (2). Ask for every format you need in that call: markdown, links, metadata, html, screenshot, json. Default for any page.",
|
|
122
122
|
"- Raw JSON/XML/API body, headers or status -> fetch_url (1). Not for HTML you will read: scrape returns markdown from the same fetch.",
|
|
123
123
|
"- Find pages for a query -> search_web (5); snippets often answer without a scrape. Google organic position -> serp_rank (5). Reddit -> reddit_search (5); reddit.com blocks direct scraping.",
|
|
124
|
-
"- Blocked (403/429/CAPTCHA/challenge page/empty shell) -> stealth_mode operation:\"scrape\" (5). Needs a click, login or scroll -> scrape_with_actions (5). Never start with these.",
|
|
124
|
+
"- Blocked (403/429/CAPTCHA/challenge page/empty shell) -> stealth_mode operation:\"scrape\" (5). scrape with escalate:true when a site is known to block (projected 7, charged 2 when the plain fetch works); still never stealth_mode first. Needs a click, login or scroll -> scrape_with_actions (5). Never start with these.",
|
|
125
125
|
"- 2-50 known URLs -> one batch_scrape (5), never a loop of scrape calls.",
|
|
126
126
|
"- A site's URL list -> map_site (2); many pages of one site -> crawl_deep (4).",
|
|
127
127
|
"- Exact values from a Next.js/Nuxt/Redux payload -> extract_embedded_state (2) with a path.",
|
|
@@ -165,6 +165,7 @@ server.registerPrompt("getting-started", {
|
|
|
165
165
|
"\n" +
|
|
166
166
|
"Hard pages\n" +
|
|
167
167
|
"- stealth_mode (5): after a 403/429/CAPTCHA/challenge page or an empty shell - never first.\n" +
|
|
168
|
+
"- scrape with escalate:true (projected 7): when a site is known to block, one call that reads the page and only falls back to the stealth browser if the plain fetch is walled - charged 2 when it is not. Still never stealth_mode first.\n" +
|
|
168
169
|
"- scrape_with_actions (5): click, log in, scroll or wait, then scrape.\n" +
|
|
169
170
|
"- localization (2): country and locale context for geo-specific content.\n" +
|
|
170
171
|
"\n" +
|
|
@@ -236,7 +237,22 @@ const deepResearchTool = new DeepResearchTool();
|
|
|
236
237
|
const trackChangesTool = new TrackChangesTool();
|
|
237
238
|
const generateLLMsTxtTool = new GenerateLLMsTxtTool();
|
|
238
239
|
const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
|
|
239
|
-
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
240
|
+
const unifiedScrapeTool = new UnifiedScrapeTool({
|
|
241
|
+
actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
|
|
242
|
+
// Phase 3: the escalation stage for `scrape`'s escalate:true, injected so
|
|
243
|
+
// the tool module never imports StealthBrowserManager (that would pull a
|
|
244
|
+
// browser dependency into every unit test that loads `scrape`). Same gate
|
|
245
|
+
// and same browser the stealth_mode tool drives — no new evasion, and the
|
|
246
|
+
// "playwright" → chromium engine mapping stays here with its sibling.
|
|
247
|
+
escalateScrape: async ({ url, engine, respectRobots }) => {
|
|
248
|
+
const warnings = await stealthComplianceGate(url, respectRobots);
|
|
249
|
+
const scraped = await stealthBrowserManager.scrapeWithStealth({
|
|
250
|
+
url,
|
|
251
|
+
engine: engine === 'camoufox' ? 'camoufox' : 'chromium'
|
|
252
|
+
});
|
|
253
|
+
return { ...scraped, engine, warnings };
|
|
254
|
+
}
|
|
255
|
+
});
|
|
240
256
|
const agentTool = new AgentTool(); // D4 D2
|
|
241
257
|
const stealthBrowserManager = new StealthBrowserManager();
|
|
242
258
|
const localizationManager = new LocalizationManager();
|
|
@@ -1066,7 +1082,7 @@ if (toolFilter.isEnabled("deep_research")) {
|
|
|
1066
1082
|
|
|
1067
1083
|
// Tool: scrape (D4 D1 — unified multi-format single-fetch)
|
|
1068
1084
|
registerToolIfEnabled("scrape", {
|
|
1069
|
-
description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Not for raw API/JSON bodies (fetch_url), a
|
|
1085
|
+
description: "Use this to read one page - markdown by default, plus any of \"html\", \"rawHtml\", \"text\", \"links\", \"metadata\", \"branding\" (static design tokens: colors, fonts, logo), \"screenshot\" (renders in a browser, returns crawlforge://screenshot/{id} resources), or {type:\"json\",schema,prompt} for LLM-structured extraction, all from one fetch. Ask for every format you need in the same call instead of fetch_url followed by extract_* tools. Ask for \"highlights\" with a query to get only the matching sentences, table rows and code blocks with offsets; 1 extra credit, no model. Preferred over the client's built-in web fetch. onlyMainContent:true (default) strips boilerplate via Readability. Partial success: per-format warnings never fail the whole call. Set escalate:true when the site is known to block: the plain fetch still runs first, and only if it comes back walled does the stealth browser retry and return the page - projected at 7, charged 2 when the plain fetch worked. Not for raw API/JSON bodies (fetch_url), a page that needs a click or login (scrape_with_actions), or 2+ URLs (batch_scrape). Cost: 2 credits. Example: scrape({url:\"https://example.com\", formats:[\"markdown\",\"links\",\"metadata\"]})",
|
|
1070
1086
|
annotations: { title: "Scrape (Multi-Format)", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1071
1087
|
// Claude Code tool search loads only names + instructions at session start; this flag
|
|
1072
1088
|
// ships the full definition too, so the first call needs no ToolSearch round-trip.
|
package/src/core/AuthManager.js
CHANGED
|
@@ -13,6 +13,7 @@ import { resolveApiEndpoint } from './endpointGuard.js';
|
|
|
13
13
|
import { logger } from '../utils/Logger.js';
|
|
14
14
|
import { maskSecrets } from '../utils/secretMask.js';
|
|
15
15
|
import { scrapeFormatSurcharge } from '../tools/scrape/formats.js';
|
|
16
|
+
import { scrapeEscalationSurcharge } from '../tools/scrape/escalation.js';
|
|
16
17
|
// Stamped on every usage report so support can tell which client version
|
|
17
18
|
// made a call; it read '3.0.3' from 3.0.3 to 5.6.10 (the website stores it
|
|
18
19
|
// per record from its Phase 1.3 on).
|
|
@@ -626,11 +627,13 @@ class AuthManager {
|
|
|
626
627
|
}
|
|
627
628
|
|
|
628
629
|
// The query-scoped formats (highlights, question) add 1 once per call,
|
|
629
|
-
// and mode:"model" adds 3 once;
|
|
630
|
-
//
|
|
630
|
+
// and mode:"model" adds 3 once; escalate:true adds the stealth browser's
|
|
631
|
+
// own 5, since that is the ceiling the call may reach. The base price in
|
|
632
|
+
// the table is what the backend sees. Both rules live with the schemas
|
|
633
|
+
// that declare the params.
|
|
631
634
|
if (tool === 'scrape') {
|
|
632
635
|
const { query, model } = scrapeFormatSurcharge(params?.formats);
|
|
633
|
-
return costs.scrape + query + model;
|
|
636
|
+
return costs.scrape + query + model + scrapeEscalationSurcharge(params?.escalate);
|
|
634
637
|
}
|
|
635
638
|
|
|
636
639
|
return costs[tool] ?? 1;
|
|
@@ -689,7 +692,9 @@ class AuthManager {
|
|
|
689
692
|
break;
|
|
690
693
|
case 'scrape': {
|
|
691
694
|
projected = base;
|
|
692
|
-
note = 'Base 2; a highlights or question format adds 1 once per call, mode:"model" adds 3 once (charged at the extractive price when no LLM route exists).
|
|
695
|
+
note = 'Base 2; a highlights or question format adds 1 once per call, mode:"model" adds 3 once (charged at the extractive price when no LLM route exists). ' +
|
|
696
|
+
'escalate:true adds 5 for the stealth browser it may need — the projection is the ceiling, and the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. ' +
|
|
697
|
+
'json format may incur external LLM cost (billed by your provider).';
|
|
693
698
|
break;
|
|
694
699
|
}
|
|
695
700
|
case 'agent': {
|
|
@@ -124,6 +124,11 @@ const scrapeShape = {
|
|
|
124
124
|
vendor: z.string().optional(),
|
|
125
125
|
evidence: z.string().optional()
|
|
126
126
|
}).passthrough().optional().describe('Present when a bot-defence vendor served a challenge page; the fallback hint names the tool to try next'),
|
|
127
|
+
escalated: z.boolean().optional().describe('Present only when escalate:true was passed: whether the blocked plain fetch was retried in the stealth browser. False means the plain fetch sufficed and the call is charged at the base price'),
|
|
128
|
+
stealth: z.object({
|
|
129
|
+
engine: z.string().optional(),
|
|
130
|
+
vendor_detected: z.string().nullable().optional()
|
|
131
|
+
}).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran, and the bot-defence vendor the plain fetch hit (null when the block named none)'),
|
|
127
132
|
content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
|
|
128
133
|
warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
|
|
129
134
|
...resultHandleShape,
|
|
@@ -42,26 +42,51 @@ export const FALLBACK_HINTS = Object.freeze({
|
|
|
42
42
|
scrape_template: 'Use template:"list" to see valid template ids, template:"auto" to pick from the URL, or scrape for a site without a template.'
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The second-stage hint (Phase 3, 3.4). A `scrape` that came back
|
|
47
|
+
* `escalated: true` has ALREADY run the stealth browser on this URL, so the
|
|
48
|
+
* ordinary hint would send the model back to a tool that has just failed.
|
|
49
|
+
* What is left is a region-specific block, which `localization` can change,
|
|
50
|
+
* or a block that is final: an Akamai-style TLS-level wall is beaten with
|
|
51
|
+
* residential proxies, which CrawlForge does not offer.
|
|
52
|
+
*/
|
|
53
|
+
export const SCRAPE_ESCALATED_HINT =
|
|
54
|
+
'The stealth browser has already run on this URL (escalate:true) - do not call stealth_mode or repeat this call. ' +
|
|
55
|
+
'If the block is regional, set a country with localization and try once more; otherwise the block is final - ' +
|
|
56
|
+
'a TLS-level wall needs residential proxies, which CrawlForge does not offer. Get the content from another source.';
|
|
57
|
+
|
|
58
|
+
/** The hint for this result: the tool's, unless a scrape already escalated. */
|
|
59
|
+
function hintFor(toolName, parsed) {
|
|
60
|
+
if (toolName === 'scrape' && parsed?.escalated === true) return SCRAPE_ESCALATED_HINT;
|
|
61
|
+
return FALLBACK_HINTS[toolName];
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
/**
|
|
46
65
|
* Append the hint for `toolName` to an error result in place. No-op for
|
|
47
66
|
* success results, unknown tools, non-text content, or a hint already present.
|
|
48
67
|
*/
|
|
49
68
|
export function appendFallbackHint(toolName, result) {
|
|
50
|
-
|
|
51
|
-
if (!hint || result?.isError !== true || !Array.isArray(result.content)) return result;
|
|
69
|
+
if (result?.isError !== true || !Array.isArray(result.content)) return result;
|
|
52
70
|
const first = result.content[0];
|
|
53
71
|
if (first?.type !== 'text' || typeof first.text !== 'string') return result;
|
|
54
|
-
|
|
72
|
+
|
|
73
|
+
// Parsed first: which hint a `scrape` failure gets depends on whether the
|
|
74
|
+
// body says the escalation already ran.
|
|
75
|
+
let parsed = null;
|
|
55
76
|
try {
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
parsed.next_step = hint;
|
|
59
|
-
first.text = JSON.stringify(parsed, null, 2);
|
|
60
|
-
return result;
|
|
61
|
-
}
|
|
77
|
+
const candidate = JSON.parse(first.text);
|
|
78
|
+
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) parsed = candidate;
|
|
62
79
|
} catch {
|
|
63
80
|
// plain text
|
|
64
81
|
}
|
|
82
|
+
|
|
83
|
+
const hint = hintFor(toolName, parsed);
|
|
84
|
+
if (!hint || first.text.includes(hint)) return result;
|
|
85
|
+
if (parsed) {
|
|
86
|
+
parsed.next_step = hint;
|
|
87
|
+
first.text = JSON.stringify(parsed, null, 2);
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
65
90
|
first.text = `${first.text}\nNext step: ${hint}`;
|
|
66
91
|
return result;
|
|
67
92
|
}
|
|
@@ -90,6 +90,7 @@ Do not suggest adding API keys — local Ollama is the intended zero-cost defaul
|
|
|
90
90
|
| Symptom | Try next |
|
|
91
91
|
|---------|----------|
|
|
92
92
|
| `scrape` / `fetch_url` returns 403, 429, CAPTCHA, or empty JS shell | `stealth_mode` (crawlforge-stealth-browsing) |
|
|
93
|
+
| A site you already know blocks | `scrape` with `escalate: true` — plain fetch first, stealth browser only if it is walled; never `stealth_mode` first |
|
|
93
94
|
| No template for a known site | `scrape_structured` → `extract_structured` → `extract_with_llm` |
|
|
94
95
|
| LLM extraction unavailable (no Ollama/keys) | `scrape_structured` with CSS selectors |
|
|
95
96
|
| Single page too slow / many pages | `batch_scrape` (async + webhook) |
|
|
@@ -20,7 +20,7 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
20
20
|
|
|
21
21
|
| Tool | Notes |
|
|
22
22
|
|------|-------|
|
|
23
|
-
| `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. |
|
|
23
|
+
| `scrape` | Unified multi-format single fetch. A `highlights` or `question` format adds 1 once per call; `mode:"model"` on one of them adds 3 once. `escalate:true` adds 5 as the projected ceiling; the actual charge drops back to the base when the plain fetch succeeded and no escalation ran. |
|
|
24
24
|
| `scrape_structured` | CSS-selector extraction. |
|
|
25
25
|
| `extract_embedded_state` | Embedded JS state (`__NEXT_DATA__`, RSC, Nuxt, Apollo, Redux). |
|
|
26
26
|
| `extract_content` | Readability-cleaned article. |
|
|
@@ -77,4 +77,8 @@ metered; there is no free tier. Tools marked "scales" cost more as work grows.
|
|
|
77
77
|
- `get_batch_results` (1) is cheap — submit a batch once, page through results.
|
|
78
78
|
- A result over `max_inline_chars` costs nothing extra; read the stored copy with
|
|
79
79
|
`read_result` (1) instead of fetching again.
|
|
80
|
+
- On a site that blocks, one `scrape` with `escalate:true` costs at most 7 and
|
|
81
|
+
only 2 when the plain fetch works; `scrape` then `stealth_mode` costs 7 either
|
|
82
|
+
way. Do not set it on a site that reads fine — it raises the projection your
|
|
83
|
+
balance is checked against.
|
|
80
84
|
- Errors are charged at half the tool cost; creator mode is unlimited.
|
|
@@ -24,6 +24,11 @@ Escalate from a normal `scrape` / `fetch_url` (see crawlforge-web-scraping) when
|
|
|
24
24
|
`stealth_mode` drives a real browser with randomized fingerprints, human
|
|
25
25
|
behavior simulation, and WebRTC/canvas/WebGL spoofing.
|
|
26
26
|
|
|
27
|
+
When you already know the site blocks, `scrape` with `escalate: true` does both
|
|
28
|
+
steps in one call: the plain fetch first, then the same stealth browser only if
|
|
29
|
+
that fetch is walled. Projected at 7, charged 2 when the plain fetch worked.
|
|
30
|
+
Still never reach for `stealth_mode` first.
|
|
31
|
+
|
|
27
32
|
## stealth_mode (cost: 5)
|
|
28
33
|
|
|
29
34
|
`stealth_mode` is operation-based. Typical flow: create a context, then create a
|
|
@@ -36,7 +36,9 @@ site discovery (sitemaps and URL maps), and whole-site crawling.
|
|
|
36
36
|
5. **`crawl_deep`** to walk an entire site and (optionally) extract content.
|
|
37
37
|
|
|
38
38
|
If a page returns 403/429, a CAPTCHA, or empty "enable JavaScript" content,
|
|
39
|
-
switch to the **crawlforge-stealth-browsing** skill (`stealth_mode`).
|
|
39
|
+
switch to the **crawlforge-stealth-browsing** skill (`stealth_mode`). When the
|
|
40
|
+
site is *known* to block, use `scrape` with `escalate: true` instead of two
|
|
41
|
+
calls — still never `stealth_mode` first.
|
|
40
42
|
|
|
41
43
|
## scrape — unified multi-format (cost: 2)
|
|
42
44
|
|
|
@@ -59,6 +61,33 @@ Get markdown + links + metadata in a single call:
|
|
|
59
61
|
a failing format adds a `warnings[]` entry instead of failing the whole call.
|
|
60
62
|
`onlyMainContent` (default `true`) strips boilerplate via Readability.
|
|
61
63
|
|
|
64
|
+
### escalate — one call for a site that blocks (projected 7, charged 2 when it doesn't)
|
|
65
|
+
|
|
66
|
+
`escalate: true` keeps the plain fetch first and retries in the stealth browser
|
|
67
|
+
only if that fetch comes back walled (403/429, a challenge page, an empty
|
|
68
|
+
shell). The escalated page goes through the same formats, so `markdown`,
|
|
69
|
+
`highlights` and the rest work exactly as they do on a plain read.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"tool": "scrape",
|
|
74
|
+
"params": {
|
|
75
|
+
"url": "https://www.producthunt.com/",
|
|
76
|
+
"formats": ["markdown"],
|
|
77
|
+
"escalate": true,
|
|
78
|
+
"escalate_engine": "playwright"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The result adds `escalated: true|false`, and `stealth: { engine,
|
|
84
|
+
vendor_detected }` when the browser ran. The projection is `2 + 5`; the charge
|
|
85
|
+
falls back to the base when the plain fetch succeeded and nothing escalated.
|
|
86
|
+
`escalate_engine` is `"playwright"` (default) or `"camoufox"`. robots.txt is
|
|
87
|
+
respected on the escalated path too, and a second call to a host that blocked
|
|
88
|
+
within the last 24 hours skips the doomed plain fetch and says so in
|
|
89
|
+
`warnings[]`.
|
|
90
|
+
|
|
62
91
|
Query-scoped formats return only the parts of the page that match, verbatim,
|
|
63
92
|
with offsets into the `markdown` of the same call:
|
|
64
93
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalation.js — the one declaration of `scrape`'s opt-in auto-escalation.
|
|
3
|
+
*
|
|
4
|
+
* `escalate: true` asks the tool to run the stealth browser itself when the
|
|
5
|
+
* plain fetch comes back walled (Phase 3). The plain fetch always runs first
|
|
6
|
+
* (G1: never start with stealth), so the escalation is a second stage, not a
|
|
7
|
+
* different tool: it reuses the same compliance gate and the same
|
|
8
|
+
* StealthBrowserManager `stealth_mode` uses, and adds no new evasion.
|
|
9
|
+
*
|
|
10
|
+
* The two fields and the price rule live here so `server.js`, the tool
|
|
11
|
+
* module, `AuthManager.getToolCost` and the output schema read one
|
|
12
|
+
* declaration (G5) — the same arrangement `formats.js` already has for the
|
|
13
|
+
* query-format surcharge.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
/** What an escalated call adds to `scrape`'s price: the stealth browser's own. */
|
|
19
|
+
export const SCRAPE_ESCALATION_CREDITS = 5;
|
|
20
|
+
|
|
21
|
+
/** The two public fields, in tools/list order, with the text the client sees. */
|
|
22
|
+
export const SCRAPE_ESCALATION_SHAPE = {
|
|
23
|
+
escalate: z.boolean().optional().default(false).describe('When the plain fetch comes back blocked (403/429/challenge page/empty shell), retry once in the stealth browser and return its content instead of the block. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
|
|
24
|
+
escalate_engine: z.enum(['playwright', 'camoufox']).optional().default('playwright').describe('Stealth engine for the escalated retry (default: "playwright")')
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What escalation adds to `scrape`'s base price: 5 when the caller asked for
|
|
29
|
+
* it, nothing otherwise. Reads the RAW param — getToolCost runs before
|
|
30
|
+
* validation, so anything that is not exactly `true` prices as 0.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} escalate the raw `escalate` param
|
|
33
|
+
* @returns {0 | 5}
|
|
34
|
+
*/
|
|
35
|
+
export function scrapeEscalationSurcharge(escalate) {
|
|
36
|
+
return escalate === true ? SCRAPE_ESCALATION_CREDITS : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a second, browser-backed attempt could plausibly change this
|
|
41
|
+
* outcome.
|
|
42
|
+
*
|
|
43
|
+
* A named vendor's wall qualifies, and so does a bare 403 or 429 — an
|
|
44
|
+
* IP-reputation, WAF or User-Agent block, which a real browser and fingerprint
|
|
45
|
+
* often do pass (travel.state.gov answers some networks with exactly that). So
|
|
46
|
+
* does any failure that arrived with a 2xx: an empty shell, a JavaScript-only
|
|
47
|
+
* document or a soft-error placeholder is precisely what rendering fixes.
|
|
48
|
+
*
|
|
49
|
+
* A 404 is not a wall and a 5xx is the server's own failure. Escalating either
|
|
50
|
+
* would spend the browser's 5 credits to be told the same thing, so `escalate`
|
|
51
|
+
* leaves them as the plain fetch reported them (G4 — never charge for work
|
|
52
|
+
* that cannot help).
|
|
53
|
+
*
|
|
54
|
+
* @param {{ blocked?: unknown } | null | undefined} verdict
|
|
55
|
+
* @param {number | null | undefined} status
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function aBrowserMightPass(verdict, status) {
|
|
59
|
+
if (verdict && verdict.blocked) return true;
|
|
60
|
+
if (status === 403 || status === 429) return true;
|
|
61
|
+
return status === null || status === undefined || (status >= 200 && status < 300);
|
|
62
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema,
|
|
21
21
|
scrapeFormatSurcharge
|
|
22
22
|
} from './formats.js';
|
|
23
|
+
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from './escalation.js';
|
|
23
24
|
import { toPublicUnit, parseChosenIndexes, groundingCheck } from './_highlights.js';
|
|
24
25
|
import { setActualCost } from '../../server/requestContext.js';
|
|
25
26
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
@@ -29,11 +30,15 @@ import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
|
29
30
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
30
31
|
import { extractBlockText } from '../basic/extractText.js';
|
|
31
32
|
import { pageTitle } from '../../utils/pageTitle.js';
|
|
32
|
-
import { noteHostBlocked, clearHostBlocked } from '../../utils/hostRateLimiter.js';
|
|
33
|
+
import { noteHostBlocked, clearHostBlocked, getHostBlock } from '../../utils/hostRateLimiter.js';
|
|
33
34
|
|
|
34
35
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
35
36
|
|
|
36
37
|
export { SCRAPE_STRING_FORMATS, JsonFormatSchema, HighlightsFormatSchema, QuestionFormatSchema, FormatSchema };
|
|
38
|
+
export { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS };
|
|
39
|
+
|
|
40
|
+
/** scrape's base price, as AuthManager's table spells it. */
|
|
41
|
+
const SCRAPE_BASE_CREDITS = 2;
|
|
37
42
|
|
|
38
43
|
// Above this, a markdown result is the whole page in the context window;
|
|
39
44
|
// the warning names the format that returns only the matching units (1.4).
|
|
@@ -61,7 +66,9 @@ export const SCRAPE_INPUT_SHAPE = {
|
|
|
61
66
|
fullPage: z.boolean().optional().default(false).describe('Capture the full scrollable page'),
|
|
62
67
|
format: z.enum(['png', 'jpeg']).optional().default('png'),
|
|
63
68
|
quality: z.number().min(0).max(100).optional().describe('JPEG quality (jpeg only)')
|
|
64
|
-
}).optional().describe('Options for the "screenshot" format')
|
|
69
|
+
}).optional().describe('Options for the "screenshot" format'),
|
|
70
|
+
// Opt-in second stage (Phase 3): the plain fetch still runs first.
|
|
71
|
+
...SCRAPE_ESCALATION_SHAPE
|
|
65
72
|
};
|
|
66
73
|
|
|
67
74
|
export const UnifiedScrapeSchema = z.object({
|
|
@@ -194,6 +201,15 @@ export class UnifiedScrapeTool {
|
|
|
194
201
|
// Optional shared ActionExecutor (injected from server.js so we reuse the
|
|
195
202
|
// existing browser pool rather than spinning up a second one).
|
|
196
203
|
this._actionExecutor = options.actionExecutor || null;
|
|
204
|
+
// Optional escalation stage (Phase 3), injected from server.js the same
|
|
205
|
+
// way. The tool module must never import StealthBrowserManager itself:
|
|
206
|
+
// that would pull a browser dependency into every unit test that loads
|
|
207
|
+
// `scrape`. Signature:
|
|
208
|
+
// ({ url, engine, respectRobots }) =>
|
|
209
|
+
// { html, url, title, text, status, engine, warnings? }
|
|
210
|
+
// The injected function owns the compliance gate (robots + blocklist) and
|
|
211
|
+
// the engine-name mapping, exactly as the stealth_mode tool does.
|
|
212
|
+
this._escalateScrape = options.escalateScrape || null;
|
|
197
213
|
this._mcpServer = null;
|
|
198
214
|
}
|
|
199
215
|
|
|
@@ -278,59 +294,183 @@ export class UnifiedScrapeTool {
|
|
|
278
294
|
const validated = UnifiedScrapeSchema.parse(params);
|
|
279
295
|
const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
|
|
280
296
|
|
|
297
|
+
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
298
|
+
// per-format ones, so the caller sees the decision in the response.
|
|
299
|
+
const warnings = [];
|
|
300
|
+
// Whether a model completed for a mode:"model" format, and whether the
|
|
301
|
+
// stealth stage ran; the surcharge for each is charged only then.
|
|
302
|
+
let modelUsed = false;
|
|
303
|
+
let escalationRan = false;
|
|
304
|
+
let stealthEngine = null;
|
|
305
|
+
|
|
306
|
+
// One charge computation for every return path (3.2). The projection is
|
|
307
|
+
// the ceiling a caller saw before the call, so each stage that did not
|
|
308
|
+
// run only ever lowers the bill (G4): the model surcharge when nothing
|
|
309
|
+
// completed, the escalation surcharge when the plain fetch sufficed.
|
|
310
|
+
const surcharge = scrapeFormatSurcharge(formats);
|
|
311
|
+
const reportCost = () => setActualCost(
|
|
312
|
+
SCRAPE_BASE_CREDITS +
|
|
313
|
+
surcharge.query +
|
|
314
|
+
(modelUsed ? surcharge.model : 0) +
|
|
315
|
+
(escalationRan ? SCRAPE_ESCALATION_CREDITS : 0)
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Opt-in second stage (Phase 3): the plain fetch runs first and the
|
|
319
|
+
// stealth browser only follows a blocked verdict.
|
|
320
|
+
const escalate = validated.escalate === true;
|
|
321
|
+
// 3.3: a host that walled us within the last 24 hours (0.6's memory) has
|
|
322
|
+
// a doomed plain fetch ahead of it. Read ONLY when escalating — an
|
|
323
|
+
// ordinary scrape must behave exactly as it did before.
|
|
324
|
+
const remembered = escalate ? getHostBlock(url) : null;
|
|
325
|
+
|
|
281
326
|
// Single fetch
|
|
282
|
-
let html, $,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
327
|
+
let html, $, fetchWarnings, status, title, verdict;
|
|
328
|
+
let finalUrl = url;
|
|
329
|
+
if (remembered) {
|
|
330
|
+
warnings.push(
|
|
331
|
+
`escalate: ${remembered.vendor || 'a bot wall'} walled this host within the last 24 hours; the plain fetch was skipped and the stealth browser ran first`
|
|
332
|
+
);
|
|
333
|
+
} else {
|
|
334
|
+
try {
|
|
335
|
+
({ html, $, finalUrl, warnings: fetchWarnings, status } = await fetchAndParse(url, {
|
|
336
|
+
timeoutMs,
|
|
337
|
+
userAgent: validated.user_agent,
|
|
338
|
+
respectRobots: validated.respect_robots,
|
|
339
|
+
tool: 'scrape',
|
|
340
|
+
stripTags: [], // we handle boilerplate ourselves
|
|
341
|
+
// A real wall reaches a plain fetch as a 403 with the challenge in
|
|
342
|
+
// the body; the verdict below needs that body and the status.
|
|
343
|
+
errorDocuments: true
|
|
344
|
+
}));
|
|
345
|
+
} catch (err) {
|
|
346
|
+
throw new Error(`scrape: fetch failed for ${url}: ${err.message}`);
|
|
347
|
+
}
|
|
348
|
+
warnings.push(...fetchWarnings);
|
|
349
|
+
|
|
350
|
+
// A bot wall, an HTTP error page, an empty shell or an error placeholder
|
|
351
|
+
// arrives with a title and prose of its own; reported as a successful
|
|
352
|
+
// scrape it hid the block for three rounds (producthunt.com, R10 Q1 →
|
|
353
|
+
// R15). The stealth path has named these since 5.6.2; this path never
|
|
354
|
+
// looked, and threw away every non-2xx body before it could (Phase 0,
|
|
355
|
+
// 0.1). Scripts stay in $ for the rawHtml and metadata formats,
|
|
356
|
+
// so the text is measured on a copy without them — the vendor tables
|
|
357
|
+
// count the characters a reader would see, as the browser path does.
|
|
358
|
+
// Nothing below runs on a failed verdict: a screenshot would launch a
|
|
359
|
+
// browser on the wall and branding would fetch its stylesheets.
|
|
360
|
+
title = pageTitle($);
|
|
361
|
+
const $visible = load(html);
|
|
362
|
+
$visible('script, style, noscript, template').remove();
|
|
363
|
+
verdict = documentVerdict(
|
|
364
|
+
{ url: finalUrl, title, text: $visible('body').text(), html, status },
|
|
365
|
+
{ fetcher: 'a plain fetch', rendered: false, contentReturned: false }
|
|
366
|
+
);
|
|
367
|
+
// Host memory (0.6): remember the vendor that walled this host, keyed by
|
|
368
|
+
// the URL the caller passed as well as the final one when a redirect
|
|
369
|
+
// moved hosts, since a later call looks it up by what it was given.
|
|
370
|
+
const hosts = [finalUrl, url];
|
|
371
|
+
if (verdict.blocked) {
|
|
372
|
+
for (const h of hosts) noteHostBlocked(h, verdict.blocked.vendor);
|
|
373
|
+
} else if (verdict.success) {
|
|
374
|
+
for (const h of hosts) clearHostBlocked(h);
|
|
375
|
+
}
|
|
296
376
|
}
|
|
297
377
|
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
378
|
+
// ── Escalation (Phase 3) ─────────────────────────────────────────────
|
|
379
|
+
// Opt-in, and second by construction: the plain fetch above has already
|
|
380
|
+
// run and only a blocked verdict gets here (G1 — never start with
|
|
381
|
+
// stealth). The stage reuses the compliance gate and the browser
|
|
382
|
+
// `stealth_mode` already drives, so it adds no evasion of its own.
|
|
383
|
+
// The vendor the plain fetch hit, or the one this host is remembered for
|
|
384
|
+
// when that fetch was skipped.
|
|
385
|
+
const vendorDetected = remembered ? (remembered.vendor ?? null) : (verdict?.blocked?.vendor ?? null);
|
|
386
|
+
// Set when the stage was asked for and could not run at all; it is the
|
|
387
|
+
// only thing left to report when the plain fetch was skipped as well.
|
|
388
|
+
let escalationError = null;
|
|
389
|
+
|
|
390
|
+
// Only a failure a browser could plausibly change is worth the second
|
|
391
|
+
// stage: a wall or a bare 403/429, or a page that failed with a 2xx (an
|
|
392
|
+
// empty shell renders). A 404 or a 5xx escalates nothing — see
|
|
393
|
+
// aBrowserMightPass.
|
|
394
|
+
if (escalate && (remembered || (!verdict.success && aBrowserMightPass(verdict, status)))) {
|
|
395
|
+
if (!this._escalateScrape) {
|
|
396
|
+
escalationError = 'no stealth stage is wired into this server build';
|
|
397
|
+
warnings.push(`escalate: ${escalationError}`);
|
|
398
|
+
} else {
|
|
399
|
+
try {
|
|
400
|
+
const stealth = await this._escalateScrape({
|
|
401
|
+
url: remembered ? url : finalUrl,
|
|
402
|
+
engine: validated.escalate_engine,
|
|
403
|
+
respectRobots: validated.respect_robots
|
|
404
|
+
});
|
|
405
|
+
escalationRan = true;
|
|
406
|
+
stealthEngine = stealth.engine || validated.escalate_engine;
|
|
407
|
+
if (Array.isArray(stealth.warnings)) warnings.push(...stealth.warnings);
|
|
408
|
+
|
|
409
|
+
// The stealth render replaces the document every format below is
|
|
410
|
+
// built from, so an escalated page goes through the SAME formats
|
|
411
|
+
// loop a plain fetch does — highlights and question included.
|
|
412
|
+
html = stealth.html || '';
|
|
413
|
+
finalUrl = stealth.url || finalUrl;
|
|
414
|
+
status = stealth.status ?? null;
|
|
415
|
+
$ = load(html);
|
|
416
|
+
title = stealth.title || pageTitle($);
|
|
417
|
+
const $rendered = load(html);
|
|
418
|
+
$rendered('script, style, noscript, template').remove();
|
|
419
|
+
// Re-run the verdict on what the browser rendered: a wall that
|
|
420
|
+
// survives the stealth pass must not come back as a success.
|
|
421
|
+
verdict = documentVerdict(
|
|
422
|
+
{ url: finalUrl, title, text: stealth.text || $rendered('body').text(), html, status },
|
|
423
|
+
{ waitedMs: stealth.gracedMs || 0, fetcher: 'the stealth browser', rendered: true, contentReturned: false }
|
|
424
|
+
);
|
|
425
|
+
// Escalation fires on ANY failed verdict, not only a named vendor —
|
|
426
|
+
// an empty shell and an error placeholder are exactly the cases a
|
|
427
|
+
// browser fixes — so the wording must not call every one a block.
|
|
428
|
+
warnings.push(
|
|
429
|
+
`escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
|
|
430
|
+
`the ${stealthEngine} stealth browser ${verdict.success ? 'returned it' : 'did not get it either'}`
|
|
431
|
+
);
|
|
432
|
+
// The host memory is deliberately left alone here. It records what
|
|
433
|
+
// a PLAIN fetch met, which is what 3.3 reads before deciding to
|
|
434
|
+
// skip one; a stealth success says nothing about that, and only a
|
|
435
|
+
// clean plain fetch clears the entry (0.6).
|
|
436
|
+
} catch (err) {
|
|
437
|
+
// The gate refuses a disallowed URL by throwing, after stamping
|
|
438
|
+
// markPreflightRefusal('ROBOTS_DISALLOWED') on the request context.
|
|
439
|
+
// That flag makes withAuth bill the WHOLE call zero — plain fetch
|
|
440
|
+
// included — which is the intended outcome: we never rendered the
|
|
441
|
+
// page we were refused. Caught here so a refusal is a warning on
|
|
442
|
+
// the verdict we already have rather than a throw out of the tool.
|
|
443
|
+
escalationError = err.message;
|
|
444
|
+
warnings.push(`escalate: the stealth retry did not run — ${escalationError}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
323
447
|
}
|
|
324
|
-
|
|
448
|
+
|
|
449
|
+
// `escalated` is reported only to a caller who asked to escalate: a call
|
|
450
|
+
// that never asked keeps exactly the result shape it had before.
|
|
451
|
+
const escalationFields = escalate
|
|
452
|
+
? {
|
|
453
|
+
escalated: escalationRan,
|
|
454
|
+
...(escalationRan ? { stealth: { engine: stealthEngine, vendor_detected: vendorDetected } } : {})
|
|
455
|
+
}
|
|
456
|
+
: {};
|
|
457
|
+
|
|
458
|
+
if (!verdict || !verdict.success) {
|
|
459
|
+
reportCost();
|
|
325
460
|
return {
|
|
326
461
|
success: false,
|
|
327
462
|
url: finalUrl,
|
|
328
|
-
status,
|
|
463
|
+
// A stealth render reports no status when nothing navigated, and a
|
|
464
|
+
// skipped plain fetch reports none at all.
|
|
465
|
+
...(typeof status === 'number' ? { status } : {}),
|
|
329
466
|
title,
|
|
330
|
-
error: verdict
|
|
331
|
-
|
|
467
|
+
error: verdict
|
|
468
|
+
? verdict.error
|
|
469
|
+
: `The plain fetch was skipped because ${vendorDetected || 'a bot wall'} walled this host within the last 24 hours, and the stealth retry did not run: ${escalationError}`,
|
|
470
|
+
...(verdict?.blocked ? { blocked: verdict.blocked } : {}),
|
|
471
|
+
...escalationFields,
|
|
332
472
|
content: {},
|
|
333
|
-
warnings:
|
|
473
|
+
warnings: warnings.length > 0 ? warnings : undefined
|
|
334
474
|
};
|
|
335
475
|
}
|
|
336
476
|
|
|
@@ -380,14 +520,7 @@ export class UnifiedScrapeTool {
|
|
|
380
520
|
if (units === null) units = segmentUnits(getMarkdown());
|
|
381
521
|
return units;
|
|
382
522
|
}
|
|
383
|
-
// Whether a model completed for a mode:"model" format; the model
|
|
384
|
-
// surcharge is charged only then.
|
|
385
|
-
let modelUsed = false;
|
|
386
|
-
|
|
387
523
|
const content = {};
|
|
388
|
-
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
389
|
-
// per-format ones, so the caller sees the decision in the response.
|
|
390
|
-
const warnings = [...fetchWarnings];
|
|
391
524
|
|
|
392
525
|
// Kept for the rawHtml format, which must survive the strip below.
|
|
393
526
|
const pristineHtml = html;
|
|
@@ -652,15 +785,15 @@ export class UnifiedScrapeTool {
|
|
|
652
785
|
if (!queryScoped && typeof content.markdown === 'string' && content.markdown.length > OVERSIZED_MARKDOWN_CHARS) {
|
|
653
786
|
warnings.push(`markdown: ${content.markdown.length} characters; ask for {type:"highlights", query} (1 extra credit, no model) to get only the matching sentences, table rows and code blocks`);
|
|
654
787
|
}
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
|
|
659
|
-
if (surcharge.model > 0 && !modelUsed) setActualCost(2 + surcharge.query);
|
|
788
|
+
// What this call actually spent: the model step and the escalation each
|
|
789
|
+
// drop out when they did not run (withAuth clamps to the projection
|
|
790
|
+
// either way, so this can only lower the charge).
|
|
791
|
+
reportCost();
|
|
660
792
|
|
|
661
793
|
return {
|
|
662
794
|
success: true,
|
|
663
795
|
url: finalUrl,
|
|
796
|
+
...escalationFields,
|
|
664
797
|
content,
|
|
665
798
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
666
799
|
};
|