27 Commits
Author SHA1 Message Date
Peter Steinberger 9b5e575f92 chore(release): 0.6.0 2026-01-05 04:01:04 +01:00
Peter Steinberger 6fbbaebb68 fix: improve unbookmark url handling 2026-01-05 03:51:56 +01:00
Mariano Belinky 04a0c98362 feat: add unbookmark command to remove bookmarks 2026-01-05 02:10:10 +00:00
Peter Steinberger 1d1ed6b515 chore: prep 0.6.0 unreleased 2026-01-05 01:40:43 +01:00
Peter Steinberger f5402f2b43 feat: add runtime feature overrides 2026-01-05 01:23:32 +01:00
Pedro Pinera Buendia a3eb372494 fix: add responsive_web_grok_annotations_enabled to all feature sets
X/Twitter now requires this feature flag in API requests.
Without it, requests fail with HTTP 400:
'The following features cannot be null: responsive_web_grok_annotations_enabled'

Fixes #24
2026-01-05 00:07:01 +00:00
Peter Steinberger a3e2dccc46 fix: restore tweet lookup in instructions 2026-01-04 23:42:34 +00:00
Peter Steinberger f765955116 fix: stabilize bookmark pagination cursors 2026-01-04 23:42:34 +00:00
Leon Ho 3442ba84c6 feat: add cursor-based pagination to bookmarks command
- Add --cursor option to resume pagination from a specific point
- Change --max-pages to enable pagination independently
- Return nextCursor in JSON output for subsequent requests

feat: add cursor param and nextCursor to pagination methods

not modifying max-pages
2026-01-04 23:42:34 +00:00
Peter Steinberger c4fa04e0b2 feat: include raw tweet data in mapping 2026-01-05 00:30:01 +01:00
Peter Steinberger 3728f7a4f5 test: add mapTweetResult media coverage 2026-01-03 18:40:05 +01:00
Peter Steinberger b83d065907 fix: restore list types and tidy tests 2026-01-03 18:34:32 +01:00
Peter Steinberger 00aee16c08 docs: update changelog for media output 2026-01-03 18:34:32 +01:00
Peter Steinberger d8643ae1d1 fix: improve media extraction 2026-01-03 18:34:32 +01:00
Nikita b4542b2213 test: add comprehensive tests for extractMedia function 2026-01-03 18:34:32 +01:00
Nikita 067c98c245 feat: add media extraction support for photos, videos, and GIFs 2026-01-03 18:34:32 +01:00
Peter Steinberger e537e102e3 fix: normalize list privacy parsing 2026-01-03 16:55:29 +01:00
Harper ReedandClaude Opus 4.5 0e8a1349c5 test(lists): add boundary and fragment tests for extractListId
- Test that 5-digit IDs (minimum length) are accepted
- Test that URLs with fragment identifiers work correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 16:55:29 +01:00
Harper ReedandClaude Opus 4.5 59e827e153 test(lists): improve test coverage and extract list ID parsing
- Extract extractListId to src/lib/extract-list-id.ts with proper tests
- Add json-full/includeRaw test for list timeline
- Add 404 retry tests for getOwnedLists and getListMemberships
- Add tests for skipping invalid list entries and missing owner handling
- Use shared TwitterClientPrivate type from fixtures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 16:55:29 +01:00
Harper ReedandClaude Opus 4.5 7818440d65 feat(lists): add support for Twitter Lists
Add commands to fetch owned lists, list memberships, and list timelines:
- `bird lists` shows lists you own
- `bird lists --member-of` shows lists you're a member of
- `bird list-timeline <id-or-url>` fetches tweets from a list

Includes full --json and --json-full support for list timeline output.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-03 16:55:29 +01:00
Peter Steinberger 3db3aca0a0 style: lint fixes 2026-01-03 15:51:49 +01:00
Peter Steinberger dac4ba6456 docs: update changelog 2026-01-03 15:51:49 +01:00
Peter Steinberger b27643edbb fix: harden bookmark retry handling 2026-01-03 15:51:49 +01:00
Haoli 5b61f65a7c Revert local bookmark artifact ignore entries 2026-01-03 15:51:49 +01:00
Haoli 56ca7b9841 Add paged bookmark export with gating and retries 2026-01-03 15:51:49 +01:00
Peter Steinberger 533db2d210 test: cover json-full timelines 2026-01-03 06:59:47 +01:00
cc-vpsandChristian Catalan 3815ebfa03 feat: add --json-full flag to include raw API response in _raw field
Add a new --json-full flag to tweet-fetching commands that outputs JSON
with an additional _raw field containing the full GraphQL response.

This provides power users access to additional data not exposed in the
curated TweetData interface (media, entities, hashtags, cards, etc.)
while maintaining backward compatibility with --json.

Supported commands:
- read, replies, thread (TweetDetail API)
- search, mentions (SearchTimeline API)
- bookmarks (Bookmarks API)
- likes (Likes API)

Not supported: following, followers (uses mixed GraphQL/REST fallback
with different response structures)

Implementation:
- Add _raw?: GraphqlTweetResult optional field to TweetData type
- Add includeRaw option to mapTweetResult() and parseTweetsFromInstructions()
- Add TweetFetchOptions, SearchFetchOptions, TimelineFetchOptions interfaces
- Update client methods to accept options parameter
- Add --json-full flag to CLI commands
- Update help text to document the new flag
- Add comprehensive test coverage (16 tests)

