From 978d07f60f92b851bb4b7d22b2cff90e823fa5b0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 26 Dec 2025 14:58:03 +0100 Subject: [PATCH] feat(graphql): runtime query-id refresh --- CHANGELOG.md | 1 + README.md | 14 ++ src/index.ts | 59 +++++++ src/lib/runtime-query-ids.ts | 303 ++++++++++++++++++++++++++++++++ src/lib/twitter-client.ts | 285 +++++++++++++++++++----------- tests/cli-args.test.ts | 1 + tests/runtime-query-ids.test.ts | 63 +++++++ tests/twitter-client.test.ts | 11 +- 8 files changed, 628 insertions(+), 109 deletions(-) create mode 100644 src/lib/runtime-query-ids.ts create mode 100644 tests/runtime-query-ids.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 90675c3..d353b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`). - `help` command: `bird help `. +- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404). ### Fixed - CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available. diff --git a/README.md b/README.md index c4c47b6..04dcdf8 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ bird replies 1234567890123456789 bird search "from:steipete" -n 5 bird mentions -n 5 bird mentions --user @steipete -n 5 + +# Refresh GraphQL query IDs cache (no rebuild) +bird query-ids --fresh ``` ## Commands @@ -38,6 +41,7 @@ bird mentions --user @steipete -n 5 - `bird tweet ""` — post a new tweet. - `bird reply ""` — reply to a tweet using its ID or URL. - `bird help [command]` — show help (or help for a subcommand). +- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs. - `bird read [--json]` — fetch tweet content as text or JSON. - `bird [--json]` — shorthand for `read` when only a URL or ID is provided. - `bird replies [--json]` — list replies to a tweet. @@ -101,6 +105,16 @@ Environment shortcuts: - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). +## Query IDs (GraphQL) + +X rotates GraphQL query IDs frequently. `bird` caches refreshed IDs on disk and retries automatically on 404s. + +Refresh on demand: + +```bash +bird query-ids --fresh +``` + Exit codes: - `0`: success - `1`: runtime error (network/auth/etc) diff --git a/src/index.ts b/src/index.ts index 16c70bb..2d71043 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { resolveOutputConfigFromCommander, statusPrefix, } from './lib/output.js'; +import { runtimeQueryIds } from './lib/runtime-query-ids.js'; import { SweetisticsClient } from './lib/sweetistics-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { getCliVersion } from './lib/version.js'; @@ -110,6 +111,7 @@ const config = loadConfig(); const KNOWN_COMMANDS = new Set([ 'tweet', 'reply', + 'query-ids', 'read', 'replies', 'thread', @@ -267,6 +269,63 @@ program cmd.outputHelp(); }); +program + .command('query-ids') + .description('Show or refresh cached Twitter GraphQL query IDs') + .option('--json', 'Output as JSON') + .option('--fresh', 'Force refresh (downloads X client bundles)', false) + .action(async (cmdOpts: { json?: boolean; fresh?: boolean }) => { + const operations = [ + 'CreateTweet', + 'CreateRetweet', + 'FavoriteTweet', + 'TweetDetail', + 'SearchTimeline', + 'UserArticlesTweets', + ]; + + if (cmdOpts.fresh) { + console.error(`${p('info')}Refreshing GraphQL query IDs…`); + await runtimeQueryIds.refresh(operations, { force: true }); + } + + const info = await runtimeQueryIds.getSnapshotInfo(); + if (!info) { + if (cmdOpts.json) { + console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2)); + return; + } + console.log(`${p('warn')}No cached query IDs yet.`); + console.log(`${p('info')}Run: bird query-ids --fresh`); + return; + } + + if (cmdOpts.json) { + console.log( + JSON.stringify( + { + cached: true, + cachePath: info.cachePath, + fetchedAt: info.snapshot.fetchedAt, + isFresh: info.isFresh, + ageMs: info.ageMs, + ids: info.snapshot.ids, + discovery: info.snapshot.discovery, + }, + null, + 2, + ), + ); + return; + } + + console.log(`${p('ok')}GraphQL query IDs cached`); + console.log(`path: ${info.cachePath}`); + console.log(`fetched_at: ${info.snapshot.fetchedAt}`); + console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`); + console.log(`ops: ${Object.keys(info.snapshot.ids).length}`); + }); + // Tweet command program .command('tweet') diff --git a/src/lib/runtime-query-ids.ts b/src/lib/runtime-query-ids.ts new file mode 100644 index 0000000..9dfbf90 --- /dev/null +++ b/src/lib/runtime-query-ids.ts @@ -0,0 +1,303 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json'; +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; + +const DISCOVERY_PAGES = [ + 'https://x.com/?lang=en', + 'https://x.com/explore', + 'https://x.com/notifications', + 'https://x.com/settings/profile', +]; + +const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; + +const OPERATION_PATTERNS = [ + { + regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs, + operationGroup: 2, + queryIdGroup: 1, + }, + { + regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs, + operationGroup: 1, + queryIdGroup: 2, + }, + { + regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs, + operationGroup: 1, + queryIdGroup: 3, + }, + { + regex: /queryId\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)operationName\s*[:=]\s*["']([^"']+)["']/gs, + operationGroup: 3, + queryIdGroup: 1, + }, +] as const; + +const HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36', + Accept: 'text/html,application/json;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', +}; + +export type RuntimeQueryIdSnapshot = { + fetchedAt: string; + ttlMs: number; + ids: Record; + discovery: { + pages: string[]; + bundles: string[]; + }; +}; + +export type RuntimeQueryIdSnapshotInfo = { + snapshot: RuntimeQueryIdSnapshot; + cachePath: string; + ageMs: number; + isFresh: boolean; +}; + +export type RuntimeQueryIdsOptions = { + cachePath?: string; + ttlMs?: number; + fetchImpl?: typeof fetch; +}; + +async function fetchText(fetchImpl: typeof fetch, url: string): Promise { + const response = await fetchImpl(url, { headers: HEADERS }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status} for ${url}: ${body.slice(0, 120)}`); + } + return response.text(); +} + +function resolveDefaultCachePath(): string { + const override = process.env.BIRD_QUERY_IDS_CACHE; + if (override && override.trim().length > 0) { + return path.resolve(override.trim()); + } + return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME); +} + +function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { + if (!raw || typeof raw !== 'object') return null; + const record = raw as Record; + const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null; + const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null; + const ids = record.ids && typeof record.ids === 'object' ? (record.ids as Record) : null; + const discovery = + record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record) : null; + + if (!fetchedAt || !ttlMs || !ids || !discovery) return null; + const pages = Array.isArray(discovery.pages) ? discovery.pages : null; + const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null; + if (!pages || !bundles) return null; + + const normalizedIds: Record = {}; + for (const [key, value] of Object.entries(ids)) { + if (typeof value === 'string' && value.trim().length > 0) { + normalizedIds[key] = value.trim(); + } + } + + return { + fetchedAt, + ttlMs, + ids: normalizedIds, + discovery: { + pages: pages.filter((p) => typeof p === 'string') as string[], + bundles: bundles.filter((b) => typeof b === 'string') as string[], + }, + }; +} + +async function readSnapshotFromDisk(cachePath: string): Promise { + try { + const raw = await readFile(cachePath, 'utf8'); + return parseSnapshot(JSON.parse(raw)); + } catch (error) { + const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : ''; + if (code !== 'ENOENT') return null; + return null; + } +} + +async function writeSnapshotToDisk(cachePath: string, snapshot: RuntimeQueryIdSnapshot): Promise { + await mkdir(path.dirname(cachePath), { recursive: true }); + await writeFile(cachePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8'); +} + +async function discoverBundles(fetchImpl: typeof fetch): Promise { + const bundles = new Set(); + for (const page of DISCOVERY_PAGES) { + try { + const html = await fetchText(fetchImpl, page); + for (const match of html.matchAll(BUNDLE_URL_REGEX)) { + bundles.add(match[0]); + } + } catch { + // ignore discovery page failures; other pages often work + } + } + const discovered = [...bundles]; + if (discovered.length === 0) { + throw new Error('No client bundles discovered; x.com layout may have changed.'); + } + return discovered; +} + +function extractOperations( + bundleContents: string, + bundleLabel: string, + targets: Set, + discovered: Map, +): void { + for (const pattern of OPERATION_PATTERNS) { + pattern.regex.lastIndex = 0; + while (true) { + const match = pattern.regex.exec(bundleContents); + if (match === null) break; + const operationName = match[pattern.operationGroup]; + const queryId = match[pattern.queryIdGroup]; + if (!operationName || !queryId) continue; + if (!targets.has(operationName)) continue; + if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue; + if (discovered.has(operationName)) continue; + discovered.set(operationName, { queryId, bundle: bundleLabel }); + if (discovered.size === targets.size) return; + } + } +} + +async function fetchAndExtract( + fetchImpl: typeof fetch, + bundleUrls: string[], + targets: Set, +): Promise> { + const discovered = new Map(); + const CONCURRENCY = 6; + + for (let i = 0; i < bundleUrls.length; i += CONCURRENCY) { + const chunk = bundleUrls.slice(i, i + CONCURRENCY); + await Promise.all( + chunk.map(async (url) => { + if (discovered.size === targets.size) return; + const label = url.split('/').at(-1) ?? url; + try { + const js = await fetchText(fetchImpl, url); + extractOperations(js, label, targets, discovered); + } catch { + // ignore failed bundles + } + }), + ); + if (discovered.size === targets.size) break; + } + + return discovered; +} + +export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath(); + + let memorySnapshot: RuntimeQueryIdSnapshot | null = null; + let loadOnce: Promise | null = null; + let refreshInFlight: Promise | null = null; + + const loadSnapshot = async (): Promise => { + if (memorySnapshot) return memorySnapshot; + if (!loadOnce) { + loadOnce = (async () => { + const fromDisk = await readSnapshotFromDisk(cachePath); + memorySnapshot = fromDisk; + return fromDisk; + })(); + } + return loadOnce; + }; + + const getSnapshotInfo = async (): Promise => { + const snapshot = await loadSnapshot(); + if (!snapshot) return null; + const fetchedAtMs = new Date(snapshot.fetchedAt).getTime(); + const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY; + const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs; + const isFresh = ageMs <= effectiveTtl; + return { snapshot, cachePath, ageMs, isFresh }; + }; + + const getQueryId = async (operationName: string): Promise => { + const info = await getSnapshotInfo(); + if (!info) return null; + return info.snapshot.ids[operationName] ?? null; + }; + + const refresh = async ( + operationNames: string[], + opts: { force?: boolean } = {}, + ): Promise => { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + const current = await getSnapshotInfo(); + if (!opts.force && current?.isFresh) { + return current; + } + + const targets = new Set(operationNames); + const bundleUrls = await discoverBundles(fetchImpl); + const discovered = await fetchAndExtract(fetchImpl, bundleUrls, targets); + if (discovered.size === 0) { + return current ?? null; + } + + const ids: Record = {}; + for (const name of operationNames) { + const entry = discovered.get(name); + if (entry?.queryId) { + ids[name] = entry.queryId; + } + } + + const snapshot: RuntimeQueryIdSnapshot = { + fetchedAt: new Date().toISOString(), + ttlMs, + ids, + discovery: { + pages: [...DISCOVERY_PAGES], + bundles: bundleUrls.map((url) => url.split('/').at(-1) ?? url), + }, + }; + + await writeSnapshotToDisk(cachePath, snapshot); + memorySnapshot = snapshot; + + return getSnapshotInfo(); + })().finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; + }; + + return { + cachePath, + ttlMs, + getSnapshotInfo, + getQueryId, + refresh, + clearMemory() { + memorySnapshot = null; + loadOnce = null; + }, + }; +} + +export const runtimeQueryIds = createRuntimeQueryIdStore(); diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 8963049..a843b2c 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -4,6 +4,7 @@ import type { TwitterCookies } from './cookies.js'; import queryIds from './query-ids.json' with { type: 'json' }; +import { runtimeQueryIds } from './runtime-query-ids.js'; const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql'; @@ -27,13 +28,7 @@ const QUERY_IDS: Record = { ...(queryIds as Partial>), }; -const TWEET_DETAIL_QUERY_IDS = Array.from( - new Set([QUERY_IDS.TweetDetail, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']), -); - -const SEARCH_TIMELINE_QUERY_IDS = Array.from( - new Set([QUERY_IDS.SearchTimeline, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']), -); +const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; type GraphqlTweetResult = { rest_id?: string; @@ -266,6 +261,32 @@ export class TwitterClient { this.timeoutMs = options.timeoutMs; } + private async getQueryId(operationName: OperationName): Promise { + const cached = await runtimeQueryIds.getQueryId(operationName); + return cached ?? QUERY_IDS[operationName]; + } + + private async refreshQueryIds(): Promise { + if (process.env.NODE_ENV === 'test') { + return; + } + try { + await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true }); + } catch { + // ignore refresh failures; callers will fall back to baked-in IDs + } + } + + private async getTweetDetailQueryIds(): Promise { + const primary = await this.getQueryId('TweetDetail'); + return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g'])); + } + + private async getSearchTimelineQueryIds(): Promise { + const primary = await this.getQueryId('SearchTimeline'); + return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ'])); + } + private async fetchWithTimeout(url: string, init: RequestInit): Promise { if (!this.timeoutMs || this.timeoutMs <= 0) { return fetch(url, init); @@ -738,7 +759,8 @@ export class TwitterClient { fieldToggles: JSON.stringify(this.buildArticleFieldToggles()), }); - const url = `${TWITTER_API_BASE}/${QUERY_IDS.UserArticlesTweets}/UserArticlesTweets?${params}`; + const queryId = await this.getQueryId('UserArticlesTweets'); + const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`; try { const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); @@ -875,32 +897,51 @@ export class TwitterClient { }; let lastError: string | undefined; + let had404 = false; - for (const queryId of TWEET_DETAIL_QUERY_IDS) { - const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); + const tryOnce = async () => { + const queryIds = await this.getTweetDetailQueryIds(); - if (response.status !== 404) { - return await parseResponse(response); + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status !== 404) { + return await parseResponse(response); + } + + had404 = true; + + const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ variables, features, queryId }), + }); + + if (postResponse.status !== 404) { + return await parseResponse(postResponse); + } + + lastError = 'HTTP 404'; } - const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify({ variables, features, queryId }), - }); + return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' }; + }; - if (postResponse.status !== 404) { - return await parseResponse(postResponse); - } - - lastError = 'HTTP 404'; + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return firstAttempt; } - return { success: false, error: lastError ?? 'Unknown error fetching tweet detail' }; + if (had404) { + await this.refreshQueryIds(); + return await tryOnce(); + } + + return firstAttempt; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; } @@ -1070,13 +1111,14 @@ export class TwitterClient { variables: Record, features: Record, ): Promise { - const queryId = QUERY_IDS.CreateTweet; - const urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; + let queryId = await this.getQueryId('CreateTweet'); + let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; - const body = JSON.stringify({ variables, features, queryId }); + const buildBody = () => JSON.stringify({ variables, features, queryId }); + let body = buildBody(); try { - const response = await this.fetchWithTimeout(urlWithOperation, { + let response = await this.fetchWithTimeout(urlWithOperation, { method: 'POST', headers: this.getHeaders(), body, @@ -1085,27 +1127,40 @@ export class TwitterClient { // Twitter increasingly prefers POST to /i/api/graphql with queryId in the payload. // If the operation URL 404s, retry the generic endpoint. if (response.status === 404) { - const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + await this.refreshQueryIds(); + queryId = await this.getQueryId('CreateTweet'); + urlWithOperation = `${TWITTER_API_BASE}/${queryId}/CreateTweet`; + body = buildBody(); + + response = await this.fetchWithTimeout(urlWithOperation, { method: 'POST', headers: this.getHeaders(), body, }); - if (!retry.ok) { - const text = await retry.text(); - return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` }; + if (response.status === 404) { + const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, { + method: 'POST', + headers: this.getHeaders(), + body, + }); + + if (!retry.ok) { + const text = await retry.text(); + return { success: false, error: `HTTP ${retry.status}: ${text.slice(0, 200)}` }; + } + + const data = (await retry.json()) as CreateTweetResponse; + + if (data.errors && data.errors.length > 0) { + return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + } + + const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; + if (tweetId) return { success: true, tweetId }; + + return { success: false, error: 'Tweet created but no ID returned' }; } - - const data = (await retry.json()) as CreateTweetResponse; - - if (data.errors && data.errors.length > 0) { - return { success: false, error: data.errors.map((e) => e.message).join(', ') }; - } - - const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; - if (tweetId) return { success: true, tweetId }; - - return { success: false, error: 'Tweet created but no ID returned' }; } if (!response.ok) { @@ -1158,57 +1213,63 @@ export class TwitterClient { const features = this.buildSearchFeatures(); - let lastError: string | undefined; + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + }); - for (const queryId of SEARCH_TIMELINE_QUERY_IDS) { - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - }); - const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`; + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getSearchTimelineQueryIds(); - try { - const response = await this.fetchWithTimeout(url, { - method: 'POST', - headers: this.getHeaders(), - body: JSON.stringify({ features, queryId }), - }); + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`; - if (response.status === 404) { - lastError = `HTTP ${response.status}`; - continue; - } + try { + const response = await this.fetchWithTimeout(url, { + method: 'POST', + headers: this.getHeaders(), + body: JSON.stringify({ features, queryId }), + }); - if (!response.ok) { - const text = await response.text(); - return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; - } + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } - const data = (await response.json()) as { - data?: { - search_by_raw_query?: { - search_timeline?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - in_reply_to_status_id_str?: string; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + search_by_raw_query?: { + search_timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + in_reply_to_status_id_str?: string; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; }; }; }; @@ -1216,30 +1277,42 @@ export class TwitterClient { }; }; }; - }; + }>; }>; - }>; + }; }; }; }; + errors?: Array<{ message: string }>; }; - errors?: Array<{ message: string }>; - }; - if (data.errors && data.errors.length > 0) { - return { success: false, error: data.errors.map((e) => e.message).join(', ') }; + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions; + const tweets = this.parseTweetsFromInstructions(instructions); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); } - - const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions; - const tweets = this.parseTweetsFromInstructions(instructions); - - return { success: true, tweets }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); } + + return { success: false as const, error: lastError ?? 'Unknown error fetching search results', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; + return { success: false, error: secondAttempt.error }; } - return { success: false, error: lastError ?? 'Unknown error fetching search results' }; + return { success: false, error: firstAttempt.error }; } /** diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts index a6c16d6..5bf1ee5 100644 --- a/tests/cli-args.test.ts +++ b/tests/cli-args.test.ts @@ -5,6 +5,7 @@ describe('cli-args', () => { const known = new Set([ 'tweet', 'reply', + 'query-ids', 'read', 'replies', 'thread', diff --git a/tests/runtime-query-ids.test.ts b/tests/runtime-query-ids.test.ts new file mode 100644 index 0000000..0cc72aa --- /dev/null +++ b/tests/runtime-query-ids.test.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js'; + +describe('runtime-query-ids', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('refreshes IDs by scanning discovered bundles and persists cache', async () => { + const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`); + await mkdir(cacheDir, { recursive: true }); + const cachePath = path.join(cacheDir, 'query-ids.json'); + + const html = ` + + + + `; + const js = [ + `e.exports={queryId:"AAA",operationName:"CreateTweet"}`, + `e.exports={queryId:"BBB",operationName:"CreateRetweet"}`, + `e.exports={queryId:"CCC",operationName:"FavoriteTweet"}`, + `e.exports={queryId:"DDD",operationName:"TweetDetail"}`, + `e.exports={queryId:"EEE",operationName:"SearchTimeline"}`, + `e.exports={queryId:"FFF",operationName:"UserArticlesTweets"}`, + ].join('\n'); + + const fetchMock = vi.fn(async (url: string | URL) => { + const asString = String(url); + if (asString.startsWith('https://x.com/')) { + return new Response(html, { status: 200 }); + } + if (asString.startsWith('https://abs.twimg.com/')) { + return new Response(js, { status: 200 }); + } + return new Response('not found', { status: 404 }); + }); + + vi.stubGlobal('fetch', fetchMock); + + const store = createRuntimeQueryIdStore({ + cachePath, + ttlMs: 1000, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const info = await store.refresh( + ['CreateTweet', 'CreateRetweet', 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', 'UserArticlesTweets'], + { force: true }, + ); + + expect(info?.snapshot.ids.CreateTweet).toBe('AAA'); + expect(info?.snapshot.ids.TweetDetail).toBe('DDD'); + expect(info?.cachePath).toBe(cachePath); + + const raw = await readFile(cachePath, 'utf8'); + const parsed = JSON.parse(raw) as { ids?: Record }; + expect(parsed.ids?.SearchTimeline).toBe('EEE'); + }); +}); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 4089149..1e448bd 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -85,6 +85,11 @@ describe('TwitterClient', () => { it('retries CreateTweet via /i/api/graphql when operation URL 404s', async () => { mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => '', + }) .mockResolvedValueOnce({ ok: false, status: 404, @@ -111,12 +116,12 @@ describe('TwitterClient', () => { expect(result.success).toBe(true); expect(result.tweetId).toBe('1234567890'); - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(3); const [firstUrl] = mockFetch.mock.calls[0]; - const [secondUrl] = mockFetch.mock.calls[1]; + const [thirdUrl] = mockFetch.mock.calls[2]; expect(String(firstUrl)).toContain('/CreateTweet'); - expect(String(secondUrl)).toBe('https://x.com/i/api/graphql'); + expect(String(thirdUrl)).toBe('https://x.com/i/api/graphql'); }); it('should handle API errors', async () => {