chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-01 10:46:10 +01:00
28 changed files with 2302 additions and 176 deletions
+20 -1
View File
@@ -1,12 +1,31 @@
# Changelog # Changelog
## 0.4.1 — Unreleased ## 0.4.2 — Unreleased
### Added
- `likes` command to list your liked tweets (thanks @swairshah).
### Changed
- Query ID updater now tracks the Likes GraphQL operation.
## 0.4.1 — 2025-12-31
### Added
- `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Added ### Added
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml). - Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
### Changed ### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`). - Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
- Query ID updater now tracks the Bookmarks GraphQL operation.
- Lint rules stricter (block statements, no-negation-else, useConst/useTemplate, top-level regex, import extension enforcement).
- `pnpm lint` now runs both Biome and oxlint (type-aware).
### Tests
- Coverage thresholds raised to 90% statements/lines/functions (80% branches).
- Added targeted Twitter client coverage suites.
## 0.4.0 — 2025-12-26 ## 0.4.0 — 2025-12-26
+10 -1
View File
@@ -48,6 +48,13 @@ bird search "from:steipete" -n 5
bird mentions -n 5 bird mentions -n 5
bird mentions --user @steipete -n 5 bird mentions --user @steipete -n 5
# Bookmarks
bird bookmarks -n 5
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
# Likes
bird likes -n 5
# Refresh GraphQL query IDs cache (no rebuild) # Refresh GraphQL query IDs cache (no rebuild)
bird query-ids --fresh bird query-ids --fresh
``` ```
@@ -64,6 +71,8 @@ bird query-ids --fresh
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread. - `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query. - `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
- `bird likes [-n count] [--json]` — list your liked tweets.
- `bird whoami` — print which Twitter account your cookies belong to. - `bird whoami` — print which Twitter account your cookies belong to.
- `bird check` — show which credentials are available and where they were sourced from. - `bird check` — show which credentials are available and where they were sourced from.
@@ -120,7 +129,7 @@ Environment shortcuts:
## Output ## Output
- `--json` prints raw tweet objects for read/replies/thread/search/mentions. - `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks.
- `read` returns full text for Notes and Articles when present. - `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color). - Use `--plain` for stable, script-friendly output (no emoji, no color).
+23 -2
View File
@@ -11,10 +11,31 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"complexity": { "complexity": {
"noForEach": "off" "noForEach": "error"
},
"correctness": {
"useImportExtensions": {
"level": "error",
"options": {
"forceJsExtensions": true
}
}
},
"performance": {
"useTopLevelRegex": "error"
}, },
"style": { "style": {
"noNonNullAssertion": "off" "noNegationElse": "error",
"noNonNullAssertion": "error",
"useBlockStatements": "error",
"useConst": "error",
"useTemplate": "error"
},
"suspicious": {
"noExplicitAny": "error"
},
"nursery": {
"useRegexpExec": "error"
} }
} }
}, },
+11 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "@steipete/bird", "name": "@steipete/bird",
"version": "0.4.0", "version": "0.4.1",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API", "description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -21,14 +21,18 @@
"bird": "pnpm run build:dist && node dist/index.js", "bird": "pnpm run build:dist && node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "biome check .", "lint": "pnpm run lint:biome && pnpm run lint:oxlint",
"lint:fix": "biome check --write .", "lint:biome": "biome check .",
"lint:oxlint": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings src tests scripts",
"lint:fix": "pnpm run lint:biome:fix && pnpm run lint:oxlint:fix",
"lint:biome:fix": "biome check --write .",
"lint:oxlint:fix": "oxlint --type-aware --tsconfig tsconfig.oxlint.json --import-plugin --node-plugin --vitest-plugin --deny-warnings --fix src tests scripts",
"format": "biome format --write .", "format": "biome format --write .",
"binary": "pnpm run build:binary", "binary": "pnpm run build:binary",
"graphql:update": "tsx scripts/update-query-ids.ts" "graphql:update": "tsx scripts/update-query-ids.ts"
}, },
"dependencies": { "dependencies": {
"@steipete/sweet-cookie": "github:steipete/sweet-cookie#path:packages/core", "@steipete/sweet-cookie": "0.1.0",
"commander": "^14.0.2", "commander": "^14.0.2",
"json5": "^2.2.3", "json5": "^2.2.3",
"kleur": "^4.1.5" "kleur": "^4.1.5"
@@ -37,11 +41,13 @@
"@biomejs/biome": "^2.3.10", "@biomejs/biome": "^2.3.10",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@vitest/coverage-v8": "4.0.16", "@vitest/coverage-v8": "4.0.16",
"oxlint": "^1.36.0",
"oxlint-tsgolint": "^0.10.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=22"
} }
} }
+158 -11
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@steipete/sweet-cookie': '@steipete/sweet-cookie':
specifier: github:steipete/sweet-cookie#path:packages/core specifier: 0.1.0
version: https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec#path:packages/core version: 0.1.0
commander: commander:
specifier: ^14.0.2 specifier: ^14.0.2
version: 14.0.2 version: 14.0.2
@@ -30,6 +30,12 @@ importers:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: 4.0.16 specifier: 4.0.16
version: 4.0.16([email protected](@types/[email protected])([email protected])) version: 4.0.16([email protected](@types/[email protected])([email protected]))
oxlint:
specifier: ^1.36.0
version: 1.36.0([email protected])
oxlint-tsgolint:
specifier: ^0.10.0
version: 0.10.0
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@@ -282,6 +288,76 @@ packages:
'@jridgewell/[email protected]': '@jridgewell/[email protected]':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-mhBF/pjey0UdLL1ocU46Fqta+uJuRfqrLfDpcViRg17BtDiUNd8JY9iN2FOoS2HGSCAgCUjZ0AZkwkHwFs/VTw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-roLi34mw/i1z+NS7luboix55SXyhVv38dNUTcRDkk+0lNPzI9ngrM+1y1N2oBSUmz5o9OZGnfJJ7BSGCw/fFEQ==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-HL9NThPH1V2F6l9XhwNmhQZUknN4m4yQYEvQFFGfZTYN6cvEEBIiqfF4KvBUg8c0xadMbQlW+Ug7/ybA9Nn+CA==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-Tw8QNq8ab+4+qE5krvJyMA66v6XE3GoiISRD5WmJ7YOxUnu//jSw/bBm7OYf/TNEZyeV0BTR7zXzhT5R+VFWlQ==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-LTogmTRwpwQqVaH1Ama8Wd5/VVZWBSF8v5qTbeT628+1F5Kt1V5eHBvyFh4oN18UCZlgqrh7DqkDhsieXUaC8Q==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/[email protected]':
resolution: {integrity: sha512-ygqxx8EmNWy9/wCQS5uXq9k/o2EyYNwNxY1ZHNzlmZC/kV06Aemx5OBDafefawBNqH7xTZPfccUrjdiy+QlTrw==}
cpu: [x64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-MJkj82GH+nhvWKJhSIM6KlZ8tyGKdogSQXtNdpIyP02r/tTayFJQaAEWayG2Jhsn93kske+nimg5MYFhwO/rlg==}
cpu: [arm64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-VvEhfkqj/99dCTqOcfkyFXOSbx4lIy5u2m2GHbK4WCMDySokOcMTNRHGw8fH/WgQ5cDrDMSTYIGQTmnBGi9tiQ==}
cpu: [x64]
os: [darwin]
'@oxlint/[email protected]':
resolution: {integrity: sha512-EMx92X5q+hHc3olTuj/kgkx9+yP0p/AVs4yvHbUfzZhBekXNpUWxWvg4hIKmQWn+Ee2j4o80/0ACGO0hDYJ9mg==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-7YCxtrPIctVYLqWrWkk8pahdCxch6PtsaucfMLC7TOlDt4nODhnQd4yzEscKqJ8Gjrw1bF4g+Ngob1gB+Qr9Fw==}
cpu: [arm64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-lnaJVlx5r3NWmoOMesfQXJSf78jHTn8Z+sdAf795Kgteo72+qGC1Uax2SToCJVN2J8PNG3oRV5bLriiCNR2i6Q==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-AhuEU2Qdl66lSfTGu/Htirq8r/8q2YnZoG3yEXLMQWnPMn7efy8spD/N1NA7kH0Hll+cdfwgQkQqC2G4MS2lPQ==}
cpu: [x64]
os: [linux]
'@oxlint/[email protected]':
resolution: {integrity: sha512-GlWCBjUJY2QgvBFuNRkiRJu7K/djLmM0UQKfZV8IN+UXbP/JbjZHWKRdd4LXlQmzoz7M5Hd6p+ElCej8/90FCg==}
cpu: [arm64]
os: [win32]
'@oxlint/[email protected]':
resolution: {integrity: sha512-J+Vc00Utcf8p77lZPruQgb0QnQXuKnFogN88kCnOqs2a83I+vTBB8ILr0+L9sTwVRvIDMSC0pLdeQH4svWGFZg==}
cpu: [x64]
os: [win32]
'@rollup/[email protected]': '@rollup/[email protected]':
resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==}
cpu: [arm] cpu: [arm]
@@ -395,15 +471,9 @@ packages:
'@standard-schema/[email protected]': '@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@steipete/sweet-cookie@https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec#path:packages/core': '@steipete/sweet-cookie@0.1.0':
resolution: {path: packages/core, tarball: https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec} resolution: {integrity: sha512-cF8hS6rTohAFI1NAm2/HM9omBlw6V1DpwF6q5e9FB+l274HjtyirCgCc6QK2Rtuh7uDoqWHshFQZPsceyJuVgw==}
version: 0.0.0
engines: {node: '>=22'} engines: {node: '>=22'}
peerDependencies:
chrome-cookies-secure: ^3.0.0
peerDependenciesMeta:
chrome-cookies-secure:
optional: true
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -567,6 +637,20 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
[email protected]:
resolution: {integrity: sha512-LDDSIu5J/4D4gFUuQQIEQpAC6maNEbMg4nC8JL/+Pe0cUDR86dtVZ09E2x5MwCh8f9yfktoaxt5x6UIVyzrajg==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-IicUdXfXgI8OKrDPnoSjvBfeEF8PkKtm+CoLlg4LYe4ypc8U+T4r7730XYshdBGZdelg+JRw8GtCb2w/KaaZvw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.10.0'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
[email protected]: [email protected]:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -857,6 +941,48 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint-tsgolint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@oxlint/[email protected]':
optional: true
'@rollup/[email protected]': '@rollup/[email protected]':
optional: true optional: true
@@ -925,7 +1051,7 @@ snapshots:
'@standard-schema/[email protected]': {} '@standard-schema/[email protected]': {}
'@steipete/sweet-cookie@https://codeload.github.com/steipete/sweet-cookie/tar.gz/62b5e7815c792674d3414aa8ae1face5992c40ec#path:packages/core': {} '@steipete/sweet-cookie@0.1.0': {}
'@types/[email protected]': '@types/[email protected]':
dependencies: dependencies:
@@ -1111,6 +1237,27 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.10.0
'@oxlint-tsgolint/darwin-x64': 0.10.0
'@oxlint-tsgolint/linux-arm64': 0.10.0
'@oxlint-tsgolint/linux-x64': 0.10.0
'@oxlint-tsgolint/win32-arm64': 0.10.0
'@oxlint-tsgolint/win32-x64': 0.10.0
[email protected]([email protected]):
optionalDependencies:
'@oxlint/darwin-arm64': 1.36.0
'@oxlint/darwin-x64': 1.36.0
'@oxlint/linux-arm64-gnu': 1.36.0
'@oxlint/linux-arm64-musl': 1.36.0
'@oxlint/linux-x64-gnu': 1.36.0
'@oxlint/linux-x64-musl': 1.36.0
'@oxlint/win32-arm64': 1.36.0
'@oxlint/win32-x64': 1.36.0
oxlint-tsgolint: 0.10.0
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+2
View File
@@ -13,6 +13,8 @@ const TARGET_OPERATIONS = [
'FavoriteTweet', 'FavoriteTweet',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'Bookmarks',
'Likes',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
+114 -34
View File
@@ -17,6 +17,7 @@ import JSON5 from 'json5';
import kleur from 'kleur'; import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js'; import { resolveCliInvocation } from './lib/cli-args.js';
import { type CookieSource, resolveCredentials } from './lib/cookies.js'; import { type CookieSource, resolveCredentials } from './lib/cookies.js';
import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js';
import { extractTweetId } from './lib/extract-tweet-id.js'; import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import { import {
@@ -32,16 +33,19 @@ import { runtimeQueryIds } from './lib/runtime-query-ids.js';
import { type TweetData, TwitterClient } from './lib/twitter-client.js'; import { type TweetData, TwitterClient } from './lib/twitter-client.js';
import { getCliVersion } from './lib/version.js'; import { getCliVersion } from './lib/version.js';
const program = new Command(); const program: Command = new Command();
const rawArgs = process.argv.slice(2); const rawArgs: string[] = process.argv.slice(2);
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
const isTty = process.stdout.isTTY; const isTty: boolean = process.stdout.isTTY;
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty); let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
kleur.enabled = output.color; kleur.enabled = output.color;
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text); const wrap =
const collect = (value: string, previous: string[] = []) => { (styler: (text: string) => string): ((text: string) => string) =>
(text: string): string =>
isTty ? styler(text) : text;
const collect = (value: string, previous: string[] = []): string[] => {
previous.push(value); previous.push(value);
return previous; return previous;
}; };
@@ -50,16 +54,22 @@ const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
function parseCookieSource(value: string): CookieSource { function parseCookieSource(value: string): CookieSource {
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized; if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
return normalized;
}
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`); throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
} }
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined { function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
if (typeof input === 'string') return [parseCookieSource(input)]; if (typeof input === 'string') {
return [parseCookieSource(input)];
}
if (Array.isArray(input)) { if (Array.isArray(input)) {
const result: CookieSource[] = []; const result: CookieSource[] = [];
for (const entry of input) { for (const entry of input) {
if (typeof entry !== 'string') continue; if (typeof entry !== 'string') {
continue;
}
result.push(parseCookieSource(entry)); result.push(parseCookieSource(entry));
} }
return result.length > 0 ? result : undefined; return result.length > 0 ? result : undefined;
@@ -67,15 +77,15 @@ function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
return undefined; return undefined;
} }
const collectCookieSource = (value: string, previous: CookieSource[] = []) => { const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
previous.push(parseCookieSource(value)); previous.push(parseCookieSource(value));
return previous; return previous;
}; };
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output); const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output); const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
function applyOutputFromCommand(command: Command) { function applyOutputFromCommand(command: Command): void {
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean }; const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
output = resolveOutputConfigFromCommander(opts, process.env, isTty); output = resolveOutputConfigFromCommander(opts, process.env, isTty);
kleur.enabled = output.color; kleur.enabled = output.color;
@@ -103,7 +113,9 @@ type BirdConfig = {
}; };
function readConfigFile(path: string): Partial<BirdConfig> { function readConfigFile(path: string): Partial<BirdConfig> {
if (!existsSync(path)) return {}; if (!existsSync(path)) {
return {};
}
try { try {
const raw = readFileSync(path, 'utf8'); const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw) as Partial<BirdConfig>; const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
@@ -140,6 +152,7 @@ const KNOWN_COMMANDS = new Set([
'search', 'search',
'mentions', 'mentions',
'bookmarks', 'bookmarks',
'likes',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
@@ -152,7 +165,7 @@ program.addHelpText(
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion()); program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
const formatExample = (command: string, description: string) => const formatExample = (command: string, description: string): string =>
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`; `${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
program.addHelpText( program.addHelpText(
@@ -194,7 +207,7 @@ type CredentialsOptions = {
cookieSource?: CookieSource[]; cookieSource?: CookieSource[];
}; };
function resolveCredentialsFromOptions(opts: CredentialsOptions) { function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
const cookieSource = opts.cookieSource?.length const cookieSource = opts.cookieSource?.length
? opts.cookieSource ? opts.cookieSource
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES); : (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
@@ -215,9 +228,13 @@ type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined { function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) { for (const value of values) {
if (value === undefined || value === null || value === '') continue; if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number(value); const parsed = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed; if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
} }
return undefined; return undefined;
} }
@@ -241,17 +258,31 @@ function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number })
function detectMime(path: string): string | null { function detectMime(path: string): string | null {
const ext = path.toLowerCase(); const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
if (ext.endsWith('.png')) return 'image/png'; return 'image/jpeg';
if (ext.endsWith('.webp')) return 'image/webp'; }
if (ext.endsWith('.gif')) return 'image/gif'; if (ext.endsWith('.png')) {
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4'; return 'image/png';
if (ext.endsWith('.mov')) return 'video/quicktime'; }
if (ext.endsWith('.webp')) {
return 'image/webp';
}
if (ext.endsWith('.gif')) {
return 'image/gif';
}
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
return 'video/mp4';
}
if (ext.endsWith('.mov')) {
return 'video/quicktime';
}
return null; return null;
} }
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] { function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
if (opts.media.length === 0) return []; if (opts.media.length === 0) {
return [];
}
const specs: MediaSpec[] = []; const specs: MediaSpec[] = [];
for (const [index, path] of opts.media.entries()) { for (const [index, path] of opts.media.entries()) {
const mime = detectMime(path); const mime = detectMime(path);
@@ -263,9 +294,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
} }
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length; const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
if (videoCount > 1) throw new Error('Only one video can be attached'); if (videoCount > 1) {
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media'); throw new Error('Only one video can be attached');
if (specs.length > 4) throw new Error('Maximum 4 media attachments'); }
if (videoCount === 1 && specs.length > 1) {
throw new Error('Video cannot be combined with other media');
}
if (specs.length > 4) {
throw new Error('Maximum 4 media attachments');
}
return specs; return specs;
} }
@@ -327,6 +364,7 @@ program
'SearchTimeline', 'SearchTimeline',
'UserArticlesTweets', 'UserArticlesTweets',
'Bookmarks', 'Bookmarks',
'Likes',
]; ];
if (cmdOpts.fresh) { if (cmdOpts.fresh) {
@@ -675,13 +713,13 @@ program
if (!query) { if (!query) {
const who = await client.getCurrentUser(); const who = await client.getCurrentUser();
const handle = normalizeHandle(who.user?.username); const handle = normalizeHandle(who.user?.username);
if (!handle) { if (handle) {
query = `@${handle}`;
} else {
console.error( console.error(
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`, `${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
); );
process.exit(1); process.exit(1);
} else {
query = `@${handle}`;
} }
} }
@@ -700,6 +738,48 @@ program
.command('bookmarks') .command('bookmarks')
.description('Get your bookmarked tweets') .description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20') .option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
if (result.success && result.tweets) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
} else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
// Likes command - get user's liked tweets
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => { .action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
@@ -719,12 +799,12 @@ program
} }
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getBookmarks(count); const result = await client.getLikes(count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' }); printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
} else { } else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`); console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1); process.exit(1);
} }
}); });
+3 -1
View File
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
export function looksLikeTweetInput(value: string): boolean { export function looksLikeTweetInput(value: string): boolean {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return false; if (!trimmed) {
return false;
}
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed); return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
} }
+40 -14
View File
@@ -21,10 +21,12 @@ export type CookieSource = 'safari' | 'chrome' | 'firefox';
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const; const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
const TWITTER_URL = 'https://x.com/'; const TWITTER_URL = 'https://x.com/';
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/']; const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
function normalizeValue(value: unknown): string | null { function normalizeValue(value: unknown): string | null {
if (typeof value !== 'string') return null; if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; return trimmed.length > 0 ? trimmed : null;
} }
@@ -38,24 +40,36 @@ function buildEmpty(): TwitterCookies {
} }
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void { function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
if (cookies[field]) return; if (cookies[field]) {
return;
}
for (const key of keys) { for (const key of keys) {
const value = normalizeValue(process.env[key]); const value = normalizeValue(process.env[key]);
if (!value) continue; if (!value) {
continue;
}
cookies[field] = value; cookies[field] = value;
if (!cookies.source) cookies.source = `env ${key}`; if (!cookies.source) {
cookies.source = `env ${key}`;
}
break; break;
} }
} }
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] { function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
if (Array.isArray(cookieSource)) return cookieSource; if (Array.isArray(cookieSource)) {
if (cookieSource) return [cookieSource]; return cookieSource;
}
if (cookieSource) {
return [cookieSource];
}
return ['safari', 'chrome', 'firefox']; return ['safari', 'chrome', 'firefox'];
} }
function labelForSource(source: CookieSource, profile?: string): string { function labelForSource(source: CookieSource, profile?: string): string {
if (source === 'safari') return 'Safari'; if (source === 'safari') {
return 'Safari';
}
if (source === 'chrome') { if (source === 'chrome') {
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
} }
@@ -67,13 +81,19 @@ function pickCookieValue(
name: (typeof TWITTER_COOKIE_NAMES)[number], name: (typeof TWITTER_COOKIE_NAMES)[number],
): string | null { ): string | null {
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string'); const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
if (matches.length === 0) return null; if (matches.length === 0) {
return null;
}
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com')); const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
if (preferred?.value) return preferred.value; if (preferred?.value) {
return preferred.value;
}
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com')); const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
if (twitter?.value) return twitter.value; if (twitter?.value) {
return twitter.value;
}
return matches[0]?.value ?? null; return matches[0]?.value ?? null;
} }
@@ -99,8 +119,12 @@ async function readTwitterCookiesFromBrowser(options: {
const authToken = pickCookieValue(cookies, 'auth_token'); const authToken = pickCookieValue(cookies, 'auth_token');
const ct0 = pickCookieValue(cookies, 'ct0'); const ct0 = pickCookieValue(cookies, 'ct0');
if (authToken) out.authToken = authToken; if (authToken) {
if (ct0) out.ct0 = ct0; out.authToken = authToken;
}
if (ct0) {
out.ct0 = ct0;
}
if (out.authToken && out.ct0) { if (out.authToken && out.ct0) {
out.cookieHeader = cookieHeader(out.authToken, out.ct0); out.cookieHeader = cookieHeader(out.authToken, out.ct0);
@@ -156,7 +180,9 @@ export async function resolveCredentials(options: {
} }
if (options.ct0) { if (options.ct0) {
cookies.ct0 = options.ct0; cookies.ct0 = options.ct0;
if (!cookies.source) cookies.source = 'CLI argument'; if (!cookies.source) {
cookies.source = 'CLI argument';
}
} }
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken'); readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
+20
View File
@@ -0,0 +1,20 @@
/**
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
*/
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
export function extractBookmarkFolderId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+3 -1
View File
@@ -1,9 +1,11 @@
/** /**
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID. * Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
*/ */
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
export function extractTweetId(input: string): string { export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID // If it's a URL, extract the tweet ID
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/); const urlMatch = TWEET_URL_REGEX.exec(input);
if (urlMatch) { if (urlMatch) {
return urlMatch[1]; return urlMatch[1];
} }
+14 -4
View File
@@ -1,13 +1,21 @@
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
export function normalizeHandle(input?: string | null): string | null { export function normalizeHandle(input?: string | null): string | null {
const raw = (input ?? '').trim(); const raw = (input ?? '').trim();
if (!raw) return null; if (!raw) {
return null;
}
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw; const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
const handle = withoutAt.trim(); const handle = withoutAt.trim();
if (!handle) return null; if (!handle) {
return null;
}
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries. // X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null; if (!HANDLE_REGEX.test(handle)) {
return null;
}
return handle; return handle;
} }
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
query: string | null; query: string | null;
error: string | null; error: string | null;
} { } {
if (typeof userOption === 'undefined') return { query: null, error: null }; if (typeof userOption === 'undefined') {
return { query: null, error: null };
}
const handle = normalizeHandle(userOption); const handle = normalizeHandle(userOption);
if (!handle) { if (!handle) {
+18 -6
View File
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
} }
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string { export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
if (cfg.plain) return `${STATUS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${STATUS[kind].emoji} `; return `${STATUS[kind].plain} `;
}
if (cfg.emoji) {
return `${STATUS[kind].emoji} `;
}
return `${STATUS[kind].text} `; return `${STATUS[kind].text} `;
} }
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string { export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
if (cfg.plain) return `${LABELS[kind].plain} `; if (cfg.plain) {
if (cfg.emoji) return `${LABELS[kind].emoji} `; return `${LABELS[kind].plain} `;
}
if (cfg.emoji) {
return `${LABELS[kind].emoji} `;
}
return `${LABELS[kind].text} `; return `${LABELS[kind].text} `;
} }
@@ -72,8 +80,12 @@ export function formatStatsLine(
const retweetCount = stats.retweetCount ?? 0; const retweetCount = stats.retweetCount ?? 0;
const replyCount = stats.replyCount ?? 0; const replyCount = stats.replyCount ?? 0;
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`; if (cfg.plain) {
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`; return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
}
if (!cfg.emoji) {
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
}
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`; return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
} }
+2 -1
View File
@@ -3,5 +3,6 @@
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA", "TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ" "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Likes": "JR2gceKucIKcVNB_9JkhsA"
} }
+59 -21
View File
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json'; const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
const DISCOVERY_PAGES = [ const DISCOVERY_PAGES: string[] = [
'https://x.com/?lang=en', 'https://x.com/?lang=en',
'https://x.com/explore', 'https://x.com/explore',
'https://x.com/notifications', 'https://x.com/notifications',
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
]; ];
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g; const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
const OPERATION_PATTERNS = [ const OPERATION_PATTERNS = [
{ {
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}; };
export type RuntimeQueryIdStore = {
cachePath: string;
ttlMs: number;
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
getQueryId: (operationName: string) => Promise<string | null>;
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
clearMemory: () => void;
};
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> { async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
const response = await fetchImpl(url, { headers: HEADERS }); const response = await fetchImpl(url, { headers: HEADERS });
if (!response.ok) { if (!response.ok) {
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
} }
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null { function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') {
return null;
}
const record = raw as Record<string, unknown>; const record = raw as Record<string, unknown>;
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null; const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null; const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
const discovery = const discovery =
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null; record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
if (!fetchedAt || !ttlMs || !ids || !discovery) return null; if (!fetchedAt || !ttlMs || !ids || !discovery) {
return null;
}
const pages = Array.isArray(discovery.pages) ? discovery.pages : null; const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null; const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
if (!pages || !bundles) return null; if (!pages || !bundles) {
return null;
}
const normalizedIds: Record<string, string> = {}; const normalizedIds: Record<string, string> = {};
for (const [key, value] of Object.entries(ids)) { for (const [key, value] of Object.entries(ids)) {
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
try { try {
const raw = await readFile(cachePath, 'utf8'); const raw = await readFile(cachePath, 'utf8');
return parseSnapshot(JSON.parse(raw)); return parseSnapshot(JSON.parse(raw));
} catch (error) { } catch {
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
if (code !== 'ENOENT') return null;
return null; return null;
} }
} }
@@ -161,15 +175,27 @@ function extractOperations(
pattern.regex.lastIndex = 0; pattern.regex.lastIndex = 0;
while (true) { while (true) {
const match = pattern.regex.exec(bundleContents); const match = pattern.regex.exec(bundleContents);
if (match === null) break; if (match === null) {
break;
}
const operationName = match[pattern.operationGroup]; const operationName = match[pattern.operationGroup];
const queryId = match[pattern.queryIdGroup]; const queryId = match[pattern.queryIdGroup];
if (!operationName || !queryId) continue; if (!operationName || !queryId) {
if (!targets.has(operationName)) continue; continue;
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue; }
if (discovered.has(operationName)) continue; if (!targets.has(operationName)) {
continue;
}
if (!QUERY_ID_REGEX.test(queryId)) {
continue;
}
if (discovered.has(operationName)) {
continue;
}
discovered.set(operationName, { queryId, bundle: bundleLabel }); discovered.set(operationName, { queryId, bundle: bundleLabel });
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
} }
} }
} }
@@ -186,7 +212,9 @@ async function fetchAndExtract(
const chunk = bundleUrls.slice(i, i + CONCURRENCY); const chunk = bundleUrls.slice(i, i + CONCURRENCY);
await Promise.all( await Promise.all(
chunk.map(async (url) => { chunk.map(async (url) => {
if (discovered.size === targets.size) return; if (discovered.size === targets.size) {
return;
}
const label = url.split('/').at(-1) ?? url; const label = url.split('/').at(-1) ?? url;
try { try {
const js = await fetchText(fetchImpl, url); const js = await fetchText(fetchImpl, url);
@@ -196,13 +224,15 @@ async function fetchAndExtract(
} }
}), }),
); );
if (discovered.size === targets.size) break; if (discovered.size === targets.size) {
break;
}
} }
return discovered; return discovered;
} }
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) { export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath(); const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null; let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => { const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
if (memorySnapshot) return memorySnapshot; if (memorySnapshot) {
return memorySnapshot;
}
if (!loadOnce) { if (!loadOnce) {
loadOnce = (async () => { loadOnce = (async () => {
const fromDisk = await readSnapshotFromDisk(cachePath); const fromDisk = await readSnapshotFromDisk(cachePath);
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => { const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
const snapshot = await loadSnapshot(); const snapshot = await loadSnapshot();
if (!snapshot) return null; if (!snapshot) {
return null;
}
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime(); const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY; const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs; const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
const getQueryId = async (operationName: string): Promise<string | null> => { const getQueryId = async (operationName: string): Promise<string | null> => {
const info = await getSnapshotInfo(); const info = await getSnapshotInfo();
if (!info) return null; if (!info) {
return null;
}
return info.snapshot.ids[operationName] ?? null; return info.snapshot.ids[operationName] ?? null;
}; };
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
operationNames: string[], operationNames: string[],
opts: { force?: boolean } = {}, opts: { force?: boolean } = {},
): Promise<RuntimeQueryIdSnapshotInfo | null> => { ): Promise<RuntimeQueryIdSnapshotInfo | null> => {
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) {
return refreshInFlight;
}
refreshInFlight = (async () => { refreshInFlight = (async () => {
const current = await getSnapshotInfo(); const current = await getSnapshotInfo();
+359 -45
View File
@@ -4,6 +4,7 @@
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import type { TwitterCookies } from './cookies.js'; import type { TwitterCookies } from './cookies.js';
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import queryIds from './query-ids.json' with { type: 'json' }; import queryIds from './query-ids.json' with { type: 'json' };
import { runtimeQueryIds } from './runtime-query-ids.js'; import { runtimeQueryIds } from './runtime-query-ids.js';
@@ -12,6 +13,9 @@ const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json'; const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json'; const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json'; const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
// Query IDs rotate frequently; the values in query-ids.json are refreshed by // 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 // scripts/update-query-ids.ts. The fallback values keep the client usable if
@@ -24,6 +28,8 @@ const FALLBACK_QUERY_IDS = {
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
} as const; } as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS; type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -356,7 +362,9 @@ export class TwitterClient {
| undefined, | undefined,
tweetId: string, tweetId: string,
) { ) {
if (!instructions) return undefined; if (!instructions) {
return undefined;
}
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries || []) { for (const entry of instruction.entries || []) {
@@ -418,10 +426,14 @@ export class TwitterClient {
private mediaCategoryForMime(mimeType: string): string | null { private mediaCategoryForMime(mimeType: string): string | null {
if (mimeType.startsWith('image/')) { if (mimeType.startsWith('image/')) {
if (mimeType === 'image/gif') return 'tweet_gif'; if (mimeType === 'image/gif') {
return 'tweet_gif';
}
return 'tweet_image'; return 'tweet_image';
} }
if (mimeType.startsWith('video/')) return 'tweet_video'; if (mimeType.startsWith('video/')) {
return 'tweet_video';
}
return null; return null;
} }
@@ -431,7 +443,9 @@ export class TwitterClient {
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> { async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
const category = this.mediaCategoryForMime(input.mimeType); const category = this.mediaCategoryForMime(input.mimeType);
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` }; if (!category) {
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
}
try { try {
const initParams = new URLSearchParams({ const initParams = new URLSearchParams({
@@ -459,7 +473,9 @@ export class TwitterClient {
: initBody.media_id !== undefined : initBody.media_id !== undefined
? String(initBody.media_id) ? String(initBody.media_id)
: undefined; : undefined;
if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' }; if (!mediaId) {
return { success: false, error: 'Media upload INIT did not return media_id' };
}
const chunkSize = 5 * 1024 * 1024; const chunkSize = 5 * 1024 * 1024;
let segmentIndex = 0; let segmentIndex = 0;
@@ -515,7 +531,10 @@ export class TwitterClient {
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2; const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
await this.sleep(delaySecs * 1000); await this.sleep(delaySecs * 1000);
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ command: 'STATUS', media_id: mediaId })}`; const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
command: 'STATUS',
media_id: mediaId,
}).toString()}`;
const statusResp = await this.fetchWithTimeout(statusUrl, { const statusResp = await this.fetchWithTimeout(statusUrl, {
method: 'GET', method: 'GET',
headers: this.getUploadHeaders(), headers: this.getUploadHeaders(),
@@ -533,11 +552,15 @@ export class TwitterClient {
error?: { message?: string; name?: string }; error?: { message?: string; name?: string };
}; };
}; };
if (!statusBody.processing_info) break; if (!statusBody.processing_info) {
break;
}
info.state = statusBody.processing_info.state; info.state = statusBody.processing_info.state;
info.check_after_secs = statusBody.processing_info.check_after_secs; info.check_after_secs = statusBody.processing_info.check_after_secs;
info.error = statusBody.processing_info.error; info.error = statusBody.processing_info.error;
if (info.state === 'succeeded') break; if (info.state === 'succeeded') {
break;
}
attempts += 1; attempts += 1;
} }
} }
@@ -564,15 +587,21 @@ export class TwitterClient {
for (const value of values) { for (const value of values) {
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed) return trimmed; if (trimmed) {
return trimmed;
}
} }
} }
return undefined; return undefined;
} }
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void { private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) return; if (!value) {
if (typeof value === 'string') return; return;
}
if (typeof value === 'string') {
return;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const item of value) { for (const item of value) {
@@ -586,7 +615,9 @@ export class TwitterClient {
if (keys.has(key)) { if (keys.has(key)) {
if (typeof nested === 'string') { if (typeof nested === 'string') {
const trimmed = nested.trim(); const trimmed = nested.trim();
if (trimmed) output.push(trimmed); if (trimmed) {
output.push(trimmed);
}
continue; continue;
} }
} }
@@ -599,7 +630,9 @@ export class TwitterClient {
const seen = new Set<string>(); const seen = new Set<string>();
const result: string[] = []; const result: string[] = [];
for (const value of values) { for (const value of values) {
if (seen.has(value)) continue; if (seen.has(value)) {
continue;
}
seen.add(value); seen.add(value);
result.push(value); result.push(value);
} }
@@ -608,7 +641,9 @@ export class TwitterClient {
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined { private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article; const article = result?.article;
if (!article) return undefined; if (!article) {
return undefined;
}
const articleResult = article.article_results?.result ?? article; const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') { if (process.env.BIRD_DEBUG_ARTICLE === '1') {
@@ -673,7 +708,9 @@ export class TwitterClient {
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined { private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result; const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) return undefined; if (!note) {
return undefined;
}
return this.firstText( return this.firstText(
note.text, note.text,
@@ -704,10 +741,14 @@ export class TwitterClient {
const username = userLegacy?.screen_name ?? userCore?.screen_name; const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username; const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id; const userId = userResult?.rest_id;
if (!result?.rest_id || !username) return undefined; if (!result?.rest_id || !username) {
return undefined;
}
const text = this.extractTweetText(result); const text = this.extractTweetText(result);
if (!text) return undefined; if (!text) {
return undefined;
}
let quotedTweet: TweetData | undefined; let quotedTweet: TweetData | undefined;
if (quoteDepth > 0) { if (quoteDepth > 0) {
@@ -774,7 +815,9 @@ export class TwitterClient {
}): GraphqlTweetResult[] { }): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = []; const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => { const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) results.push(result); if (result?.rest_id) {
results.push(result);
}
}; };
const content = entry.content; const content = entry.content;
@@ -841,7 +884,9 @@ export class TwitterClient {
const results = this.collectTweetResultsFromEntry(entry); const results = this.collectTweetResultsFromEntry(entry);
for (const result of results) { for (const result of results) {
const mapped = this.mapTweetResult(result); const mapped = this.mapTweetResult(result);
if (!mapped || seen.has(mapped.id)) continue; if (!mapped || seen.has(mapped.id)) {
continue;
}
seen.add(mapped.id); seen.add(mapped.id);
tweets.push(mapped); tweets.push(mapped);
} }
@@ -988,7 +1033,7 @@ export class TwitterClient {
}); });
const queryId = await this.getQueryId('UserArticlesTweets'); const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() }); const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
@@ -1022,7 +1067,9 @@ export class TwitterClient {
for (const instruction of instructions) { for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) { for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result; const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) continue; if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result; const articleResult = result.article?.article_results?.result;
const title = this.firstText(articleResult?.title, result.article?.title); const title = this.firstText(articleResult?.title, result.article?.title);
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text); const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
@@ -1131,7 +1178,7 @@ export class TwitterClient {
const queryIds = await this.getTweetDetailQueryIds(); const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
method: 'GET', method: 'GET',
headers: this.getHeaders(), headers: this.getHeaders(),
@@ -1384,12 +1431,16 @@ export class TwitterClient {
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables); const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) return fallback; if (fallback) {
return fallback;
}
return { success: false, error: this.formatErrors(data.errors) }; return { success: false, error: this.formatErrors(data.errors) };
} }
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id; const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
if (tweetId) return { success: true, tweetId }; if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' }; return { success: false, error: 'Tweet created but no ID returned' };
} }
@@ -1407,7 +1458,9 @@ export class TwitterClient {
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
const fallback = await this.tryStatusUpdateFallback(data.errors, variables); const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
if (fallback) return fallback; if (fallback) {
return fallback;
}
return { return {
success: false, success: false,
error: this.formatErrors(data.errors), error: this.formatErrors(data.errors),
@@ -1446,7 +1499,9 @@ export class TwitterClient {
mediaIds?: string[]; mediaIds?: string[];
} | null { } | null {
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null; const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
if (!text) return null; if (!text) {
return null;
}
const reply = variables.reply; const reply = variables.reply;
const inReplyToTweetId = const inReplyToTweetId =
@@ -1518,7 +1573,9 @@ export class TwitterClient {
const tweetId = const tweetId =
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined; typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
if (tweetId) return { success: true, tweetId }; if (tweetId) {
return { success: true, tweetId };
}
return { success: false, error: 'Tweet created but no ID returned' }; return { success: false, error: 'Tweet created but no ID returned' };
} catch (error) { } catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }; return { success: false, error: error instanceof Error ? error.message : String(error) };
@@ -1529,12 +1586,18 @@ export class TwitterClient {
errors: Array<{ message: string; code?: number }>, errors: Array<{ message: string; code?: number }>,
variables: Record<string, unknown>, variables: Record<string, unknown>,
): Promise<TweetResult | null> { ): Promise<TweetResult | null> {
if (!errors.some((error) => error.code === 226)) return null; if (!errors.some((error) => error.code === 226)) {
return null;
}
const input = this.statusUpdateInputFromCreateTweetVariables(variables); const input = this.statusUpdateInputFromCreateTweetVariables(variables);
if (!input) return null; if (!input) {
return null;
}
const fallback = await this.postStatusUpdate(input); const fallback = await this.postStatusUpdate(input);
if (fallback.success) return fallback; if (fallback.success) {
return fallback;
}
return { return {
success: false, success: false,
@@ -1543,8 +1606,12 @@ export class TwitterClient {
} }
private async ensureClientUserId(): Promise<void> { private async ensureClientUserId(): Promise<void> {
if (process.env.NODE_ENV === 'test') return; if (process.env.NODE_ENV === 'test') {
if (this.clientUserId) return; return;
}
if (this.clientUserId) {
return;
}
const result = await this.getCurrentUser(); const result = await this.getCurrentUser();
if (result.success && result.user?.id) { if (result.success && result.user?.id) {
this.clientUserId = result.user.id; this.clientUserId = result.user.id;
@@ -1574,7 +1641,7 @@ export class TwitterClient {
const queryIds = await this.getSearchTimelineQueryIds(); const queryIds = await this.getSearchTimelineQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
@@ -1654,12 +1721,16 @@ export class TwitterClient {
}; };
const firstAttempt = await tryOnce(); const firstAttempt = await tryOnce();
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) { if (firstAttempt.had404) {
await this.refreshQueryIds(); await this.refreshQueryIds();
const secondAttempt = await tryOnce(); const secondAttempt = await tryOnce();
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error }; return { success: false, error: secondAttempt.error };
} }
@@ -1761,9 +1832,9 @@ export class TwitterClient {
} }
const html = await response.text(); const html = await response.text();
const usernameMatch = html.match(/"screen_name":"([^"]+)"/); const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/); const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/); const nameMatch = SETTINGS_NAME_REGEX.exec(html);
const username = usernameMatch?.[1]; const username = usernameMatch?.[1];
const userId = idMatch?.[1]; const userId = idMatch?.[1];
@@ -1797,7 +1868,9 @@ export class TwitterClient {
*/ */
async getReplies(tweetId: string): Promise<SearchResult> { async getReplies(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId); const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response; if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions); const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1811,7 +1884,9 @@ export class TwitterClient {
*/ */
async getThread(tweetId: string): Promise<SearchResult> { async getThread(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId); const response = await this.fetchTweetDetail(tweetId);
if (!response.success) return response; if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions; const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions); const tweets = this.parseTweetsFromInstructions(instructions);
@@ -1829,10 +1904,10 @@ export class TwitterClient {
return { success: true, tweets: thread }; return { success: true, tweets: thread };
} }
private buildBookmarksFeatures(): Record<string, boolean> { // Shared timeline features for likes/bookmarks-style timelines.
private buildTimelineFeatures(): Record<string, boolean> {
return { return {
...this.buildSearchFeatures(), ...this.buildSearchFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
blue_business_profile_image_shape_enabled: true, blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false, responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true, tweetypie_unmention_optimization_enabled: true,
@@ -1844,11 +1919,27 @@ export class TwitterClient {
}; };
} }
private buildBookmarksFeatures(): Record<string, boolean> {
return {
...this.buildTimelineFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
};
}
private buildLikesFeatures(): Record<string, boolean> {
return this.buildTimelineFeatures();
}
private async getBookmarksQueryIds(): Promise<string[]> { private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks'); const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
} }
private async getBookmarkFolderQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('BookmarkFolderTimeline');
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
}
/** /**
* Get the authenticated user's bookmarks * Get the authenticated user's bookmarks
*/ */
@@ -1874,7 +1965,7 @@ export class TwitterClient {
const queryIds = await this.getBookmarksQueryIds(); const queryIds = await this.getBookmarksQueryIds();
for (const queryId of queryIds) { for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params}`; const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try { try {
const response = await this.fetchWithTimeout(url, { const response = await this.fetchWithTimeout(url, {
@@ -1931,12 +2022,235 @@ export class TwitterClient {
}; };
const firstAttempt = await tryOnce(); const firstAttempt = await tryOnce();
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) { if (firstAttempt.had404) {
await this.refreshQueryIds(); await this.refreshQueryIds();
const secondAttempt = await tryOnce(); const secondAttempt = await tryOnce();
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20): Promise<SearchResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
};
const features = this.buildLikesFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getLikesQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
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?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
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?.user?.result?.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);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', 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: firstAttempt.error };
}
/**
* Get the authenticated user's bookmark folder timeline
*/
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
const variablesWithCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
count,
};
const variablesWithoutCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
};
const features = this.buildBookmarksFeatures();
const tryOnce = async (variables: Record<string, unknown>) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
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?: {
bookmark_collection_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
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?.bookmark_collection_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);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
};
let firstAttempt = await tryOnce(variablesWithCount);
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
firstAttempt = await tryOnce(variablesWithoutCount);
}
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
let secondAttempt = await tryOnce(variablesWithCount);
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
secondAttempt = await tryOnce(variablesWithoutCount);
}
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error }; return { success: false, error: secondAttempt.error };
} }
+46 -17
View File
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
declare const __dirname: string | undefined; declare const __dirname: string | undefined;
export const FALLBACK_VERSION = 'unknown'; export const FALLBACK_VERSION = 'unknown';
const REF_PREFIX_REGEX = /^ref:\s*/i;
const LINE_SPLIT_REGEX = /\r?\n/;
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
function readPackageVersionFromJsonFile(candidate: string): string | null { function readPackageVersionFromJsonFile(candidate: string): string | null {
try { try {
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
} }
} }
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname; if (typeof __dirname === 'string' && __dirname.length > 0) {
return __dirname;
}
return process.cwd(); return process.cwd();
} }
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
? process.env.BIRD_VERSION.trim() ? process.env.BIRD_VERSION.trim()
: ''; : '';
if (injected.length > 0) return injected; if (injected.length > 0) {
return injected;
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
const version = const version =
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ?? readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
readVersionFromTextFile(path.join(dir, 'VERSION')); readVersionFromTextFile(path.join(dir, 'VERSION'));
if (version) return version; if (version) {
return version;
}
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
return FALLBACK_VERSION; return FALLBACK_VERSION;
} }
function truncateSha(sha: string, length = 8): string { function truncateSha(sha: string, length: number = 8): string {
const trimmed = sha.trim(); const trimmed = sha.trim();
if (!trimmed) return ''; if (!trimmed) {
if (trimmed.length <= length) return trimmed; return '';
}
if (trimmed.length <= length) {
return trimmed;
}
return trimmed.slice(0, length); return trimmed.slice(0, length);
} }
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
return null; return null;
} }
if (!head) return null; if (!head) {
return null;
}
if (!head.startsWith('ref:')) { if (!head.startsWith('ref:')) {
const sha = truncateSha(head); const sha = truncateSha(head);
return sha.length > 0 ? sha : null; return sha.length > 0 ? sha : null;
} }
const ref = head.replace(/^ref:\s*/i, '').trim(); const ref = head.replace(REF_PREFIX_REGEX, '').trim();
if (!ref) return null; if (!ref) {
return null;
}
const refPath = path.join(gitDir, ref); const refPath = path.join(gitDir, ref);
try { try {
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
const packedRefsPath = path.join(gitDir, 'packed-refs'); const packedRefsPath = path.join(gitDir, 'packed-refs');
try { try {
const packed = fs.readFileSync(packedRefsPath, 'utf8'); const packed = fs.readFileSync(packedRefsPath, 'utf8');
const lines = packed.split(/\r?\n/); const lines = packed.split(LINE_SPLIT_REGEX);
for (const line of lines) { for (const line of lines) {
if (!line || line.startsWith('#') || line.startsWith('^')) continue; if (!line || line.startsWith('#') || line.startsWith('^')) {
continue;
}
const [shaRaw, refName] = line.split(' '); const [shaRaw, refName] = line.split(' ');
if (refName?.trim() === ref) { if (refName?.trim() === ref) {
const sha = truncateSha(shaRaw ?? ''); const sha = truncateSha(shaRaw ?? '');
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string' typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
? process.env.BIRD_GIT_SHA.trim() ? process.env.BIRD_GIT_SHA.trim()
: ''; : '';
if (injected.length > 0) return truncateSha(injected); if (injected.length > 0) {
return truncateSha(injected);
}
let dir = resolveStartDir(importMetaUrl); let dir = resolveStartDir(importMetaUrl);
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
const stat = fs.statSync(dotGit); const stat = fs.statSync(dotGit);
if (stat.isDirectory()) { if (stat.isDirectory()) {
const sha = resolveGitShaFromGitDir(dotGit); const sha = resolveGitShaFromGitDir(dotGit);
if (sha) return sha; if (sha) {
return sha;
}
} else if (stat.isFile()) { } else if (stat.isFile()) {
const txt = fs.readFileSync(dotGit, 'utf8'); const txt = fs.readFileSync(dotGit, 'utf8');
const match = txt.match(/gitdir:\s*(.+)\s*$/i); const match = GITDIR_REGEX.exec(txt);
const gitDir = match?.[1]?.trim(); const gitDir = match?.[1]?.trim();
if (gitDir) { if (gitDir) {
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir); const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
const sha = resolveGitShaFromGitDir(resolved); const sha = resolveGitShaFromGitDir(resolved);
if (sha) return sha; if (sha) {
return sha;
}
} }
} }
} catch { } catch {
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
} }
const parent = path.dirname(dir); const parent = path.dirname(dir);
if (parent === dir) break; if (parent === dir) {
break;
}
dir = parent; dir = parent;
} }
+27
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
import { extractTweetId } from '../src/lib/extract-tweet-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => { describe('CLI utilities', () => {
@@ -29,4 +30,30 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
}); });
describe('extractBookmarkFolderId', () => {
it('should extract ID from x.com bookmarks URL', () => {
const url = 'https://x.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should extract ID from twitter.com bookmarks URL', () => {
const url = 'https://twitter.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should accept a numeric ID as-is', () => {
const id = '1976792203235119344';
expect(extractBookmarkFolderId(id)).toBe('1976792203235119344');
});
it('should return null for invalid values', () => {
expect(extractBookmarkFolderId('not-an-id')).toBeNull();
});
it('should return null for folder_id query URLs', () => {
const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344';
expect(extractBookmarkFolderId(url)).toBeNull();
});
});
}); });
+63 -5
View File
@@ -18,8 +18,6 @@ vi.mock('@steipete/sweet-cookie', () => ({
}), }),
})); }));
const itIfDarwin = process.platform === 'darwin' ? it : it.skip;
describe('cookies', () => { describe('cookies', () => {
const originalEnv = process.env; const originalEnv = process.env;
@@ -63,7 +61,10 @@ describe('cookies', () => {
expect(result.cookies.source).toContain('Firefox'); expect(result.cookies.source).toContain('Firefox');
}); });
itIfDarwin('honors cookieSource=safari', async () => { it('honors cookieSource=safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', { sweet.results.set('safari', {
cookies: [ cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
@@ -191,10 +192,64 @@ describe('cookies', () => {
expect(result.cookies.ct0).toBe('test_ct0'); expect(result.cookies.ct0).toBe('test_ct0');
expect(result.cookies.source).toContain('Chrome'); expect(result.cookies.source).toContain('Chrome');
}); });
it('uses default browser order when cookieSource is omitted', async () => {
sweet.results.set('safari', { cookies: [], warnings: [] });
sweet.results.set('chrome', { cookies: [], warnings: [] });
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'firefox_auth', domain: 'x.com' },
{ name: 'ct0', value: 'firefox_ct0', domain: 'x.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({});
expect(result.cookies.authToken).toBe('firefox_auth');
expect(result.cookies.ct0).toBe('firefox_ct0');
expect(result.cookies.source).toContain('Firefox');
});
it('prefers twitter.com cookies when x.com is missing', async () => {
sweet.results.set('chrome', {
cookies: [
{ name: 'auth_token', value: 'twitter_auth', domain: 'twitter.com' },
{ name: 'ct0', value: 'twitter_ct0', domain: 'twitter.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'chrome' });
expect(result.cookies.authToken).toBe('twitter_auth');
expect(result.cookies.ct0).toBe('twitter_ct0');
});
it('falls back to the first cookie when no domain matches', async () => {
sweet.results.set('firefox', {
cookies: [
{ name: 'auth_token', value: 'first_auth', domain: 'example.com' },
{ name: 'ct0', value: 'first_ct0', domain: 'example.com' },
],
warnings: [],
});
const { resolveCredentials } = await import('../src/lib/cookies.js');
const result = await resolveCredentials({ cookieSource: 'firefox' });
expect(result.cookies.authToken).toBe('first_auth');
expect(result.cookies.ct0).toBe('first_ct0');
});
}); });
describe('extractCookiesFromSafari', () => { describe('extractCookiesFromSafari', () => {
itIfDarwin('returns cookies from Safari', async () => { it('returns cookies from Safari', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', { sweet.results.set('safari', {
cookies: [ cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
@@ -211,7 +266,10 @@ describe('cookies', () => {
expect(result.cookies.source).toBe('Safari'); expect(result.cookies.source).toBe('Safari');
}); });
itIfDarwin('prefers Safari over Chrome when both are available', async () => { it('prefers Safari over Chrome when both are available', async () => {
if (process.platform !== 'darwin') {
return;
}
sweet.results.set('safari', { sweet.results.set('safari', {
cookies: [ cookies: [
{ name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, { name: 'auth_token', value: 'safari_auth', domain: 'x.com' },
+3 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../src/lib/normalize-handle.js';
const INVALID_HANDLE_REGEX = /Invalid --user handle/;
describe('normalizeHandle', () => { describe('normalizeHandle', () => {
it('accepts bare handle', () => { it('accepts bare handle', () => {
expect(normalizeHandle('steipete')).toBe('steipete'); expect(normalizeHandle('steipete')).toBe('steipete');
@@ -36,6 +38,6 @@ describe('mentionsQueryFromUserOption', () => {
it('returns error for invalid handle', () => { it('returns error for invalid handle', () => {
const result = mentionsQueryFromUserOption('@stei-pete'); const result = mentionsQueryFromUserOption('@stei-pete');
expect(result.query).toBeNull(); expect(result.query).toBeNull();
expect(result.error).toMatch(/Invalid --user handle/); expect(result.error).toMatch(INVALID_HANDLE_REGEX);
}); });
}); });
+81 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { mkdir, readFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { createRuntimeQueryIdStore } from '../src/lib/runtime-query-ids.js';
describe('runtime-query-ids', () => { describe('runtime-query-ids', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
delete process.env.BIRD_QUERY_IDS_CACHE;
}); });
it('refreshes IDs by scanning discovered bundles and persists cache', async () => { it('refreshes IDs by scanning discovered bundles and persists cache', async () => {
@@ -60,4 +61,83 @@ describe('runtime-query-ids', () => {
const parsed = JSON.parse(raw) as { ids?: Record<string, string> }; const parsed = JSON.parse(raw) as { ids?: Record<string, string> };
expect(parsed.ids?.SearchTimeline).toBe('EEE'); expect(parsed.ids?.SearchTimeline).toBe('EEE');
}); });
it('uses env cache path override and returns fresh snapshot without refreshing', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids-cache.json');
process.env.BIRD_QUERY_IDS_CACHE = cachePath;
const snapshot = {
fetchedAt: new Date().toISOString(),
ttlMs: 60_000,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const fetchMock = vi.fn();
const store = createRuntimeQueryIdStore({ fetchImpl: fetchMock as unknown as typeof fetch });
const info = await store.refresh(['CreateTweet']);
expect(store.cachePath).toBe(cachePath);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
expect(fetchMock).not.toHaveBeenCalled();
store.clearMemory();
});
it('returns current snapshot when refresh finds no matching operations', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const snapshot = {
fetchedAt: new Date(0).toISOString(),
ttlMs: 1,
ids: { CreateTweet: 'AAA' },
discovery: { pages: ['https://x.com/'], bundles: ['main.test.js'] },
};
await writeFile(cachePath, JSON.stringify(snapshot), 'utf8');
const html = `<script src="https://abs.twimg.com/responsive-web/client-web/main.test.js"></script>`;
const js = 'console.log("no ops here")';
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 });
});
const store = createRuntimeQueryIdStore({
cachePath,
ttlMs: 1,
fetchImpl: fetchMock as unknown as typeof fetch,
});
const info = await store.refresh(['CreateTweet']);
expect(info?.snapshot.ids.CreateTweet).toBe('AAA');
});
it('throws when no bundles can be discovered', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'query-ids.json');
const fetchMock = vi.fn(async () => new Response('nope', { status: 500 }));
const store = createRuntimeQueryIdStore({
cachePath,
fetchImpl: fetchMock as unknown as typeof fetch,
});
await expect(store.refresh(['CreateTweet'], { force: true })).rejects.toThrow(
'No client bundles discovered; x.com layout may have changed.',
);
});
}); });
+360
View File
@@ -0,0 +1,360 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>;
getLikesQueryIds: () => Promise<string[]>;
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getBookmarkFolderQueryIds: () => Promise<string[]>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient API coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('createTweet fallback paths', () => {
it('returns an error when retry response is not ok', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'boom' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors when retry response contains errors', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'rate limited', code: 1 }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('rate limited');
});
it('returns an error when retry response has no tweet id', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ data: { create_tweet: { tweet_results: { result: {} } } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.tweet('hi');
expect(result.success).toBe(false);
expect(result.error).toContain('no ID');
});
});
describe('search error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test');
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmarks error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => [];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmarks');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('likes error paths', () => {
const stubCurrentUser = async () => ({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => [];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching likes');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmark folder error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => [];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmark folder');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
});
+248
View File
@@ -0,0 +1,248 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { runtimeQueryIds } from '../src/lib/runtime-query-ids.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientPrivate = TwitterClient & {
refreshQueryIds: () => Promise<void>;
fetchWithTimeout: (url: string, init: RequestInit) => Promise<ResponseLike>;
ensureClientUserId: () => Promise<void>;
getBaseHeaders: () => Record<string, string>;
fetchUserArticlePlainText: (userId: string, tweetId: string) => Promise<{ title?: string; plainText?: string }>;
fetchTweetDetail: (tweetId: string) => Promise<{ success: true } | { success: false; error: string }>;
postStatusUpdate: (input: { text: string }) => Promise<{ success: boolean; error?: string }>;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient core coverage', () => {
const originalFetch = global.fetch;
const originalNodeEnv = process.env.NODE_ENV;
afterEach(() => {
global.fetch = originalFetch;
process.env.NODE_ENV = originalNodeEnv;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
it('refreshQueryIds calls runtime refresh outside test env', async () => {
const client = new TwitterClient({ cookies: validCookies });
const refreshSpy = vi.spyOn(runtimeQueryIds, 'refresh').mockResolvedValue(null);
process.env.NODE_ENV = 'production';
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.refreshQueryIds();
expect(refreshSpy).toHaveBeenCalled();
});
it('fetchWithTimeout uses an abort signal when timeout is set', async () => {
const mockFetch = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBeInstanceOf(AbortSignal);
return makeResponse();
});
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies, timeoutMs: 5 });
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.fetchWithTimeout('https://example.com', { method: 'GET' });
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('returns a not found error when tweet data is missing', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'other',
legacy: { full_text: 'nope', created_at: '2024-01-01T00:00:00Z' },
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
},
},
],
},
],
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('123');
expect(result.success).toBe(false);
expect(result.error).toBe('Tweet not found in response');
});
it('adds client user id header after ensureClientUserId', async () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.NODE_ENV = 'production';
vi.spyOn(client, 'getCurrentUser').mockResolvedValue({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
const clientPrivate = client as unknown as TwitterClientPrivate;
await clientPrivate.ensureClientUserId();
const headers = clientPrivate.getBaseHeaders();
expect(headers['x-twitter-client-user-id']).toBe('42');
});
describe('fetchUserArticlePlainText failures', () => {
it('returns empty object when response is not ok', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(makeResponse({ ok: false, status: 500 }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
it('returns empty object when no matching tweet is found', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
user: { result: { timeline: { timeline: { instructions: [] } } } },
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchUserArticlePlainText('user', 'tweet');
expect(result).toEqual({});
});
});
describe('fetchTweetDetail error handling', () => {
it('returns API errors from response payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad news' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toBe('bad news');
});
it('parses POST responses when GET returns 404', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 404, text: async () => 'nope' }))
.mockResolvedValueOnce(
makeResponse({
json: async () => ({
data: {
tweetResult: {
result: {
rest_id: '1',
legacy: {
full_text: 'hi',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
},
core: { user_results: { result: { legacy: { screen_name: 'user', name: 'User' } } } },
},
},
},
}),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(true);
});
it('returns an error when fetch throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.fetchTweetDetail('1');
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('postStatusUpdate error handling', () => {
it('returns an error when postStatusUpdate throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
const result = await clientPrivate.postStatusUpdate({ text: 'hello' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
});
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
const validCookies = {
authToken: 'test_auth_token',
ct0: 'test_ct0_token',
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test',
};
type ResponseLike = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
};
type TwitterClientUploadPrivate = TwitterClient & {
sleep: (ms: number) => Promise<void>;
extractArticleText: (result: unknown) => string | undefined;
};
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
ok: true,
status: 200,
json: async (): Promise<unknown> => ({}),
text: async (): Promise<string> => '',
...overrides,
});
describe('TwitterClient upload coverage', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
delete process.env.BIRD_DEBUG_ARTICLE;
vi.restoreAllMocks();
});
describe('uploadMedia error paths', () => {
it('rejects unsupported media types', async () => {
global.fetch = vi.fn() as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1, 2, 3]), mimeType: 'application/pdf' });
expect(result.success).toBe(false);
expect(global.fetch).not.toHaveBeenCalled();
});
it('returns an error when INIT fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when APPEND fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when FINALIZE fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns an error when media processing fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'failed', error: { message: 'processing failed' } } }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toBe('processing failed');
});
it('returns an error when STATUS fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ ok: false, status: 503, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 503');
});
it('retries STATUS and continues when processing is pending', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(
makeResponse({
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
}),
)
.mockResolvedValueOnce(makeResponse({ json: async () => ({ processing_info: { state: 'succeeded' } }) }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
clientPrivate.sleep = vi.fn().mockResolvedValue(undefined);
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'video/mp4' });
expect(result.success).toBe(true);
});
it('returns an error when metadata upload fails', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ json: async () => ({ media_id_string: '1' }) }))
.mockResolvedValueOnce(makeResponse())
.mockResolvedValueOnce(makeResponse({ json: async () => ({}) }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 400, text: async () => 'bad' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png', alt: 'alt' });
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 400');
});
it('returns an error when upload throws', async () => {
const mockFetch = vi.fn().mockRejectedValueOnce(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.uploadMedia({ data: new Uint8Array([1]), mimeType: 'image/png' });
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
});
describe('article extraction edge cases', () => {
it('logs article payloads when debug flag is set', () => {
const client = new TwitterClient({ cookies: validCookies });
process.env.BIRD_DEBUG_ARTICLE = '1';
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
const result = clientPrivate.extractArticleText({
rest_id: '1',
article: {
title: 'Title',
plain_text: 'Body',
article_results: { result: { title: 'Title', plain_text: 'Body' } },
},
});
expect(result).toBe('Title\n\nBody');
expect(errorSpy).toHaveBeenCalled();
});
it('drops duplicate body when it matches the title', () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientUploadPrivate;
const result = clientPrivate.extractArticleText({
rest_id: '1',
article: {
title: 'Same',
plain_text: 'Same',
article_results: { result: { title: 'Same', plain_text: 'Same' } },
},
});
expect(result).toBe('Same');
});
});
});
+322
View File
@@ -9,6 +9,14 @@ describe('TwitterClient', () => {
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test', source: 'test',
}; };
type TwitterClientPrivate = TwitterClient & {
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getLikesQueryIds: () => Promise<string[]>;
};
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
@@ -1161,6 +1169,320 @@ describe('TwitterClient', () => {
}); });
}); });
describe('bookmarks', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmarks and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('1');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Bookmarks?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
});
describe('likes', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches likes and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Likes?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.userId).toBe('42');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
});
it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('no user');
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('bookmark folders', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmark folder timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('9');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/BookmarkFolderTimeline?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.bookmark_collection_id).toBe('123');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
it('retries without count when API rejects the count variable', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Variable "$count" is not defined by operation' }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> };
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstVars = JSON.parse(
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string,
);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(firstVars.count).toBe(2);
expect(secondVars.count).toBeUndefined();
});
});
describe('conversation helpers', () => { describe('conversation helpers', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
+59 -1
View File
@@ -3,7 +3,13 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { FALLBACK_VERSION, formatVersionLine, resolveGitSha, resolvePackageVersion } from '../src/lib/version.js'; import {
FALLBACK_VERSION,
formatVersionLine,
getCliVersion,
resolveGitSha,
resolvePackageVersion,
} from '../src/lib/version.js';
function withTempDir<T>(fn: (dir: string) => T): T { function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bird-version-test-'));
@@ -53,6 +59,17 @@ describe('getCliVersion', () => {
}); });
}); });
it('falls back to VERSION file when package.json has no version', () => {
withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({}));
fs.writeFileSync(path.join(dir, 'VERSION'), '5.5.5\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolvePackageVersion(pathToFileURL(entry).href)).toBe('5.5.5');
});
});
it('falls back to unknown when no version files exist', () => { it('falls back to unknown when no version files exist', () => {
withTempDir((dir) => { withTempDir((dir) => {
const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts'); const entry = path.join(dir, 'a', 'b', 'c', 'entry.ts');
@@ -63,6 +80,31 @@ describe('getCliVersion', () => {
}); });
}); });
it('returns null when git HEAD is missing', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('returns null when git ref cannot be resolved', () => {
withTempDir((dir) => {
const gitDir = path.join(dir, '.git');
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n');
const entry = path.join(dir, 'entry.ts');
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('resolves git sha from .git directory HEAD', () => { it('resolves git sha from .git directory HEAD', () => {
withTempDir((dir) => { withTempDir((dir) => {
const gitDir = path.join(dir, '.git'); const gitDir = path.join(dir, '.git');
@@ -113,6 +155,16 @@ describe('getCliVersion', () => {
}); });
}); });
it('returns null when no git directory exists', () => {
withTempDir((dir) => {
const entry = path.join(dir, 'nested', 'entry.ts');
fs.mkdirSync(path.dirname(entry), { recursive: true });
fs.writeFileSync(entry, '// noop');
expect(resolveGitSha(pathToFileURL(entry).href)).toBeNull();
});
});
it('formats version line with version + sha when both available', () => { it('formats version line with version + sha when both available', () => {
withTempDir((dir) => { withTempDir((dir) => {
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' })); fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '3.3.3' }));
@@ -126,4 +178,10 @@ describe('getCliVersion', () => {
expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)'); expect(formatVersionLine(pathToFileURL(entry).href)).toBe('3.3.3 (dddddddd)');
}); });
}); });
it('returns a CLI version string', () => {
const value = getCliVersion();
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
}); });
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"],
"exclude": ["node_modules", "dist", "coverage"]
}
+4 -4
View File
@@ -11,10 +11,10 @@ export default defineConfig({
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/index.ts'], exclude: ['src/index.ts'],
thresholds: { thresholds: {
statements: 70, statements: 90,
branches: 70, branches: 80,
functions: 70, functions: 90,
lines: 70, lines: 90,
}, },
}, },
}, },