/** * Browser fingerprint profiles used for plain HTTP fetches. * * A profile is a coherent set of request headers (in the order the real browser sends them), the * matching client-hints, and a TLS cipher/curve preference list approximating the browser's * ClientHello. Sticky sessions pick a profile deterministically from their key so the identity stays * stable across requests; otherwise a weighted-random profile is used and rotated on every retry. */ import type { ConcreteNetwork } from "@fetcha/core"; export type ProfileDevice = "desktop" | "mobile" | "tablet"; export type TlsFamily = "chrome" | "firefox" | "safari"; export interface FingerprintProfile { id: string; device: ProfileDevice; tls: TlsFamily; /** Weight for random selection (desktop Chrome dominates real traffic). */ weight: number; /** Ordered header list; `accept-language` is filled from the request locale. */ headers: Array<[string, string]>; /** Ordered navigational Sec-Fetch headers (Chromium & Firefox). */ secFetch: boolean; userAgent: string; /** Playwright-facing attributes for the managed browser. */ viewport: { width: number; height: number }; platform: string; } const CHROME_VER = "140"; const CHROME_FULL = "140.0.7339.128"; const FIREFOX_VER = "143"; const SAFARI_VER = "18.6"; const chromeAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"; const firefoxAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8"; const safariAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; function chromeDesktop(id: string, platformToken: string, uaPlatform: string, uaPlatformHint: string, weight: number, brandExtra = ""): FingerprintProfile { const ua = `Mozilla/5.0 (${uaPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_FULL} Safari/537.36${brandExtra}`; const brands = brandExtra.includes("Edg") ? `"Chromium";v="${CHROME_VER}", "Microsoft Edge";v="${CHROME_VER}", "Not_A Brand";v="24"` : `"Chromium";v="${CHROME_VER}", "Google Chrome";v="${CHROME_VER}", "Not_A Brand";v="24"`; return { id, device: "desktop", tls: "chrome", weight, userAgent: ua, platform: platformToken, viewport: { width: 1920, height: 1080 }, secFetch: true, headers: [ ["sec-ch-ua", brands], ["sec-ch-ua-mobile", "?0"], ["sec-ch-ua-platform", `"${uaPlatformHint}"`], ["upgrade-insecure-requests", "1"], ["user-agent", ua], ["accept", chromeAccept], ["sec-fetch-site", "none"], ["sec-fetch-mode", "navigate"], ["sec-fetch-user", "?1"], ["sec-fetch-dest", "document"], ["accept-encoding", "gzip, deflate, br, zstd"], ["accept-language", "{lang}"], ["priority", "u=0, i"], ], }; } export const PROFILES: FingerprintProfile[] = [ chromeDesktop("chrome-win", "Win32", "Windows NT 10.0; Win64; x64", "Windows", 40), chromeDesktop("chrome-mac", "MacIntel", "Macintosh; Intel Mac OS X 10_15_7", "macOS", 20), chromeDesktop("edge-win", "Win32", "Windows NT 10.0; Win64; x64", "Windows", 8, ` Edg/${CHROME_FULL}`), chromeDesktop("chrome-linux", "Linux x86_64", "X11; Linux x86_64", "Linux", 4), { id: "firefox-win", device: "desktop", tls: "firefox", weight: 8, platform: "Win32", viewport: { width: 1920, height: 1080 }, secFetch: true, userAgent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${FIREFOX_VER}.0) Gecko/20100101 Firefox/${FIREFOX_VER}.0`, headers: [ ["user-agent", `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${FIREFOX_VER}.0) Gecko/20100101 Firefox/${FIREFOX_VER}.0`], ["accept", firefoxAccept], ["accept-language", "{lang}"], ["accept-encoding", "gzip, deflate, br, zstd"], ["upgrade-insecure-requests", "1"], ["sec-fetch-dest", "document"], ["sec-fetch-mode", "navigate"], ["sec-fetch-site", "none"], ["sec-fetch-user", "?1"], ["priority", "u=0, i"], ["te", "trailers"], ], }, { id: "safari-mac", device: "desktop", tls: "safari", weight: 8, platform: "MacIntel", viewport: { width: 1728, height: 1117 }, secFetch: true, userAgent: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI_VER} Safari/605.1.15`, headers: [ ["accept", safariAccept], ["sec-fetch-site", "none"], ["accept-encoding", "gzip, deflate, br"], ["sec-fetch-mode", "navigate"], ["user-agent", `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI_VER} Safari/605.1.15`], ["accept-language", "{lang}"], ["sec-fetch-dest", "document"], ["priority", "u=0, i"], ], }, { id: "safari-iphone", device: "mobile", tls: "safari", weight: 7, platform: "iPhone", viewport: { width: 393, height: 852 }, secFetch: true, userAgent: `Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI_VER} Mobile/15E148 Safari/604.1`, headers: [ ["accept", safariAccept], ["sec-fetch-site", "none"], ["accept-encoding", "gzip, deflate, br"], ["sec-fetch-mode", "navigate"], ["user-agent", `Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI_VER} Mobile/15E148 Safari/604.1`], ["accept-language", "{lang}"], ["sec-fetch-dest", "document"], ["priority", "u=0, i"], ], }, { id: "chrome-android", device: "mobile", tls: "chrome", weight: 5, platform: "Linux armv81", viewport: { width: 412, height: 915 }, secFetch: true, userAgent: `Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_FULL} Mobile Safari/537.36`, headers: [ ["sec-ch-ua", `"Chromium";v="${CHROME_VER}", "Google Chrome";v="${CHROME_VER}", "Not_A Brand";v="24"`], ["sec-ch-ua-mobile", "?1"], ["sec-ch-ua-platform", '"Android"'], ["upgrade-insecure-requests", "1"], ["user-agent", `Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_FULL} Mobile Safari/537.36`], ["accept", chromeAccept], ["sec-fetch-site", "none"], ["sec-fetch-mode", "navigate"], ["sec-fetch-user", "?1"], ["sec-fetch-dest", "document"], ["accept-encoding", "gzip, deflate, br, zstd"], ["accept-language", "{lang}"], ["priority", "u=0, i"], ], }, { id: "safari-ipad", device: "tablet", tls: "safari", weight: 2, platform: "MacIntel", viewport: { width: 1024, height: 1366 }, secFetch: true, userAgent: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI_VER} Safari/605.1.15`, headers: [ ["accept", safariAccept], ["sec-fetch-site", "none"], ["accept-encoding", "gzip, deflate, br"], ["sec-fetch-mode", "navigate"], ["user-agent", `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/${SAFARI_VER} Safari/605.1.15`], ["accept-language", "{lang}"], ["sec-fetch-dest", "document"], ["priority", "u=0, i"], ], }, ]; /** Cipher suites in browser preference order (OpenSSL names). Node cannot reorder TLS extensions, * but matching the suite list and curves removes the most obvious "Node.js" JA3 tells. */ export const TLS_CIPHERS: Record = { chrome: [ "TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-ECDSA-AES256-GCM-SHA384", "ECDHE-RSA-AES256-GCM-SHA384", "ECDHE-ECDSA-CHACHA20-POLY1305", "ECDHE-RSA-CHACHA20-POLY1305", "ECDHE-RSA-AES128-SHA", "ECDHE-RSA-AES256-SHA", "AES128-GCM-SHA256", "AES256-GCM-SHA384", "AES128-SHA", "AES256-SHA", ].join(":"), firefox: [ "TLS_AES_128_GCM_SHA256", "TLS_CHACHA20_POLY1305_SHA256", "TLS_AES_256_GCM_SHA384", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-ECDSA-CHACHA20-POLY1305", "ECDHE-RSA-CHACHA20-POLY1305", "ECDHE-ECDSA-AES256-GCM-SHA384", "ECDHE-RSA-AES256-GCM-SHA384", "ECDHE-ECDSA-AES256-SHA", "ECDHE-ECDSA-AES128-SHA", "ECDHE-RSA-AES128-SHA", "ECDHE-RSA-AES256-SHA", "AES128-GCM-SHA256", "AES256-GCM-SHA384", "AES128-SHA", "AES256-SHA", ].join(":"), safari: [ "TLS_AES_128_GCM_SHA256", "TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256", "ECDHE-ECDSA-AES256-GCM-SHA384", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-ECDSA-CHACHA20-POLY1305", "ECDHE-RSA-AES256-GCM-SHA384", "ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-RSA-CHACHA20-POLY1305", "ECDHE-ECDSA-AES256-SHA384", "ECDHE-ECDSA-AES128-SHA256", "ECDHE-RSA-AES256-SHA384", "ECDHE-RSA-AES128-SHA256", "ECDHE-ECDSA-AES256-SHA", "ECDHE-ECDSA-AES128-SHA", "ECDHE-RSA-AES256-SHA", "ECDHE-RSA-AES128-SHA", "AES256-GCM-SHA384", "AES128-GCM-SHA256", "AES256-SHA256", "AES128-SHA256", "AES256-SHA", "AES128-SHA", ].join(":"), }; export const TLS_CURVES: Record = { chrome: "X25519:P-256:P-384", firefox: "X25519:P-256:P-384:P-521", safari: "X25519:P-256:P-384:P-521", }; export const TLS_SIGALGS: Record = { chrome: "ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:ecdsa_secp384r1_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha512", firefox: "ecdsa_secp256r1_sha256:ecdsa_secp384r1_sha384:ecdsa_secp521r1_sha512:rsa_pss_rsae_sha256:rsa_pss_rsae_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha256:rsa_pkcs1_sha384:rsa_pkcs1_sha512:ecdsa_sha1:rsa_pkcs1_sha1", safari: "ecdsa_secp256r1_sha256:rsa_pss_rsae_sha256:rsa_pkcs1_sha256:ecdsa_secp384r1_sha384:ecdsa_sha1:rsa_pss_rsae_sha384:rsa_pss_rsae_sha384:rsa_pkcs1_sha384:rsa_pss_rsae_sha512:rsa_pkcs1_sha512:rsa_pkcs1_sha1", }; function hash32(s: string): number { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } export interface ProfileSelection { device?: ProfileDevice | null; /** Deterministic seed (sticky session key). */ seed?: string | null; /** Attempt number: rotates the profile on retries when there is no seed. */ attempt?: number; /** Exclude a profile id (e.g. the one that just got blocked). */ exclude?: string[]; } export function pickProfile(sel: ProfileSelection = {}): FingerprintProfile { let pool = PROFILES.filter((p) => (sel.device ? p.device === sel.device : p.device === "desktop")); if (sel.exclude?.length) { const filtered = pool.filter((p) => !sel.exclude!.includes(p.id)); if (filtered.length) pool = filtered; } const total = pool.reduce((s, p) => s + p.weight, 0); let r: number; if (sel.seed) r = (hash32(`${sel.seed}:${sel.device ?? "desktop"}`) % 10_000) / 10_000; else r = Math.random(); let acc = 0; for (const p of pool) { acc += p.weight / total; if (r <= acc) return p; } return pool[pool.length - 1]!; } export function profileById(id: string): FingerprintProfile | undefined { return PROFILES.find((p) => p.id === id); } /** Accept-Language for a locale, with a realistic q-cascade. */ export function acceptLanguage(locale: string | null | undefined, country: string | null | undefined): string { if (locale) { const l = locale.trim(); if (l.includes(",")) return l; // caller provided a full header value const base = l.split("-")[0]!; return base === l ? `${l},en;q=0.9` : `${l},${base};q=0.9,en;q=0.8`; } const byCountry: Record = { CA: "en-CA,en;q=0.9,fr-CA;q=0.8,fr;q=0.7", US: "en-US,en;q=0.9", GB: "en-GB,en;q=0.9", AU: "en-AU,en;q=0.9", FR: "fr-FR,fr;q=0.9,en;q=0.8", DE: "de-DE,de;q=0.9,en;q=0.8", ES: "es-ES,es;q=0.9,en;q=0.8", IT: "it-IT,it;q=0.9,en;q=0.8", BR: "pt-BR,pt;q=0.9,en;q=0.8", PT: "pt-PT,pt;q=0.9,en;q=0.8", NL: "nl-NL,nl;q=0.9,en;q=0.8", JP: "ja-JP,ja;q=0.9,en;q=0.8", KR: "ko-KR,ko;q=0.9,en;q=0.8", MX: "es-MX,es;q=0.9,en;q=0.8", IN: "en-IN,en;q=0.9,hi;q=0.8", SE: "sv-SE,sv;q=0.9,en;q=0.8", PL: "pl-PL,pl;q=0.9,en;q=0.8", CH: "de-CH,de;q=0.9,fr;q=0.8,en;q=0.7", BE: "fr-BE,fr;q=0.9,nl;q=0.8,en;q=0.7", }; return (country && byCountry[country.toUpperCase()]) || "en-US,en;q=0.9"; } const SEARCH_REFERERS = ["https://www.google.com/", "https://www.bing.com/", "https://duckduckgo.com/", "https://www.google.ca/", "https://www.google.co.uk/"]; /** Referer for a retry: a search engine landing referer looks like organic traffic. */ export function retryReferer(attempt: number, country?: string | null): string { if (country === "CA") return attempt % 2 ? "https://www.google.ca/" : "https://www.google.com/"; if (country === "GB") return "https://www.google.co.uk/"; return SEARCH_REFERERS[attempt % SEARCH_REFERERS.length]!; } /** Build the ordered header list for a request. Caller overrides win and keep the profile position when the name exists. */ export function buildHeaders(profile: FingerprintProfile, opts: { locale?: string | null; country?: string | null; referer?: string | null; overrides?: Record; hasBody?: boolean; contentType?: string | null; network?: ConcreteNetwork }): Array<[string, string]> { const lang = acceptLanguage(opts.locale, opts.country); const overrides = new Map(); for (const [k, v] of Object.entries(opts.overrides ?? {})) overrides.set(k.toLowerCase(), v); const out: Array<[string, string]> = []; const placed = new Set(); for (const [k, v0] of profile.headers) { let v = v0 === "{lang}" ? lang : v0; if (overrides.has(k)) v = overrides.get(k)!; if (k === "sec-fetch-site" && opts.referer && !overrides.has(k)) v = "cross-site"; out.push([k, v]); placed.add(k); } if (opts.referer && !placed.has("referer")) { // Chrome places referer right after the sec-fetch-* block; Safari/Firefox after accept-language. const idx = out.findIndex(([k]) => k === "accept-encoding"); out.splice(idx === -1 ? out.length : idx, 0, ["referer", opts.referer]); placed.add("referer"); } if (opts.hasBody && !placed.has("content-type") && !overrides.has("content-type")) out.push(["content-type", opts.contentType ?? "application/json"]); for (const [k, v] of overrides) if (!placed.has(k)) out.push([k, v]); return out; }