crawlforge-mcp-server 6.10.0 → 6.12.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/CLAUDE.md CHANGED
@@ -236,6 +236,11 @@ CRAWLFORGE_STEALTH_PROXIES= # comma-separated proxy URLs used by the es
236
236
  # from the PROXY_ROTATION_* family, which belongs to
237
237
  # localization. The agent's automatic stealth retry
238
238
  # (stealth review Phase 3) reads it too.
239
+ CRAWLFORGE_IMPIT= # `off` skips escalation's impit step (a Chrome TLS handshake with
240
+ # the honest UA, tried before the browser; stealth review Phase 7).
241
+ # Exit-IP dependent like the engine: it cleared indeed.com from a
242
+ # residential IP and nothing from the hosted datacenter IP, so
243
+ # render.yaml sets it off there. Measure with scripts/impit-probe.mjs.
239
244
  CRAWLFORGE_CLEARANCE_JAR= # `off` disables the clearance jar (stealth review Phase 4):
240
245
  # cf_clearance/__cf_bm/datadome cookies a stealth render earned
241
246
  # are replayed to the next stealth context with the same engine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.10.0",
3
+ "version": "6.12.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 31 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -121,7 +121,7 @@
121
121
  "cheerio": "^1.1.2",
122
122
  "commander": "^14.0.3",
123
123
  "compromise": "^14.14.4",
124
- "crawlforge-extractors": "^1.9.0",
124
+ "crawlforge-extractors": "^1.10.0",
125
125
  "diff": "^9.0.0",
126
126
  "dotenv": "^17.2.1",
127
127
  "franc": "^6.2.0",
@@ -139,7 +139,8 @@
139
139
  "zod": "^4.5.4"
140
140
  },
141
141
  "optionalDependencies": {
142
- "camoufox": "^0.1.19"
142
+ "camoufox": "^0.1.19",
143
+ "impit": "^0.14.5"
143
144
  },
