TypeScript 97.5%
SQL 1.4%
Python 0.8%
1/**2 * Browser fingerprint profiles used for plain HTTP fetches.3 *4 * A profile is a coherent set of request headers (in the order the real browser sends them), the5 * matching client-hints, and a TLS cipher/curve preference list approximating the browser's6 * ClientHello. Sticky sessions pick a profile deterministically from their key so the identity stays7 * stable across requests; otherwise a weighted-random profile is used and rotated on every retry.8 */9import type { ConcreteNetwork } from "@fetcha/core";1011export type ProfileDevice = "desktop" | "mobile" | "tablet";12export type TlsFamily = "chrome" | "firefox" | "safari";1314export interface FingerprintProfile {15 id: string;16 device: ProfileDevice;17 tls: TlsFamily;18 /** Weight for random selection (desktop Chrome dominates real traffic). */19 weight: number;20 /** Ordered header list; `accept-language` is filled from the request locale. */21 headers: Array<[string, string]>;22 /** Ordered navigational Sec-Fetch headers (Chromium & Firefox). */23 secFetch: boolean;24 userAgent: string;25 /** Playwright-facing attributes for the managed browser. */26 viewport: { width: number; height: number };27 platform: string;28}2930const CHROME_VER = "140";31const CHROME_FULL = "140.0.7339.128";32const FIREFOX_VER = "143";33const SAFARI_VER = "18.6";3435const 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";36const firefoxAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8";37const safariAccept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";3839function chromeDesktop(id: string, platformToken: string, uaPlatform: string, uaPlatformHint: string, weight: number, brandExtra = ""): FingerprintProfile {40 const ua = `Mozilla/5.0 (${uaPlatform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_FULL} Safari/537.36${brandExtra}`;41 const brands = brandExtra.includes("Edg")42 ? `"Chromium";v="${CHROME_VER}", "Microsoft Edge";v="${CHROME_VER}", "Not_A Brand";v="24"`43 : `"Chromium";v="${CHROME_VER}", "Google Chrome";v="${CHROME_VER}", "Not_A Brand";v="24"`;44 return {45 id,46 device: "desktop",47 tls: "chrome",48 weight,49 userAgent: ua,50 platform: platformToken,51 viewport: { width: 1920, height: 1080 },52 secFetch: true,53 headers: [54 ["sec-ch-ua", brands],55 ["sec-ch-ua-mobile", "?0"],56 ["sec-ch-ua-platform", `"${uaPlatformHint}"`],57 ["upgrade-insecure-requests", "1"],58 ["user-agent", ua],59 ["accept", chromeAccept],60 ["sec-fetch-site", "none"],61 ["sec-fetch-mode", "navigate"],62 ["sec-fetch-user", "?1"],63 ["sec-fetch-dest", "document"],64 ["accept-encoding", "gzip, deflate, br, zstd"],65 ["accept-language", "{lang}"],66 ["priority", "u=0, i"],67 ],68 };69}7071export const PROFILES: FingerprintProfile[] = [72 chromeDesktop("chrome-win", "Win32", "Windows NT 10.0; Win64; x64", "Windows", 40),73 chromeDesktop("chrome-mac", "MacIntel", "Macintosh; Intel Mac OS X 10_15_7", "macOS", 20),74 chromeDesktop("edge-win", "Win32", "Windows NT 10.0; Win64; x64", "Windows", 8, ` Edg/${CHROME_FULL}`),75 chromeDesktop("chrome-linux", "Linux x86_64", "X11; Linux x86_64", "Linux", 4),76 {77 id: "firefox-win",78 device: "desktop",79 tls: "firefox",80 weight: 8,81 platform: "Win32",82 viewport: { width: 1920, height: 1080 },83 secFetch: true,84 userAgent: `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${FIREFOX_VER}.0) Gecko/20100101 Firefox/${FIREFOX_VER}.0`,85 headers: [86 ["user-agent", `Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${FIREFOX_VER}.0) Gecko/20100101 Firefox/${FIREFOX_VER}.0`],87 ["accept", firefoxAccept],88 ["accept-language", "{lang}"],89 ["accept-encoding", "gzip, deflate, br, zstd"],90 ["upgrade-insecure-requests", "1"],91 ["sec-fetch-dest", "document"],92 ["sec-fetch-mode", "navigate"],93 ["sec-fetch-site", "none"],94 ["sec-fetch-user", "?1"],95 ["priority", "u=0, i"],96 ["te", "trailers"],97 ],98 },99 {100 id: "safari-mac",101 device: "desktop",102 tls: "safari",103 weight: 8,104 platform: "MacIntel",105 viewport: { width: 1728, height: 1117 },106 secFetch: true,107 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`,108 headers: [109 ["accept", safariAccept],110 ["sec-fetch-site", "none"],111 ["accept-encoding", "gzip, deflate, br"],112 ["sec-fetch-mode", "navigate"],113 ["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`],114 ["accept-language", "{lang}"],115 ["sec-fetch-dest", "document"],116 ["priority", "u=0, i"],117 ],118 },119 {120 id: "safari-iphone",121 device: "mobile",122 tls: "safari",123 weight: 7,124 platform: "iPhone",125 viewport: { width: 393, height: 852 },126 secFetch: true,127 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`,128 headers: [129 ["accept", safariAccept],130 ["sec-fetch-site", "none"],131 ["accept-encoding", "gzip, deflate, br"],132 ["sec-fetch-mode", "navigate"],133 ["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`],134 ["accept-language", "{lang}"],135 ["sec-fetch-dest", "document"],136 ["priority", "u=0, i"],137 ],138 },139 {140 id: "chrome-android",141 device: "mobile",142 tls: "chrome",143 weight: 5,144 platform: "Linux armv81",145 viewport: { width: 412, height: 915 },146 secFetch: true,147 userAgent: `Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_FULL} Mobile Safari/537.36`,148 headers: [149 ["sec-ch-ua", `"Chromium";v="${CHROME_VER}", "Google Chrome";v="${CHROME_VER}", "Not_A Brand";v="24"`],150 ["sec-ch-ua-mobile", "?1"],151 ["sec-ch-ua-platform", '"Android"'],152 ["upgrade-insecure-requests", "1"],153 ["user-agent", `Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_FULL} Mobile Safari/537.36`],154 ["accept", chromeAccept],155 ["sec-fetch-site", "none"],156 ["sec-fetch-mode", "navigate"],157 ["sec-fetch-user", "?1"],158 ["sec-fetch-dest", "document"],159 ["accept-encoding", "gzip, deflate, br, zstd"],160 ["accept-language", "{lang}"],161 ["priority", "u=0, i"],162 ],163 },164 {165 id: "safari-ipad",166 device: "tablet",167 tls: "safari",168 weight: 2,169 platform: "MacIntel",170 viewport: { width: 1024, height: 1366 },171 secFetch: true,172 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`,173 headers: [174 ["accept", safariAccept],175 ["sec-fetch-site", "none"],176 ["accept-encoding", "gzip, deflate, br"],177 ["sec-fetch-mode", "navigate"],178 ["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`],179 ["accept-language", "{lang}"],180 ["sec-fetch-dest", "document"],181 ["priority", "u=0, i"],182 ],183 },184];185186/** Cipher suites in browser preference order (OpenSSL names). Node cannot reorder TLS extensions,187 * but matching the suite list and curves removes the most obvious "Node.js" JA3 tells. */188export const TLS_CIPHERS: Record<TlsFamily, string> = {189 chrome: [190 "TLS_AES_128_GCM_SHA256",191 "TLS_AES_256_GCM_SHA384",192 "TLS_CHACHA20_POLY1305_SHA256",193 "ECDHE-ECDSA-AES128-GCM-SHA256",194 "ECDHE-RSA-AES128-GCM-SHA256",195 "ECDHE-ECDSA-AES256-GCM-SHA384",196 "ECDHE-RSA-AES256-GCM-SHA384",197 "ECDHE-ECDSA-CHACHA20-POLY1305",198 "ECDHE-RSA-CHACHA20-POLY1305",199 "ECDHE-RSA-AES128-SHA",200 "ECDHE-RSA-AES256-SHA",201 "AES128-GCM-SHA256",202 "AES256-GCM-SHA384",203 "AES128-SHA",204 "AES256-SHA",205 ].join(":"),206 firefox: [207 "TLS_AES_128_GCM_SHA256",208 "TLS_CHACHA20_POLY1305_SHA256",209 "TLS_AES_256_GCM_SHA384",210 "ECDHE-ECDSA-AES128-GCM-SHA256",211 "ECDHE-RSA-AES128-GCM-SHA256",212 "ECDHE-ECDSA-CHACHA20-POLY1305",213 "ECDHE-RSA-CHACHA20-POLY1305",214 "ECDHE-ECDSA-AES256-GCM-SHA384",215 "ECDHE-RSA-AES256-GCM-SHA384",216 "ECDHE-ECDSA-AES256-SHA",217 "ECDHE-ECDSA-AES128-SHA",218 "ECDHE-RSA-AES128-SHA",219 "ECDHE-RSA-AES256-SHA",220 "AES128-GCM-SHA256",221 "AES256-GCM-SHA384",222 "AES128-SHA",223 "AES256-SHA",224 ].join(":"),225 safari: [226 "TLS_AES_128_GCM_SHA256",227 "TLS_AES_256_GCM_SHA384",228 "TLS_CHACHA20_POLY1305_SHA256",229 "ECDHE-ECDSA-AES256-GCM-SHA384",230 "ECDHE-ECDSA-AES128-GCM-SHA256",231 "ECDHE-ECDSA-CHACHA20-POLY1305",232 "ECDHE-RSA-AES256-GCM-SHA384",233 "ECDHE-RSA-AES128-GCM-SHA256",234 "ECDHE-RSA-CHACHA20-POLY1305",235 "ECDHE-ECDSA-AES256-SHA384",236 "ECDHE-ECDSA-AES128-SHA256",237 "ECDHE-RSA-AES256-SHA384",238 "ECDHE-RSA-AES128-SHA256",239 "ECDHE-ECDSA-AES256-SHA",240 "ECDHE-ECDSA-AES128-SHA",241 "ECDHE-RSA-AES256-SHA",242 "ECDHE-RSA-AES128-SHA",243 "AES256-GCM-SHA384",244 "AES128-GCM-SHA256",245 "AES256-SHA256",246 "AES128-SHA256",247 "AES256-SHA",248 "AES128-SHA",249 ].join(":"),250};251252export const TLS_CURVES: Record<TlsFamily, string> = {253 chrome: "X25519:P-256:P-384",254 firefox: "X25519:P-256:P-384:P-521",255 safari: "X25519:P-256:P-384:P-521",256};257258export const TLS_SIGALGS: Record<TlsFamily, string> = {259 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",260 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",261 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",262};263264function hash32(s: string): number {265 let h = 2166136261;266 for (let i = 0; i < s.length; i++) {267 h ^= s.charCodeAt(i);268 h = Math.imul(h, 16777619);269 }270 return h >>> 0;271}272273export interface ProfileSelection {274 device?: ProfileDevice | null;275 /** Deterministic seed (sticky session key). */276 seed?: string | null;277 /** Attempt number: rotates the profile on retries when there is no seed. */278 attempt?: number;279 /** Exclude a profile id (e.g. the one that just got blocked). */280 exclude?: string[];281}282283export function pickProfile(sel: ProfileSelection = {}): FingerprintProfile {284 let pool = PROFILES.filter((p) => (sel.device ? p.device === sel.device : p.device === "desktop"));285 if (sel.exclude?.length) {286 const filtered = pool.filter((p) => !sel.exclude!.includes(p.id));287 if (filtered.length) pool = filtered;288 }289 const total = pool.reduce((s, p) => s + p.weight, 0);290 let r: number;291 if (sel.seed) r = (hash32(`${sel.seed}:${sel.device ?? "desktop"}`) % 10_000) / 10_000;292 else r = Math.random();293 let acc = 0;294 for (const p of pool) {295 acc += p.weight / total;296 if (r <= acc) return p;297 }298 return pool[pool.length - 1]!;299}300301export function profileById(id: string): FingerprintProfile | undefined {302 return PROFILES.find((p) => p.id === id);303}304305/** Accept-Language for a locale, with a realistic q-cascade. */306export function acceptLanguage(locale: string | null | undefined, country: string | null | undefined): string {307 if (locale) {308 const l = locale.trim();309 if (l.includes(",")) return l; // caller provided a full header value310 const base = l.split("-")[0]!;311 return base === l ? `${l},en;q=0.9` : `${l},${base};q=0.9,en;q=0.8`;312 }313 const byCountry: Record<string, string> = {314 CA: "en-CA,en;q=0.9,fr-CA;q=0.8,fr;q=0.7",315 US: "en-US,en;q=0.9",316 GB: "en-GB,en;q=0.9",317 AU: "en-AU,en;q=0.9",318 FR: "fr-FR,fr;q=0.9,en;q=0.8",319 DE: "de-DE,de;q=0.9,en;q=0.8",320 ES: "es-ES,es;q=0.9,en;q=0.8",321 IT: "it-IT,it;q=0.9,en;q=0.8",322 BR: "pt-BR,pt;q=0.9,en;q=0.8",323 PT: "pt-PT,pt;q=0.9,en;q=0.8",324 NL: "nl-NL,nl;q=0.9,en;q=0.8",325 JP: "ja-JP,ja;q=0.9,en;q=0.8",326 KR: "ko-KR,ko;q=0.9,en;q=0.8",327 MX: "es-MX,es;q=0.9,en;q=0.8",328 IN: "en-IN,en;q=0.9,hi;q=0.8",329 SE: "sv-SE,sv;q=0.9,en;q=0.8",330 PL: "pl-PL,pl;q=0.9,en;q=0.8",331 CH: "de-CH,de;q=0.9,fr;q=0.8,en;q=0.7",332 BE: "fr-BE,fr;q=0.9,nl;q=0.8,en;q=0.7",333 };334 return (country && byCountry[country.toUpperCase()]) || "en-US,en;q=0.9";335}336337const SEARCH_REFERERS = ["https://www.google.com/", "https://www.bing.com/", "https://duckduckgo.com/", "https://www.google.ca/", "https://www.google.co.uk/"];338339/** Referer for a retry: a search engine landing referer looks like organic traffic. */340export function retryReferer(attempt: number, country?: string | null): string {341 if (country === "CA") return attempt % 2 ? "https://www.google.ca/" : "https://www.google.com/";342 if (country === "GB") return "https://www.google.co.uk/";343 return SEARCH_REFERERS[attempt % SEARCH_REFERERS.length]!;344}345346/** Build the ordered header list for a request. Caller overrides win and keep the profile position when the name exists. */347export function buildHeaders(profile: FingerprintProfile, opts: { locale?: string | null; country?: string | null; referer?: string | null; overrides?: Record<string, string>; hasBody?: boolean; contentType?: string | null; network?: ConcreteNetwork }): Array<[string, string]> {348 const lang = acceptLanguage(opts.locale, opts.country);349 const overrides = new Map<string, string>();350 for (const [k, v] of Object.entries(opts.overrides ?? {})) overrides.set(k.toLowerCase(), v);351 const out: Array<[string, string]> = [];352 const placed = new Set<string>();353 for (const [k, v0] of profile.headers) {354 let v = v0 === "{lang}" ? lang : v0;355 if (overrides.has(k)) v = overrides.get(k)!;356 if (k === "sec-fetch-site" && opts.referer && !overrides.has(k)) v = "cross-site";357 out.push([k, v]);358 placed.add(k);359 }360 if (opts.referer && !placed.has("referer")) {361 // Chrome places referer right after the sec-fetch-* block; Safari/Firefox after accept-language.362 const idx = out.findIndex(([k]) => k === "accept-encoding");363 out.splice(idx === -1 ? out.length : idx, 0, ["referer", opts.referer]);364 placed.add("referer");365 }366 if (opts.hasBody && !placed.has("content-type") && !overrides.has("content-type")) out.push(["content-type", opts.contentType ?? "application/json"]);367 for (const [k, v] of overrides) if (!placed.has(k)) out.push([k, v]);368 return out;369}370