Co-authored-by: Christian Catalan <[email protected]>
2026-01-03 06:54:36 +01:00
41 changed files with 4236 additions and 223 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 0.6.0 — 2026-01-05
### Added
- Bookmark exports now support pagination (`--all`, `--max-pages`) with retries (#15) — thanks @Nano1337.
- `lists` + `list-timeline` commands for Twitter Lists (#21) — thanks @harperreed
- Tweet JSON output now includes media items (photos, videos, GIFs) (#14) — thanks @Hormold
- Bookmarks can resume pagination from a cursor (#26) — thanks @leonho
- `unbookmark` command to remove bookmarked tweets (#22) — thanks @mbelinky.
### Changed
- Feature flags can be overridden at runtime via `features.json` (refreshable via `query-ids`).
## 0.5.1 — 2026-01-01
### Changed
+6 -1
View File
@@ -51,6 +51,10 @@ 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>
bird bookmarks --all --json
bird bookmarks --all --max-pages 2 --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes
bird likes -n 5
@@ -91,7 +95,8 @@ const result = await client.search('from:steipete', 50);
- `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 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 bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`.
- `bird unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `bird likes [-n count] [--json]` — list your liked tweets.
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@steipete/bird",
"version": "0.5.1",
"version": "0.6.0",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module",
"main": "dist/index.js",
+4 -1
View File
@@ -7,10 +7,13 @@ const files = [
from: resolve(root, 'src/lib/query-ids.json'),
to: resolve(root, 'dist/lib/query-ids.json'),
},
{
from: resolve(root, 'src/lib/features.json'),
to: resolve(root, 'dist/lib/features.json'),
},
];
for (const file of files) {
mkdirSync(dirname(file.to), { recursive: true });
writeFileSync(file.to, readFileSync(file.from));
}
+1
View File
@@ -11,6 +11,7 @@ const TARGET_OPERATIONS = [
'CreateTweet',
'CreateRetweet',
'FavoriteTweet',
'DeleteBookmark',
'TweetDetail',
'SearchTimeline',
'Bookmarks',
+10 -1
View File
@@ -2,10 +2,12 @@ import { Command } from 'commander';
import { registerBookmarksCommand } from '../commands/bookmarks.js';
import { registerCheckCommand } from '../commands/check.js';
import { registerHelpCommand } from '../commands/help.js';
import { registerListsCommand } from '../commands/lists.js';
import { registerPostCommands } from '../commands/post.js';
import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js';
import { type CliContext, collectCookieSource } from './shared.js';
@@ -20,9 +22,12 @@ export const KNOWN_COMMANDS = new Set([
'search',
'mentions',
'bookmarks',
'unbookmark',
'following',
'followers',
'likes',
'lists',
'list-timeline',
'help',
'whoami',
'check',
@@ -81,7 +86,9 @@ export function createProgram(ctx: CliContext): Command {
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, query-ids`,
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, lists, list-timeline, query-ids`,
)}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`,
)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
);
@@ -122,6 +129,8 @@ export function createProgram(ctx: CliContext): Command {
registerReadCommands(program, ctx);
registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerListsCommand(program, ctx);
registerUserCommands(program, ctx);
registerCheckCommand(program, ctx);
+46 -5
View File
@@ -9,11 +9,25 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--all', 'Fetch all bookmarks (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(
async (cmdOpts: {
count?: string;
json?: boolean;
jsonFull?: boolean;
folderId?: string;
all?: boolean;
maxPages?: string;
cursor?: string;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined;
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
@@ -26,22 +40,49 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
process.exit(1);
}
const usePagination = cmdOpts.all || cmdOpts.cursor;
if (maxPages !== undefined && !usePagination) {
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
process.exit(1);
}
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1);
}
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const includeRaw = cmdOpts.jsonFull ?? false;
const timelineOptions = { includeRaw };
const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor };
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
? usePagination
? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions)
: await client.getBookmarkFolderTimeline(folderId, count, timelineOptions)
: usePagination
? await client.getAllBookmarks(paginationOptions)
: await client.getBookmarks(count, timelineOptions);
if (result.success && result.tweets) {
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
const isJson = cmdOpts.json || cmdOpts.jsonFull;
if (isJson && usePagination) {
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
}
} else {
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
}
});
},
);
}
+117
View File
@@ -0,0 +1,117 @@
// ABOUTME: CLI command for fetching Twitter Lists.
// ABOUTME: Supports listing owned lists, memberships, and list timelines.
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { extractListId } from '../lib/extract-list-id.js';
import type { TwitterList } from '../lib/twitter-client.js';
import { TwitterClient } from '../lib/twitter-client.js';
function printLists(lists: TwitterList[], ctx: CliContext): void {
if (lists.length === 0) {
console.log('No lists found.');
return;
}
for (const list of lists) {
const visibility = list.isPrivate ? '[private]' : '[public]';
console.log(`${list.name} ${ctx.colors.muted(visibility)}`);
if (list.description) {
console.log(` ${list.description.slice(0, 100)}${list.description.length > 100 ? '...' : ''}`);
}
console.log(` ${ctx.p('info')}${list.memberCount?.toLocaleString() ?? 0} members`);
if (list.owner) {
console.log(` ${ctx.colors.muted(`Owner: @${list.owner.username}`)}`);
}
console.log(` ${ctx.colors.accent(`https://x.com/i/lists/${list.id}`)}`);
console.log('──────────────────────────────────────────────────');
}
}
export function registerListsCommand(program: Command, ctx: CliContext): void {
program
.command('lists')
.description('Get your Twitter lists')
.option('--member-of', 'Show lists you are a member of (instead of owned lists)')
.option('-n, --count <number>', 'Number of lists to fetch', '100')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { memberOf?: boolean; count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '100', 10);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = cmdOpts.memberOf ? await client.getListMemberships(count) : await client.getOwnedLists(count);
if (result.success && result.lists) {
if (cmdOpts.json) {
console.log(JSON.stringify(result.lists, null, 2));
} else {
const emptyMessage = cmdOpts.memberOf ? 'You are not a member of any lists.' : 'You do not own any lists.';
if (result.lists.length === 0) {
console.log(emptyMessage);
} else {
printLists(result.lists, ctx);
}
}
} else {
console.error(`${ctx.p('err')}Failed to fetch lists: ${result.error}`);
process.exit(1);
}
});
program
.command('list-timeline <list-id-or-url>')
.description('Get tweets from a list timeline')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (listIdOrUrl: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const listId = extractListId(listIdOrUrl);
if (!listId) {
console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/<id>.`);
process.exit(2);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getListTimeline(listId, count, { includeRaw: cmdOpts.jsonFull });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No tweets found in this list.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
process.exit(1);
}
});
}
+38 -1
View File
@@ -1,7 +1,25 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import {
type FeatureOverrides,
getFeatureOverridesSnapshot,
refreshFeatureOverridesCache,
} from '../lib/runtime-features.js';
import { runtimeQueryIds } from '../lib/runtime-query-ids.js';
function countFeatureOverrides(overrides: FeatureOverrides): number {
let count = 0;
if (overrides.global) {
count += Object.keys(overrides.global).length;
}
if (overrides.sets) {
for (const setOverrides of Object.values(overrides.sets)) {
count += Object.keys(setOverrides).length;
}
}
return count;
}
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
program
.command('query-ids')
@@ -25,16 +43,31 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
if (cmdOpts.fresh) {
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
await runtimeQueryIds.refresh(operations, { force: true });
console.error(`${ctx.p('info')}Refreshing feature overrides…`);
await refreshFeatureOverridesCache();
}
const featureSnapshot = getFeatureOverridesSnapshot();
const info = await runtimeQueryIds.getSnapshotInfo();
if (!info) {
if (cmdOpts.json) {
console.log(JSON.stringify({ cached: false, cachePath: runtimeQueryIds.cachePath }, null, 2));
console.log(
JSON.stringify(
{
cached: false,
cachePath: runtimeQueryIds.cachePath,
featuresPath: featureSnapshot.cachePath,
features: featureSnapshot.overrides,
},
null,
2,
),
);
return;
}
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
console.log(`features_path: ${featureSnapshot.cachePath}`);
return;
}
@@ -49,6 +82,8 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
ageMs: info.ageMs,
ids: info.snapshot.ids,
discovery: info.snapshot.discovery,
featuresPath: featureSnapshot.cachePath,
features: featureSnapshot.overrides,
},
null,
2,
@@ -62,5 +97,7 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
console.log(`features_path: ${featureSnapshot.cachePath}`);
console.log(`features: ${countFeatureOverrides(featureSnapshot.overrides)}`);
});
}
+18 -9
View File
@@ -9,7 +9,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
.description('Read/fetch a tweet by ID or URL')
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -28,10 +29,11 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getTweet(tweetId);
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getTweet(tweetId, { includeRaw });
if (result.success && result.tweet) {
if (cmdOpts.json) {
if (cmdOpts.json || cmdOpts.jsonFull) {
console.log(JSON.stringify(result.tweet, null, 2));
} else {
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
@@ -52,7 +54,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
.description('List replies to a tweet (by ID or URL)')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -70,10 +73,11 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getReplies(tweetId);
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getReplies(tweetId, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No replies found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
process.exit(1);
@@ -85,7 +89,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
.description('Show the full conversation thread containing the tweet')
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
.option('--json', 'Output as JSON')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -103,10 +108,14 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getThread(tweetId);
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getThread(tweetId, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No thread tweets found.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
process.exit(1);
+10 -6
View File
@@ -10,7 +10,8 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (query: string, cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -28,10 +29,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.search(query, count);
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.search(query, count, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No tweets found.' });
} else {
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
process.exit(1);
@@ -44,7 +46,8 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
.option('-u, --user <handle>', 'User handle (e.g. @steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -84,10 +87,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
}
}
const result = await client.search(query, count);
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.search(query, count, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage: 'No mentions found.' });
} else {
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
process.exit(1);
+43
View File
@@ -0,0 +1,43 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUnbookmarkCommand(program: Command, ctx: CliContext): void {
program
.command('unbookmark')
.description('Remove bookmarked tweets')
.argument('<tweet-id-or-url...>', 'Tweet IDs or URLs to remove from bookmarks')
.action(async (tweetIdOrUrls: string[]) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs });
let failures = 0;
for (const input of tweetIdOrUrls) {
const tweetId = ctx.extractTweetId(input);
const result = await client.unbookmark(tweetId);
if (result.success) {
console.log(`${ctx.p('ok')}Removed bookmark for ${tweetId}`);
} else {
failures += 1;
console.error(`${ctx.p('err')}Failed to remove bookmark for ${tweetId}: ${result.error}`);
}
}
if (failures > 0) {
process.exit(1);
}
});
}
+8 -3
View File
@@ -130,7 +130,8 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action(async (cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
@@ -148,10 +149,14 @@ export function registerUserCommands(program: Command, ctx: CliContext): void {
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getLikes(count);
const includeRaw = cmdOpts.jsonFull ?? false;
const result = await client.getLikes(count, { includeRaw });
if (result.success && result.tweets) {
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
ctx.printTweets(result.tweets, {
json: cmdOpts.json || cmdOpts.jsonFull,
emptyMessage: 'No liked tweets found.',
});
} else {
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
+20
View File
@@ -0,0 +1,20 @@
// ABOUTME: Extract list ID from an X/Twitter list URL.
// ABOUTME: Returns list ID or null if input is invalid.
const LIST_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/lists\/(\d+)/i;
const LIST_ID_REGEX = /^\d{5,}$/;
export function extractListId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = LIST_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (LIST_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 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+)/;
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/(?:\w+\/status|i\/web\/status)\/(\d+)/i;
export function extractTweetId(input: string): string {
// If it's a URL, extract the tweet ID
+5
View File
@@ -0,0 +1,5 @@
{
"global": {
"responsive_web_grok_annotations_enabled": false
}
}
+3
View File
@@ -18,4 +18,7 @@ export {
type TwitterClientOptions,
type TwitterUser,
} from './twitter-client.js';
export type { SearchFetchOptions } from './twitter-client-search.js';
export type { TimelineFetchOptions } from './twitter-client-timelines.js';
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js';
+6 -1
View File
@@ -2,11 +2,16 @@
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
"Following": "BEkNpEt5pNETESoqMsTEGA",
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
"Likes": "JR2gceKucIKcVNB_9JkhsA"
"Likes": "JR2gceKucIKcVNB_9JkhsA",
"ListOwnerships": "wQcOSjSQ8NtgxIwvYl1lMg",
"ListMemberships": "BlEXXdARdSeL_0KyKHHvvg",
"ListLatestTweetsTimeline": "2TemLyqrMpTeAmysdbnVqw",
"ListByRestId": "wXzyA5vM_aVkBL9G8Vp3kw"
}
+184
View File
@@ -0,0 +1,184 @@
import { existsSync, readFileSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
import defaultOverrides from './features.json' with { type: 'json' };
export type FeatureOverrides = {
global?: Record<string, boolean>;
sets?: Record<string, Record<string, boolean>>;
};
type NormalizedFeatureOverrides = {
global: Record<string, boolean>;
sets: Record<string, Record<string, boolean>>;
};
export type FeatureOverridesSnapshot = {
cachePath: string;
overrides: FeatureOverrides;
};
const DEFAULT_CACHE_FILENAME = 'features.json';
let cachedOverrides: NormalizedFeatureOverrides | null = null;
function normalizeFeatureMap(value: unknown): Record<string, boolean> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const result: Record<string, boolean> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (typeof entry === 'boolean') {
result[key] = entry;
}
}
return result;
}
function normalizeOverrides(value: unknown): NormalizedFeatureOverrides {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { global: {}, sets: {} };
}
const record = value as Record<string, unknown>;
const global = normalizeFeatureMap(record.global);
const sets: Record<string, Record<string, boolean>> = {};
const rawSets =
record.sets && typeof record.sets === 'object' && !Array.isArray(record.sets)
? (record.sets as Record<string, unknown>)
: {};
for (const [setName, setValue] of Object.entries(rawSets)) {
const normalized = normalizeFeatureMap(setValue);
if (Object.keys(normalized).length > 0) {
sets[setName] = normalized;
}
}
return { global, sets };
}
function mergeOverrides(
base: NormalizedFeatureOverrides,
next: NormalizedFeatureOverrides,
): NormalizedFeatureOverrides {
const sets: Record<string, Record<string, boolean>> = { ...base.sets };
for (const [setName, overrides] of Object.entries(next.sets)) {
const existing = sets[setName];
sets[setName] = existing ? { ...existing, ...overrides } : { ...overrides };
}
return {
global: { ...base.global, ...next.global },
sets,
};
}
function toFeatureOverrides(overrides: NormalizedFeatureOverrides): FeatureOverrides {
const result: FeatureOverrides = {};
if (Object.keys(overrides.global).length > 0) {
result.global = overrides.global;
}
const setEntries = Object.entries(overrides.sets).filter(([, value]) => Object.keys(value).length > 0);
if (setEntries.length > 0) {
result.sets = Object.fromEntries(setEntries);
}
return result;
}
function resolveFeaturesCachePath(): string {
const override = process.env.BIRD_FEATURES_CACHE ?? process.env.BIRD_FEATURES_PATH;
if (override && override.trim().length > 0) {
return path.resolve(override.trim());
}
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
}
function readOverridesFromFile(cachePath: string): NormalizedFeatureOverrides | null {
if (!existsSync(cachePath)) {
return null;
}
try {
const raw = readFileSync(cachePath, 'utf8');
return normalizeOverrides(JSON.parse(raw));
} catch {
return null;
}
}
function readOverridesFromEnv(): NormalizedFeatureOverrides | null {
const raw = process.env.BIRD_FEATURES_JSON;
if (!raw || raw.trim().length === 0) {
return null;
}
try {
return normalizeOverrides(JSON.parse(raw));
} catch {
return null;
}
}
function writeOverridesToDisk(cachePath: string, overrides: NormalizedFeatureOverrides): Promise<void> {
const payload = toFeatureOverrides(overrides);
return mkdir(path.dirname(cachePath), { recursive: true }).then(() =>
writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'),
);
}
export function loadFeatureOverrides(): NormalizedFeatureOverrides {
if (cachedOverrides) {
return cachedOverrides;
}
const base = normalizeOverrides(defaultOverrides);
const fromFile = readOverridesFromFile(resolveFeaturesCachePath());
const fromEnv = readOverridesFromEnv();
let merged = base;
if (fromFile) {
merged = mergeOverrides(merged, fromFile);
}
if (fromEnv) {
merged = mergeOverrides(merged, fromEnv);
}
cachedOverrides = merged;
return merged;
}
export function getFeatureOverridesSnapshot(): FeatureOverridesSnapshot {
const overrides = toFeatureOverrides(loadFeatureOverrides());
return {
cachePath: resolveFeaturesCachePath(),
overrides,
};
}
export function applyFeatureOverrides(setName: string, base: Record<string, boolean>): Record<string, boolean> {
const overrides = loadFeatureOverrides();
const globalOverrides = overrides.global;
const setOverrides = overrides.sets[setName];
if (Object.keys(globalOverrides).length === 0 && (!setOverrides || Object.keys(setOverrides).length === 0)) {
return base;
}
if (setOverrides) {
return {
...base,
...globalOverrides,
...setOverrides,
};
}
return {
...base,
...globalOverrides,
};
}
export async function refreshFeatureOverridesCache(): Promise<FeatureOverridesSnapshot> {
const cachePath = resolveFeaturesCachePath();
const base = normalizeOverrides(defaultOverrides);
const fromFile = readOverridesFromFile(cachePath);
const merged = mergeOverrides(base, fromFile ?? { global: {}, sets: {} });
await writeOverridesToDisk(cachePath, merged);
cachedOverrides = null;
return { cachePath, overrides: toFeatureOverrides(merged) };
}
export function clearFeatureOverridesCache(): void {
cachedOverrides = null;
}
+80
View File
@@ -0,0 +1,80 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL } from './twitter-client-constants.js';
import type { BookmarkMutationResult } from './twitter-client-types.js';
export interface TwitterClientBookmarkMethods {
unbookmark(tweetId: string): Promise<BookmarkMutationResult>;
}
export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientBookmarkMethods> {
abstract class TwitterClientBookmarks extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
async unbookmark(tweetId: string): Promise<BookmarkMutationResult> {
// TODO: verify if DeleteBookmark requires client user ID or additional payload fields; add ensureClientUserId() if needed (needs live API test).
const variables = { tweet_id: tweetId };
let queryId = await this.getQueryId('DeleteBookmark');
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
const buildBody = () => JSON.stringify({ variables, queryId });
const buildHeaders = () => ({ ...this.getHeaders(), referer: `https://x.com/i/status/${tweetId}` });
let body = buildBody();
const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => {
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as { errors?: Array<{ message: string }> };
if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
}
return { success: true };
};
try {
let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers: buildHeaders(),
body,
});
if (response.status === 404) {
await this.refreshQueryIds();
queryId = await this.getQueryId('DeleteBookmark');
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
body = buildBody();
response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers: buildHeaders(),
body,
});
if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST',
headers: buildHeaders(),
body,
});
return parseResponse(retry);
}
}
return parseResponse(response);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
return TwitterClientBookmarks;
}
+5
View File
@@ -17,6 +17,7 @@ export const FALLBACK_QUERY_IDS = {
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
@@ -25,6 +26,10 @@ export const FALLBACK_QUERY_IDS = {
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg',
ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg',
ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw',
ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw',
} as const;
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
+61 -15
View File
@@ -1,5 +1,7 @@
import { applyFeatureOverrides } from './runtime-features.js';
export function buildArticleFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('article', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
@@ -13,6 +15,7 @@ export function buildArticleFeatures(): Record<string, boolean> {
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
@@ -34,11 +37,11 @@ export function buildArticleFeatures(): Record<string, boolean> {
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
});
}
export function buildTweetDetailFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('tweetDetail', {
...buildArticleFeatures(),
responsive_web_graphql_exclude_directive_enabled: true,
communities_web_enable_tweet_community_results_fetch: true,
@@ -51,7 +54,7 @@ export function buildTweetDetailFeatures(): Record<string, boolean> {
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
verified_phone_label_enabled: false,
};
});
}
export function buildArticleFieldToggles(): Record<string, boolean> {
@@ -66,7 +69,7 @@ export function buildArticleFieldToggles(): Record<string, boolean> {
}
export function buildSearchFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('search', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
@@ -81,6 +84,7 @@ export function buildSearchFeatures(): Record<string, boolean> {
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
@@ -103,11 +107,11 @@ export function buildSearchFeatures(): Record<string, boolean> {
responsive_web_grok_community_note_auto_translation_is_enabled: false,
articles_preview_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
});
}
export function buildTweetCreateFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('tweetCreate', {
rweb_video_screen_enabled: true,
creator_subscriptions_tweet_preview_api_enabled: true,
premium_content_api_read_enabled: false,
@@ -115,6 +119,7 @@ export function buildTweetCreateFeatures(): Record<string, boolean> {
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
@@ -142,11 +147,11 @@ export function buildTweetCreateFeatures(): Record<string, boolean> {
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
});
}
export function buildTimelineFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('timeline', {
...buildSearchFeatures(),
blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false,
@@ -156,22 +161,62 @@ export function buildTimelineFeatures(): Record<string, boolean> {
interactive_text_enabled: true,
longform_notetweets_richtext_consumption_enabled: true,
responsive_web_media_download_video_enabled: false,
};
});
}
export function buildBookmarksFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('bookmarks', {
...buildTimelineFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
};
});
}
export function buildLikesFeatures(): Record<string, boolean> {
return buildTimelineFeatures();
return applyFeatureOverrides('likes', buildTimelineFeatures());
}
export function buildListsFeatures(): Record<string, boolean> {
return applyFeatureOverrides('lists', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildFollowingFeatures(): Record<string, boolean> {
return {
return applyFeatureOverrides('following', {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: false,
responsive_web_profile_redirect_enabled: true,
@@ -185,6 +230,7 @@ export function buildFollowingFeatures(): Record<string, boolean> {
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_grok_annotations_enabled: false,
responsive_web_jetfuel_frame: false,
responsive_web_grok_share_attachment_enabled: false,
articles_preview_enabled: true,
@@ -206,5 +252,5 @@ export function buildFollowingFeatures(): Record<string, boolean> {
responsive_web_grok_imagine_annotation_enabled: false,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
});
}
+429
View File
@@ -0,0 +1,429 @@
// ABOUTME: Mixin for Twitter Lists GraphQL operations.
// ABOUTME: Provides methods to fetch user's owned lists, memberships, and list timelines.
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildListsFeatures } from './twitter-client-features.js';
import type { TimelineFetchOptions } from './twitter-client-timelines.js';
import type { GraphqlTweetResult, ListsResult, SearchResult, TwitterList } from './twitter-client-types.js';
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientListMethods {
getOwnedLists(count?: number): Promise<ListsResult>;
getListMemberships(count?: number): Promise<ListsResult>;
getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
}
interface GraphqlListResult {
id_str?: string;
name?: string;
description?: string;
member_count?: number;
subscriber_count?: number;
mode?: string;
created_at?: string;
user_results?: {
result?: {
rest_id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
};
};
}
function parseList(listResult: GraphqlListResult): TwitterList | null {
if (!listResult.id_str || !listResult.name) {
return null;
}
const owner = listResult.user_results?.result;
return {
id: listResult.id_str,
name: listResult.name,
description: listResult.description,
memberCount: listResult.member_count,
subscriberCount: listResult.subscriber_count,
isPrivate: listResult.mode?.toLowerCase() === 'private',
createdAt: listResult.created_at,
owner: owner
? {
id: owner.rest_id ?? '',
username: owner.legacy?.screen_name ?? '',
name: owner.legacy?.name ?? '',
}
: undefined,
};
}
function parseListsFromInstructions(
instructions:
| Array<{
entries?: Array<{
content?: {
itemContent?: {
list?: GraphqlListResult;
};
};
}>;
}>
| undefined,
): TwitterList[] {
const lists: TwitterList[] = [];
if (!instructions) {
return lists;
}
for (const instruction of instructions) {
if (!instruction.entries) {
continue;
}
for (const entry of instruction.entries) {
const listResult = entry.content?.itemContent?.list;
if (listResult) {
const parsed = parseList(listResult);
if (parsed) {
lists.push(parsed);
}
}
}
}
return lists;
}
export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientListMethods> {
abstract class TwitterClientLists extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getListOwnershipsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListOwnerships');
return Array.from(new Set([primary, 'wQcOSjSQ8NtgxIwvYl1lMg']));
}
private async getListMembershipsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListMemberships');
return Array.from(new Set([primary, 'BlEXXdARdSeL_0KyKHHvvg']));
}
private async getListTimelineQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListLatestTweetsTimeline');
return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw']));
}
/**
* Get lists owned by the authenticated user
*/
async getOwnedLists(count = 100): Promise<ListsResult> {
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,
isListMembershipShown: true,
isListMemberTargetUserId: userResult.user.id,
};
const features = buildListsFeatures();
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.getListOwnershipsQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListOwnerships?${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?: {
list?: GraphqlListResult;
};
};
}>;
}>;
};
};
};
};
};
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 lists = parseListsFromInstructions(instructions);
return { success: true as const, lists, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching owned lists', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, lists: firstAttempt.lists };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, lists: secondAttempt.lists };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get lists the authenticated user is a member of
*/
async getListMemberships(count = 100): Promise<ListsResult> {
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,
isListMembershipShown: true,
isListMemberTargetUserId: userResult.user.id,
};
const features = buildListsFeatures();
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.getListMembershipsQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListMemberships?${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?: {
list?: GraphqlListResult;
};
};
}>;
}>;
};
};
};
};
};
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 lists = parseListsFromInstructions(instructions);
return { success: true as const, lists, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching list memberships', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, lists: firstAttempt.lists };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, lists: secondAttempt.lists };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get tweets from a list timeline
*/
async getListTimeline(listId: string, count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const variables = {
listId,
count,
};
const features = buildListsFeatures();
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.getListTimelineQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/ListLatestTweetsTimeline?${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?: {
list?: {
tweets_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?.list?.tweets_timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
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 list timeline', 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 };
}
}
return TwitterClientLists;
}
+10 -3
View File
@@ -4,8 +4,14 @@ import { buildSearchFeatures } from './twitter-client-features.js';
import type { SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
/** Options for search methods */
export interface SearchFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
export interface TwitterClientSearchMethods {
search(query: string, count?: number): Promise<SearchResult>;
search(query: string, count?: number, options?: SearchFetchOptions): Promise<SearchResult>;
}
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -20,7 +26,8 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
/**
* Search for tweets matching a query
*/
async search(query: string, count = 20): Promise<SearchResult> {
async search(query: string, count = 20, options: SearchFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const features = buildSearchFeatures();
const pageSize = 20;
const seen = new Set<string>();
@@ -113,7 +120,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
}
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
+344 -127
View File
@@ -1,13 +1,28 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
/** Options for timeline fetch methods */
export interface TimelineFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paged timeline fetch methods */
export interface TimelinePaginationOptions extends TimelineFetchOptions {
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
}
export interface TwitterClientTimelineMethods {
getBookmarks(count?: number): Promise<SearchResult>;
getLikes(count?: number): Promise<SearchResult>;
getBookmarkFolderTimeline(folderId: string, count?: number): Promise<SearchResult>;
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult>;
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
}
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -19,6 +34,17 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
super(...args);
}
private logBookmarksDebug(message: string, data?: Record<string, unknown>): void {
if (process.env.BIRD_DEBUG_BOOKMARKS !== '1') {
return;
}
if (data) {
console.error(`[bird][debug][bookmarks] ${message}`, JSON.stringify(data));
} else {
console.error(`[bird][debug][bookmarks] ${message}`);
}
}
private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
@@ -37,105 +63,19 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
/**
* Get the authenticated user's bookmarks
*/
async getBookmarks(count = 20): Promise<SearchResult> {
const variables = {
count,
includePromotedContent: false,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
};
const features = buildBookmarksFeatures();
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.getBookmarksQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${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;
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
return this.getBookmarksPaged(count, options);
}
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_timeline_v2?: {
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_timeline_v2?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
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 bookmarks', 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 };
async getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getBookmarksPaged(Number.POSITIVE_INFINITY, options);
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20): Promise<SearchResult> {
async getLikes(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
@@ -212,7 +152,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
return { success: true as const, tweets, had404 };
} catch (error) {
@@ -243,25 +183,206 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
/**
* 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,
};
async getBookmarkFolderTimeline(
folderId: string,
count = 20,
options: TimelineFetchOptions = {},
): Promise<SearchResult> {
return this.getBookmarkFolderTimelinePaged(folderId, count, options);
}
const variablesWithoutCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
};
async getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult> {
return this.getBookmarkFolderTimelinePaged(folderId, Number.POSITIVE_INFINITY, options);
}
private async getBookmarksPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
const features = buildBookmarksFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const tryOnce = async (variables: Record<string, unknown>) => {
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarksQueryIds();
const variables = {
count: pageCount,
includePromotedContent: false,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
try {
this.logBookmarksDebug('request bookmarks page', {
queryId,
pageCount,
hasCursor: Boolean(pageCursor),
});
const response = await this.fetchWithRetry(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
this.logBookmarksDebug('bookmarks 404', { queryId });
continue;
}
if (!response.ok) {
const text = await response.text();
this.logBookmarksDebug('bookmarks non-200', {
queryId,
status: response.status,
body: text.slice(0, 200),
});
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_timeline_v2?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
if (data.errors && data.errors.length > 0) {
this.logBookmarksDebug('bookmarks graphql errors (non-fatal)', { queryId, errors: data.errors });
if (!instructions) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
}
this.logBookmarksDebug('bookmarks page parsed', {
queryId,
tweets: pageTweets.length,
hasNextCursor: Boolean(nextCursor),
});
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
this.logBookmarksDebug('bookmarks request error', { queryId, error: lastError });
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 };
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = !Number.isFinite(limit);
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
if (!unlimited && tweets.length >= limit) {
break;
}
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
private async getBookmarkFolderTimelinePaged(
folderId: string,
limit: number,
options: TimelinePaginationOptions = {},
): Promise<SearchResult> {
const features = buildBookmarksFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const { includeRaw = false, maxPages } = options;
const buildVariables = (pageCount: number, pageCursor: string | undefined, includeCount: boolean) => ({
bookmark_collection_id: folderId,
includePromotedContent: true,
...(includeCount ? { count: pageCount } : {}),
...(pageCursor ? { cursor: pageCursor } : {}),
});
const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
const tryOnce = async (variables: Record<string, unknown>) => {
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
@@ -271,7 +392,13 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
this.logBookmarksDebug('request bookmark folder page', {
queryId,
pageCount,
hasCursor: Boolean(pageCursor),
includeCount: Object.hasOwn(variables, 'count'),
});
const response = await this.fetchWithRetry(url, {
method: 'GET',
headers: this.getHeaders(),
});
@@ -279,11 +406,17 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
this.logBookmarksDebug('bookmark folder 404', { queryId });
continue;
}
if (!response.ok) {
const text = await response.text();
this.logBookmarksDebug('bookmark folder non-200', {
queryId,
status: response.status,
body: text.slice(0, 200),
});
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
@@ -308,43 +441,127 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
errors?: Array<{ message: string }>;
};
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const nextCursor = extractCursorFromInstructions(instructions);
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
this.logBookmarksDebug('bookmark folder graphql errors (non-fatal)', { queryId, errors: data.errors });
if (!instructions) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
}
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
this.logBookmarksDebug('bookmark folder page parsed', {
queryId,
tweets: pageTweets.length,
hasNextCursor: Boolean(nextCursor),
});
return { success: true as const, tweets, had404 };
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
this.logBookmarksDebug('bookmark folder request error', { queryId, error: lastError });
}
}
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 };
let attempt = await tryOnce(buildVariables(pageCount, pageCursor, true));
if (!attempt.success && attempt.error?.includes('Variable "$count"')) {
attempt = await tryOnce(buildVariables(pageCount, pageCursor, false));
}
if (!attempt.success && attempt.error?.includes('Variable "$cursor"') && pageCursor) {
return {
success: false as const,
error: 'Bookmark folder pagination rejected the cursor parameter',
had404: attempt.had404,
};
}
return attempt;
};
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCount, pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
let secondAttempt = await tryOnce(variablesWithCount);
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
secondAttempt = await tryOnce(variablesWithoutCount);
}
const secondAttempt = await fetchPage(pageCount, pageCursor);
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
const unlimited = !Number.isFinite(limit);
while (unlimited || tweets.length < limit) {
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) {
return { success: false, error: page.error };
}
pagesFetched += 1;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
if (!unlimited && tweets.length >= limit) {
break;
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) {
nextCursor = undefined;
break;
}
if (maxPages && pagesFetched >= maxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
private async fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
const maxRetries = 2;
const baseDelayMs = 500;
const retryable = new Set([429, 500, 502, 503, 504]);
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
const response = await this.fetchWithTimeout(url, init);
if (!retryable.has(response.status) || attempt === maxRetries) {
return response;
}
this.logBookmarksDebug('retrying bookmarks request', {
status: response.status,
attempt,
});
// Retry-After supports delta-seconds only; HTTP-date falls back to backoff.
const retryAfter = response.headers?.get?.('retry-after');
const retryAfterMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : Number.NaN;
const backoffMs = Number.isFinite(retryAfterMs)
? retryAfterMs
: baseDelayMs * 2 ** attempt + Math.floor(Math.random() * baseDelayMs);
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
return this.fetchWithTimeout(url, init);
}
}
+18 -9
View File
@@ -10,10 +10,16 @@ import {
parseTweetsFromInstructions,
} from './twitter-client-utils.js';
/** Options for tweet fetching methods */
export interface TweetFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
export interface TwitterClientTweetDetailMethods {
getTweet(tweetId: string): Promise<GetTweetResult>;
getReplies(tweetId: string): Promise<SearchResult>;
getThread(tweetId: string): Promise<SearchResult>;
getTweet(tweetId: string, options?: TweetFetchOptions): Promise<GetTweetResult>;
getReplies(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
getThread(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
}
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -246,7 +252,8 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
/**
* Get tweet details by ID
*/
async getTweet(tweetId: string): Promise<GetTweetResult> {
async getTweet(tweetId: string, options: TweetFetchOptions = {}): Promise<GetTweetResult> {
const { includeRaw = false } = options;
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
@@ -271,7 +278,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
tweetId,
);
const mapped = mapTweetResult(tweetResult, this.quoteDepth);
const mapped = mapTweetResult(tweetResult, { quoteDepth: this.quoteDepth, includeRaw });
if (mapped) {
if (tweetResult?.article) {
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
@@ -294,14 +301,15 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
/**
* Get replies to a tweet by ID
*/
async getReplies(tweetId: string): Promise<SearchResult> {
async getReplies(tweetId: string, options: TweetFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
return { success: true, tweets: replies };
@@ -310,14 +318,15 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
/**
* Get full conversation thread for a tweet ID
*/
async getThread(tweetId: string): Promise<SearchResult> {
async getThread(tweetId: string, options: TweetFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const target = tweets.find((t) => t.id === tweetId);
const rootId = target?.conversationId || tweetId;
+77
View File
@@ -1,5 +1,28 @@
import type { TwitterCookies } from './cookies.js';
// Raw media entity from Twitter API
export interface GraphqlMediaEntity {
id_str?: string;
media_url_https?: string;
type?: 'photo' | 'video' | 'animated_gif';
url?: string;
expanded_url?: string;
sizes?: {
thumb?: { w: number; h: number; resize: string };
small?: { w: number; h: number; resize: string };
medium?: { w: number; h: number; resize: string };
large?: { w: number; h: number; resize: string };
};
video_info?: {
duration_millis?: number;
variants?: Array<{
bitrate?: number;
content_type?: string;
url?: string;
}>;
};
}
export type GraphqlTweetResult = {
__typename?: string;
rest_id?: string;
@@ -11,6 +34,12 @@ export type GraphqlTweetResult = {
favorite_count?: number;
conversation_id_str?: string;
in_reply_to_status_id_str?: string | null;
entities?: {
media?: GraphqlMediaEntity[];
};
extended_entities?: {
media?: GraphqlMediaEntity[];
};
};
core?: {
user_results?: {
@@ -162,12 +191,33 @@ export type TweetResult =
error: string;
};
export type BookmarkMutationResult =
| {
success: true;
}
| {
success: false;
error: string;
};
export interface UploadMediaResult {
success: boolean;
mediaId?: string;
error?: string;
}
// Parsed media item for output
export interface TweetMedia {
type: 'photo' | 'video' | 'animated_gif';
url: string;
previewUrl?: string;
width?: number;
height?: number;
// For video/animated_gif: best quality video URL
videoUrl?: string;
durationMs?: number;
}
export interface TweetData {
id: string;
text: string;
@@ -184,6 +234,10 @@ export interface TweetData {
inReplyToStatusId?: string;
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
quotedTweet?: TweetData;
// Media attachments (photos, videos, GIFs)
media?: TweetMedia[];
// Raw GraphQL tweet result (only when includeRaw is enabled)
_raw?: GraphqlTweetResult;
}
export interface GetTweetResult {
@@ -196,6 +250,8 @@ export interface SearchResult {
success: boolean;
tweets?: TweetData[];
error?: string;
/** Cursor for fetching the next page of results */
nextCursor?: string;
}
export interface CurrentUserResult {
@@ -234,6 +290,27 @@ export interface TwitterClientOptions {
quoteDepth?: number;
}
export interface TwitterList {
id: string;
name: string;
description?: string;
memberCount?: number;
subscriberCount?: number;
isPrivate?: boolean;
createdAt?: string;
owner?: {
id: string;
username: string;
name: string;
};
}
export interface ListsResult {
success: boolean;
lists?: TwitterList[];
error?: string;
}
export interface CreateTweetResponse {
data?: {
create_tweet?: {
+98 -7
View File
@@ -1,4 +1,4 @@
import type { GraphqlTweetResult, TweetData, TwitterUser } from './twitter-client-types.js';
import type { GraphqlTweetResult, TweetData, TweetMedia, TwitterUser } from './twitter-client-types.js';
export function normalizeQuoteDepth(value?: number): number {
if (value === undefined || value === null) {
@@ -153,6 +153,67 @@ export function extractTweetText(result: GraphqlTweetResult | undefined): string
return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text);
}
export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia[] | undefined {
// Prefer extended_entities (has video info), fall back to entities
const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media;
if (!rawMedia || rawMedia.length === 0) {
return undefined;
}
const media: TweetMedia[] = [];
for (const item of rawMedia) {
if (!item.type || !item.media_url_https) {
continue;
}
const mediaItem: TweetMedia = {
type: item.type,
url: item.media_url_https,
};
// Get dimensions from largest available size
const sizes = item.sizes;
if (sizes?.large) {
mediaItem.width = sizes.large.w;
mediaItem.height = sizes.large.h;
} else if (sizes?.medium) {
mediaItem.width = sizes.medium.w;
mediaItem.height = sizes.medium.h;
}
// For thumbnails/previews
if (sizes?.small) {
mediaItem.previewUrl = `${item.media_url_https}:small`;
}
// Extract video URL for video/animated_gif
if ((item.type === 'video' || item.type === 'animated_gif') && item.video_info?.variants) {
// Prefer highest bitrate MP4, fall back to first MP4 when bitrate is missing.
const mp4Variants = item.video_info.variants.filter(
(v): v is { bitrate?: number; content_type: string; url: string } =>
v.content_type === 'video/mp4' && typeof v.url === 'string',
);
const mp4WithBitrate = mp4Variants
.filter((v): v is { bitrate: number; content_type: string; url: string } => typeof v.bitrate === 'number')
.sort((a, b) => b.bitrate - a.bitrate);
const selectedVariant = mp4WithBitrate[0] ?? mp4Variants[0];
if (selectedVariant) {
mediaItem.videoUrl = selectedVariant.url;
}
if (typeof item.video_info.duration_millis === 'number') {
mediaItem.durationMs = item.video_info.duration_millis;
}
}
media.push(mediaItem);
}
return media.length > 0 ? media : undefined;
}
export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
if (!result) {
return undefined;
@@ -163,7 +224,19 @@ export function unwrapTweetResult(result: GraphqlTweetResult | undefined): Graph
return result;
}
export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth: number): TweetData | undefined {
export interface MapTweetResultOptions {
quoteDepth: number;
includeRaw?: boolean;
}
export function mapTweetResult(
result: GraphqlTweetResult | undefined,
quoteDepthOrOptions: number | MapTweetResultOptions,
): TweetData | undefined {
const options: MapTweetResultOptions =
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
const { quoteDepth, includeRaw = false } = options;
const userResult = result?.core?.user_results?.result;
const userLegacy = userResult?.legacy;
const userCore = userResult?.core;
@@ -183,11 +256,13 @@ export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDept
if (quoteDepth > 0) {
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
if (quotedResult) {
quotedTweet = mapTweetResult(quotedResult, quoteDepth - 1);
quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw });
}
}
return {
const media = extractMedia(result);
const tweetData: TweetData = {
id: result.rest_id,
text,
createdAt: result.legacy?.created_at,
@@ -202,7 +277,14 @@ export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDept
},
authorId: userId,
quotedTweet,
media,
};
if (includeRaw) {
(tweetData as TweetData & { _raw: GraphqlTweetResult })._raw = result;
}
return tweetData;
}
export function findTweetInInstructions(
@@ -220,7 +302,7 @@ export function findTweetInInstructions(
}>
| undefined,
tweetId: string,
) {
): GraphqlTweetResult | undefined {
if (!instructions) {
return undefined;
}
@@ -294,6 +376,11 @@ export function collectTweetResultsFromEntry(entry: {
return results;
}
export interface ParseTweetsOptions {
quoteDepth: number;
includeRaw?: boolean;
}
export function parseTweetsFromInstructions(
instructions:
| Array<{
@@ -336,8 +423,12 @@ export function parseTweetsFromInstructions(
}>;
}>
| undefined,
quoteDepth: number,
quoteDepthOrOptions: number | ParseTweetsOptions,
): TweetData[] {
const options: ParseTweetsOptions =
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
const { quoteDepth, includeRaw = false } = options;
const tweets: TweetData[] = [];
const seen = new Set<string>();
@@ -345,7 +436,7 @@ export function parseTweetsFromInstructions(
for (const entry of instruction.entries ?? []) {
const results = collectTweetResultsFromEntry(entry);
for (const result of results) {
const mapped = mapTweetResult(result, quoteDepth);
const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
if (!mapped || seen.has(mapped.id)) {
continue;
}
+8 -1
View File
@@ -1,5 +1,7 @@
import type { AbstractConstructor } from './twitter-client-base.js';
import { TwitterClientBase } from './twitter-client-base.js';
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js';
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js';
import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js';
import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js';
@@ -8,6 +10,8 @@ import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitte
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
type TwitterClientInstance = TwitterClientBase &
TwitterClientBookmarkMethods &
TwitterClientListMethods &
TwitterClientMediaMethods &
TwitterClientPostingMethods &
TwitterClientSearchMethods &
@@ -16,19 +20,22 @@ type TwitterClientInstance = TwitterClientBase &
TwitterClientUserMethods;
const MixedTwitterClient = withUsers(
withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase))))),
withLists(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
) as AbstractConstructor<TwitterClientInstance>;
export class TwitterClient extends MixedTwitterClient {}
export type {
BookmarkMutationResult,
CurrentUserResult,
FollowingResult,
GetTweetResult,
ListsResult,
SearchResult,
TweetData,
TweetResult,
TwitterClientOptions,
TwitterList,
TwitterUser,
UploadMediaResult,
} from './twitter-client-types.js';
+57
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
import { extractListId } from '../src/lib/extract-list-id.js';
import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => {
@@ -19,6 +20,11 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789');
});
it('should extract ID from i/web/status URLs', () => {
const url = 'https://x.com/i/web/status/1234567890123456789';
expect(extractTweetId(url)).toBe('1234567890123456789');
});
it('should return ID as-is if already an ID', () => {
const id = '1234567890123456789';
expect(extractTweetId(id)).toBe('1234567890123456789');
@@ -56,4 +62,55 @@ describe('CLI utilities', () => {
expect(extractBookmarkFolderId(url)).toBeNull();
});
});
describe('extractListId', () => {
it('should extract ID from x.com list URL', () => {
const url = 'https://x.com/i/lists/1234567890123456789';
expect(extractListId(url)).toBe('1234567890123456789');
});
it('should extract ID from twitter.com list URL', () => {
const url = 'https://twitter.com/i/lists/1234567890123456789';
expect(extractListId(url)).toBe('1234567890123456789');
});
it('should extract ID from URL with query params', () => {
const url = 'https://x.com/i/lists/1234567890123456789?s=20';
expect(extractListId(url)).toBe('1234567890123456789');
});
it('should accept a numeric ID as-is', () => {
const id = '1234567890123456789';
expect(extractListId(id)).toBe('1234567890123456789');
});
it('should return null for invalid values', () => {
expect(extractListId('not-an-id')).toBeNull();
});
it('should return null for empty string', () => {
expect(extractListId('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractListId(' ')).toBeNull();
});
it('should return null for short numeric IDs', () => {
expect(extractListId('1234')).toBeNull();
});
it('should accept minimum 5-digit IDs', () => {
expect(extractListId('12345')).toBe('12345');
});
it('should trim whitespace from input', () => {
expect(extractListId(' 1234567890123456789 ')).toBe('1234567890123456789');
});
it('should extract ID from URL with fragment', () => {
const url = 'https://x.com/i/lists/1234567890123456789#section';
expect(extractListId(url)).toBe('1234567890123456789');
});
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerBookmarksCommand } from '../src/commands/bookmarks.js';
describe('bookmarks command', () => {
it('requires --all or --cursor when --max-pages is provided', async () => {
const program = new Command();
const ctx = {
resolveTimeoutFromOptions: () => undefined,
resolveCredentialsFromOptions: async () => ({
cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' },
warnings: [],
}),
p: () => '',
printTweets: () => undefined,
} as unknown as CliContext;
registerBookmarksCommand(program, ctx);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`exit ${code}`);
}) as never);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
await expect(program.parseAsync(['node', 'bird', 'bookmarks', '--max-pages', '2'])).rejects.toThrow('exit 1');
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor'));
} finally {
exitSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
+332
View File
@@ -0,0 +1,332 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { extractMedia } from '../src/lib/twitter-client-utils.js';
describe('extractMedia', () => {
it('returns undefined when no legacy data', () => {
expect(extractMedia(undefined)).toBeUndefined();
expect(extractMedia({})).toBeUndefined();
expect(extractMedia({ legacy: {} })).toBeUndefined();
});
it('returns undefined when media array is empty', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: { media: [] },
},
};
expect(extractMedia(result)).toBeUndefined();
});
it('extracts photo with dimensions and preview', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/test.jpg',
sizes: {
large: { w: 1920, h: 1080, resize: 'fit' },
small: { w: 680, h: 383, resize: 'fit' },
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item).toEqual({
type: 'photo',
url: 'https://pbs.twimg.com/media/test.jpg',
width: 1920,
height: 1080,
previewUrl: 'https://pbs.twimg.com/media/test.jpg:small',
});
});
it('falls back to medium size when large unavailable', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/test.jpg',
sizes: {
medium: { w: 1200, h: 675, resize: 'fit' },
},
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.width).toBe(1200);
expect(item?.height).toBe(675);
expect(item?.previewUrl).toBeUndefined();
});
it('extracts video with highest bitrate mp4', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg',
sizes: {
large: { w: 1280, h: 720, resize: 'fit' },
small: { w: 680, h: 383, resize: 'fit' },
},
video_info: {
duration_millis: 30000,
variants: [
{ bitrate: 256000, content_type: 'video/mp4', url: 'https://video.twimg.com/low.mp4' },
{ bitrate: 2176000, content_type: 'video/mp4', url: 'https://video.twimg.com/high.mp4' },
{ bitrate: 832000, content_type: 'video/mp4', url: 'https://video.twimg.com/medium.mp4' },
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' },
],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item).toEqual({
type: 'video',
url: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg',
width: 1280,
height: 720,
previewUrl: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg:small',
videoUrl: 'https://video.twimg.com/high.mp4',
durationMs: 30000,
});
});
it('falls back to first mp4 when bitrate is missing', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg',
video_info: {
variants: [
{ content_type: 'video/mp4', url: 'https://video.twimg.com/no-bitrate.mp4' },
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' },
],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.videoUrl).toBe('https://video.twimg.com/no-bitrate.mp4');
});
it('extracts animated_gif with video url', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'animated_gif',
media_url_https: 'https://pbs.twimg.com/tweet_video_thumb/test.jpg',
sizes: {
large: { w: 480, h: 270, resize: 'fit' },
small: { w: 480, h: 270, resize: 'fit' },
},
video_info: {
variants: [
{ bitrate: 0, content_type: 'video/mp4', url: 'https://video.twimg.com/tweet_video/test.mp4' },
],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.type).toBe('animated_gif');
expect(item?.videoUrl).toBe('https://video.twimg.com/tweet_video/test.mp4');
expect(item?.durationMs).toBeUndefined();
});
it('keeps zero duration for videos', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/zero.jpg',
video_info: {
duration_millis: 0,
variants: [{ content_type: 'video/mp4', url: 'https://video.twimg.com/zero.mp4' }],
},
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.durationMs).toBe(0);
});
it('handles multiple media items', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/photo1.jpg',
sizes: { large: { w: 800, h: 600, resize: 'fit' } },
},
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/photo2.jpg',
sizes: { large: { w: 1024, h: 768, resize: 'fit' } },
},
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/vid.jpg',
sizes: { large: { w: 1920, h: 1080, resize: 'fit' } },
video_info: {
duration_millis: 15000,
variants: [{ bitrate: 1000000, content_type: 'video/mp4', url: 'https://video.twimg.com/vid.mp4' }],
},
},
],
},
},
};
const media = extractMedia(result) ?? [];
expect(media).toHaveLength(3);
const [first, second, third] = media;
expect(first?.type).toBe('photo');
expect(second?.type).toBe('photo');
expect(third?.type).toBe('video');
expect(third?.videoUrl).toBe('https://video.twimg.com/vid.mp4');
});
it('skips items missing type or url', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{ type: 'photo' } as never, // missing media_url_https
{ media_url_https: 'https://pbs.twimg.com/media/test.jpg' } as never, // missing type
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/valid.jpg',
sizes: { large: { w: 100, h: 100, resize: 'fit' } },
},
],
},
},
};
const media = extractMedia(result);
expect(media).toHaveLength(1);
const item = media?.[0];
expect(item?.url).toBe('https://pbs.twimg.com/media/valid.jpg');
});
it('prefers extended_entities over entities', () => {
const result: GraphqlTweetResult = {
legacy: {
entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/from_entities.jpg',
sizes: { large: { w: 100, h: 100, resize: 'fit' } },
},
],
},
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/from_extended.jpg',
sizes: { large: { w: 200, h: 200, resize: 'fit' } },
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.url).toBe('https://pbs.twimg.com/media/from_extended.jpg');
});
it('falls back to entities when extended_entities missing', () => {
const result: GraphqlTweetResult = {
legacy: {
entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/from_entities.jpg',
sizes: { large: { w: 100, h: 100, resize: 'fit' } },
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.url).toBe('https://pbs.twimg.com/media/from_entities.jpg');
});
it('handles video without mp4 variants', () => {
const result: GraphqlTweetResult = {
legacy: {
extended_entities: {
media: [
{
type: 'video',
media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/test.jpg',
sizes: { large: { w: 1280, h: 720, resize: 'fit' } },
video_info: {
duration_millis: 10000,
variants: [{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' }],
},
},
],
},
},
};
const media = extractMedia(result);
const item = media?.[0];
expect(item?.type).toBe('video');
expect(item?.videoUrl).toBeUndefined();
expect(item?.durationMs).toBe(10000);
});
});
+49
View File
@@ -21,6 +21,8 @@ describe('root help output', () => {
expect(help).toContain('bird <tweet-id-or-url>');
expect(help).toContain('--json');
expect(help).toContain('--json-full');
expect(help).toContain('_raw');
});
it('shows global options in subcommand help', () => {
@@ -48,4 +50,51 @@ describe('root help output', () => {
expect(help).toContain('--timeout');
expect(help).toContain('--quote-depth');
});
it('shows --json-full option in read command help', () => {
const ctx = createCliContext([]);
const program = createProgram(ctx);
const cmd = program.commands.find((c) => c.name() === 'read');
if (!cmd) {
throw new Error('Expected "read" command to be registered');
}
let help = '';
const output = {
writeOut: (s) => {
help += s;
},
writeErr: () => {},
};
program.configureOutput(output);
cmd.configureOutput(output);
cmd.outputHelp();
expect(help).toContain('--json-full');
expect(help).toContain('_raw');
});
it('shows --json-full option in search command help', () => {
const ctx = createCliContext([]);
const program = createProgram(ctx);
const cmd = program.commands.find((c) => c.name() === 'search');
if (!cmd) {
throw new Error('Expected "search" command to be registered');
}
let help = '';
const output = {
writeOut: (s) => {
help += s;
},
writeErr: () => {},
};
program.configureOutput(output);
cmd.configureOutput(output);
cmd.outputHelp();
expect(help).toContain('--json-full');
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { GraphqlTweetResult } from '../src/lib/twitter-client-types.js';
import { mapTweetResult } from '../src/lib/twitter-client-utils.js';
describe('mapTweetResult media', () => {
const makeUserResult = () => ({
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'alice', name: 'Alice' },
},
},
},
});
it('includes extracted media on mapped tweet', () => {
const result: GraphqlTweetResult = {
rest_id: '1',
legacy: {
full_text: 'hello',
extended_entities: {
media: [
{
type: 'photo',
media_url_https: 'https://pbs.twimg.com/media/test.jpg',
sizes: {
large: { w: 800, h: 600, resize: 'fit' },
small: { w: 320, h: 240, resize: 'fit' },
},
},
],
},
},
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped).toBeDefined();
const media = mapped?.media ?? [];
expect(media).toHaveLength(1);
expect(media[0]).toEqual({
type: 'photo',
url: 'https://pbs.twimg.com/media/test.jpg',
width: 800,
height: 600,
previewUrl: 'https://pbs.twimg.com/media/test.jpg:small',
});
});
it('omits media when none present', () => {
const result: GraphqlTweetResult = {
rest_id: '2',
legacy: { full_text: 'no media' },
...makeUserResult(),
};
const mapped = mapTweetResult(result, 0);
expect(mapped?.media).toBeUndefined();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { clearFeatureOverridesCache, refreshFeatureOverridesCache } from '../src/lib/runtime-features.js';
import { buildLikesFeatures, buildSearchFeatures } from '../src/lib/twitter-client-features.js';
describe('runtime-features', () => {
afterEach(() => {
delete process.env.BIRD_FEATURES_JSON;
delete process.env.BIRD_FEATURES_CACHE;
delete process.env.BIRD_FEATURES_PATH;
clearFeatureOverridesCache();
});
it('applies global and set overrides from env json', () => {
process.env.BIRD_FEATURES_JSON = JSON.stringify({
global: {
global_flag: true,
ignored: 'nope',
},
sets: {
search: {
global_flag: false,
search_only: true,
bad: 1,
},
likes: {
likes_only: true,
},
},
});
clearFeatureOverridesCache();
const search = buildSearchFeatures();
expect(search.global_flag).toBe(false);
expect(search.search_only).toBe(true);
expect(search.bad).toBeUndefined();
const likes = buildLikesFeatures();
expect(likes.global_flag).toBe(true);
expect(likes.likes_only).toBe(true);
});
it('refresh merges defaults with existing cache', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'features.json');
await writeFile(
cachePath,
JSON.stringify(
{
global: {
existing_flag: true,
},
sets: {
search: {
existing_search: false,
},
},
},
null,
2,
),
);
process.env.BIRD_FEATURES_PATH = cachePath;
await refreshFeatureOverridesCache();
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as {
global?: Record<string, boolean>;
sets?: Record<string, Record<string, boolean>>;
};
expect(parsed.global?.existing_flag).toBe(true);
expect(parsed.global?.responsive_web_grok_annotations_enabled).toBe(false);
expect(parsed.sets?.search?.existing_search).toBe(false);
});
it('applies overrides from cache file', async () => {
const cacheDir = path.join(os.tmpdir(), `bird-test-${randomUUID()}`);
await mkdir(cacheDir, { recursive: true });
const cachePath = path.join(cacheDir, 'features.json');
await writeFile(
cachePath,
JSON.stringify(
{
global: {
file_flag: true,
},
sets: {
search: {
file_search: true,
},
},
},
null,
2,
),
);
process.env.BIRD_FEATURES_PATH = cachePath;
clearFeatureOverridesCache();
const search = buildSearchFeatures();
expect(search.file_flag).toBe(true);
expect(search.file_search).toBe(true);
});
});
+16
View File
@@ -155,10 +155,14 @@ describe('TwitterClient API coverage', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }))
.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.getBookmarksQueryIds = async () => ['test'];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
@@ -174,6 +178,8 @@ describe('TwitterClient API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => ['test'];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
@@ -185,6 +191,8 @@ describe('TwitterClient API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarksQueryIds = async () => ['test'];
const result = await client.getBookmarks(1);
expect(result.success).toBe(false);
@@ -362,10 +370,14 @@ describe('TwitterClient API coverage', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }))
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }))
.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.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
@@ -381,6 +393,8 @@ describe('TwitterClient API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
@@ -392,6 +406,8 @@ describe('TwitterClient API coverage', () => {
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
+4
View File
@@ -14,4 +14,8 @@ export type TwitterClientPrivate = TwitterClient & {
error?: string;
}>;
getLikesQueryIds: () => Promise<string[]>;
getListTimelineQueryIds: () => Promise<string[]>;
getListOwnershipsQueryIds: () => Promise<string[]>;
getListMembershipsQueryIds: () => Promise<string[]>;
refreshQueryIds: () => Promise<void>;
};
+477
View File
@@ -0,0 +1,477 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { mapTweetResult, parseTweetsFromInstructions } from '../src/lib/twitter-client-utils.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('mapTweetResult with includeRaw', () => {
const makeTweetResult = (id: string, text: string, username = `user${id}`) => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: `u${id}`,
legacy: { screen_name: username, name: `User ${id}` },
},
},
},
// Extra fields that should only be in _raw
entities: {
urls: [{ url: 'https://t.co/abc', expanded_url: 'https://example.com' }],
hashtags: [{ text: 'test' }],
},
extended_entities: {
media: [{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/test.jpg' }],
},
});
it('does not include _raw by default', () => {
const result = makeTweetResult('1', 'hello');
const mapped = mapTweetResult(result, { quoteDepth: 1 });
expect(mapped).toBeDefined();
expect(mapped?.id).toBe('1');
expect(mapped?.text).toBe('hello');
expect(mapped?._raw).toBeUndefined();
});
it('does not include _raw when includeRaw is false', () => {
const result = makeTweetResult('1', 'hello');
const mapped = mapTweetResult(result, { quoteDepth: 1, includeRaw: false });
expect(mapped).toBeDefined();
expect(mapped?._raw).toBeUndefined();
});
it('includes _raw when includeRaw is true', () => {
const result = makeTweetResult('1', 'hello');
const mapped = mapTweetResult(result, { quoteDepth: 1, includeRaw: true });
expect(mapped).toBeDefined();
expect(mapped?.id).toBe('1');
expect(mapped?.text).toBe('hello');
expect(mapped?._raw).toBeDefined();
expect(mapped?._raw).toBe(result);
// Verify the raw data contains fields not in the curated output
expect(mapped?._raw?.entities).toBeDefined();
expect(mapped?._raw?.extended_entities).toBeDefined();
});
it('includes _raw in quoted tweets when includeRaw is true', () => {
const quoted = makeTweetResult('2', 'quoted tweet');
const root = makeTweetResult('1', 'root tweet');
(root as Record<string, unknown>).quoted_status_result = { result: quoted };
const mapped = mapTweetResult(root, { quoteDepth: 1, includeRaw: true });
expect(mapped?._raw).toBeDefined();
expect(mapped?.quotedTweet?._raw).toBeDefined();
expect(mapped?.quotedTweet?._raw).toBe(quoted);
});
it('does not include _raw in quoted tweets when includeRaw is false', () => {
const quoted = makeTweetResult('2', 'quoted tweet');
const root = makeTweetResult('1', 'root tweet');
(root as Record<string, unknown>).quoted_status_result = { result: quoted };
const mapped = mapTweetResult(root, { quoteDepth: 1, includeRaw: false });
expect(mapped?._raw).toBeUndefined();
expect(mapped?.quotedTweet?._raw).toBeUndefined();
});
it('works with legacy number-only quoteDepth parameter', () => {
const result = makeTweetResult('1', 'hello');
// This tests backward compatibility
const mapped = mapTweetResult(result, 1);
expect(mapped).toBeDefined();
expect(mapped?.id).toBe('1');
expect(mapped?._raw).toBeUndefined();
});
});
describe('parseTweetsFromInstructions with includeRaw', () => {
const makeInstruction = (tweets: Array<{ id: string; text: string }>) => [
{
entries: tweets.map((t) => ({
content: {
itemContent: {
tweet_results: {
result: {
rest_id: t.id,
legacy: {
full_text: t.text,
created_at: '2024-01-01T00:00:00Z',
},
core: {
user_results: {
result: {
legacy: { screen_name: `user${t.id}`, name: `User ${t.id}` },
},
},
},
},
},
},
},
})),
},
];
it('does not include _raw by default', () => {
const instructions = makeInstruction([{ id: '1', text: 'tweet 1' }]);
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: 1 });
expect(tweets).toHaveLength(1);
expect(tweets[0]._raw).toBeUndefined();
});
it('includes _raw when includeRaw is true', () => {
const instructions = makeInstruction([
{ id: '1', text: 'tweet 1' },
{ id: '2', text: 'tweet 2' },
]);
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: 1, includeRaw: true });
expect(tweets).toHaveLength(2);
expect(tweets[0]._raw).toBeDefined();
expect(tweets[0]._raw?.rest_id).toBe('1');
expect(tweets[1]._raw).toBeDefined();
expect(tweets[1]._raw?.rest_id).toBe('2');
});
it('works with legacy number-only quoteDepth parameter', () => {
const instructions = makeInstruction([{ id: '1', text: 'tweet 1' }]);
// This tests backward compatibility
const tweets = parseTweetsFromInstructions(instructions, 1);
expect(tweets).toHaveLength(1);
expect(tweets[0]._raw).toBeUndefined();
});
});
describe('TwitterClient getTweet with includeRaw option', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
const mockTweetResponse = {
data: {
tweetResult: {
result: {
rest_id: '12345',
legacy: {
full_text: 'Test tweet',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
},
core: {
user_results: {
result: {
rest_id: 'u123',
legacy: { screen_name: 'testuser', name: 'Test User' },
},
},
},
entities: {
urls: [{ url: 'https://t.co/abc', expanded_url: 'https://example.com' }],
},
},
},
},
};
it('does not include _raw by default', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTweetResponse,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('12345');
expect(result.success).toBe(true);
expect(result.tweet?._raw).toBeUndefined();
});
it('includes _raw when includeRaw option is true', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTweetResponse,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getTweet('12345', { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweet?._raw).toBeDefined();
expect(result.tweet?._raw?.rest_id).toBe('12345');
expect(result.tweet?._raw?.entities).toBeDefined();
});
});
describe('TwitterClient getReplies with includeRaw option', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
const mockRepliesResponse = {
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '99999',
legacy: {
full_text: 'A reply',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
in_reply_to_status_id_str: '12345',
},
core: {
user_results: {
result: {
legacy: { screen_name: 'replier', name: 'Replier' },
},
},
},
},
},
},
},
},
],
},
],
},
},
};
it('includes _raw in replies when includeRaw is true', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockRepliesResponse,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getReplies('12345', { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
expect(result.tweets?.[0]._raw?.rest_id).toBe('99999');
});
});
describe('TwitterClient getThread with includeRaw option', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
const mockThreadResponse = {
data: {
threaded_conversation_with_injections_v2: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '12345',
legacy: {
full_text: 'Thread start',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
conversation_id_str: '12345',
},
core: {
user_results: {
result: {
legacy: { screen_name: 'author', name: 'Author' },
},
},
},
},
},
},
},
},
],
},
],
},
},
};
it('includes _raw in thread tweets when includeRaw is true', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockThreadResponse,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getThread('12345', { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
});
});
describe('TwitterClient search with includeRaw option', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
const mockSearchResponse = {
data: {
search_by_raw_query: {
search_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '11111',
legacy: {
full_text: 'Search result',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
},
core: {
user_results: {
result: {
legacy: { screen_name: 'found', name: 'Found User' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
};
it('includes _raw in search results when includeRaw is true', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockSearchResponse,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test query', 10, { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
expect(result.tweets?.[0]._raw?.rest_id).toBe('11111');
});
it('does not include _raw in search results by default', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockSearchResponse,
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.search('test query', 10);
expect(result.success).toBe(true);
expect(result.tweets?.[0]._raw).toBeUndefined();
});
});
describe('--json-full flag behavior (without --json)', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
const mockTweetResponse = {
data: {
tweetResult: {
result: {
rest_id: '12345',
legacy: {
full_text: 'Test tweet',
created_at: 'Mon Jan 01 00:00:00 +0000 2024',
},
core: {
user_results: {
result: {
legacy: { screen_name: 'testuser', name: 'Test User' },
},
},
},
},
},
},
};
it('includeRaw works independently without requiring other json options', async () => {
// This tests that --json-full alone (without --json) will include _raw
// The CLI handles output format separately, but the client should work with just includeRaw
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTweetResponse,
});
const client = new TwitterClient({ cookies: validCookies });
// Only passing includeRaw: true, simulating --json-full without --json
const result = await client.getTweet('12345', { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweet).toBeDefined();
expect(result.tweet?._raw).toBeDefined();
// Verify the result is JSON-serializable (would be used for JSON output)
const serialized = JSON.stringify(result.tweet);
expect(serialized).toContain('"_raw"');
expect(serialized).toContain('"id":"12345"');
});
});
@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient timelines includeRaw', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('includes _raw for bookmarks when includeRaw is true', 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',
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
entities: {
hashtags: [{ text: 'bookmark' }],
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarks(1, { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
expect(result.tweets?.[0]._raw?.entities?.hashtags?.[0].text).toBe('bookmark');
});
it('includes _raw for bookmark folder timelines when includeRaw is true', 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',
},
core: {
user_results: {
result: { legacy: { screen_name: 'folder', name: 'Folder' } },
},
},
entities: {
urls: [{ url: 'https://t.co/abc', expanded_url: 'https://example.com' }],
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1, { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
expect(result.tweets?.[0]._raw?.entities?.urls?.[0].expanded_url).toBe('https://example.com');
});
it('includes _raw for likes when includeRaw is true', 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',
},
core: {
user_results: {
result: { legacy: { screen_name: 'root', name: 'Root' } },
},
},
entities: {
hashtags: [{ text: 'like' }],
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
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(1, { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
expect(result.tweets?.[0]._raw?.entities?.hashtags?.[0].text).toBe('like');
});
it('includes _raw for list timeline when includeRaw is true', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '777',
legacy: {
full_text: 'from list',
created_at: '2024-01-01T00:00:00Z',
},
core: {
user_results: {
result: { legacy: { screen_name: 'listmember', name: 'List Member' } },
},
},
entities: {
hashtags: [{ text: 'listtweet' }],
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('12345', 1, { includeRaw: true });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0]._raw).toBeDefined();
expect(result.tweets?.[0]._raw?.entities?.hashtags?.[0].text).toBe('listtweet');
});
});
+825
View File
@@ -0,0 +1,825 @@
// ABOUTME: Tests for TwitterClient list methods.
// ABOUTME: Tests getOwnedLists, getListMemberships, and getListTimeline.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { type TwitterClientPrivate, validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient lists', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
describe('getOwnedLists', () => {
it('fetches owned lists and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '1234567890',
name: 'My Test List',
description: 'A test list for testing',
member_count: 10,
subscriber_count: 5,
mode: 'Public',
created_at: '2024-01-01T00:00:00Z',
user_results: {
result: {
rest_id: '12345',
legacy: { screen_name: 'testuser', name: 'Test User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].id).toBe('1234567890');
expect(result.lists?.[0].name).toBe('My Test List');
expect(result.lists?.[0].description).toBe('A test list for testing');
expect(result.lists?.[0].memberCount).toBe(10);
expect(result.lists?.[0].subscriberCount).toBe(5);
expect(result.lists?.[0].isPrivate).toBe(false);
expect(result.lists?.[0].owner?.username).toBe('testuser');
});
it('handles private lists correctly', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'Secret List',
mode: 'Private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles lowercase private mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '8888',
name: 'Lowercase Private',
mode: 'private',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(true);
});
it('handles missing mode', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '9999',
name: 'No Mode',
mode: null,
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].isPrivate).toBe(false);
});
it('returns empty array when no lists exist', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toEqual([]);
});
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns error when getCurrentUser fails', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: false,
error: 'Unauthorized',
});
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
// When getCurrentUser fails with an error, that error is used; otherwise fallback message is used
expect(result.error).toContain('Unauthorized');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Rate limit exceeded' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(false);
expect(result.error).toContain('Rate limit exceeded');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Retry List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('333');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('skips list entries with missing id_str or name', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '111',
name: 'Valid List',
},
},
},
},
{
content: {
itemContent: {
list: {
// Missing id_str
name: 'Invalid List 1',
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '222',
// Missing name
},
},
},
},
{
content: {
itemContent: {
list: {
id_str: '333',
name: 'Another Valid List',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(2);
expect(result.lists?.[0].id).toBe('111');
expect(result.lists?.[1].id).toBe('333');
});
it('handles list with missing owner gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '444',
name: 'List Without Owner',
// No user_results
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListOwnershipsQueryIds = async () => ['test'];
const result = await client.getOwnedLists(100);
expect(result.success).toBe(true);
expect(result.lists).toHaveLength(1);
expect(result.lists?.[0].owner).toBeUndefined();
});
});
describe('getListMemberships', () => {
it('fetches list memberships and parses list results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '5555',
name: 'Member List',
member_count: 100,
user_results: {
result: {
rest_id: '99999',
legacy: { screen_name: 'otheruser', name: 'Other User' },
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('5555');
expect(result.lists?.[0].name).toBe('Member List');
expect(result.lists?.[0].owner?.username).toBe('otheruser');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
list: {
id_str: '6666',
name: 'Retry Membership List',
mode: 'Public',
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '12345', username: 'testuser', name: 'Test User' },
});
clientPrivate.getListMembershipsQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListMemberships(100);
expect(result.success).toBe(true);
expect(result.lists?.[0].id).toBe('6666');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('getListTimeline', () => {
it('fetches list timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '111',
legacy: {
full_text: 'Tweet from list',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '111',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'listmember', name: 'List Member' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('111');
expect(result.tweets?.[0].text).toBe('Tweet from list');
expect(result.tweets?.[0].author.username).toBe('listmember');
});
it('returns empty array when list has no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('returns error on API failure', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
text: async () => 'Forbidden',
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 403');
});
it('handles API errors in response', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'List not found' }],
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getListTimeline('nonexistent', 20);
expect(result.success).toBe(false);
expect(result.error).toContain('List not found');
});
it('retries on 404 error after refreshing query IDs', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not Found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '222',
legacy: {
full_text: 'Retry success',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '222',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User Two' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
clientPrivate.refreshQueryIds = async () => {};
const result = await client.getListTimeline('1234567890', 20);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('222');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});
@@ -413,6 +413,338 @@ describe('TwitterClient bookmarks', () => {
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
it('paginates bookmarks when fetching all', 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 page 1',
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' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'saved page 2',
created_at: '2024-01-02T00: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 result = await client.getAllBookmarks();
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2']);
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(secondVars.cursor).toBe('cursor-1');
});
it('respects maxPages when fetching all', 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 page 1',
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' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'saved page 2',
created_at: '2024-01-02T00: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 result = await client.getAllBookmarks({ maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(result.nextCursor).toBe('cursor-1');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('does not return a stale cursor when pagination ends', 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 page 1',
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.getAllBookmarks({ cursor: 'cursor-1' });
expect(result.success).toBe(true);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(1);
const vars = JSON.parse(new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string);
expect(vars.cursor).toBe('cursor-1');
});
it('treats graphql errors as non-fatal when instructions are present', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Query: Unspecified' }],
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved with warning',
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.getAllBookmarks({ maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
});
});
describe('TwitterClient bookmark folders', () => {