refactor(tiers): split into tiers/ and utils/, dedupe cookie and network-failure helpers

This commit is contained in:
germondai
2026-07-09 03:23:30 +02:00
parent 25fe9d739a
commit 7a36a6c2c9
18 changed files with 119 additions and 139 deletions
+10 -10
View File
@@ -1,3 +1,10 @@
export type { OrchestratorDeps } from "./orchestrator"
export { ScrapeError, scrape } from "./orchestrator"
export { solvePageCaptchas } from "./solvers"
export { runTier1 } from "./tiers/1"
export { runTier2 } from "./tiers/2"
export { runTier3 } from "./tiers/3"
export { runTier4 } from "./tiers/4"
export {
detectChallengeType,
hasHcaptcha,
@@ -8,10 +15,8 @@ export {
isBrowserErrorPage,
isCloudflarePage,
needsJs,
} from "./detect"
export type { OrchestratorDeps } from "./orchestrator"
export { ScrapeError, scrape } from "./orchestrator"
export { normalizeProxy, ProxyPool } from "./proxyRotator"
} from "./utils/detect"
export { normalizeProxy, ProxyPool } from "./utils/proxyRotator"
export {
isValidMethod,
RESERVED_HEADER_NAMES,
@@ -21,9 +26,4 @@ export {
SUPPORTED_METHODS,
type SupportedMethod,
sanitizeHeaders,
} from "./sanitize"
export { solvePageCaptchas } from "./solvers"
export { runTier1 } from "./tier1"
export { runTier2 } from "./tier2"
export { runTier3 } from "./tier3"
export { runTier4 } from "./tier4"
} from "./utils/sanitize"
+7 -7
View File
@@ -1,13 +1,13 @@
import type { BrowserHandle } from "@trawl/browser"
import { FINGERPRINT, FINGERPRINT_POOL } from "@trawl/browser"
import type { Cookie, ScrapeRequest, ScrapeResult, SessionData, TierResult } from "@trawl/types"
import { normalizeHtml } from "./html"
import type { ProxyPool } from "./proxyRotator"
import { requireContentTypeForBody, sanitizeHeaders } from "./sanitize"
import { runTier1 } from "./tier1"
import { runTier2 } from "./tier2"
import { runTier3 } from "./tier3"
import { runTier4 } from "./tier4"
import { runTier1 } from "./tiers/1"
import { runTier2 } from "./tiers/2"
import { runTier3 } from "./tiers/3"
import { runTier4 } from "./tiers/4"
import { normalizeHtml } from "./utils/html"
import type { ProxyPool } from "./utils/proxyRotator"
import { requireContentTypeForBody, sanitizeHeaders } from "./utils/sanitize"
// Bounds how many distinct proxies a single request will try per tier before giving up —
// keeps a long proxy list from blowing the request's maxTimeout budget.
@@ -1,7 +1,7 @@
import { FINGERPRINT } from "@trawl/browser"
import type { TierResult } from "@trawl/types"
import { hasHcaptcha, hasRecaptcha, hasTurnstile, isBlocked, isCloudflarePage } from "./detect"
import { normalizeHtml } from "./html"
import { hasHcaptcha, hasRecaptcha, hasTurnstile, isBlocked, isCloudflarePage } from "../utils/detect"
import { normalizeHtml } from "../utils/html"
export interface Tier1Result extends TierResult {
tier: 1
@@ -1,10 +1,11 @@
import type { BrowserHandle } from "@trawl/browser"
import type { Cookie, SessionData, TierResult } from "@trawl/types"
import { isBlocked, isBrowserErrorPage, isCloudflarePage } from "./detect"
import { normalizeHtml } from "./html"
import type { RouteLike } from "./sanitize"
import { routeContinueOverrides } from "./sanitize"
import { solvePageCaptchas } from "./solvers"
import { solvePageCaptchas } from "../solvers"
import { normalizeSameSite, toCookies } from "../utils/cookies"
import { isBlocked, isBrowserErrorPage, isCloudflarePage } from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
export interface Tier2Result extends TierResult {
tier: 2
@@ -14,13 +15,6 @@ export interface Tier2Result extends TierResult {
captchasSolved?: string[]
}
// Playwright's cookie.sameSite is `"Strict" | "Lax" | "None"` but can be undefined when
// the cookie was set without an explicit sameSite. Normalize to the Playwright literal
// union with a default of "Lax" (matches browser default for same-origin cookies).
function normalizeSameSite(s: string | undefined): "Strict" | "Lax" | "None" {
return s === "Strict" || s === "Lax" || s === "None" ? s : "Lax"
}
export async function runTier2(
url: string,
handle: BrowserHandle,
@@ -94,28 +88,7 @@ export async function runTier2(
captchasSolved = result.solved
}
const rawCookies = await handle.context.cookies()
const cookies: Cookie[] = rawCookies.map(
(c: {
name: string
value: string
domain: string
path: string
expires: number
httpOnly: boolean
secure: boolean
sameSite?: string
}) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires ?? -1,
httpOnly: c.httpOnly,
secure: c.secure,
sameSite: c.sameSite,
}),
)
const cookies: Cookie[] = toCookies(await handle.context.cookies())
return {
tier: 2,
@@ -1,13 +1,21 @@
import type { BrowserHandle } from "@trawl/browser"
import { FINGERPRINT, newFreshContext } from "@trawl/browser"
import type { Cookie, TierResult } from "@trawl/types"
import { waitForChallengeResolution } from "./challengeWait"
import { detectChallengeType, hasImpervaChallenge, isBlocked, isBrowserErrorPage, isCloudflarePage } from "./detect"
import { normalizeHtml } from "./html"
import { waitForImpervaResolution } from "./impervaWait"
import type { RouteLike } from "./sanitize"
import { routeContinueOverrides } from "./sanitize"
import { solvePageCaptchas } from "./solvers"
import { solvePageCaptchas } from "../solvers"
import { waitForChallengeResolution } from "../utils/challengeWait"
import { toCookies } from "../utils/cookies"
import {
detectChallengeType,
hasImpervaChallenge,
isBlocked,
isBrowserErrorPage,
isCloudflarePage,
} from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import { waitForImpervaResolution } from "../utils/impervaWait"
import { isHardNetworkFailure } from "../utils/network"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
export interface Tier3Result extends TierResult {
tier: 3
@@ -65,17 +73,10 @@ export async function runTier3(
.catch((e: Error) => e)
// Abort early on hard network failures — no point running challenge wait
if (gotoErr instanceof Error) {
const msg = gotoErr.message
const isHardFail =
/ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_CONNECTION_TIMED_OUT|ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED/i.test(
msg,
)
if (isHardFail) {
return { tier: 3, status: "error", durationMs: Date.now() - start, reason: msg.split("\n")[0] }
}
// Otherwise (navigation interrupted by CF redirect) — fall through and keep going
if (isHardNetworkFailure(gotoErr)) {
return { tier: 3, status: "error", durationMs: Date.now() - start, reason: gotoErr.message.split("\n")[0] }
}
// Otherwise (navigation interrupted by CF redirect) — fall through and keep going
const remaining = maxTimeout - (Date.now() - start)
const peekHtml = await page.content().catch(() => "")
@@ -151,28 +152,7 @@ export async function runTier3(
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
}
const rawCookies = await freshCtx.cookies()
const cookies: Cookie[] = rawCookies.map(
(c: {
name: string
value: string
domain: string
path: string
expires: number
httpOnly: boolean
secure: boolean
sameSite?: string
}) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires ?? -1,
httpOnly: c.httpOnly,
secure: c.secure,
sameSite: c.sameSite,
}),
)
const cookies: Cookie[] = toCookies(await freshCtx.cookies())
return {
tier: 3,
@@ -1,13 +1,21 @@
import type { BrowserHandle } from "@trawl/browser"
import { FINGERPRINT } from "@trawl/browser"
import type { Cookie, TierResult } from "@trawl/types"
import { waitForChallengeResolution } from "./challengeWait"
import { detectChallengeType, hasImpervaChallenge, isBlocked, isBrowserErrorPage, isCloudflarePage } from "./detect"
import { normalizeHtml } from "./html"
import { waitForImpervaResolution } from "./impervaWait"
import type { RouteLike } from "./sanitize"
import { routeContinueOverrides } from "./sanitize"
import { solvePageCaptchas } from "./solvers"
import { solvePageCaptchas } from "../solvers"
import { waitForChallengeResolution } from "../utils/challengeWait"
import { toCookies } from "../utils/cookies"
import {
detectChallengeType,
hasImpervaChallenge,
isBlocked,
isBrowserErrorPage,
isCloudflarePage,
} from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import { waitForImpervaResolution } from "../utils/impervaWait"
import { isHardNetworkFailure } from "../utils/network"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
export interface Tier4Result extends TierResult {
tier: 4
@@ -84,15 +92,8 @@ export async function runTier4(
})
.catch((e: Error) => e)
if (gotoErr instanceof Error) {
const msg = gotoErr.message
const isHardFail =
/ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_CONNECTION_TIMED_OUT|ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED/i.test(
msg,
)
if (isHardFail) {
return { tier: 4, status: "error", durationMs: Date.now() - start, reason: msg.split("\n")[0] }
}
if (isHardNetworkFailure(gotoErr)) {
return { tier: 4, status: "error", durationMs: Date.now() - start, reason: gotoErr.message.split("\n")[0] }
}
const remaining = maxTimeout - (Date.now() - start)
@@ -165,28 +166,7 @@ export async function runTier4(
return { tier: 4, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
}
const rawCookies = await proxyContext.cookies()
const cookies: Cookie[] = rawCookies.map(
(c: {
name: string
value: string
domain: string
path: string
expires: number
httpOnly: boolean
secure: boolean
sameSite?: string
}) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires ?? -1,
httpOnly: c.httpOnly,
secure: c.secure,
sameSite: c.sameSite,
}),
)
const cookies: Cookie[] = toCookies(await proxyContext.cookies())
return {
tier: 4,
+34
View File
@@ -0,0 +1,34 @@
import type { Cookie } from "@trawl/types"
interface RawCookie {
name: string
value: string
domain: string
path: string
expires: number
httpOnly: boolean
secure: boolean
sameSite?: string
}
// Playwright's cookie.sameSite is `"Strict" | "Lax" | "None"` but can be undefined when
// the cookie was set without an explicit sameSite. Normalize to the Playwright literal
// union with a default of "Lax" (matches browser default for same-origin cookies).
export function normalizeSameSite(s: string | undefined): "Strict" | "Lax" | "None" {
return s === "Strict" || s === "Lax" || s === "None" ? s : "Lax"
}
// Maps Playwright's raw context.cookies() shape to TRAWL's Cookie type — shared by
// tiers 2-4, which each read cookies back off the browser context after a successful load.
export function toCookies(rawCookies: RawCookie[]): Cookie[] {
return rawCookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
expires: c.expires ?? -1,
httpOnly: c.httpOnly,
secure: c.secure,
sameSite: c.sameSite,
}))
}
+9
View File
@@ -0,0 +1,9 @@
// Shared "hard network failure" check — duplicated verbatim in tier3/tier4's goto-error
// handling before this extraction. These are Chromium/Playwright ERR_* strings that mean
// the browser never reached a server, so there's no point running challenge-wait logic.
const HARD_NETWORK_FAILURE =
/ERR_NAME_NOT_RESOLVED|ERR_CONNECTION_REFUSED|ERR_CONNECTION_TIMED_OUT|ERR_TUNNEL_CONNECTION_FAILED|ERR_PROXY_CONNECTION_FAILED/i
export function isHardNetworkFailure(err: unknown): err is Error {
return err instanceof Error && HARD_NETWORK_FAILURE.test(err.message)
}
@@ -42,7 +42,13 @@ export function sanitizeHeaders(headers?: Record<string, string>): Record<string
return Object.keys(out).length ? out : undefined
}
export const SUPPORTED_METHODS = [
import type { SupportedMethod } from "@trawl/types"
// SupportedMethod now lives in @trawl/types (single source of truth, shared with
// ScrapeRequest.method); re-exported here for backward compat.
export type { SupportedMethod } from "@trawl/types"
export const SUPPORTED_METHODS: readonly SupportedMethod[] = [
"GET",
"POST",
"PUT",
@@ -53,9 +59,7 @@ export const SUPPORTED_METHODS = [
"TRACE",
// RFC 9341 — safe verb that carries the query in the request body.
"QUERY",
] as const
export type SupportedMethod = (typeof SUPPORTED_METHODS)[number]
]
const SUPPORTED_METHOD_SET: ReadonlySet<string> = new Set(SUPPORTED_METHODS)
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { normalizeProxy } from "../src/proxyRotator"
import { normalizeProxy } from "../src/utils/proxyRotator"
// Covers issue #12: Prowlarr's Cardigann flow serializes `proxy` as an object
// {url, username, password}; other callers send a plain URL string. The API
+1 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { ProxyPool } from "../src/proxyRotator"
import { ProxyPool } from "../src/utils/proxyRotator"
describe("ProxyPool", () => {
test("round-robins across proxies for different domains", () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { runTier1 } from "../src/tier1"
import { runTier1 } from "../src/tiers/1"
interface RecordedCall {
url: string
@@ -89,7 +89,7 @@ describe("runTier1 — POST support", () => {
// This demonstrates the tier's pass-through behaviour: a non-reserved
// header does override. Reserved headers are stripped upstream by
// sanitizeHeaders(); this test pins both halves of the contract.
const { sanitizeHeaders } = await import("../src/sanitize")
const { sanitizeHeaders } = await import("../src/utils/sanitize")
const cleaned = sanitizeHeaders({ "User-Agent": "evil-spider/1.0", Accept: "application/json" })
expect(cleaned).toEqual({ Accept: "application/json" }) // UA was reserved, dropped
+1 -1
View File
@@ -5,7 +5,7 @@ import {
RequestValidationError,
requireContentTypeForBody,
sanitizeHeaders,
} from "../src/sanitize"
} from "../src/utils/sanitize"
describe("sanitizeHeaders", () => {
test("returns undefined for undefined / empty input", () => {