SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
14.5 KB · 370 lines typescript
Raw Blame History
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