feat(tiers): add Akamai Bot Manager (behavioral / sec-cpt) challenge support

trawl returned some Akamai-fronted pages as 200 'success' with only the
~2KB sec-cpt behavioral interstitial as content, because tier detection
knew Cloudflare/Imperva but not Akamai.

- detect.ts: hasAkamaiChallenge() + 'akamai' ChallengeType (sec-if-cpt-container
  / behavioral-content markers, size-gated sensor fallback); wired into
  detectChallengeType/isBlocked/needsJs.
- akamaiWait.ts (new): Akamai analogue of challengeWait/impervaWait — drives
  human-like mouse motion, press-and-hold on the behavioral widget, waits for
  the sensor's location.reload() into real content.
- tiers 1-4: escalate the 200 interstitial (needs-js), invalidate a stale
  cached-session interstitial, dispatch the resolver, report akamai-persistent.

Additive; Cloudflare/Imperva paths untouched. Verified against Edmunds.
This commit is contained in:
Erik Dasque
2026-07-19 00:17:17 -04:00
parent 21ef01ac37
commit 4ac9e74bab
6 changed files with 202 additions and 11 deletions
+13 -1
View File
@@ -1,6 +1,13 @@
import { FINGERPRINT } from "@trawl/browser"
import type { TierResult } from "@trawl/types"
import { hasHcaptcha, hasRecaptcha, hasTurnstile, isBlocked, isCloudflarePage } from "../utils/detect"
import {
hasAkamaiChallenge,
hasHcaptcha,
hasRecaptcha,
hasTurnstile,
isBlocked,
isCloudflarePage,
} from "../utils/detect"
import { normalizeHtml } from "../utils/html"
export interface Tier1Result extends TierResult {
@@ -56,6 +63,11 @@ export async function runTier1(
if (hasTurnstile(html)) {
return { tier: 1, status: "needs-js", durationMs: Date.now() - start, reason: "turnstile-shell" }
}
// Akamai's behavioral interstitial is served with HTTP 200; escalate to a browser
// tier so the sensor JS runs and akamaiWait can drive the challenge.
if (hasAkamaiChallenge(html, headers)) {
return { tier: 1, status: "needs-js", durationMs: Date.now() - start, reason: "akamai-interstitial" }
}
if (isBlocked(res.status, html)) {
return { tier: 1, status: "blocked", durationMs: Date.now() - start, reason: `http-${res.status}` }
+7 -1
View File
@@ -2,7 +2,7 @@ import type { BrowserHandle } from "@trawl/browser"
import type { Cookie, SessionData, TierResult } from "@trawl/types"
import { solvePageCaptchas } from "../solvers"
import { normalizeSameSite, toCookies } from "../utils/cookies"
import { isBlocked, isBrowserErrorPage, isCloudflarePage } from "../utils/detect"
import { hasAkamaiChallenge, isBlocked, isBrowserErrorPage, isCloudflarePage } from "../utils/detect"
import { normalizeHtml } from "../utils/html"
import type { RouteLike } from "../utils/sanitize"
import { routeContinueOverrides } from "../utils/sanitize"
@@ -75,6 +75,12 @@ export async function runTier2(
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: "session-expired" }
}
// A cached session that lands back on Akamai's interstitial is stale — force a
// fresh Tier-3 solve rather than returning the ~2KB challenge stub as content.
if (hasAkamaiChallenge(html)) {
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: "akamai-session-expired" }
}
if (isBlocked(statusCode, html)) {
return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
}
+13 -4
View File
@@ -2,10 +2,12 @@ import type { BrowserHandle } from "@trawl/browser"
import { FINGERPRINT, newFreshContext } from "@trawl/browser"
import type { Cookie, TierResult } from "@trawl/types"
import { solvePageCaptchas } from "../solvers"
import { waitForAkamaiResolution } from "../utils/akamaiWait"
import { waitForChallengeResolution } from "../utils/challengeWait"
import { toCookies } from "../utils/cookies"
import {
detectChallengeType,
hasAkamaiChallenge,
hasImpervaChallenge,
isBlocked,
isBrowserErrorPage,
@@ -84,7 +86,9 @@ export async function runTier3(
const resolution =
challengeType === "imperva"
? await waitForImpervaResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url)
: challengeType === "akamai"
? await waitForAkamaiResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url)
if (resolution !== "ok") {
return {
@@ -96,9 +100,7 @@ export async function runTier3(
? challengeType === "imperva"
? "datacenter-ip-blocked (imperva sensor cookie obtained but challenge persisted — needs residential proxy)"
: "datacenter-ip-blocked (cf_clearance obtained but redirect never completed — needs residential proxy)"
: challengeType === "imperva"
? "imperva-challenge-timeout"
: "cloudflare-challenge-timeout",
: `${challengeType === "none" ? "cloudflare" : challengeType}-challenge-timeout`,
}
}
@@ -148,6 +150,13 @@ export async function runTier3(
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: "imperva-persistent" }
}
if (hasAkamaiChallenge(html)) {
const pageTitle = await page.title().catch(() => "?")
const pageUrl = page.url()
console.log(`[tier3] akamai-persistent: url="${pageUrl}" title="${pageTitle}" html=${html.length}b`)
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: "akamai-persistent" }
}
if (isBlocked(statusCode, html)) {
return { tier: 3, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
}
+15 -4
View File
@@ -2,10 +2,12 @@ import type { BrowserHandle } from "@trawl/browser"
import { FINGERPRINT } from "@trawl/browser"
import type { Cookie, TierResult } from "@trawl/types"
import { solvePageCaptchas } from "../solvers"
import { waitForAkamaiResolution } from "../utils/akamaiWait"
import { waitForChallengeResolution } from "../utils/challengeWait"
import { toCookies } from "../utils/cookies"
import {
detectChallengeType,
hasAkamaiChallenge,
hasImpervaChallenge,
isBlocked,
isBrowserErrorPage,
@@ -102,7 +104,9 @@ export async function runTier4(
const resolution =
challengeType === "imperva"
? await waitForImpervaResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url)
: challengeType === "akamai"
? await waitForAkamaiResolution(page, remaining, url)
: await waitForChallengeResolution(page, remaining, url)
if (resolution !== "ok") {
return {
@@ -112,9 +116,7 @@ export async function runTier4(
reason:
resolution === "ip-blocked"
? "proxy-ip-blocked"
: challengeType === "imperva"
? "imperva-challenge-timeout"
: "cloudflare-challenge-timeout",
: `${challengeType === "none" ? "cloudflare" : challengeType}-challenge-timeout`,
}
}
@@ -162,6 +164,15 @@ export async function runTier4(
}
}
if (hasAkamaiChallenge(html)) {
return {
tier: 4,
status: "blocked",
durationMs: Date.now() - start,
reason: "akamai-persistent",
}
}
if (isBlocked(statusCode, html)) {
return { tier: 4, status: "blocked", durationMs: Date.now() - start, reason: `http-${statusCode}` }
}
+133
View File
@@ -0,0 +1,133 @@
// Akamai Bot Manager "Behavioral Detection" (sec-cpt / SBSD) resolver — the Akamai
// analogue of challengeWait.ts (Cloudflare) and impervaWait.ts (Imperva).
//
// Akamai's interstitial is a hidden #sec-if-cpt-container ("behavioral-content")
// driven by an obfuscated sensor script. The sensor collects pointer/timing telemetry
// and POSTs it; an inline hook then location.reload()s into the real page. Some
// variants also surface a press-and-hold "progress button" that must be actuated.
//
// Unlike CF/Imperva (which resolve from JS execution + a cookie alone), Akamai's
// behavioral challenge scores *interaction*, so we drive human-like mouse motion and,
// if the hold button becomes visible, press-and-hold it — then wait for the reload.
//
// Known risk: Akamai's behavioral scoring is adversarial to synthetic input; success
// is not guaranteed even from a real browser. Camoufox emits genuine Firefox-level
// pointer events (not CDP synthetic ones), which is the best available shot.
import type { Page } from "patchright"
import { hasAkamaiChallenge } from "./detect"
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export async function waitForAkamaiResolution(
page: Page,
timeoutMs: number,
originalUrl?: string,
): Promise<"ok" | "ip-blocked" | "timeout"> {
const deadline = Date.now() + Math.max(timeoutMs, 35_000)
const early = await page.content().catch(() => "")
if (early && !hasAkamaiChallenge(early)) {
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {})
return "ok"
}
// Let the sensor script boot and start listening for behavioral telemetry.
await sleep(1500)
await wanderMouse(page, 8)
let heldOnce = false
let navigatedOnce = false
const sawCookieAt: { t: number | null } = { t: null }
while (Date.now() < deadline) {
const html = await page.content().catch(() => "")
if (html && !hasAkamaiChallenge(html) && html.length > 3500) {
await page.waitForLoadState("load", { timeout: 5000 }).catch(() => {})
return "ok"
}
// Once Akamai has set its clearance cookie but the reload hasn't fired, give it a
// grace period then navigate to the original URL ourselves (mirrors CF/Imperva).
const hasAbck = await pageHasAkamaiCookie(page)
if (hasAbck && sawCookieAt.t === null) {
sawCookieAt.t = Date.now()
console.log("[akamai] _abck cookie present")
}
if (hasAbck && !navigatedOnce && originalUrl && sawCookieAt.t && Date.now() - sawCookieAt.t > 6000) {
navigatedOnce = true
console.log("[akamai] cookie set but still on interstitial — navigating to original URL")
await page.goto(originalUrl, { waitUntil: "domcontentloaded", timeout: 15_000 }).catch(() => {})
await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => {})
continue
}
// If the behavioral widget surfaced a press-and-hold button, actuate it once.
if (!heldOnce) {
heldOnce = await pressAndHold(page).catch(() => false)
}
await wanderMouse(page, 3)
await sleep(600)
}
return "timeout"
}
async function pageHasAkamaiCookie(page: Page): Promise<boolean> {
const cookies: Array<{ name: string; value: string }> = await page
.context()
.cookies()
.catch(() => [])
// A validated _abck has its 2nd '~'-segment != "-1"; presence + validation both help.
const abck = cookies.find((c) => c.name === "_abck")
if (!abck) return false
const seg = abck.value.split("~")[1]
return seg !== undefined && seg !== "-1"
}
// Move the pointer along a wandering path to produce plausible behavioral telemetry.
async function wanderMouse(page: Page, points: number): Promise<void> {
try {
const vp = page.viewportSize() || { width: 1280, height: 800 }
let x = Math.random() * vp.width
let y = Math.random() * vp.height
for (let i = 0; i < points; i++) {
const nx = Math.max(2, Math.min(vp.width - 2, x + (Math.random() - 0.5) * vp.width * 0.5))
const ny = Math.max(2, Math.min(vp.height - 2, y + (Math.random() - 0.5) * vp.height * 0.5))
await page.mouse.move(nx, ny, { steps: 4 + Math.floor(Math.random() * 8) })
x = nx
y = ny
await sleep(60 + Math.random() * 140)
}
} catch {
// page navigating / closed — ignore
}
}
// Press-and-hold the behavioral "progress button" if the sensor has made it visible.
async function pressAndHold(page: Page): Promise<boolean> {
const sel = "#progress-button, .behavioral-button, #sec-if-cpt-container [role='button']"
try {
const el = page.locator(sel).first()
if (!(await el.isVisible({ timeout: 500 }).catch(() => false))) return false
const box = await el.boundingBox().catch(() => null)
if (!box || box.width < 4 || box.height < 4) return false
const cx = box.x + box.width / 2
const cy = box.y + box.height / 2
await page.mouse.move(cx - 18, cy - 10, { steps: 6 })
await page.mouse.move(cx, cy, { steps: 8 })
await page.mouse.down()
// Hold ~5.5s with micro-jitter so the "progress" bar fills.
const holdUntil = Date.now() + 5500
while (Date.now() < holdUntil) {
await page.mouse.move(cx + (Math.random() - 0.5) * 3, cy + (Math.random() - 0.5) * 3, { steps: 2 })
await sleep(220)
}
await page.mouse.up()
console.log("[akamai] press-and-hold performed")
return true
} catch {
return false
}
}
+21 -1
View File
@@ -5,6 +5,7 @@ export type ChallengeType =
| "recaptcha"
| "cap"
| "imperva"
| "akamai"
| "none"
export function isCloudflarePage(html: string, headers: Record<string, string>): boolean {
@@ -83,10 +84,28 @@ export function hasImpervaChallenge(html: string, headers: Record<string, string
return false
}
// Akamai Bot Manager "Behavioral Detection" (sec-cpt / SBSD) interstitial. Akamai
// serves a near-empty page whose only real content is a hidden #sec-if-cpt-container
// (the "behavioral-content" widget, often a press-and-hold button) plus an obfuscated
// sensor script; once the sensor's XHR posts telemetry the page location.reload()s
// into the real content. trawl solves this by driving human-like interaction and
// waiting for the reload — see akamaiWait.ts. These DOM markers are challenge-only
// (the class/id names don't appear on ordinary Akamai-fronted pages), so no size gate
// is needed for them; the sensor-bootstrap fallback IS size-gated to avoid flagging
// full pages that merely carry passive Akamai telemetry.
export function hasAkamaiChallenge(html: string, _headers: Record<string, string> = {}): boolean {
if (/id=["']?sec-if-cpt-container|class=["'][^"']*behavioral-content|sec-bc-tile|scf-akamai-logo/i.test(html))
return true
if (/\/_sec\/(cp_challenge|verify)\//i.test(html)) return true
if (html.length < 3500 && /akamai\.com/i.test(html) && /(progress-button|behavioral|sec-cpt)/i.test(html)) return true
return false
}
export function detectChallengeType(html: string, headers: Record<string, string> = {}): ChallengeType {
if (hasTurnstile(html)) return "cloudflare-turnstile"
if (isCloudflarePage(html, headers)) return "cloudflare-interstitial"
if (hasImpervaChallenge(html, headers)) return "imperva"
if (hasAkamaiChallenge(html, headers)) return "akamai"
if (hasHcaptcha(html)) return "hcaptcha"
if (hasRecaptcha(html)) return "recaptcha"
if (hasCapChallenge(html)) return "cap"
@@ -98,9 +117,10 @@ export function isBlocked(status: number, html: string): boolean {
if (status === 202 || status === 403 || status === 429) return true
if (isCloudflarePage(html, {})) return true
if (hasImpervaChallenge(html)) return true
if (hasAkamaiChallenge(html)) return true
return false
}
export function needsJs(html: string, headers: Record<string, string>): boolean {
return isCloudflarePage(html, headers) || hasImpervaChallenge(html, headers)
return isCloudflarePage(html, headers) || hasImpervaChallenge(html, headers) || hasAkamaiChallenge(html, headers)
}