From 0f6cc5c6630e0118b54e2a611f01b973bdd21985 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 2 Dec 2025 14:53:35 +0000 Subject: [PATCH] Add engine switch and Sweetistics endpoints for tweet reading --- README.md | 7 +- package.json | 3 +- scripts/update-query-ids.ts | 215 +++++++++++++++++++++++++ src/index.ts | 293 ++++++++++++++++++++++++++++------ src/lib/query-ids.json | 7 + src/lib/sweetistics-client.ts | 80 ++++++++++ src/lib/twitter-client.ts | 284 ++++++++++++++++++++------------ 7 files changed, 740 insertions(+), 149 deletions(-) create mode 100644 scripts/update-query-ids.ts create mode 100644 src/lib/query-ids.json diff --git a/README.md b/README.md index cb0b7a3..58e3721 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,15 @@ pnpm run binary # Creates the 'bird' executable - `bird tweet ""` — post a new tweet. - `bird reply ""` — reply to a tweet using its ID or URL. - `bird read [--json]` — fetch tweet content as text or JSON. +- `bird replies [--json]` — list replies to a tweet. +- `bird thread [--json]` — show the full conversation thread. - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--json]` — find tweets mentioning @clawdbot. - `bird check` — show which credentials are available and where they were sourced from. +Global engine switch: +- `--engine graphql|sweetistics|auto` (default `auto`). `auto` uses Sweetistics when an API key is provided, otherwise falls back to direct GraphQL. `sweetistics` requires `--sweetistics-api-key` (or env) and uses Sweetistics for all commands. `graphql` forces direct Twitter cookies even if an API key is present. + ### Post a tweet ```bash @@ -132,4 +137,4 @@ pnpm run lint:fix - Chrome cookie extraction requires macOS (uses `sqlite3` and `security` CLI tools). - The keychain access may block when running over SSH; use environment variables instead. -- Twitter/X may rotate GraphQL query IDs; update `src/lib/twitter-client.ts` if requests start failing. +- Twitter/X rotates GraphQL query IDs; refresh them with `pnpm run graphql:update` (writes `src/lib/query-ids.json`). diff --git a/package.json b/package.json index bfc7c83..86f4dcc 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", - "binary": "bun build --compile --minify src/index.ts --outfile bird" + "binary": "bun build --compile --minify src/index.ts --outfile bird", + "graphql:update": "tsx scripts/update-query-ids.ts" }, "dependencies": { "commander": "^12.1.0" diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts new file mode 100644 index 0000000..ecd1239 --- /dev/null +++ b/scripts/update-query-ids.ts @@ -0,0 +1,215 @@ +#!/usr/bin/env tsx +/** + * Fetches current Twitter/X GraphQL query IDs from public client bundles and + * updates src/lib/query-ids.json. Inspired by Sweetistics' extractor. + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const TARGET_OPERATIONS = [ + 'CreateTweet', + 'CreateRetweet', + 'FavoriteTweet', + 'TweetDetail', + 'SearchTimeline', +] as const; + +type OperationName = (typeof TARGET_OPERATIONS)[number]; + +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: /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 QUERY_IDS_PATH = path.resolve(process.cwd(), 'src/lib/query-ids.json'); +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', +}; + +interface DiscoveredOperation { + queryId: string; + bundle: string; +} + +async function readExistingIds(): Promise> { + try { + const contents = await fs.readFile(QUERY_IDS_PATH, 'utf8'); + const parsed = JSON.parse(contents) as Record; + const result: Partial> = {}; + for (const op of TARGET_OPERATIONS) { + if (typeof parsed[op] === 'string' && parsed[op].trim().length > 0) { + result[op] = parsed[op].trim(); + } + } + return result as Record; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[warn] Failed to read existing query IDs:', error); + } + return {} as Record; + } +} + +async function fetchText(url: string): Promise { + const response = await fetch(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(); +} + +async function discoverBundles(): Promise { + const bundles = new Set(); + for (const page of DISCOVERY_PAGES) { + try { + const html = await fetchText(page); + for (const match of html.matchAll(BUNDLE_URL_REGEX)) { + bundles.add(match[0]); + } + } catch (error) { + console.warn(`[warn] Could not fetch ${page}:`, error instanceof Error ? error.message : error); + } + } + + const discovered = Array.from(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; // reset stateful regex + let match: RegExpExecArray | null; + while ((match = pattern.regex.exec(bundleContents)) !== null) { + const operationName = match[pattern.operationGroup]; + const queryId = match[pattern.queryIdGroup]; + if (!operationName || !queryId) continue; + + if (!targets.has(operationName as OperationName)) continue; + if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue; + const op = operationName as OperationName; + if (discovered.has(op)) continue; + discovered.set(op, { queryId, bundle: bundleLabel }); + if (discovered.size === targets.size) { + return; + } + } + } +} + +async function fetchAndExtract( + 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(url); + extractOperations(js, label, targets, discovered); + } catch (error) { + console.warn(`[warn] Failed to scan ${label}:`, error instanceof Error ? error.message : error); + } + }), + ); + if (discovered.size === targets.size) { + break; + } + } + + return discovered; +} + +async function writeIds(ids: Record): Promise { + const ordered: Record = {} as Record; + for (const op of TARGET_OPERATIONS) { + if (ids[op]) { + ordered[op] = ids[op]; + } + } + const json = `${JSON.stringify(ordered, null, 2)}\n`; + await fs.mkdir(path.dirname(QUERY_IDS_PATH), { recursive: true }); + await fs.writeFile(QUERY_IDS_PATH, json, 'utf8'); +} + +async function main(): Promise { + console.log('[info] Discovering Twitter/X client bundles…'); + const bundleUrls = await discoverBundles(); + console.log(`[info] Found ${bundleUrls.length} bundles`); + + const targets = new Set(TARGET_OPERATIONS); + const existing = await readExistingIds(); + + const discovered = await fetchAndExtract(bundleUrls, targets); + if (discovered.size === 0) { + throw new Error('No query IDs discovered; extraction patterns may need an update.'); + } + + const nextIds: Record = { ...existing }; + for (const op of TARGET_OPERATIONS) { + const found = discovered.get(op); + if (found?.queryId) { + nextIds[op] = found.queryId; + } + } + + await writeIds(nextIds); + + for (const op of TARGET_OPERATIONS) { + const previous = existing[op]; + const current = nextIds[op]; + const source = discovered.get(op)?.bundle ?? 'existing file'; + if (previous && current && previous !== current) { + console.log(`✅ ${op}: ${previous} → ${current} (${source})`); + } else if (current) { + console.log(`✅ ${op}: ${current} (${source})`); + } else { + console.warn(`⚠️ ${op}: not found (kept previous value if present)`); + } + } + + console.log(`[info] Updated ${QUERY_IDS_PATH}`); +} + +main().catch((error) => { + console.error('[error]', error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/index.ts b/src/index.ts index 2623189..3ceef0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { Command } from 'commander'; import { resolveCredentials } from './lib/cookies.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; -import { TwitterClient } from './lib/twitter-client.js'; +import { TwitterClient, type TweetData } from './lib/twitter-client.js'; import { SweetisticsClient } from './lib/sweetistics-client.js'; const program = new Command(); @@ -25,7 +25,14 @@ program .option('--ct0 ', 'Twitter ct0 cookie') .option('--chrome-profile ', 'Chrome profile name for cookie extraction') .option('--sweetistics-api-key ', 'Sweetistics API key (or set SWEETISTICS_API_KEY)') - .option('--sweetistics-base-url ', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com'); + .option('--sweetistics-base-url ', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com') + .option( + '--engine ', + 'Engine: graphql | sweetistics | auto', + process.env.BIRD_ENGINE || 'auto', + ); + +type EngineMode = 'graphql' | 'sweetistics' | 'auto'; function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) { const apiKey = @@ -39,6 +46,45 @@ function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetis return { apiKey, baseUrl }; } +function resolveEngineMode(value?: string): EngineMode { + const normalized = (value || 'auto').toLowerCase(); + if (normalized === 'graphql' || normalized === 'sweetistics' || normalized === 'auto') { + return normalized; + } + return 'auto'; +} + +function shouldUseSweetistics(engine: EngineMode, hasApiKey: boolean): boolean { + if (engine === 'sweetistics') return true; + if (engine === 'graphql') return false; + return hasApiKey; // auto +} + +function printTweets( + tweets: TweetData[], + opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {}, +) { + if (opts.json) { + console.log(JSON.stringify(tweets, null, 2)); + return; + } + if (tweets.length === 0) { + console.log(opts.emptyMessage ?? 'No tweets found.'); + return; + } + for (const tweet of tweets) { + console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); + console.log(tweet.text); + if (tweet.createdAt) { + console.log(`📅 ${tweet.createdAt}`); + } + console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`); + if (opts.showSeparator ?? true) { + console.log('─'.repeat(50)); + } + } +} + // Tweet command program .command('tweet') @@ -47,8 +93,14 @@ program .action(async (text: string) => { const opts = program.opts(); const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); - if (sweetistics.apiKey) { + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } try { const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, @@ -110,9 +162,15 @@ program .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); const tweetId = extractTweetId(tweetIdOrUrl); - if (sweetistics.apiKey) { + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } try { const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, @@ -175,6 +233,37 @@ program .option('--json', 'Output as JSON') .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); + const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); + + const tweetId = extractTweetId(tweetIdOrUrl); + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } + const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const result = await client.read(tweetId); + if (result.success && result.tweet) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.tweet, null, 2)); + } else { + console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); + console.log(result.tweet.text); + if (result.tweet.createdAt) { + console.log(`\n📅 ${result.tweet.createdAt}`); + } + console.log( + `❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`, + ); + } + return; + } + console.error(`❌ Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`); + process.exit(1); + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -190,7 +279,6 @@ program process.exit(1); } - const tweetId = extractTweetId(tweetIdOrUrl); const client = new TwitterClient({ cookies }); const result = await client.getTweet(tweetId); @@ -213,15 +301,33 @@ program } }); -// Search command - find tweets +// Replies command - list replies to a tweet program - .command('search') - .description('Search for tweets') - .argument('', 'Search query (e.g., "@clawdbot" or "from:clawdbot")') - .option('-n, --count ', 'Number of tweets to fetch', '10') + .command('replies') + .description('List replies to a tweet (by ID or URL)') + .argument('', 'Tweet ID or URL') .option('--json', 'Output as JSON') - .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { + .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); + const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); + const tweetId = extractTweetId(tweetIdOrUrl); + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } + const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const result = await client.replies(tweetId); + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); + return; + } + console.error(`❌ Failed to fetch replies via Sweetistics: ${result.error}`); + process.exit(1); + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -238,27 +344,118 @@ program } const client = new TwitterClient({ cookies }); + const result = await client.getReplies(tweetId); + + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' }); + } else { + console.error(`❌ Failed to fetch replies: ${result.error}`); + process.exit(1); + } + }); + +// Thread command - show full conversation thread +program + .command('thread') + .description('Show the full conversation thread containing the tweet') + .argument('', 'Tweet ID or URL') + .option('--json', 'Output as JSON') + .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); + const tweetId = extractTweetId(tweetIdOrUrl); + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } + const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const result = await client.thread(tweetId); + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); + return; + } + console.error(`❌ Failed to fetch thread via Sweetistics: ${result.error}`); + process.exit(1); + } + + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); + + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } + + const client = new TwitterClient({ cookies }); + const result = await client.getThread(tweetId); + + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' }); + } else { + console.error(`❌ Failed to fetch thread: ${result.error}`); + process.exit(1); + } + }); + +// Search command - find tweets +program + .command('search') + .description('Search for tweets') + .argument('', 'Search query (e.g., "@clawdbot" or "from:clawdbot")') + .option('-n, --count ', 'Number of tweets to fetch', '10') + .option('--json', 'Output as JSON') + .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { + const opts = program.opts(); const count = parseInt(cmdOpts.count || '10', 10); + const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); + + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } + const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const result = await client.search(query, count); + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); + return; + } + console.error(`❌ Search failed via Sweetistics: ${result.error}`); + process.exit(1); + } + + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); + + for (const warning of warnings) { + console.error(`⚠️ ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('❌ Missing required credentials'); + process.exit(1); + } + + const client = new TwitterClient({ cookies }); const result = await client.search(query, count); if (result.success && result.tweets) { - if (cmdOpts.json) { - console.log(JSON.stringify(result.tweets, null, 2)); - } else { - if (result.tweets.length === 0) { - console.log('No tweets found.'); - } else { - for (const tweet of result.tweets) { - console.log(`\n@${tweet.author.username} (${tweet.author.name}):`); - console.log(tweet.text); - if (tweet.createdAt) { - console.log(`📅 ${tweet.createdAt}`); - } - console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`); - console.log('─'.repeat(50)); - } - } - } + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' }); } else { console.error(`❌ Search failed: ${result.error}`); process.exit(1); @@ -273,6 +470,26 @@ program .option('--json', 'Output as JSON') .action(async (cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); + const count = parseInt(cmdOpts.count || '10', 10); + const sweetistics = resolveSweetisticsConfig(opts); + const engine = resolveEngineMode(opts.engine); + const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey)); + + if (useSweetistics) { + if (!sweetistics.apiKey) { + console.error('❌ Sweetistics engine selected but no API key provided.'); + process.exit(1); + } + const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey }); + const result = await client.search('@clawdbot', count); + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); + return; + } + console.error(`❌ Failed to fetch mentions via Sweetistics: ${result.error}`); + process.exit(1); + } + const { cookies, warnings } = await resolveCredentials({ authToken: opts.authToken, ct0: opts.ct0, @@ -289,28 +506,10 @@ program } const client = new TwitterClient({ cookies }); - const count = parseInt(cmdOpts.count || '10', 10); const result = await client.search('@clawdbot', count); if (result.success && result.tweets) { - if (cmdOpts.json) { - console.log(JSON.stringify(result.tweets, null, 2)); - } else { - if (result.tweets.length === 0) { - console.log('No mentions found.'); - } else { - console.log(`Found ${result.tweets.length} mentions:\n`); - for (const tweet of result.tweets) { - console.log(`@${tweet.author.username} (${tweet.author.name}):`); - console.log(tweet.text); - if (tweet.createdAt) { - console.log(`📅 ${tweet.createdAt}`); - } - console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`); - console.log('─'.repeat(50)); - } - } - } + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' }); } else { console.error(`❌ Failed to fetch mentions: ${result.error}`); process.exit(1); diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json new file mode 100644 index 0000000..9b76a72 --- /dev/null +++ b/src/lib/query-ids.json @@ -0,0 +1,7 @@ +{ + "CreateTweet": "TAJw1rBsjAtdNgTdlo2oeg", + "CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA", + "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", + "TweetDetail": "nBS-WpgA6ZG0CyNHD517JQ", + "SearchTimeline": "Tp1sewRU1AsZpBWhqCZicQ" +} diff --git a/src/lib/sweetistics-client.ts b/src/lib/sweetistics-client.ts index ea6979d..d10fc69 100644 --- a/src/lib/sweetistics-client.ts +++ b/src/lib/sweetistics-client.ts @@ -10,6 +10,33 @@ export interface SweetisticsTweetResult { error?: string; } +export interface SweetisticsTweet { + id: string; + text: string; + author: { + username: string; + name: string; + }; + createdAt?: string; + replyCount?: number; + retweetCount?: number; + likeCount?: number; + conversationId?: string; + inReplyToStatusId?: string; +} + +export interface SweetisticsTimelineResult { + success: boolean; + tweets?: SweetisticsTweet[]; + error?: string; +} + +export interface SweetisticsReadResult { + success: boolean; + tweet?: SweetisticsTweet; + error?: string; +} + function normalizeBaseUrl(value: string): string { const trimmed = value.trim(); if (!trimmed) return 'https://sweetistics.com'; @@ -67,4 +94,57 @@ export class SweetisticsClient { return { success: true, tweetId }; } + + async read(tweetId: string): Promise { + return this.fetchJson(`${this.baseUrl}/api/twitter/tweet/${tweetId}`); + } + + async replies(tweetId: string): Promise { + return this.fetchJson(`${this.baseUrl}/api/twitter/tweet/${tweetId}/replies`); + } + + async thread(tweetId: string): Promise { + return this.fetchJson(`${this.baseUrl}/api/twitter/tweet/${tweetId}/thread`); + } + + async search(query: string, count: number): Promise { + const url = new URL(`${this.baseUrl}/api/twitter/search`); + url.searchParams.set('q', query); + url.searchParams.set('count', String(count)); + return this.fetchJson(url.toString()); + } + + private async fetchJson( + url: string, + init?: RequestInit, + ): Promise { + const response = await fetch(url, { + ...init, + headers: { + authorization: `Bearer ${this.apiKey}`, + ...(this.userAgent ? { 'user-agent': this.userAgent } : {}), + ...(init?.headers ?? {}), + }, + }); + + let data: unknown; + try { + data = await response.json(); + } catch (error) { + return { + success: false, + error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`, + } as T; + } + + if (!response.ok || !(data as { success?: unknown })?.success) { + const message = + (typeof (data as { error?: unknown })?.error === 'string' + ? (data as { error?: string }).error + : undefined) || `HTTP ${response.status}`; + return { ...(data as object), success: false, error: message } as T; + } + + return data as T; + } } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 41c391a..c78cd65 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -3,18 +3,49 @@ */ import type { TwitterCookies } from './cookies.js'; +import queryIds from './query-ids.json' with { type: 'json' }; const TWITTER_API_BASE = 'https://x.com/i/api/graphql'; -// Twitter GraphQL query IDs - these may need to be updated periodically -// as Twitter rotates them. Check browser network tab for current values. -// Source: https://github.com/fa0311/TwitterInternalAPIDocument -const QUERY_IDS = { +// Query IDs rotate frequently; the values in query-ids.json are refreshed by +// scripts/update-query-ids.ts. The fallback values keep the client usable if +// the file is missing or incomplete. +const FALLBACK_QUERY_IDS = { CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ', SearchTimeline: 'Tp1sewRU1AsZpBWhqCZicQ', +} as const; + +type OperationName = keyof typeof FALLBACK_QUERY_IDS; + +const QUERY_IDS: Record = { + ...FALLBACK_QUERY_IDS, + ...(queryIds as Partial>), +}; + +type GraphqlTweetResult = { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + conversation_id_str?: string; + in_reply_to_status_id_str?: string | null; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; }; export interface TweetResult { @@ -34,6 +65,8 @@ export interface TweetData { replyCount?: number; retweetCount?: number; likeCount?: number; + conversationId?: string; + inReplyToStatusId?: string; } export interface GetTweetResult { @@ -150,10 +183,74 @@ export class TwitterClient { }; } - /** - * Get tweet details by ID - */ - async getTweet(tweetId: string): Promise { + private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined { + if (!result?.legacy || !result.core?.user_results?.result?.legacy?.screen_name) return undefined; + return { + id: result.rest_id || '', + text: result.legacy.full_text || '', + createdAt: result.legacy.created_at, + replyCount: result.legacy.reply_count, + retweetCount: result.legacy.retweet_count, + likeCount: result.legacy.favorite_count, + conversationId: result.legacy.conversation_id_str, + inReplyToStatusId: result.legacy.in_reply_to_status_id_str ?? undefined, + author: { + username: result.core.user_results.result.legacy.screen_name, + name: result.core.user_results.result.legacy.name || result.core.user_results.result.legacy.screen_name, + }, + }; + } + + private parseTweetsFromInstructions( + instructions: + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }> + | undefined, + ): TweetData[] { + const tweets: TweetData[] = []; + for (const instruction of instructions ?? []) { + for (const entry of instruction.entries ?? []) { + const result = entry.content?.itemContent?.tweet_results?.result; + const mapped = this.mapTweetResult(result); + if (mapped) tweets.push(mapped); + } + } + return tweets; + } + + private async fetchTweetDetail( + tweetId: string, + ): Promise< + | { + success: true; + data: { + tweetResult?: { result?: GraphqlTweetResult }; + threaded_conversation_with_injections_v2?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + } + | { success: false; error: string } + > { const variables = { focalTweetId: tweetId, with_rux_injections: false, @@ -206,62 +303,19 @@ export class TwitterClient { if (!response.ok) { const text = await response.text(); - return { - success: false, - error: `HTTP ${response.status}: ${text.slice(0, 200)}`, - }; + return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; } const data = (await response.json()) as { data?: { - tweetResult?: { - result?: { - rest_id?: string; - legacy?: { - full_text?: string; - created_at?: string; - reply_count?: number; - retweet_count?: number; - favorite_count?: number; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - }; - }; + tweetResult?: { result?: GraphqlTweetResult }; threaded_conversation_with_injections_v2?: { 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; - }; - core?: { - user_results?: { - result?: { - legacy?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - }; + result?: GraphqlTweetResult; }; }; }; @@ -269,61 +323,54 @@ export class TwitterClient { }>; }; }; - errors?: Array<{ message: string }>; + errors?: Array<{ message: string; code?: number }>; }; if (data.errors && data.errors.length > 0) { - return { - success: false, - error: data.errors.map((e) => e.message).join(', '), - }; + return { success: false, error: data.errors.map((e) => e.message).join(', ') }; } - // Prefer direct tweetResult if present, otherwise search the conversation thread - const tweetResult = - data.data?.tweetResult?.result ?? - this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId); - - if (!tweetResult) { - return { - success: false, - error: 'Tweet not found in response', - }; - } - - const legacy = tweetResult.legacy; - const userLegacy = tweetResult.core?.user_results?.result?.legacy; - - if (!legacy?.full_text || !userLegacy?.screen_name) { - return { - success: false, - error: 'Incomplete tweet data', - }; - } - - return { - success: true, - tweet: { - id: tweetResult.rest_id || tweetId, - text: legacy.full_text, - author: { - username: userLegacy.screen_name, - name: userLegacy.name || userLegacy.screen_name, - }, - createdAt: legacy.created_at, - replyCount: legacy.reply_count, - retweetCount: legacy.retweet_count, - likeCount: legacy.favorite_count, - }, - }; + return { success: true, data: data.data ?? {} }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return { success: false, error: error instanceof Error ? error.message : String(error) }; } } + /** + * Get tweet details by ID + */ + async getTweet(tweetId: string): Promise { + const response = await this.fetchTweetDetail(tweetId); + if (!response.success) { + return response; + } + + const tweetResult = + (response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ?? + this.findTweetInInstructions( + response.data.threaded_conversation_with_injections_v2?.instructions as + | Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }> + | undefined, + tweetId, + ); + + const mapped = this.mapTweetResult(tweetResult); + if (mapped) { + return { success: true, tweet: mapped }; + } + return { success: false, error: 'Tweet not found in response' }; + } + /** * Post a new tweet */ @@ -635,4 +682,41 @@ export class TwitterClient { }; } } + + /** + * Get replies to a tweet by ID + */ + async getReplies(tweetId: string): Promise { + const response = await this.fetchTweetDetail(tweetId); + if (!response.success) return response; + + const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; + const tweets = this.parseTweetsFromInstructions(instructions); + const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId); + + return { success: true, tweets: replies }; + } + + /** + * Get full conversation thread for a tweet ID + */ + async getThread(tweetId: string): Promise { + const response = await this.fetchTweetDetail(tweetId); + if (!response.success) return response; + + const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; + const tweets = this.parseTweetsFromInstructions(instructions); + + const target = tweets.find((t) => t.id === tweetId); + const rootId = target?.conversationId || tweetId; + const thread = tweets.filter((tweet) => tweet.conversationId === rootId); + + thread.sort((a, b) => { + const aTime = a.createdAt ? Date.parse(a.createdAt) : 0; + const bTime = b.createdAt ? Date.parse(b.createdAt) : 0; + return aTime - bTime; + }); + + return { success: true, tweets: thread }; + } }