144
145
  "devDependencies": {
145
146
  "@jest/globals": "^30.3.0",
package/server.js CHANGED
@@ -59,7 +59,9 @@ import { READ_RESULT_INPUT_SHAPE, readResultHandler } from "./src/tools/result/r
59
59
  import { MAX_INLINE_CHARS_PARAM } from "./src/server/inlineThreshold.js"; // Phase 2
60
60
  import { REDACT_PII_PARAM } from "./src/server/redaction.js"; // Phase 5 (5.3)
61
61
  import { SEARCH_QUERIES_PARAM, EXACTLY_ONE_QUERY_MESSAGE } from "./src/tools/search/batchSearch.js"; // Phase 5 (5.1)
62
- import { markPreflightRefusal } from "./src/server/requestContext.js";
62
+ import { markPreflightRefusal, internalOwnerToken } from "./src/server/requestContext.js";
63
+ import { recordStealthEscalation } from "./src/utils/complianceAudit.js";
64
+ import { loadImpit, impitEnabled, impitFetchPage, IMPIT_ENGINE } from "./src/utils/impitRung.js"; // stealth review Phase 7
63
65
  // D1.1 Resources + D1.2 Prompts + D1.4 Elicitation
64
66
  import { ResourceRegistry, MAX_RESOURCE_BLOB_BYTES } from "./src/resources/ResourceRegistry.js";
65
67
  import { PROMPTS, getPromptMessages } from "./src/prompts/PromptRegistry.js";
@@ -108,7 +110,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
108
110
  // Create the server
109
111
  const server = new McpServer({
110
112
  name: "crawlforge",
111
- version: "6.10.0",
113
+ version: "6.12.0",
112
114
  description: "Production-ready MCP server with 31 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, stateful browser sessions with element refs, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
113
115
  homepage: "https://www.crawlforge.dev",
114
116
  icon: "https://www.crawlforge.dev/icon.png",
@@ -248,17 +250,30 @@ const scrapeTemplateTool = new ScrapeTemplateTool(); // D3.3
248
250
  // go through the same gate, engine resolver and server-level proxy list.
249
251
  // Injected so the tool modules never import StealthBrowserManager (that would pull a
250
252
  // browser dependency into every unit test that loads `scrape`). Same gate
251
- // and same browser the stealth_mode tool drives — no new evasion, and the
253
+ // and same browser the stealth_mode tool drives, plus the impit TLS try, and the
252
254
  // engine name is resolved here, beside its sibling, by the one resolver
253
255
  // every stealth entry point shares.
254
- const stealthEscalation = async ({ url, engine, respectRobots }) => {
256
+ const stealthEscalation = async ({ url, engine, respectRobots, tool }) => {
255
257
  const warnings = await stealthComplianceGate(url, respectRobots);
258
+ const auditIdentity = { apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken() };
259
+ // Stealth review Phase 7: under "auto", a Chrome TLS handshake with the
260
+ // honest User-Agent is tried before any browser launches. A caller who
261
+ // named an engine asked for that browser, so it is not tried for them.
262
+ // CRAWLFORGE_IMPIT=off skips it for a deployment whose exit IP it cannot help.
263
+ if (engine === 'auto' && impitEnabled() && await loadImpit()) {
264
+ recordStealthEscalation({ url, tool, engine: IMPIT_ENGINE, ...auditIdentity });
265
+ const page = await impitFetchPage(url);
266
+ if (page) return { ...page, warnings };
267
+ }
256
268
  // "auto" prefers camoufox and falls back to chromium when its binary is
257
269
  // missing. Mapping here instead ("camoufox" or else chromium) collapsed
258
270
  // auto to chromium and hid the fallback; the resolver says which engine it
259
271
  // landed on and why, and the caller is told.
260
272
  const resolved = await resolveStealthEngine(engine);
261
273
  if (resolved.fallbackWarning) warnings.push(resolved.fallbackWarning);
274
+ // Audit row: the request is about to go out under a browser identity. After
275
+ // the gate (a refusal presents none) and the resolver (the engine that runs).
276
+ recordStealthEscalation({ url, tool, engine: resolved.engine, ...auditIdentity });
262
277
  const scraped = await stealthBrowserManager.scrapeWithStealth({
263
278
  url,
264
279
  engine: resolved.engine
@@ -269,9 +284,9 @@ const stealthEscalation = async ({ url, engine, respectRobots }) => {
269
284
  };
270
285
  const unifiedScrapeTool = new UnifiedScrapeTool({
271
286
  actionExecutor: scrapeWithActionsTool.actionExecutor, // D4 D1 (+v4.8 screenshot reuses the shared browser pool)
272
- escalateScrape: stealthEscalation
287
+ escalateScrape: (args) => stealthEscalation({ ...args, tool: 'scrape' })
273
288
  });
274
- const agentTool = new AgentTool({ escalateFetch: stealthEscalation }); // D4 D2 + stealth review Phase 3
289
+ const agentTool = new AgentTool({ escalateFetch: (args) => stealthEscalation({ ...args, tool: 'agent' }) }); // D4 D2 + stealth review Phase 3
275
290
  const stealthBrowserManager = new StealthBrowserManager();
276
291
  const localizationManager = new LocalizationManager();
277
292
 
@@ -1419,6 +1434,11 @@ registerToolIfEnabled("stealth_mode", {
1419
1434
  const resolvedEngine = await resolveStealthEngine(engine);
1420
1435
  if (resolvedEngine.fallbackWarning) warnings.push(resolvedEngine.fallbackWarning);
1421
1436
 
1437
+ recordStealthEscalation({
1438
+ url, tool: 'stealth_mode', engine: resolvedEngine.engine,
1439
+ apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken()
1440
+ });
1441
+
1422
1442
  const wantsScreenshot = formats.includes('screenshot');
1423
1443
  const scraped = await stealthBrowserManager.scrapeWithStealth({
1424
1444
  url,
@@ -1507,6 +1527,11 @@ registerToolIfEnabled("stealth_mode", {
1507
1527
  let navigation = null;
1508
1528
  try {
1509
1529
  if (urlToTest) {
1530
+ recordStealthEscalation({
1531
+ url: urlToTest, tool: 'stealth_mode',
1532
+ engine: stealthBrowserManager.contexts.get(contextId)?.config?.engine ?? null,
1533
+ apiKey: AuthManager.getConfig()?.apiKey, ownerToken: internalOwnerToken()
1534
+ });
1510
1535
  // page.goto returns a Playwright Response handle, which is not
1511
1536
  // JSON-serializable — extract just the useful navigation details.
1512
1537
  // Explicit timeout keeps navigation inside every caller's window
@@ -1520,6 +1545,7 @@ registerToolIfEnabled("stealth_mode", {
1520
1545
  title: await page.title().catch(() => null)
1521
1546
  };
1522
1547
  const challenge = detectChallengePage({
1548
+ status: navigation.status,
1523
1549
  title: navigation.title || '',
1524
1550
  html: await page.content().catch(() => ''),
1525
1551
  text: await page.innerText('body').catch(() => '')
@@ -26,6 +26,47 @@ import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
26
26
  // Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
27
27
  export const EMPTY_DOCUMENT_GRACE_MS = 8000;
28
28
 
29
+ // Where the Turnstile checkbox sits inside its frame: 28 px in from the left
30
+ // edge (16 px padding + half the 24 px box) of the 300×65 normal-size widget,
31
+ // vertically centred. A fixed offset into the frame's box, because the
32
+ // checkbox itself lives in a closed shadow root inside a cross-origin frame
33
+ // that vanilla Playwright cannot select into — but the frame element's box it
34
+ // can read, from page.frames(), whatever shadow root the frame is mounted in.
35
+ export const TURNSTILE_CHECKBOX_OFFSET_X = 28;
36
+ const TURNSTILE_FRAME_URL = /^https:\/\/challenges\.cloudflare\.com\//;
37
+
38
+ /**
39
+ * The first Turnstile frame box whose checkbox point is on screen. A page can
40
+ * mount a widget off screen (nowsecure.nl mounts one at y=-147), and a click
41
+ * there lands on nothing.
42
+ * @param {Array<{x:number,y:number,width:number,height:number}|null>} boxes
43
+ * @param {{width:number,height:number}|null} viewport
44
+ * @returns {{x:number,y:number,width:number,height:number}|null}
45
+ */
46
+ export function pickTurnstileBox(boxes, viewport) {
47
+ for (const box of boxes) {
48
+ if (!box || !(box.width > 0) || !(box.height > 0)) continue;
49
+ const { x, y } = turnstileClickPoint(box);
50
+ if (x < 0 || y < 0) continue;
51
+ if (viewport && (x > viewport.width || y > viewport.height)) continue;
52
+ return box;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * The checkbox point for a frame box, clamped inside the box so a compact
59
+ * widget narrower than the offset is still clicked inside itself.
60
+ * @param {{x:number,y:number,width:number,height:number}} box
61
+ * @returns {{x:number,y:number}}
62
+ */
63
+ export function turnstileClickPoint(box) {
64
+ return {
65
+ x: box.x + Math.min(TURNSTILE_CHECKBOX_OFFSET_X, box.width / 2),
66
+ y: box.y + box.height / 2
67
+ };
68
+ }
69
+
29
70
  // The Chrome major claimed when the installed binary cannot be read at all.
30
71
  // Only ever a floor: installedChromeVersion() prefers playwright-core's own
31
72
  // browsers.json, and a launched browser's real version overrides both.
@@ -2740,6 +2781,69 @@ export class StealthBrowserManager {
2740
2781
  await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
2741
2782
  }
2742
2783
 
2784
+ /**
2785
+ * Click the Turnstile checkbox of a challenge that did not solve itself
2786
+ * (review Phase 5). A fixed offset into the challenges.cloudflare.com
2787
+ * frame's box, through page.mouse — plain Playwright, no shadow-root access.
2788
+ * Then wait for either the widget's token or the title to change, and give a
2789
+ * wall that is now navigating on its usual wait-out.
2790
+ *
2791
+ * Verified against Cloudflare's forced-interactive test sitekey on a local
2792
+ * page, which proves the mechanism only: whether a real site's challenge
2793
+ * accepts the click still depends on the IP and fingerprint it scores.
2794
+ *
2795
+ * @returns {Promise<boolean>} whether a click was made
2796
+ */
2797
+ async _clickTurnstile(page, { timeoutMs = 10000 } = {}) {
2798
+ const frames = page.frames().filter((frame) => TURNSTILE_FRAME_URL.test(frame.url()));
2799
+ if (frames.length === 0) return false;
2800
+ const boxes = [];
2801
+ for (const frame of frames) {
2802
+ const element = await frame.frameElement().catch(() => null);
2803
+ boxes.push(element ? await element.boundingBox().catch(() => null) : null);
2804
+ }
2805
+ const box = pickTurnstileBox(boxes, page.viewportSize());
2806
+ if (!box) return false;
2807
+ const { x, y } = turnstileClickPoint(box);
2808
+ const title = await page.title().catch(() => '');
2809
+ await page.mouse.move(x - 40, y - 12);
2810
+ await page.mouse.move(x, y, { steps: 8 });
2811
+ await page.mouse.click(x, y, { delay: 90 });
2812
+ await page
2813
+ .waitForFunction(
2814
+ (t) => document.title !== t ||
2815
+ [...document.querySelectorAll('input[name="cf-turnstile-response"]')].some((input) => input.value),
2816
+ title,
2817
+ { timeout: timeoutMs }
2818
+ )
2819
+ .catch(() => {});
2820
+ await this._waitOutChallenge(page);
2821
+ return true;
2822
+ }
2823
+
2824
+ /**
2825
+ * The click, only where it belongs: on Chromium, and only on a document the
2826
+ * verdict still calls a Cloudflare wall. A real page that embeds a widget
2827
+ * (quora.com's login, nowsecure.nl at 200) is never clicked.
2828
+ * @returns {Promise<boolean>} whether a click was made
2829
+ */
2830
+ async _clickThroughChallenge(page, { status = null, engine = null } = {}) {
2831
+ if (engine !== 'chromium') return false;
2832
+ if (!page.frames().some((frame) => TURNSTILE_FRAME_URL.test(frame.url()))) return false;
2833
+ let title; let html; let text;
2834
+ try {
2835
+ [title, html, text] = await Promise.all([
2836
+ page.title(),
2837
+ page.content(),
2838
+ page.evaluate(() => (document.body ? document.body.innerText : ''))
2839
+ ]);
2840
+ } catch {
2841
+ return false;
2842
+ }
2843
+ if (detectChallengePage({ title, html, text, status })?.vendor !== 'cloudflare') return false;
2844
+ return this._clickTurnstile(page);
2845
+ }
2846
+
2743
2847
  async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
2744
2848
  if (!url) throw new Error('scrapeWithStealth requires a url');
2745
2849
 
@@ -2775,6 +2879,8 @@ export class StealthBrowserManager {
2775
2879
  // up, so the interstitial came back as success:true (R17, 2026-09-04).
2776
2880
  // An auto-solving challenge gets one bounded wait to finish first.
2777
2881
  await this._waitOutChallenge(page);
2882
+ // A Turnstile challenge still up after that wait needs its checkbox clicked.
2883
+ await this._clickThroughChallenge(page, { status, engine: engineUsed });
2778
2884
  const emptyGraceMs = await this._waitOutEmptyDocument(page);
2779
2885
  // Last: let whatever is still rendering finish. Without this the read
2780
2886
  // below can land between "the page has content" and "the page has the
@@ -2811,7 +2917,7 @@ export class StealthBrowserManager {
2811
2917
  // Still a wall after everything above: whatever clearance this context
2812
2918
  // carried for the site did not work, so closeContext drops it instead of
2813
2919
  // keeping it for the next call.
2814
- if (detectChallengePage({ title, html, text })) {
2920
+ if (detectChallengePage({ title, html, text, status })) {
2815
2921
  this._markBlocked(contextId, [url, page.url()]);
2816
2922
  }
2817
2923
 
@@ -138,7 +138,7 @@ const scrapeShape = {
138
138
  stealth: z.object({
139
139
  engine: z.string().optional(),
140
140
  vendor_detected: z.string().nullable().optional()
141
- }).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)'),
141
+ }).passthrough().optional().describe('Present when escalated is true: the stealth engine that ran ("impit" when the Chrome TLS handshake got the page without a browser), and the bot-defence vendor the plain fetch hit (null when the block named none)'),
142
142
  content: z.object(scrapeFormatShapes).passthrough().optional().describe('One key per requested format'),
143
143
  warnings: z.array(z.string()).optional().describe('Per-format warnings; partial success never fails the whole call'),
144
144
  redaction: redactionShape,
@@ -80,11 +80,17 @@ shell). The escalated page goes through the same formats, so `markdown`,
80
80
  ```
81
81
 
82
82
  The result adds `escalated: true|false`, and `stealth: { engine,
83
- vendor_detected }` when the browser ran. The projection is `2 + 5`; the charge
83
+ vendor_detected }` when the escalation ran. `engine` is `impit` when a Chrome
84
+ TLS handshake with the honest User-Agent got the page before any browser
85
+ launched (default `escalate_engine: "auto"` only). The projection is `2 + 5`,
86
+ whichever step got the page; the charge
84
87
  falls back to the base when the plain fetch succeeded and nothing escalated.
85
88
  robots.txt is respected on the escalated path too, and a second call to a host
86
89
  that blocked within the last 24 hours skips the doomed plain fetch and says so
87
- in `warnings[]`.
90
+ in `warnings[]`. That call is charged the full `2 + 5` once the browser runs,
91
+ because no plain fetch ran that could have lowered it. The memory is kept for 24
92
+ hours from the block, in the running server only, and a `scrape` without
93
+ `escalate` that gets a clean page from that host clears it sooner.
88
94
 
89
95
  `escalate_engine` defaults to `"auto"`: Camoufox (Firefox, the engine that gets
90
96
  through Cloudflare Turnstile and Akamai) when its binary is installed, Chromium
@@ -5,7 +5,9 @@
5
5
  * plain fetch comes back walled (Phase 3). The plain fetch always runs first
6
6
  * (G1: never start with stealth), so the escalation is a second stage, not a
7
7
  * different tool: it reuses the same compliance gate and the same
8
- * StealthBrowserManager `stealth_mode` uses, and adds no new evasion.
8
+ * StealthBrowserManager `stealth_mode` uses. Its one addition (stealth review
9
+ * Phase 7) is a Chrome TLS handshake tried before the browser under "auto";
10
+ * see src/utils/impitRung.js.
9
11
  *
10
12
  * The two fields and the price rule live here so `server.js`, the tool
11
13
  * module, `AuthManager.getToolCost` and the output schema read one
@@ -20,7 +22,7 @@ export const SCRAPE_ESCALATION_CREDITS = 5;
20
22
 
21
23
  /** The two public fields, in tools/list order, with the text the client sees. */
22
24
  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'),
25
+ 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. Under escalate_engine "auto" a Chrome TLS handshake with the honest CrawlForge User-Agent (impit) is tried first, and the browser runs only when that does not get the page. Projected at 2+5; the actual charge stays at the base price when the plain fetch succeeded. Default: false'),
24
26
  // "auto" is the default because the engine that gets past the walls this
25
27
  // stage exists for is camoufox, and asking for it by name used to be the
26
28
  // caller's problem. It resolves to camoufox when the package is installed
@@ -380,7 +380,7 @@ export class UnifiedScrapeTool {
380
380
  // Opt-in, and second by construction: the plain fetch above has already
381
381
  // run and only a blocked verdict gets here (G1 — never start with
382
382
  // stealth). The stage reuses the compliance gate and the browser
383
- // `stealth_mode` already drives, so it adds no evasion of its own.
383
+ // `stealth_mode` already drives, plus the impit TLS try (Phase 7) before it.
384
384
  // The vendor the plain fetch hit, or the one this host is remembered for
385
385
  // when that fetch was skipped.
386
386
  const vendorDetected = remembered ? (remembered.vendor ?? null) : (verdict?.blocked?.vendor ?? null);
@@ -428,7 +428,7 @@ export class UnifiedScrapeTool {
428
428
  // browser fixes — so the wording must not call every one a block.
429
429
  warnings.push(
430
430
  `escalate: the plain fetch ${vendorDetected ? `was blocked by ${vendorDetected}` : 'did not return the page'}; ` +
431
- `the ${stealthEngine} stealth browser ${verdict.success ? 'returned it' : 'did not get it either'}`
431
+ `${stealthEngine === 'impit' ? 'a Chrome TLS handshake (impit, honest User-Agent)' : `the ${stealthEngine} stealth browser`} ${verdict.success ? 'returned it' : 'did not get it either'}`
432
432
  );
433
433
  // The host memory is deliberately left alone here. It records what
434
434
  // a PLAIN fetch met, which is what 3.3 reads before deciding to
@@ -76,7 +76,27 @@ function looksLikeRealPage({ title = '', text = '' }) {
76
76
  }
77
77
 
78
78
  /**
79
- * @param {{ title?: string, html?: string, text?: string }} page
79
+ * A page the server answered 200 that carries only a Turnstile widget: no
80
+ * bootstrap (the caller has already ruled that out) and none of the
81
+ * interstitial's prose. Cloudflare serves its walls as 403; a 200 with a
82
+ * widget is a page that embeds one. nowsecure.nl is exactly this — 200 to the
83
+ * honest CrawlForge UA, 43 visible characters and two widgets on Cloudflare's
84
+ * test sitekey — and every "Blocked" cell recorded for it came from this
85
+ * module, not from Cloudflare (2026-09-25). The status is what separates it
86
+ * from a widget-only wall, which the length floor cannot; without a status the
87
+ * floor still decides. It still has to be a document — a title and some text —
88
+ * so an empty 200 shell carrying only the widget script stays a wall, as
89
+ * Phase 1 decided.
90
+ */
91
+ function looksLikeWidgetOnlyPage({ status, title = '', text = '' }) {
92
+ if (status !== 200) return false;
93
+ const visible = String(text).replace(/\s+/g, ' ').trim();
94
+ if (!String(title).trim() || !visible) return false;
95
+ return !INTERSTITIAL_PROSE.test(`${title} ${visible}`);
96
+ }
97
+
98
+ /**
99
+ * @param {{ title?: string, html?: string, text?: string, status?: number|null }} page
80
100
  * @returns {{ vendor: string, evidence: string } | null}
81
101
  */
82
102
  export function detectChallengePage(page = {}) {
@@ -87,6 +107,7 @@ export function detectChallengePage(page = {}) {
87
107
  // response input on a short page.
88
108
  if (!hit || hit.vendor !== 'cloudflare') return hit;
89
109
  if (looksLikeInterstitial(page)) return hit;
110
+ if (looksLikeWidgetOnlyPage(page)) return null;
90
111
  return looksLikeRealPage(page) ? null : hit;
91
112
  }
92
113
 
@@ -5,6 +5,8 @@
5
5
  * documented decision*. That means the override has to leave a trace: which key
6
6
  * asked, for which URL, from which tool, when. Without the row the override is
7
7
  * a silent product default again, which is the thing G5 exists to prevent.
8
+ * The same trail records each time a request goes out under a browser identity
9
+ * (a stealth render), so that choice is on record too.
8
10
  *
9
11
  * Rows go to `logs/compliance-audit.log` as JSONL (one row per line, appended)
10
12
  * and to a small in-memory ring the tools and tests can read back. Writing is
@@ -55,6 +57,24 @@ export function recordComplianceEvent(event = {}) {
55
57
  return row;
56
58
  }
57
59
 
60
+ /**
61
+ * Record that a request is about to go out under a browser identity (a stealth
62
+ * render). Neither the raw key nor the raw owner token is stored: both are
63
+ * reduced to apiKeyId digests. `ownerId` names the website customer behind an
64
+ * internal-proxy request, where `apiKeyId` is always the service's own key.
65
+ * Never throws.
66
+ */
67
+ export function recordStealthEscalation({ url, tool, engine, apiKey, ownerToken } = {}) {
68
+ return recordComplianceEvent({
69
+ event: 'stealth_escalation',
70
+ url,
71
+ tool,
72
+ engine: engine ?? null,
73
+ apiKeyId: apiKeyId(apiKey),
74
+ ...(ownerToken ? { ownerId: apiKeyId(ownerToken) } : {})
75
+ });
76
+ }
77
+
58
78
  /** Most recent audit rows, newest last. Test/diagnostic hook. */
59
79
  export function getComplianceAuditRows() {
60
80
  return ring.slice();
@@ -0,0 +1,157 @@
1
+ /**
2
+ * impitRung.js — the browser-TLS HTTP try inside the escalation stage
3
+ * (stealth review Phase 7).
4
+ *
5
+ * Some walls refuse Node's TLS handshake whatever the headers and accept a
6
+ * Chrome one: quora.com passed 0/4 over Node TLS and 4/4 over impit's Chrome
7
+ * profile (residential spike, 2026-09-26). impit makes that request without
8
+ * launching a browser, so the escalation stage tries it first and runs the
9
+ * browser only when it does not get the page.
10
+ *
11
+ * Owner decisions this module keeps (docs/STEALTH_REVIEW_2026-09.md, Phase 7):
12
+ * - It runs only inside escalation, whose callers already opted into a browser
13
+ * identity. The plain fetch never uses it.
14
+ * - The User-Agent stays the honest CrawlForge one; only the TLS handshake and
15
+ * impit's profile headers are Chrome's. indeed.com passed 3/4 that way and
16
+ * 0/4 with a Chrome UA.
17
+ * - The price is escalation's own (2 + 5), whichever step got the page.
18
+ *
19
+ * impit is an optional dependency with native bindings. When it is missing or
20
+ * fails to load, `loadImpit()` resolves to null and escalation runs the
21
+ * browser exactly as before.
22
+ *
23
+ * impit resolves DNS itself, outside the undici dispatcher ssrfGuard installs,
24
+ * so redirects are followed here, one hop at a time, and every hop is checked
25
+ * with DNS resolution — the same check safeGoto applies to a navigation.
26
+ */
27
+
28
+ import { load } from 'cheerio';
29
+ import { readBody } from 'crawlforge-extractors';
30
+ import { resolveUserAgent } from './fetchIdentity.js';
31
+ import { assertUrlAllowed } from './ssrfGuard.js';
32
+ import { stealthDocumentVerdict } from './stealthVerdict.js';
33
+ import { config, serverStealthProxies } from '../constants/config.js';
34
+
35
+ /** The engine name escalation reports when this step got the page. */
36
+ export const IMPIT_ENGINE = 'impit';
37
+
38
+ const IMPIT_BROWSER = 'chrome151';
39
+ const IMPIT_TIMEOUT_MS = 15000;
40
+ const MAX_REDIRECTS = 5;
41
+
42
+ // impit reads the HTML the server sent; it runs no JavaScript. quora.com
43
+ // passed the verdict through it with a normal title over 59 characters of
44
+ // "Something went wrong. Wait a moment and try again." — the client app's
45
+ // fallback, not the page (measured 2026-09-26). The browser renders the
46
+ // real page, so a document this thin goes to the browser instead.
47
+ const MIN_UNRENDERED_TEXT_CHARS = 200;
48
+
49
+ let impitClass; // undefined: not tried yet; null: unavailable
50
+
51
+ /**
52
+ * Whether this deployment runs the step. `CRAWLFORGE_IMPIT=off` turns it off.
53
+ * Like the stealth engine, what it can clear depends on the exit IP: from a
54
+ * residential IP it cleared indeed.com, and from the hosted instance's
55
+ * datacenter IP (AS14618, Ashburn) it cleared nothing in 3 runs (2026-09-27),
56
+ * so render.yaml sets it off there. Read on every call, like the stealth
57
+ * proxy list, so an operator can change it without a restart.
58
+ * @returns {boolean}
59
+ */
60
+ export function impitEnabled() {
61
+ return String(process.env.CRAWLFORGE_IMPIT || '').trim().toLowerCase() !== 'off';
62
+ }
63
+
64
+ /**
65
+ * The Impit class, or null when the optional dependency is absent or its
66
+ * native bindings do not load on this platform. Tried once per process.
67
+ * @returns {Promise<Function|null>}
68
+ */
69
+ export async function loadImpit() {
70
+ if (impitClass === undefined) {
71
+ try {
72
+ const mod = await import('impit');
73
+ impitClass = mod.Impit ?? mod.default?.Impit ?? null;
74
+ } catch {
75
+ impitClass = null;
76
+ }
77
+ }
78
+ return impitClass;
79
+ }
80
+
81
+ /**
82
+ * Fetch `url` over a Chrome TLS handshake with the honest User-Agent and
83
+ * return the page when it passes the document verdict, or null when it does
84
+ * not (a wall, an error page, an empty shell) or the request failed. A null
85
+ * means "run the browser"; this function never throws for a failed fetch.
86
+ *
87
+ * An SSRF refusal on any hop DOES throw: the browser would be refused the same
88
+ * URL, so falling through to it would only fail slower.
89
+ *
90
+ * @param {string} url
91
+ * @param {object} [options]
92
+ * @param {Function} [options.Impit] the client class (tests inject a fake)
93
+ * @param {number} [options.timeoutMs]
94
+ * @returns {Promise<{ html: string, text: string, title: string, url: string, status: number, engine: string } | null>}
95
+ */
96
+ export async function impitFetchPage(url, { Impit, timeoutMs = IMPIT_TIMEOUT_MS } = {}) {
97
+ const ImpitClass = Impit ?? await loadImpit();
98
+ if (!ImpitClass) return null;
99
+
100
+ // The operator's exit address, as the browser would use: a wall that has
101
+ // seen the server's own IP should not see it again from this step.
102
+ const proxyUrl = serverStealthProxies()[0];
103
+ const client = new ImpitClass({
104
+ browser: IMPIT_BROWSER,
105
+ headers: { 'user-agent': resolveUserAgent() },
106
+ followRedirects: false,
107
+ timeout: timeoutMs,
108
+ ...(proxyUrl ? { proxyUrl } : {})
109
+ });
110
+
111
+ const deadline = Date.now() + timeoutMs;
112
+ let current = url;
113
+ let response;
114
+ for (let hop = 0; ; hop++) {
115
+ await assertUrlAllowed(current, { resolveDns: true });
116
+ const remaining = deadline - Date.now();
117
+ if (remaining <= 0) return null;
118
+ try {
119
+ response = await client.fetch(current, { timeout: remaining });
120
+ } catch {
121
+ return null;
122
+ }
123
+ const location = response.status >= 300 && response.status < 400 ? response.headers?.get?.('location') : null;
124
+ if (!location) break;
125
+ if (hop >= MAX_REDIRECTS) return null;
126
+ try {
127
+ current = new URL(location, current).href;
128
+ } catch {
129
+ return null;
130
+ }
131
+ if (!/^https?:\/\//i.test(current)) return null;
132
+ }
133
+
134
+ let html;
135
+ try {
136
+ html = await readBody(response, { maxBytes: config.fetch.maxBodySize });
137
+ } catch {
138
+ return null;
139
+ }
140
+
141
+ const $ = load(html);
142
+ const title = $('title').first().text().trim();
143
+ $('script, style, noscript, template').remove();
144
+ const text = $('body').text().replace(/\s+/g, ' ').trim();
145
+ const verdict = stealthDocumentVerdict(
146
+ { url: current, title, text, html, status: response.status },
147
+ { fetcher: 'impit', rendered: false, contentReturned: false }
148
+ );
149
+ if (!verdict.success || text.length < MIN_UNRENDERED_TEXT_CHARS) return null;
150
+
151
+ return { html, text, title, url: current, status: response.status, engine: IMPIT_ENGINE };
152
+ }
153
+
154
+ /** Test hook: forget whether impit loaded. */
155
+ export function _resetImpitLoader() {
156
+ impitClass = undefined;
157
+ }
@@ -24,7 +24,8 @@ import { identityHeaders, resolveUserAgent } from './fetchIdentity.js';
24
24
  import { throttleHost } from './hostRateLimiter.js';
25
25
  import { recordComplianceEvent, apiKeyId } from './complianceAudit.js';
26
26
  import { signRequestHeaders } from './webBotAuth.js';
27
- import { markPreflightRefusal } from '../server/requestContext.js';
27
+ import { markPreflightRefusal, internalOwnerToken } from '../server/requestContext.js';
28
+ import AuthManager from '../core/AuthManager.js';
28
29
  import { config } from '../constants/config.js';
29
30
 
30
31
  export class RobotsDisallowedError extends Error {
@@ -112,11 +113,16 @@ export async function robotsPreflight(url, options = {}) {
112
113
  ? 'respect_robots was disabled for this request. robots.txt did not disallow this URL, so the override changed nothing. The request is recorded against your API key.'
113
114
  : `respect_robots was disabled for this request and robots.txt on ${new URL(url).host} disallows this path. Fetching anyway is your decision and is recorded against your API key.`
114
115
  );
116
+ // No fetching tool passes its key down to here, so the row falls back to
117
+ // the key this server runs under, as the stealth_escalation row does.
118
+ // Without it every live override was logged as "anonymous".
119
+ const ownerToken = internalOwnerToken();
115
120
  recordComplianceEvent({
116
121
  event: 'robots_override',
117
122
  url,
118
123
  tool: options.tool || null,
119
- apiKeyId: apiKeyId(options.apiKey),
124
+ apiKeyId: apiKeyId(options.apiKey ?? AuthManager.getConfig()?.apiKey),
125
+ ...(ownerToken ? { ownerId: apiKeyId(ownerToken) } : {}),
120
126
  userAgent,
121
127
  robotsAllowed: allowed
122
128
  });