UNPKG

@the-convocation/twitter-scraper

Version:
1 lines 376 kB
{"version":3,"file":"index.mjs","sources":["../../../src/errors.ts","../../../src/rate-limit.ts","../../../src/castle.ts","../../../src/requests.ts","../../../src/xpff.ts","../../../src/auth.ts","../../../src/platform/platform-interface.ts","../../../src/platform/index.ts","../../../src/xctxid.ts","../../../src/chrome-fingerprint.ts","../../../src/api.ts","../../../src/auth-user.ts","../../../src/api-data.ts","../../../src/profile.ts","../../../src/timeline-async.ts","../../../src/type-util.ts","../../../src/timeline-tweet-util.ts","../../../src/timeline-v2.ts","../../../src/timeline-search.ts","../../../src/search.ts","../../../src/timeline-relationship.ts","../../../src/relationships.ts","../../../src/trends.ts","../../../src/timeline-list.ts","../../../src/tweets.ts","../../../src/direct-messages-async.ts","../../../src/direct-messages.ts","../../../src/scraper.ts"],"sourcesContent":["export class ApiError extends Error {\n constructor(readonly response: Response, readonly data: any) {\n super(\n `Response status: ${response.status} | headers: ${JSON.stringify(\n headersToString(response.headers),\n )} | data: ${typeof data === 'string' ? data : JSON.stringify(data)}`,\n );\n }\n\n static async fromResponse(response: Response) {\n // Try our best to parse the result, but don't bother if we can't\n let data: string | object | undefined = undefined;\n try {\n if (response.headers.get('content-type')?.includes('application/json')) {\n data = await response.json();\n } else {\n data = await response.text();\n }\n } catch {\n try {\n data = await response.text();\n } catch {}\n }\n\n return new ApiError(response, data);\n }\n}\n\nfunction headersToString(headers: Headers): string {\n const result: string[] = [];\n headers.forEach((value, key) => {\n result.push(`${key}: ${value}`);\n });\n return result.join('\\n');\n}\n\nexport class AuthenticationError extends Error {\n constructor(message?: string) {\n super(message || 'Authentication failed');\n this.name = 'AuthenticationError';\n }\n}\n\nexport interface TwitterApiErrorPosition {\n line: number;\n column: number;\n}\n\nexport interface TwitterApiErrorTraceInfo {\n trace_id: string;\n}\n\nexport interface TwitterApiErrorExtensions {\n code?: number;\n kind?: string;\n name?: string;\n source?: string;\n tracing?: TwitterApiErrorTraceInfo;\n}\n\nexport interface TwitterApiErrorRaw extends TwitterApiErrorExtensions {\n message?: string;\n locations?: TwitterApiErrorPosition[];\n path?: string[];\n extensions?: TwitterApiErrorExtensions;\n}\n","import { FetchParameters } from './api-types';\nimport { ApiError } from './errors';\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:rate-limit');\n\n/**\n * Information about a rate-limiting event. Both the request and response\n * information are provided.\n */\nexport interface RateLimitEvent {\n /** The complete arguments that were passed to the fetch function. */\n fetchParameters: FetchParameters;\n /** The failing HTTP response. */\n response: Response;\n}\n\n/**\n * The public interface for all rate-limiting strategies. Library consumers are\n * welcome to provide their own implementations of this interface in the Scraper\n * constructor options.\n *\n * The {@link RateLimitEvent} object contains both the request and response\n * information associated with the event.\n *\n * @example\n * import { Scraper, RateLimitStrategy } from \"@the-convocation/twitter-scraper\";\n *\n * // A custom rate-limiting implementation that just logs request/response information.\n * class ConsoleLogRateLimitStrategy implements RateLimitStrategy {\n * async onRateLimit(event: RateLimitEvent): Promise<void> {\n * console.log(event.fetchParameters, event.response);\n * }\n * }\n *\n * const scraper = new Scraper({\n * rateLimitStrategy: new ConsoleLogRateLimitStrategy(),\n * });\n */\nexport interface RateLimitStrategy {\n /**\n * Called when the scraper is rate limited.\n * @param event The event information, including the request and response info.\n */\n onRateLimit(event: RateLimitEvent): Promise<void>;\n}\n\n/**\n * A rate-limiting strategy that simply waits for the current rate limit period to expire.\n * This has been known to take up to 13 minutes, in some cases.\n */\nexport class WaitingRateLimitStrategy implements RateLimitStrategy {\n async onRateLimit({ response: res }: RateLimitEvent): Promise<void> {\n /*\n Known headers at this point:\n - x-rate-limit-limit: Maximum number of requests per time period?\n - x-rate-limit-reset: UNIX timestamp when the current rate limit will be reset.\n - x-rate-limit-remaining: Number of requests remaining in current time period?\n */\n const xRateLimitLimit = res.headers.get('x-rate-limit-limit');\n const xRateLimitRemaining = res.headers.get('x-rate-limit-remaining');\n const xRateLimitReset = res.headers.get('x-rate-limit-reset');\n\n log(\n `Rate limit event: limit=${xRateLimitLimit}, remaining=${xRateLimitRemaining}, reset=${xRateLimitReset}`,\n );\n\n if (xRateLimitRemaining == '0' && xRateLimitReset) {\n const currentTime = new Date().valueOf() / 1000;\n const timeDeltaMs = 1000 * (parseInt(xRateLimitReset) - currentTime);\n\n // I have seen this block for 800s (~13 *minutes*)\n await new Promise((resolve) => setTimeout(resolve, timeDeltaMs));\n }\n }\n}\n\n/**\n * A rate-limiting strategy that throws an {@link ApiError} when a rate limiting event occurs.\n */\nexport class ErrorRateLimitStrategy implements RateLimitStrategy {\n async onRateLimit({ response: res }: RateLimitEvent): Promise<void> {\n throw await ApiError.fromResponse(res);\n }\n}\n","/**\n * castle.ts - Local Castle.io v11 token generation for Twitter/X login flow.\n *\n * Ported from yubie-re/castleio-gen (Python, MIT license, archived May 2025)\n * to TypeScript. Generates device fingerprint tokens required by Twitter's\n * login flow to avoid error 399 (\"suspicious activity\").\n *\n * This generates Castle.io SDK v2.6.0 compatible tokens (version 11).\n *\n * Token structure overview:\n * 1. Collect device/browser fingerprint data (3 parts)\n * 2. Collect behavioral event data (mouse/keyboard/touch metrics)\n * 3. Apply layered XOR encryption with timestamp and UUID keys\n * 4. Prepend header (timestamp, SDK version, publisher key, UUID)\n * 5. XXTEA-encrypt the entire payload\n * 6. Base64URL-encode with version prefix and random XOR byte\n */\n\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:castle');\n\n// ─── Field Encoding Types ────────────────────────────────────────────────────\n\n/**\n * How a fingerprint field's value is serialized into the token.\n * Each field has a 1-byte header (5-bit index + 3-bit encoding type),\n * followed by encoding-specific body bytes.\n */\n/** @internal Exported for testing */\nexport enum FieldEncoding {\n /** No body bytes (field presence alone is the signal) */\n Empty = -1,\n /** Marker field, no body bytes */\n Marker = 1,\n /** Single byte value */\n Byte = 3,\n /** XXTEA-encrypted byte array with length prefix */\n EncryptedBytes = 4,\n /** 1 or 2 byte value (2 bytes with high bit set if > 127) */\n CompactInt = 5,\n /** Single byte, value is Math.round()'d first */\n RoundedByte = 6,\n /** Raw bytes appended directly after header */\n RawAppend = 7,\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** Twitter's Castle.io publishable key (32-char, without pk_ prefix) */\nconst TWITTER_CASTLE_PK = 'AvRa79bHyJSYSQHnRpcVtzyxetSvFerx';\n\n/** XXTEA encryption key for the entire token */\nconst XXTEA_KEY = [1164413191, 3891440048, 185273099, 2746598870];\n\n/**\n * Per-field XXTEA key tail: field key = [fieldIndex, initTime, ...PER_FIELD_KEY_TAIL].\n * XXTEA uses 128-bit (4-word) keys, so only indices 0-3 of the assembled key participate\n * in encryption. The extra elements here are inert but kept for faithful parity with the\n * Python SDK source.\n */\nconst PER_FIELD_KEY_TAIL = [\n 16373134, 643144773, 1762804430, 1186572681, 1164413191,\n];\n\n/** Timestamp epoch offset: seconds since ~Aug 23 2018 */\nconst TS_EPOCH = 1535e6;\n\n/** SDK version 2.6.0 encoded as 16-bit: (3<<13)|(1<<11)|(6<<6)|0 = 0x6980 */\nconst SDK_VERSION = 0x6980;\n\n/** Token format version byte (v11) */\nconst TOKEN_VERSION = 0x0b;\n\n/**\n * Fingerprint part indices — each section is tagged with a part ID\n * in its size/index header byte.\n */\nconst FP_PART = {\n DEVICE: 0, // Part 1: hardware/OS/rendering fingerprint\n BROWSER: 4, // Part 2: browser environment fingerprint\n TIMING: 7, // Part 3: timing-based fingerprint\n} as const;\n\n// ─── Simulated Browser Profile ──────────────────────────────────────────────\n\n/**\n * Simulated browser environment values embedded in the fingerprint.\n * These should match a realistic Chrome-on-Windows configuration.\n *\n * Users can provide a partial override via `ScraperOptions.experimental.browserProfile`\n * to customize the fingerprint. Unspecified fields are randomized from realistic pools.\n */\nexport interface BrowserProfile {\n locale: string;\n language: string;\n timezone: string;\n screenWidth: number;\n screenHeight: number;\n /** Available screen width (excludes OS chrome like taskbars) */\n availableWidth: number;\n /** Available screen height (excludes OS chrome like taskbars) */\n availableHeight: number;\n /** WebGL ANGLE renderer string */\n gpuRenderer: string;\n /** Device memory in GB (encoded as value * 10) */\n deviceMemoryGB: number;\n /** Logical CPU core count */\n hardwareConcurrency: number;\n /** Screen color depth in bits */\n colorDepth: number;\n /** CSS device pixel ratio (encoded as value * 10) */\n devicePixelRatio: number;\n}\n\n/** Default fallback profile: Chrome 144 on Windows 10, NVIDIA GTX 1080 Ti, 1080p */\nconst DEFAULT_PROFILE: BrowserProfile = {\n locale: 'en-US',\n language: 'en',\n timezone: 'America/New_York',\n screenWidth: 1920,\n screenHeight: 1080,\n availableWidth: 1920,\n availableHeight: 1032, // 1080 minus Windows taskbar (~48px)\n gpuRenderer:\n 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 Ti Direct3D11 vs_5_0 ps_5_0, D3D11)',\n deviceMemoryGB: 8,\n hardwareConcurrency: 24,\n colorDepth: 24,\n devicePixelRatio: 1.0,\n};\n\n// ─── Randomization Pools ─────────────────────────────────────────────────────\n// These pools are available for callers who want to build a custom BrowserProfile.\n//\n// WARNING: Randomizing gpuRenderer without also changing the static canvas hashes\n// (fields 13 and 18 in the token) creates an impossible fingerprint — different GPUs\n// produce different canvas renderings. Twitter cross-references these, so a mismatch\n// triggers 399 bot-detection errors. Until we have per-GPU canvas hashes, the default\n// profile uses a fixed GPU + matching canvas hashes. Only override gpuRenderer if you\n// also supply matching canvas fingerprints.\n\n/** Common desktop screen resolutions (width, height, available height with taskbar) */\nconst SCREEN_RESOLUTIONS = [\n { w: 1920, h: 1080, ah: 1032 },\n { w: 2560, h: 1440, ah: 1392 },\n { w: 1366, h: 768, ah: 720 },\n { w: 1536, h: 864, ah: 816 },\n { w: 1440, h: 900, ah: 852 },\n { w: 1680, h: 1050, ah: 1002 },\n { w: 3840, h: 2160, ah: 2112 },\n];\n\n/** Common WebGL ANGLE renderer strings for Chrome on Windows.\n * Not used by default — see WARNING above about canvas hash correlation. */\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\nconst GPU_RENDERERS = [\n 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 Ti Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (AMD, AMD Radeon RX 580 Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (AMD, AMD Radeon RX 6700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (Intel, Intel(R) UHD Graphics 630 Direct3D11 vs_5_0 ps_5_0, D3D11)',\n 'ANGLE (Intel, Intel(R) Iris(R) Xe Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)',\n];\n\n/** navigator.deviceMemory values reported by Chrome (in GB) */\nconst DEVICE_MEMORY_VALUES = [4, 8, 8, 16]; // 8 weighted more heavily\n\n/** navigator.hardwareConcurrency values */\nconst HARDWARE_CONCURRENCY_VALUES = [4, 8, 8, 12, 16, 24];\n\n/**\n * Generate a randomized browser profile by picking values from realistic pools.\n *\n * **Caution:** GPU renderer is NOT randomized because the canvas fingerprint\n * hashes in the token are static and correspond to the DEFAULT_PROFILE GPU.\n * Mismatching GPU + canvas hashes triggers Twitter 399 bot-detection errors.\n * Screen resolution, device memory, and CPU cores are safe to randomize since\n * they don't affect canvas rendering.\n */\nexport function randomizeBrowserProfile(): BrowserProfile {\n const screen = SCREEN_RESOLUTIONS[randInt(0, SCREEN_RESOLUTIONS.length - 1)];\n return {\n ...DEFAULT_PROFILE,\n screenWidth: screen.w,\n screenHeight: screen.h,\n availableWidth: screen.w,\n availableHeight: screen.ah,\n // gpuRenderer intentionally NOT randomized — see JSDoc above\n deviceMemoryGB:\n DEVICE_MEMORY_VALUES[randInt(0, DEVICE_MEMORY_VALUES.length - 1)],\n hardwareConcurrency:\n HARDWARE_CONCURRENCY_VALUES[\n randInt(0, HARDWARE_CONCURRENCY_VALUES.length - 1)\n ],\n };\n}\n\n// ─── Utility Functions ───────────────────────────────────────────────────────\n\nfunction getRandomBytes(n: number): Uint8Array {\n const buf = new Uint8Array(n);\n if (\n typeof globalThis.crypto !== 'undefined' &&\n globalThis.crypto.getRandomValues\n ) {\n globalThis.crypto.getRandomValues(buf);\n } else {\n for (let i = 0; i < n; i++) buf[i] = Math.floor(Math.random() * 256);\n }\n return buf;\n}\n\nfunction randInt(min: number, max: number): number {\n return min + Math.floor(Math.random() * (max - min + 1));\n}\n\nfunction randFloat(min: number, max: number): number {\n return min + Math.random() * (max - min);\n}\n\nfunction concat(...arrays: Uint8Array[]): Uint8Array {\n const len = arrays.reduce((s, a) => s + a.length, 0);\n const out = new Uint8Array(len);\n let off = 0;\n for (const a of arrays) {\n out.set(a, off);\n off += a.length;\n }\n return out;\n}\n\nfunction toHex(input: Uint8Array): string {\n return Array.from(input)\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\nfunction fromHex(hex: string): Uint8Array {\n const out = new Uint8Array(hex.length / 2);\n for (let i = 0; i < hex.length; i += 2)\n out[i / 2] = parseInt(hex.substring(i, i + 2), 16);\n return out;\n}\n\nfunction textEnc(s: string): Uint8Array {\n return new TextEncoder().encode(s);\n}\n\n/** Create a Uint8Array from individual byte values */\nfunction u8(...vals: number[]): Uint8Array {\n return new Uint8Array(vals);\n}\n\n/** Encode a 16-bit value as 2 big-endian bytes */\nfunction be16(v: number): Uint8Array {\n return u8((v >>> 8) & 0xff, v & 0xff);\n}\n\n/** Encode a 32-bit value as 4 big-endian bytes */\nfunction be32(v: number): Uint8Array {\n return u8((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);\n}\n\nfunction xorBytes(data: Uint8Array, key: Uint8Array): Uint8Array {\n const out = new Uint8Array(data.length);\n for (let i = 0; i < data.length; i++) out[i] = data[i] ^ key[i % key.length];\n return out;\n}\n\nfunction xorNibbles(nibbles: string, keyNibble: string): string {\n const k = parseInt(keyNibble, 16);\n return nibbles\n .split('')\n .map((n) => (parseInt(n, 16) ^ k).toString(16))\n .join('');\n}\n\nfunction base64url(data: Uint8Array): string {\n if (typeof Buffer !== 'undefined') {\n return Buffer.from(data).toString('base64url');\n }\n let bin = '';\n for (let i = 0; i < data.length; i++) bin += String.fromCharCode(data[i]);\n return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n// ─── XXTEA Encryption ───────────────────────────────────────────────────────\n\n/**\n * Encrypt data using XXTEA (Corrected Block TEA) algorithm.\n * Used for both the overall token encryption and per-field encryption.\n * @internal Exported for testing\n */\nexport function xxteaEncrypt(data: Uint8Array, key: number[]): Uint8Array {\n // Pad to 4-byte boundary\n const padLen = Math.ceil(data.length / 4) * 4;\n const padded = new Uint8Array(padLen);\n padded.set(data);\n\n const n = padLen / 4;\n // Read as little-endian 32-bit words\n const v = new Uint32Array(n);\n for (let i = 0; i < n; i++) {\n v[i] =\n (padded[i * 4] |\n (padded[i * 4 + 1] << 8) |\n (padded[i * 4 + 2] << 16) |\n (padded[i * 4 + 3] << 24)) >>>\n 0;\n }\n\n if (n <= 1) return padded;\n\n const k = new Uint32Array(key.map((x) => x >>> 0));\n const DELTA = 0x9e3779b9;\n const u = n - 1;\n let sum = 0;\n let z = v[u];\n let y: number;\n let rounds = 6 + Math.floor(52 / (u + 1));\n\n while (rounds-- > 0) {\n sum = (sum + DELTA) >>> 0;\n const e = (sum >>> 2) & 3;\n for (let p = 0; p < u; p++) {\n y = v[p + 1];\n const mx =\n ((((z >>> 5) ^ (y << 2)) >>> 0) + (((y >>> 3) ^ (z << 4)) >>> 0)) ^\n (((sum ^ y) >>> 0) + ((k[(p & 3) ^ e] ^ z) >>> 0));\n v[p] = (v[p] + mx) >>> 0;\n z = v[p];\n }\n y = v[0];\n const mx =\n ((((z >>> 5) ^ (y << 2)) >>> 0) + (((y >>> 3) ^ (z << 4)) >>> 0)) ^\n (((sum ^ y) >>> 0) + ((k[(u & 3) ^ e] ^ z) >>> 0));\n v[u] = (v[u] + mx) >>> 0;\n z = v[u];\n }\n\n // Write back as little-endian bytes\n const out = new Uint8Array(n * 4);\n for (let i = 0; i < n; i++) {\n out[i * 4] = v[i] & 0xff;\n out[i * 4 + 1] = (v[i] >>> 8) & 0xff;\n out[i * 4 + 2] = (v[i] >>> 16) & 0xff;\n out[i * 4 + 3] = (v[i] >>> 24) & 0xff;\n }\n return out;\n}\n\n/** Encrypt a fingerprint field's data using per-field XXTEA key */\nfunction fieldEncrypt(\n data: Uint8Array,\n fieldIndex: number,\n initTime: number,\n): Uint8Array {\n return xxteaEncrypt(data, [\n fieldIndex,\n Math.floor(initTime),\n ...PER_FIELD_KEY_TAIL,\n ]);\n}\n\n// ─── Timestamp Encoding ─────────────────────────────────────────────────────\n\n/** @internal Exported for testing */\nexport function encodeTimestampBytes(ms: number): Uint8Array {\n let t = Math.floor(ms / 1000 - TS_EPOCH);\n t = Math.max(Math.min(t, 268435455), 0); // Clamp to 28-bit unsigned\n return be32(t);\n}\n\nfunction xorAndAppendKey(buf: Uint8Array, key: number): string {\n const hex = toHex(buf);\n const keyNib = (key & 0xf).toString(16);\n return xorNibbles(hex.substring(1), keyNib) + keyNib;\n}\n\nfunction encodeTimestampEncrypted(ms: number): string {\n const tsBytes = encodeTimestampBytes(ms);\n const slice = Math.floor(ms) % 1000;\n const sliceBytes = be16(slice);\n const k = randInt(0, 15);\n return xorAndAppendKey(tsBytes, k) + xorAndAppendKey(sliceBytes, k);\n}\n\n// ─── Key Derivation ─────────────────────────────────────────────────────────\n\n/**\n * Derive an XOR key from a hex string by slicing, rotating, and XOR-ing.\n * Used for the two-layer XOR encryption of fingerprint data.\n */\n/**\n * @internal Exported for testing only.\n */\nexport function deriveAndXor(\n keyHex: string,\n sliceLen: number,\n rotChar: string,\n data: Uint8Array,\n): Uint8Array {\n const sub = keyHex.substring(0, sliceLen).split('');\n if (sub.length === 0) return data;\n const rot = parseInt(rotChar, 16) % sub.length;\n const rotated = sub.slice(rot).concat(sub.slice(0, rot)).join('');\n return xorBytes(data, fromHex(rotated));\n}\n\n// ─── Custom Float Encoding ──────────────────────────────────────────────────\n\n/**\n * Encode a floating-point value into a compact format with configurable\n * exponent and mantissa bit widths. Used for behavioral metric encoding.\n */\n/** @internal Exported for testing */\nexport function customFloatEncode(\n expBits: number,\n manBits: number,\n value: number,\n): number {\n if (value === 0) return 0;\n let n = Math.abs(value);\n let exp = 0;\n while (2 <= n) {\n n /= 2;\n exp++;\n }\n while (n < 1 && n > 0) {\n n *= 2;\n exp--;\n }\n exp = Math.min(exp, (1 << expBits) - 1);\n const frac = n - Math.floor(n);\n let mantissa = 0;\n if (frac > 0) {\n let pos = 1;\n let tmp = frac;\n while (tmp !== 0 && pos <= manBits) {\n tmp *= 2;\n const bit = Math.floor(tmp);\n mantissa |= bit << (manBits - pos);\n tmp -= bit;\n pos++;\n }\n }\n return (exp << manBits) | mantissa;\n}\n\n/**\n * Encode a behavioral float value for the token.\n * Values 0-15 use a 2-bit exponent / 4-bit mantissa format.\n * Values > 15 use a 4-bit exponent / 3-bit mantissa format.\n */\n/** @internal Exported for testing */\nexport function encodeFloatVal(v: number): number {\n const n = Math.max(v, 0);\n if (n <= 15) return 64 | customFloatEncode(2, 4, n + 1);\n return 128 | customFloatEncode(4, 3, n - 14);\n}\n\n// ─── Field Serialization ────────────────────────────────────────────────────\n\n/**\n * Serialize a single fingerprint field into its binary representation.\n *\n * Format: [header byte] [optional body bytes]\n * Header: upper 5 bits = field index, lower 3 bits = encoding type\n *\n * @param index - Field index (0-31) within the fingerprint part\n * @param encoding - How the value should be serialized\n * @param val - The field value (number or byte array)\n * @param initTime - Init timestamp (required for EncryptedBytes encoding)\n */\n/** @internal Exported for testing */\nexport function encodeField(\n index: number,\n encoding: FieldEncoding,\n val: number | Uint8Array,\n initTime?: number,\n): Uint8Array {\n // Header byte: field index in upper 5 bits, encoding type in lower 3 bits\n // Note: FieldEncoding.Empty = -1, and 7 & -1 = 7 in JS bitwise\n const hdr = u8(((31 & index) << 3) | (7 & encoding));\n\n if (encoding === FieldEncoding.Empty || encoding === FieldEncoding.Marker)\n return hdr;\n\n let body: Uint8Array;\n switch (encoding) {\n case FieldEncoding.Byte:\n body = u8(val as number);\n break;\n case FieldEncoding.RoundedByte:\n body = u8(Math.round(val as number));\n break;\n case FieldEncoding.CompactInt: {\n const v = val as number;\n body = v <= 127 ? u8(v) : be16((1 << 15) | (32767 & v));\n break;\n }\n case FieldEncoding.EncryptedBytes: {\n if (initTime == null) {\n throw new Error('initTime is required for EncryptedBytes encoding');\n }\n const enc = fieldEncrypt(val as Uint8Array, index, initTime);\n body = concat(u8(enc.length), enc);\n break;\n }\n case FieldEncoding.RawAppend:\n body = val instanceof Uint8Array ? val : u8(val as number);\n break;\n default:\n body = new Uint8Array(0);\n }\n return concat(hdr, body);\n}\n\n// ─── Bit Encoding Helpers ───────────────────────────────────────────────────\n\n/** Pack a list of set-bit positions into a fixed-size byte array (big-endian) */\nfunction encodeBits(bits: number[], byteSize: number): Uint8Array {\n const numBytes = byteSize / 8;\n const arr = new Uint8Array(numBytes);\n for (const bit of bits) {\n const bi = numBytes - 1 - Math.floor(bit / 8);\n if (bi >= 0 && bi < numBytes) arr[bi] |= 1 << bit % 8;\n }\n return arr;\n}\n\n/**\n * Encode screen dimensions. If screen and available dimensions match,\n * uses a compact 2-byte form with high bit set; otherwise 4 bytes.\n */\nfunction screenDimBytes(screen: number, avail: number): Uint8Array {\n const r = 32767 & screen;\n const e = 65535 & avail;\n return r === e ? be16(32768 | r) : concat(be16(r), be16(e));\n}\n\n/** Convert boolean array to a packed integer bitfield */\nfunction boolsToBin(arr: boolean[], totalBits: number): number {\n const e = arr.length > totalBits ? arr.slice(0, totalBits) : arr;\n const c = e.length;\n let r = 0;\n for (let i = c - 1; i >= 0; i--) {\n if (e[i]) r |= 1 << (c - i - 1);\n }\n if (c < totalBits) r <<= totalBits - c;\n return r;\n}\n\n// ─── Codec Playability ──────────────────────────────────────────────────────\n\n/**\n * Encode media codec support as a 2-byte bitfield.\n * Values: 0 = unsupported, 1 = maybe, 2 = probably\n */\nfunction encodeCodecPlayability(): Uint8Array {\n const codecs = {\n webm: 2, // VP8/VP9\n mp4: 2, // H.264\n ogg: 0, // Theora (Chrome dropped support)\n aac: 2, // AAC audio\n xm4a: 1, // M4A container\n wav: 2, // PCM audio\n mpeg: 2, // MP3 audio\n ogg2: 2, // Vorbis audio\n };\n const bits = Object.values(codecs)\n .map((c) => c.toString(2).padStart(2, '0'))\n .join('');\n return be16(parseInt(bits, 2));\n}\n\n// ─── Timezone Utilities ─────────────────────────────────────────────────────\n\n/** Known timezone enum values for compact encoding in fingerprint Part 2 */\nconst TIMEZONE_ENUM: Record<string, number> = {\n 'America/New_York': 0,\n 'America/Sao_Paulo': 1,\n 'America/Chicago': 2,\n 'America/Los_Angeles': 3,\n 'America/Mexico_City': 4,\n 'Asia/Shanghai': 5,\n};\n\n/**\n * Compute timezone offset and DST difference for fingerprinting.\n * Returns values as (minutes / 15) encoded as unsigned bytes.\n */\nfunction getTimezoneInfo(tz: string): { offset: number; dstDiff: number } {\n const knownOffsets: Record<string, { offset: number; dstDiff: number }> = {\n 'America/New_York': { offset: 20, dstDiff: 4 },\n 'America/Chicago': { offset: 24, dstDiff: 4 },\n 'America/Los_Angeles': { offset: 32, dstDiff: 4 },\n 'America/Denver': { offset: 28, dstDiff: 4 },\n 'America/Sao_Paulo': { offset: 12, dstDiff: 4 },\n 'America/Mexico_City': { offset: 24, dstDiff: 4 },\n 'Asia/Shanghai': { offset: 246, dstDiff: 0 },\n 'Asia/Tokyo': { offset: 220, dstDiff: 0 },\n 'Europe/London': { offset: 0, dstDiff: 4 },\n 'Europe/Berlin': { offset: 252, dstDiff: 4 },\n UTC: { offset: 0, dstDiff: 0 },\n };\n\n try {\n const now = new Date();\n const jan = new Date(now.getFullYear(), 0, 1);\n const jul = new Date(now.getFullYear(), 6, 1);\n\n const getOffset = (date: Date, zone: string) => {\n const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));\n const local = new Date(date.toLocaleString('en-US', { timeZone: zone }));\n return (utc.getTime() - local.getTime()) / 60000;\n };\n\n const currentOffset = getOffset(now, tz);\n const janOffset = getOffset(jan, tz);\n const julOffset = getOffset(jul, tz);\n const dstDifference = Math.abs(janOffset - julOffset);\n\n return {\n offset: Math.floor(currentOffset / 15) & 0xff,\n dstDiff: Math.floor(dstDifference / 15) & 0xff,\n };\n } catch {\n return knownOffsets[tz] || { offset: 20, dstDiff: 4 };\n }\n}\n\n// ─── Fingerprint Part 1: Device & Rendering ─────────────────────────────────\n\n/**\n * Build device/rendering fingerprint fields (Part 1).\n * Contains hardware info, screen dimensions, browser features,\n * canvas/WebGL render hashes, and the user agent string.\n */\nfunction buildDeviceFingerprint(\n initTime: number,\n profile: BrowserProfile,\n userAgent: string,\n): Uint8Array {\n const tz = getTimezoneInfo(profile.timezone);\n const { Byte, EncryptedBytes, CompactInt, RoundedByte, RawAppend } =\n FieldEncoding;\n\n // Field 12 (user agent) uses manual XXTEA encryption with RawAppend\n const encryptedUA = fieldEncrypt(textEnc(userAgent), 12, initTime);\n const uaPayload = concat(u8(1), u8(encryptedUA.length), encryptedUA);\n\n const fields: Uint8Array[] = [\n encodeField(0, Byte, 1), // Platform: Win32\n encodeField(1, Byte, 0), // Vendor: Google Inc.\n encodeField(2, EncryptedBytes, textEnc(profile.locale), initTime), // Locale\n encodeField(3, RoundedByte, profile.deviceMemoryGB * 10), // Device memory (GB * 10)\n encodeField(\n 4,\n RawAppend,\n concat(\n // Screen dimensions (width + height)\n screenDimBytes(profile.screenWidth, profile.availableWidth),\n screenDimBytes(profile.screenHeight, profile.availableHeight),\n ),\n ),\n encodeField(5, CompactInt, profile.colorDepth), // Screen color depth\n encodeField(6, CompactInt, profile.hardwareConcurrency), // CPU logical cores\n encodeField(7, RoundedByte, profile.devicePixelRatio * 10), // Pixel ratio (* 10)\n encodeField(8, RawAppend, u8(tz.offset, tz.dstDiff)), // Timezone offset info\n // MIME type hash — captured from Chrome 144 on Windows 10.\n // Source: yubie-re/castleio-gen (Python SDK, MIT license).\n encodeField(9, RawAppend, u8(0x02, 0x7d, 0x5f, 0xc9, 0xa7)),\n // Browser plugins hash — Chrome no longer exposes plugins to navigator.plugins,\n // so this is a fixed hash. Source: yubie-re/castleio-gen (Python SDK, MIT license).\n encodeField(10, RawAppend, u8(0x05, 0x72, 0x93, 0x02, 0x08)),\n encodeField(\n 11,\n RawAppend, // Browser feature flags\n concat(u8(12), encodeBits([0, 1, 2, 3, 4, 5, 6], 16)),\n ),\n encodeField(12, RawAppend, uaPayload), // User agent (encrypted)\n // Canvas font rendering hash — generated by Castle.io SDK's canvas fingerprinting (text rendering).\n // Captured from Chrome 144 on Windows 10. Source: yubie-re/castleio-gen (Python SDK, MIT license).\n encodeField(13, EncryptedBytes, textEnc('54b4b5cf'), initTime),\n encodeField(\n 14,\n RawAppend, // Media input devices\n concat(u8(3), encodeBits([0, 1, 2], 8)),\n ),\n // Fields 15 (DoNotTrack) and 16 (JavaEnabled) intentionally omitted\n encodeField(17, Byte, 0), // productSub type\n // Canvas circle rendering hash — generated by Castle.io SDK's canvas fingerprinting (arc drawing).\n // Captured from Chrome 144 on Windows 10. Source: yubie-re/castleio-gen (Python SDK, MIT license).\n encodeField(18, EncryptedBytes, textEnc('c6749e76'), initTime),\n encodeField(19, EncryptedBytes, textEnc(profile.gpuRenderer), initTime), // WebGL renderer\n encodeField(\n 20,\n EncryptedBytes, // Epoch locale string\n textEnc('12/31/1969, 7:00:00 PM'),\n initTime,\n ),\n encodeField(\n 21,\n RawAppend, // WebDriver flags (none set)\n concat(u8(8), encodeBits([], 8)),\n ),\n encodeField(22, CompactInt, 33), // eval.toString() length\n // Field 23 (navigator.buildID) intentionally omitted (Chrome doesn't have it)\n encodeField(24, CompactInt, 12549), // Max recursion depth\n encodeField(25, Byte, 0), // Recursion error message type\n encodeField(26, Byte, 1), // Recursion error name type\n encodeField(27, CompactInt, 4644), // Stack trace string length\n encodeField(28, RawAppend, u8(0x00)), // Touch support metric\n encodeField(29, Byte, 3), // Undefined call error type\n // Navigator properties hash — hash of enumerable navigator property names.\n // Captured from Chrome 144 on Windows 10. Source: yubie-re/castleio-gen (Python SDK, MIT license).\n encodeField(30, RawAppend, u8(0x5d, 0xc5, 0xab, 0xb5, 0x88)),\n encodeField(31, RawAppend, encodeCodecPlayability()), // Codec playability\n ];\n\n const data = concat(...fields);\n const sizeIdx = ((7 & FP_PART.DEVICE) << 5) | (31 & fields.length);\n return concat(u8(sizeIdx), data);\n}\n\n// ─── Fingerprint Part 2: Browser Environment ────────────────────────────────\n\n/**\n * Build browser environment fingerprint (Part 2).\n * Contains timezone, language info, Chrome-specific feature flags,\n * and various browser environment checks.\n */\nfunction buildBrowserFingerprint(\n profile: BrowserProfile,\n initTime: number,\n): Uint8Array {\n const { Byte, EncryptedBytes, CompactInt, Marker, RawAppend } = FieldEncoding;\n\n // Use compact enum encoding for known timezones, encrypted string otherwise\n const timezoneField =\n profile.timezone in TIMEZONE_ENUM\n ? encodeField(1, Byte, TIMEZONE_ENUM[profile.timezone])\n : encodeField(1, EncryptedBytes, textEnc(profile.timezone), initTime);\n\n const fields: Uint8Array[] = [\n encodeField(0, Byte, 0), // Constant marker\n timezoneField, // Timezone\n encodeField(\n 2,\n EncryptedBytes, // Language list\n textEnc(`${profile.locale},${profile.language}`),\n initTime,\n ),\n encodeField(6, CompactInt, 0), // Expected property count\n encodeField(\n 10,\n RawAppend, // Castle data bitfield\n concat(u8(4), encodeBits([1, 2, 3], 8)),\n ),\n encodeField(12, CompactInt, 80), // Negative error string length\n encodeField(13, RawAppend, u8(9, 0, 0)), // Driver check values\n encodeField(\n 17,\n RawAppend, // Chrome feature flags\n concat(u8(0x0d), encodeBits([1, 5, 8, 9, 10], 16)),\n ),\n encodeField(18, Marker, 0), // Device logic expected\n encodeField(21, RawAppend, u8(0, 0, 0, 0)), // Class properties count\n encodeField(22, EncryptedBytes, textEnc(profile.locale), initTime), // User locale (secondary)\n encodeField(\n 23,\n RawAppend, // Worker capabilities\n concat(u8(2), encodeBits([0], 8)),\n ),\n encodeField(\n 24,\n RawAppend, // Inner/outer dimension diff\n concat(be16(0), be16(randInt(10, 30))),\n ),\n ];\n\n const data = concat(...fields);\n const sizeIdx = ((7 & FP_PART.BROWSER) << 5) | (31 & fields.length);\n return concat(u8(sizeIdx), data);\n}\n\n// ─── Fingerprint Part 3: Timing ─────────────────────────────────────────────\n\n/**\n * Build timing fingerprint (Part 3).\n * Contains Castle SDK initialization timing data.\n */\nfunction buildTimingFingerprint(initTime: number): Uint8Array {\n const minute = new Date(initTime).getUTCMinutes();\n\n const fields: Uint8Array[] = [\n encodeField(3, FieldEncoding.CompactInt, 1), // Time since window.open (ms)\n encodeField(4, FieldEncoding.CompactInt, minute), // Castle init time (minutes)\n ];\n\n const data = concat(...fields);\n const sizeIdx = ((7 & FP_PART.TIMING) << 5) | (31 & fields.length);\n return concat(u8(sizeIdx), data);\n}\n\n// ─── Event Log ──────────────────────────────────────────────────────────────\n\n/** DOM event type IDs used in the simulated event log */\nconst EventType = {\n CLICK: 0,\n FOCUS: 5,\n BLUR: 6,\n ANIMATIONSTART: 18,\n MOUSEMOVE: 21,\n MOUSELEAVE: 25,\n MOUSEENTER: 26,\n RESIZE: 27,\n} as const;\n\n/** Flag bit set on events that include a target element ID */\nconst HAS_TARGET_FLAG = 128;\n\n/** Target element ID for \"unknown element\" */\nconst TARGET_UNKNOWN = 63;\n\n/**\n * Generate a simulated DOM event log.\n * Produces a realistic-looking sequence of mouse, keyboard, and focus events.\n */\nfunction generateEventLog(): Uint8Array {\n const simpleEvents = [\n EventType.MOUSEMOVE,\n EventType.ANIMATIONSTART,\n EventType.MOUSELEAVE,\n EventType.MOUSEENTER,\n EventType.RESIZE,\n ];\n const targetedEvents: number[] = [\n EventType.CLICK,\n EventType.BLUR,\n EventType.FOCUS,\n ];\n const allEvents = [...simpleEvents, ...targetedEvents];\n\n const count = randInt(30, 70);\n const eventBytes: number[] = [];\n\n for (let i = 0; i < count; i++) {\n const eventId = allEvents[randInt(0, allEvents.length - 1)];\n if (targetedEvents.includes(eventId)) {\n eventBytes.push(eventId | HAS_TARGET_FLAG);\n eventBytes.push(TARGET_UNKNOWN);\n } else {\n eventBytes.push(eventId);\n }\n }\n\n // Format: [2-byte total length] [0x00] [2-byte event count] [event bytes...]\n const inner = concat(u8(0), be16(count), new Uint8Array(eventBytes));\n return concat(be16(inner.length), inner);\n}\n\n// ─── Behavioral Metrics ─────────────────────────────────────────────────────\n\n/**\n * Build the behavioral bitfield indicating which input types were detected.\n * Simulates a user who used mouse and keyboard (no touch).\n */\nfunction buildBehavioralBitfield(): Uint8Array {\n // 15 flags with totalBits=16: the extra bit causes a left-shift-by-1 in boolsToBin,\n // matching the Python SDK's behavior where the MSB is always 0.\n const flags = new Array(15).fill(false);\n flags[2] = true; // Has click events\n flags[3] = true; // Has keydown events\n flags[5] = true; // Has backspace key\n flags[6] = true; // Not a touch device\n flags[9] = true; // Has mouse movement\n flags[11] = true; // Has focus events\n flags[12] = true; // Has scroll events\n\n const packedBits = boolsToBin(flags, 16);\n // Encode with type prefix: (6 << 20) | (2 << 16) | value\n const encoded = (6 << 20) | (2 << 16) | (65535 & packedBits);\n return u8((encoded >>> 16) & 0xff, (encoded >>> 8) & 0xff, encoded & 0xff);\n}\n\n/** Sentinel: metric not available (e.g., touch metrics on desktop) */\nconst NO_DATA = -1;\n\n/**\n * Generate simulated behavioral float metrics.\n * Each value represents a statistical measurement of user input patterns\n * (mouse movement angles, key timing, click durations, etc.)\n */\nfunction buildFloatMetrics(): Uint8Array {\n // NO_DATA (-1) encodes as 0x00 (metric not available)\n const metrics: number[] = [\n // ── Mouse & key timing ──\n randFloat(40, 50), // 0: Mouse angle vector mean\n NO_DATA, // 1: Touch angle vector (no touch device)\n randFloat(70, 80), // 2: Key same-time difference\n NO_DATA, // 3: (unused)\n randFloat(60, 70), // 4: Mouse down-to-up time mean\n NO_DATA, // 5: (unused)\n 0, // 6: (zero placeholder)\n 0, // 7: Mouse click time difference\n\n // ── Duration distributions ──\n randFloat(60, 80), // 8: Mouse down-up duration median\n randFloat(5, 10), // 9: Mouse down-up duration std deviation\n randFloat(30, 40), // 10: Key press duration median\n randFloat(2, 5), // 11: Key press duration std deviation\n\n // ── Touch metrics (all disabled for desktop) ──\n NO_DATA,\n NO_DATA,\n NO_DATA,\n NO_DATA, // 12-15\n NO_DATA,\n NO_DATA,\n NO_DATA,\n NO_DATA, // 16-19\n\n // ── Mouse trajectory analysis ──\n randFloat(150, 180), // 20: Mouse movement angle mean\n randFloat(3, 6), // 21: Mouse movement angle std deviation\n randFloat(150, 180), // 22: Mouse movement angle mean (500ms window)\n randFloat(3, 6), // 23: Mouse movement angle std (500ms window)\n randFloat(0, 2), // 24: Mouse position deviation X\n randFloat(0, 2), // 25: Mouse position deviation Y\n 0,\n 0, // 26-27: (zero placeholders)\n\n // ── Touch sequential/gesture metrics (disabled) ──\n NO_DATA,\n NO_DATA, // 28-29\n NO_DATA,\n NO_DATA, // 30-31\n\n // ── Key pattern analysis ──\n 0,\n 0, // 32-33: Letter-digit transition ratio\n 0,\n 0, // 34-35: Digit-invalid transition ratio\n 0,\n 0, // 36-37: Double-invalid transition ratio\n\n // ── Mouse vector differences ──\n 1.0,\n 0, // 38-39: Mouse vector diff (mean, std)\n 1.0,\n 0, // 40-41: Mouse vector diff 2 (mean, std)\n randFloat(0, 4), // 42: Mouse vector diff (500ms mean)\n randFloat(0, 3), // 43: Mouse vector diff (500ms std)\n\n // ── Rounded movement metrics ──\n randFloat(25, 50), // 44: Mouse time diff (rounded mean)\n randFloat(25, 50), // 45: Mouse time diff (rounded std)\n randFloat(25, 50), // 46: Mouse vector diff (rounded mean)\n randFloat(25, 30), // 47: Mouse vector diff (rounded std)\n\n // ── Speed change analysis ──\n randFloat(0, 2), // 48: Mouse speed change mean\n randFloat(0, 1), // 49: Mouse speed change std\n randFloat(0, 1), // 50: Mouse vector 500ms aggregate\n\n // ── Trailing ──\n 1, // 51: Universal flag\n 0, // 52: Terminator\n ];\n\n const out = new Uint8Array(metrics.length);\n for (let i = 0; i < metrics.length; i++) {\n out[i] = metrics[i] === NO_DATA ? 0 : encodeFloatVal(metrics[i]);\n }\n return out;\n}\n\n/**\n * Generate simulated event count integers.\n * Each value represents how many times a specific DOM event type occurred.\n */\nfunction buildEventCounts(): Uint8Array {\n const counts: number[] = [\n randInt(100, 200), // 0: mousemove events\n randInt(1, 5), // 1: keyup events\n randInt(1, 5), // 2: click events\n 0, // 3: touchstart events (none on desktop)\n randInt(0, 5), // 4: keydown events\n 0, // 5: touchmove events (none)\n 0, // 6: mousedown-mouseup pairs\n 0, // 7: vector diff samples\n randInt(0, 5), // 8: wheel events\n randInt(0, 11), // 9: (internal counter)\n randInt(0, 1), // 10: (internal counter)\n ];\n // Append the count of entries as a trailing byte\n return concat(new Uint8Array(counts), u8(counts.length));\n}\n\n/** Combine all behavioral metrics into a single byte sequence */\nfunction buildBehavioralData(): Uint8Array {\n return concat(\n buildBehavioralBitfield(),\n buildFloatMetrics(),\n buildEventCounts(),\n );\n}\n\n// ─── Token Assembly ─────────────────────────────────────────────────────────\n\nfunction buildTokenHeader(\n uuid: string,\n publisherKey: string,\n initTime: number,\n): Uint8Array {\n const timestamp = fromHex(encodeTimestampEncrypted(initTime));\n const version = be16(SDK_VERSION);\n const pkBytes = textEnc(publisherKey);\n const uuidBytes = fromHex(uuid);\n return concat(timestamp, version, pkBytes, uuidBytes);\n}\n\n/**\n * Generate a Castle.io v11 token for Twitter's login flow.\n *\n * The token embeds a simulated browser fingerprint and behavioral data,\n * encrypted with XXTEA and layered XOR, to satisfy Twitter's anti-bot\n * checks during the login flow.\n *\n * @param userAgent - The user agent string to embed in the fingerprint.\n * Should match the UA used for HTTP requests.\n * @returns Object with `token` (the Castle request token) and `cuid` (for __cuid cookie)\n */\nexport function generateLocalCastleToken(\n userAgent: string,\n profileOverride?: Partial<BrowserProfile>,\n): {\n token: string;\n cuid: string;\n} {\n const now = Date.now();\n // Use the known-good DEFAULT_PROFILE as base. Randomization is opt-in because\n // the static canvas hashes (fields 13, 18) must match the GPU renderer — see\n // randomizeBrowserProfile() JSDoc for details.\n const profile = { ...DEFAULT_PROFILE, ...profileOverride };\n\n // Simulate page load: init_time is 2-30 minutes before current time\n const initTime = now - randFloat(2 * 60 * 1000, 30 * 60 * 1000);\n\n log('Generating local Castle.io v11 token');\n\n // ── Step 1: Collect fingerprint data ──\n const deviceFp = buildDeviceFingerprint(initTime, profile, userAgent);\n const browserFp = buildBrowserFingerprint(profile, initTime);\n const timingFp = buildTimingFingerprint(initTime);\n const eventLog = generateEventLog();\n const behavioral = buildBehavioralData();\n\n // Concatenate all parts with 0xFF terminator\n const fingerprintData = concat(\n deviceFp,\n browserFp,\n timingFp,\n eventLog,\n behavioral,\n u8(0xff),\n );\n\n // ── Step 2: First XOR layer (timestamp-derived key) ──\n const sendTime = Date.now();\n const timestampKey = encodeTimestampEncrypted(sendTime);\n const xorPass1 = deriveAndXor(\n timestampKey,\n 4,\n timestampKey[3],\n fingerprintData,\n );\n\n // ── Step 3: Second XOR layer (UUID-derived key) ──\n const tokenUuid = toHex(getRandomBytes(16));\n const withTimestampPrefix = concat(fromHex(timestampKey), xorPass1);\n const xorPass2 = deriveAndXor(\n tokenUuid,\n 8,\n tokenUuid[9],\n withTimestampPrefix,\n );\n\n // ── Step 4: Build header (timestamp, SDK version, publisher key, UUID) ──\n const header = buildTokenHeader(tokenUuid, TWITTER_CASTLE_PK, initTime);\n\n // ── Step 5: XXTEA encrypt the full payload ──\n const plaintext = concat(header, xorPass2);\n const encrypted = xxteaEncrypt(plaintext, XXTEA_KEY);\n\n // ── Step 6: Prepend version and padding info ──\n const paddingBytes = encrypted.length - plaintext.length;\n const versioned = concat(u8(TOKEN_VERSION, paddingBytes), encrypted);\n\n // ── Step 7: Random-byte XOR + length checksum ──\n const randomByte = getRandomBytes(1)[0];\n const checksum = (versioned.length * 2) & 0xff;\n const withChecksum = concat(versioned, u8(checksum));\n const xored = xorBytes(withChecksum, u8(randomByte));\n const finalPayload = concat(u8(randomByte), xored);\n\n // ── Step 8: Base64URL encode ──\n const token = base64url(finalPayload);\n\n log(\n `Generated castle token: ${token.length} chars, cuid: ${tokenUuid.substring(\n 0,\n 6,\n )}...`,\n );\n\n return { token, cuid: tokenUuid };\n}\n","import { Cookie, CookieJar } from 'tough-cookie';\nimport setCookie from 'set-cookie-parser';\nimport type { Headers as HeadersPolyfill } from 'headers-polyfill';\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:requests');\n\n/**\n * Updates a cookie jar with the Set-Cookie headers from the provided Headers instance.\n * @param cookieJar The cookie jar to update.\n * @param headers The response headers to populate the cookie jar with.\n */\nexport async function updateCookieJar(\n cookieJar: CookieJar,\n headers: Headers | HeadersPolyfill,\n) {\n // Try to use getSetCookie() if available (proper way to get all set-cookie headers)\n let setCookieHeaders: string[] = [];\n\n if (typeof headers.getSetCookie === 'function') {\n setCookieHeaders = headers.getSetCookie();\n } else {\n // Fallback: get the single set-cookie header\n const setCookieHeader = headers.get('set-cookie');\n if (setCookieHeader) {\n // Split combined set-cookie headers\n setCookieHeaders = setCookie.splitCookiesString(setCookieHeader);\n }\n }\n\n if (setCookieHeaders.length > 0) {\n for (const cookieStr of setCookieHeaders) {\n const cookie = Cookie.parse(cookieStr);\n if (!cookie) {\n log(`Failed to parse cookie: ${cookieStr.substring(0, 100)}`);\n continue;\n }\n\n // Skip cookies that are being explicitly deleted (Max-Age=0 or expired)\n // This prevents twitter from clearing important cookies like ct0\n if (\n cookie.maxAge === 0 ||\n (cookie.expires && cookie.expires < new Date())\n ) {\n if (cookie.key === 'ct0') {\n log(`Skipping deletion of ct0 cookie (Max-Age=0)`);\n }\n continue;\n }\n\n try {\n const url = `${cookie.secure ? 'https' : 'http'}://${cookie.domain}${\n cookie.path\n }`;\n await cookieJar.setCookie(cookie, url);\n if (cookie.key === 'ct0') {\n log(\n `Successfully set ct0 cookie with value: ${cookie.value.substring(\n 0,\n 20,\n )}...`,\n );\n }\n } catch (err) {\n // Log cookie setting errors\n log(`Failed to set cookie ${cookie.key}: ${err}`);\n if (cookie.key === 'ct0') {\n log(`FAILED to set ct0 cookie! Error: ${err}`);\n }\n }\n }\n } else if (typeof document !== 'undefined') {\n for (const cookie of document.cookie.split(';')) {\n const hardCookie = Cookie.parse(cookie);\n if (hardCookie) {\n await cookieJar.setCookie(hardCookie, document.location.toString());\n }\n }\n }\n}\n","import debug from 'debug';\n\nconst log = debug('twitter-scraper:xpff');\n\nlet isoCrypto: Crypto | null = null;\n\nasync function getCrypto(): Promise<Crypto> {\n if (isoCrypto != null) {\n return isoCrypto;\n }\n\n // In Node.js, the global `crypto` object is only available from v19.0.0 onwards.\n // For earlier versions, we need to import the 'crypto' module.\n if (typeof crypto === 'undefined') {\n log('Global crypto is undefined, importing from crypto module...');\n const { webcrypto } = await import('crypto');\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n isoCrypto = webcrypto as any;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return webcrypto as any;\n }\n isoCrypto = crypto;\n return crypto;\n}\n\nasync function sha256(message: string): Promise<Uint8Array> {\n const msgBuffer = new TextEncoder().encode(message);\n const crypto = await getCrypto();\n const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n return new Uint8Array(hashBuffer);\n}\n\n// https://stackoverflow.com/a/40031979\nfunction buf2hex(buffer: ArrayBuffer): string {\n return [...new Uint8Array(buffer)]\n .map((x) => x.toString(16).padStart(2, '0'))\n .join('');\n}\n\n// Adapted from https://github.com/dsekz/twitter-x-xp-forwarded-for-header\nexport class XPFFHeaderGenerator {\n constructor(private readonly seed: string) {}\n\n private async deriveKey(guestId: string): Promise<Uint8Array> {\n const combined = `${this.seed}${guestId}`;\n const result = await sha256(combined);\n return result;\n }\n\n async generateHeader(plaintext: string, guestId: string): Promise<string> {\n log(`Generating XPFF key for guest ID: ${guestId}`);\n const key = await this.deriveKey(guestId);\n const crypto = await getCrypto();\n const nonce = crypto.getRandomValues(new Uint8Array(12));\n const cipher = await crypto.subtle.importKey(\n 'raw',\n key as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['encrypt'],\n );\n const encrypted = await crypto.subtle.encrypt(\n {\n name: 'AES-GCM',\n iv: nonce,\n },\n cipher,\n new TextEncoder().encode(plaintext),\n );\n\n // Combine nonce and encrypted data\n const combined = new Uint8Array(nonce.length + encrypted.byteLength);\n combined.set(nonce);\n combined.set(new Uint8Array(encrypted), nonce.length);\n const result = buf2hex(combined.b