/** * TLS-fingerprint HTTP tier. * * Wraps the `wreq-js` napi backend in the same surface as the default HTTP * tier (HttpClient.fetch shape). The wreq module is lazy-imported on the * first call so MCP servers that never need TLS impersonation don't pay the * 654ms cold-start cost. The cached module is null when the optional dep is * not installed for the host platform; callers must handle the * `tls_tier_unavailable` rejection. * * Anti-bot signals are recognised by status code (403 / 429 / 503) and by * three challenge-page body markers (Cloudflare's `cf-browser-verification` * and `Just a moment`, plus DataDome sensor scripts). Callers (router) use * these helpers to decide whether to escalate. */ export interface TlsFetchOptions { headers?: Record; timeoutMs?: number; /** * Caller-supplied abort signal (e.g. the per-fetch deadline assembled by the * search content-fetch stage / router). When provided it is COMBINED with the * internal `timeoutMs` budget — whichever fires first wins — so the TLS tier * never mints a fresh full timeout on top of an already-spent per-fetch * deadline. Dropping it would let a timeout-escalation path stack * HTTP-timeout + a fresh TLS-timeout (a latency blowup). */ signal?: AbortSignal; } export interface TlsFetchResult { url: string; finalUrl: string; html: string; contentType: string; statusCode: number; headers: Record; rawBuffer?: Buffer; } export declare class TlsTierUnavailableError extends Error { readonly cause: unknown; constructor(cause: unknown); } interface WreqHeaders { entries?: () => Iterable<[string, string]>; forEach?: (cb: (v: string, k: string) => void) => void; } interface WreqResponse { status: number; url?: string; headers: WreqHeaders; text(): Promise; arrayBuffer?(): Promise; } interface WreqFetchInit { headers?: Record; browser?: string; signal?: AbortSignal; redirect?: 'follow' | 'manual' | 'error'; } type WreqFetch = (url: string, init?: WreqFetchInit) => Promise; interface LoadedTlsBackend { fetch: WreqFetch; } /** Test-only: reset the lazy-load memo so the next call re-imports. */ export declare function _resetTlsBackend(): void; export declare function _setTlsBackendForTests(backend: LoadedTlsBackend | null): void; /** * TLS-impersonation fetch with bounded profile rotation on anti-bot challenges. * * Returns the same shape as the default HTTP tier so router.ts can swap tiers * without branching elsewhere. When the active profile is served an anti-bot * challenge (Cloudflare/DataDome — see {@link isAntiBotSignal}) the tier * rotates to the next browser family in {@link PROFILE_FALLBACK_ORDER} and * returns the first healthy response. If every profile stays blocked it returns * the last (still-blocked) result so the router can escalate. All attempts * share ONE combined deadline (caller signal + internal timeout) so rotation * never mints a fresh full timeout per profile (a latency blowup). */ export declare function tlsFetch(url: string, options?: TlsFetchOptions): Promise; export declare function isAntiBotStatus(status: number): boolean; /** * Header-driven challenge signal. Cloudflare sets `cf-mitigated: challenge` * (or `block`) specifically to mark a challenge/block response, so it carries * ZERO over-fire risk — a real article response never sends it. Header casing * varies across tiers, so the lookup is case-insensitive. */ export declare function hasChallengeHeader(headers: Record | undefined): boolean; /** * DataDome block signal from response headers. DataDome (used by G2, Reddit, * and others) does NOT send Cloudflare's `cf-mitigated`; on a block it sets * `x-dd-b: 1` (its explicit block flag) and/or `x-datadome: protected|blocked`. * The `x-datadome-cid` client-id header is stamped on ALLOWED responses too, so * it is deliberately ignored here. Caller status-gates this (see * isChallengeResponse) for defence in depth — a real 200 never trips it. */ export declare function hasDataDomeBlockHeader(headers: Record | undefined): boolean; /** * Combined challenge classifier that recognises BOTH legacy and modern * Cloudflare challenge shapes without widening the shared CHALLENGE_MARKERS * list (which drives isRateLimit / isChallengeShell — those must not shift). * * Fires when ANY of: * - the `cf-mitigated` response header marks a challenge/block (zero * over-fire — the primary modern signal), OR * - the existing status-agnostic shell classifier fires (isChallengeShell: * anti-bot-status + legacy body markers, OR a 2xx legacy shell with the * skeleton gate), OR * - an anti-bot STATUS (403/429/503) co-occurs with the modern * `/cdn-cgi/challenge-platform/` script marker. * * The challenge-platform marker is STATUS-gated and the legacy body path keeps * its skeleton gate (via isChallengeShell), so a 200 article that merely quotes * challenge markers or references `/cdn-cgi/...` can never trip it. */ export declare function isChallengeResponse(statusCode: number, html: string | null | undefined, headers?: Record | undefined): boolean; export declare function hasChallengeBody(html: string | null | undefined): boolean; /** * Browser-tier-only heuristic: does the page look like a challenge-page * skeleton rather than a real document? True when ANY of: * - the visible body text is near-empty (interstitials carry almost no prose), OR * - a `/cdn-cgi/challenge-platform/` script is loaded, OR * - the page title is a known interstitial title. * * This is the CONTEXT gate for the contextual turnstile signal — a real login * page that embeds a Turnstile widget has substantial content, no * challenge-platform script, and a normal title, so it is never a skeleton. */ /** * Is the rendered body near-empty (below the interstitial content floor)? A * challenge poll uses this AFTER full hydration: a body that never filled with * real content did not truly clear — some bot walls (DataDome) swap the * interstitial for a tiny stub carrying no marker, which a marker-based * clear-check misreads as a pass. Empty/absent HTML counts as near-empty. */ export declare function isNearEmptyBody(html: string | null | undefined): boolean; export declare function isChallengeSkeleton(html: string | null | undefined): boolean; /** * Browser-tier challenge-body predicate. Fires when the shared body scan * matches (hasChallengeBody) OR when the Turnstile widget marker co-occurs * with a challenge-page skeleton. The turnstile signal is CONTEXTUAL — a bare * `cf-turnstile` substring on a full real page (a login form) never fires. * The shared CHALLENGE_MARKERS list is untouched. */ export declare function hasBrowserChallengeBody(html: string | null | undefined): boolean; /** * Browser-tier CLEAR-CHECK: does the CURRENTLY-RENDERED body still show a * challenge? Used as the `isStillChallenge` predicate the completion poll * re-evaluates each tick — it MUST key on the live DOM, never the (stale) nav * header, so a genuinely-cleared challenge (the real page rendered) reports * false and the poll returns cleared. * * Fires when EITHER: * - the existing browser challenge-body scan matches * (hasBrowserChallengeBody: legacy markers, or contextual turnstile on a * skeleton), OR * - the modern-CF `/cdn-cgi/challenge-platform/` script marker is present AND * the body is a near-empty skeleton (visible text under the interstitial * floor). The skeleton gate here is TEXT-LENGTH based — deliberately NOT * isChallengeSkeleton, which short-circuits true on the marker itself — so a * real full article that merely references the script path (substantial * prose) is NOT treated as still-challenge and the poll clears. */ export declare function stillShowingChallenge(html: string | null | undefined): boolean; /** * A 429 without an anti-bot challenge body is a plain * rate-limit, not an anti-bot wall. Playwright will hit the same rate * limit, so escalation just pays the browser cold-start cost for no gain. * * We treat the response as rate-limited (NOT anti-bot) when: * (a) statusCode === 429, AND * (b) the body does NOT carry a Cloudflare/DataDome challenge marker. * * Callers (router) check this first and surface the 429 up the stack * instead of escalating. A Retry-After header strengthens the signal but * isn't required — many CDNs return 429 without one. */ export declare function isRateLimit(statusCode: number, html: string | null | undefined): boolean; export declare function isAntiBotSignal(statusCode: number, html: string | null | undefined): boolean; /** * Status-agnostic challenge-shell classifier. A challenge interstitial must * never be returned as fetch content REGARDLESS of HTTP status — some bot * walls (DataDome "enable JavaScript" pages) serve the shell at HTTP 200. * * Fires when EITHER: * - (anti-bot status AND challenge body) — the pre-existing status-gated * signal, preserved verbatim, OR * - (2xx AND challenge markers present AND challenge-page skeleton) — the * 200-shell case. BOTH the markers AND the skeleton are required: markers * alone must never fire (an article quoting 'Just a moment' or 'dd-loader' * is legit content), and a skeleton alone is a plain SPA shell handled by * the SPA-empty-content path, not the challenge path. */ export declare function isChallengeShell(statusCode: number, html: string | null | undefined): boolean; /** * Heuristic: the page came back but tells the user that JavaScript is * required. Mirrors playwright-tier.shouldEscalate's marker check but is * exposed separately so the router can distinguish "TLS failed → try * Playwright" from "anti-bot wall → already escalated". */ export declare function looksJsRequired(html: string | null | undefined): boolean; /** Lightweight debug helper used by router to log routing decisions. */ export declare function describeAntiBot(statusCode: number, html: string | null | undefined): string | null; export {}; //# sourceMappingURL=tls-tier.d.ts.map