Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5e575f92 | ||
|
|
6fbbaebb68 | ||
|
|
04a0c98362 | ||
|
|
1d1ed6b515 | ||
|
|
f5402f2b43 | ||
|
|
a3eb372494 | ||
|
|
a3e2dccc46 | ||
|
|
f765955116 | ||
|
|
3442ba84c6 | ||
|
|
c4fa04e0b2 | ||
|
|
3728f7a4f5 | ||
|
|
b83d065907 | ||
|
|
00aee16c08 | ||
|
|
d8643ae1d1 | ||
|
|
b4542b2213 | ||
|
|
067c98c245 | ||
|
|
e537e102e3 | ||
|
|
0e8a1349c5 | ||
|
|
59e827e153 | ||
|
|
7818440d65 | ||
|
|
3db3aca0a0 | ||
|
|
dac4ba6456 | ||
|
|
b27643edbb | ||
|
|
5b61f65a7c | ||
|
|
56ca7b9841 | ||
|
|
533db2d210 | ||
|
|
3815ebfa03 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const TARGET_OPERATIONS = [
|
||||
'CreateTweet',
|
||||
'CreateRetweet',
|
||||
'FavoriteTweet',
|
||||
'DeleteBookmark',
|
||||
'TweetDetail',
|
||||
'SearchTimeline',
|
||||
'Bookmarks',
|
||||
|
||||
+10
-1
@@ -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);
|
||||
|
||||
|
||||
+70
-29
@@ -9,39 +9,80 @@ 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 }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
.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);
|
||||
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.error(`${ctx.p('warn')}${warning}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
||||
if (cmdOpts.folderId && !folderId) {
|
||||
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = folderId
|
||||
? await client.getBookmarkFolderTimeline(folderId, count)
|
||||
: await client.getBookmarks(count);
|
||||
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);
|
||||
}
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
|
||||
} else {
|
||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||
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
|
||||
? 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.';
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"global": {
|
||||
"responsive_web_grok_annotations_enabled": false
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
+351
-134
@@ -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,
|
||||
};
|
||||
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
||||
return this.getBookmarksPaged(count, options);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,24 +183,41 @@ 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.getBookmarkFolderQueryIds();
|
||||
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),
|
||||
@@ -268,10 +225,15 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
});
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
this.logBookmarksDebug('request bookmarks page', {
|
||||
queryId,
|
||||
pageCount,
|
||||
hasCursor: Boolean(pageCursor),
|
||||
});
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
@@ -279,17 +241,23 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
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_collection_timeline?: {
|
||||
bookmark_timeline_v2?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
@@ -308,43 +276,292 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
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) {
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||
this.logBookmarksDebug('bookmarks 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('bookmarks 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('bookmarks request error', { queryId, error: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', 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 };
|
||||
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;
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
let secondAttempt = await tryOnce(variablesWithCount);
|
||||
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
|
||||
secondAttempt = await tryOnce(variablesWithoutCount);
|
||||
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),
|
||||
});
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
||||
|
||||
try {
|
||||
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(),
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
bookmark_collection_timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
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) {
|
||||
this.logBookmarksDebug('bookmark folder graphql errors (non-fatal)', { queryId, errors: data.errors });
|
||||
if (!instructions) {
|
||||
lastError = data.errors.map((e) => e.message).join(', ');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.logBookmarksDebug('bookmark folder 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('bookmark folder request error', { queryId, error: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
|
||||
};
|
||||
|
||||
let attempt = await tryOnce(buildVariables(pageCount, pageCursor, true));
|
||||
if (!attempt.success && attempt.error?.includes('Variable "$count"')) {
|
||||
attempt = await tryOnce(buildVariables(pageCount, pageCursor, false));
|
||||
}
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
|
||||
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 { success: false, error: secondAttempt.error };
|
||||
|
||||
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();
|
||||
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: false, error: firstAttempt.error };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user