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
|
# 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
|
## 0.5.1 — 2026-01-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ bird mentions --user @steipete -n 5
|
|||||||
# Bookmarks
|
# Bookmarks
|
||||||
bird bookmarks -n 5
|
bird bookmarks -n 5
|
||||||
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
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
|
# Likes
|
||||||
bird likes -n 5
|
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 thread <tweet-id-or-url> [--json]` — show the full conversation thread.
|
||||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
||||||
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
||||||
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
|
- `bird 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 likes [-n count] [--json]` — list your liked tweets.
|
||||||
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
|
- `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).
|
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@steipete/bird",
|
"name": "@steipete/bird",
|
||||||
"version": "0.5.1",
|
"version": "0.6.0",
|
||||||
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
|
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ const files = [
|
|||||||
from: resolve(root, 'src/lib/query-ids.json'),
|
from: resolve(root, 'src/lib/query-ids.json'),
|
||||||
to: resolve(root, 'dist/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) {
|
for (const file of files) {
|
||||||
mkdirSync(dirname(file.to), { recursive: true });
|
mkdirSync(dirname(file.to), { recursive: true });
|
||||||
writeFileSync(file.to, readFileSync(file.from));
|
writeFileSync(file.to, readFileSync(file.from));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const TARGET_OPERATIONS = [
|
|||||||
'CreateTweet',
|
'CreateTweet',
|
||||||
'CreateRetweet',
|
'CreateRetweet',
|
||||||
'FavoriteTweet',
|
'FavoriteTweet',
|
||||||
|
'DeleteBookmark',
|
||||||
'TweetDetail',
|
'TweetDetail',
|
||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
'Bookmarks',
|
'Bookmarks',
|
||||||
|
|||||||
+10
-1
@@ -2,10 +2,12 @@ import { Command } from 'commander';
|
|||||||
import { registerBookmarksCommand } from '../commands/bookmarks.js';
|
import { registerBookmarksCommand } from '../commands/bookmarks.js';
|
||||||
import { registerCheckCommand } from '../commands/check.js';
|
import { registerCheckCommand } from '../commands/check.js';
|
||||||
import { registerHelpCommand } from '../commands/help.js';
|
import { registerHelpCommand } from '../commands/help.js';
|
||||||
|
import { registerListsCommand } from '../commands/lists.js';
|
||||||
import { registerPostCommands } from '../commands/post.js';
|
import { registerPostCommands } from '../commands/post.js';
|
||||||
import { registerQueryIdsCommand } from '../commands/query-ids.js';
|
import { registerQueryIdsCommand } from '../commands/query-ids.js';
|
||||||
import { registerReadCommands } from '../commands/read.js';
|
import { registerReadCommands } from '../commands/read.js';
|
||||||
import { registerSearchCommands } from '../commands/search.js';
|
import { registerSearchCommands } from '../commands/search.js';
|
||||||
|
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
|
||||||
import { registerUserCommands } from '../commands/users.js';
|
import { registerUserCommands } from '../commands/users.js';
|
||||||
import { getCliVersion } from '../lib/version.js';
|
import { getCliVersion } from '../lib/version.js';
|
||||||
import { type CliContext, collectCookieSource } from './shared.js';
|
import { type CliContext, collectCookieSource } from './shared.js';
|
||||||
@@ -20,9 +22,12 @@ export const KNOWN_COMMANDS = new Set([
|
|||||||
'search',
|
'search',
|
||||||
'mentions',
|
'mentions',
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
|
'unbookmark',
|
||||||
'following',
|
'following',
|
||||||
'followers',
|
'followers',
|
||||||
'likes',
|
'likes',
|
||||||
|
'lists',
|
||||||
|
'list-timeline',
|
||||||
'help',
|
'help',
|
||||||
'whoami',
|
'whoami',
|
||||||
'check',
|
'check',
|
||||||
@@ -81,7 +86,9 @@ export function createProgram(ctx: CliContext): Command {
|
|||||||
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
|
].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>`'),
|
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(
|
].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.)`)}`,
|
)}\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);
|
registerReadCommands(program, ctx);
|
||||||
registerSearchCommands(program, ctx);
|
registerSearchCommands(program, ctx);
|
||||||
registerBookmarksCommand(program, ctx);
|
registerBookmarksCommand(program, ctx);
|
||||||
|
registerUnbookmarkCommand(program, ctx);
|
||||||
|
registerListsCommand(program, ctx);
|
||||||
registerUserCommands(program, ctx);
|
registerUserCommands(program, ctx);
|
||||||
registerCheckCommand(program, ctx);
|
registerCheckCommand(program, ctx);
|
||||||
|
|
||||||
|
|||||||
+70
-29
@@ -9,39 +9,80 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
.description('Get your bookmarked tweets')
|
.description('Get your bookmarked tweets')
|
||||||
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
||||||
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
.option('--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')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
|
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
||||||
const opts = program.opts();
|
.action(
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
async (cmdOpts: {
|
||||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
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) {
|
for (const warning of warnings) {
|
||||||
console.error(`${ctx.p('warn')}${warning}`);
|
console.error(`${ctx.p('warn')}${warning}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cookies.authToken || !cookies.ct0) {
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
const usePagination = cmdOpts.all || cmdOpts.cursor;
|
||||||
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
if (maxPages !== undefined && !usePagination) {
|
||||||
if (cmdOpts.folderId && !folderId) {
|
console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`);
|
||||||
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
process.exit(1);
|
||||||
process.exit(1);
|
}
|
||||||
}
|
if (!usePagination && (!Number.isFinite(count) || count <= 0)) {
|
||||||
const result = folderId
|
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
|
||||||
? await client.getBookmarkFolderTimeline(folderId, count)
|
process.exit(1);
|
||||||
: await client.getBookmarks(count);
|
}
|
||||||
|
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 client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
|
if (cmdOpts.folderId && !folderId) {
|
||||||
} else {
|
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
||||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
process.exit(1);
|
||||||
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 { Command } from 'commander';
|
||||||
import type { CliContext } from '../cli/shared.js';
|
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';
|
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 {
|
export function registerQueryIdsCommand(program: Command, ctx: CliContext): void {
|
||||||
program
|
program
|
||||||
.command('query-ids')
|
.command('query-ids')
|
||||||
@@ -25,16 +43,31 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
|
|||||||
if (cmdOpts.fresh) {
|
if (cmdOpts.fresh) {
|
||||||
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
|
console.error(`${ctx.p('info')}Refreshing GraphQL query IDs…`);
|
||||||
await runtimeQueryIds.refresh(operations, { force: true });
|
await runtimeQueryIds.refresh(operations, { force: true });
|
||||||
|
console.error(`${ctx.p('info')}Refreshing feature overrides…`);
|
||||||
|
await refreshFeatureOverridesCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const featureSnapshot = getFeatureOverridesSnapshot();
|
||||||
const info = await runtimeQueryIds.getSnapshotInfo();
|
const info = await runtimeQueryIds.getSnapshotInfo();
|
||||||
if (!info) {
|
if (!info) {
|
||||||
if (cmdOpts.json) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
|
console.log(`${ctx.p('warn')}No cached query IDs yet.`);
|
||||||
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
|
console.log(`${ctx.p('info')}Run: bird query-ids --fresh`);
|
||||||
|
console.log(`features_path: ${featureSnapshot.cachePath}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +82,8 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
|
|||||||
ageMs: info.ageMs,
|
ageMs: info.ageMs,
|
||||||
ids: info.snapshot.ids,
|
ids: info.snapshot.ids,
|
||||||
discovery: info.snapshot.discovery,
|
discovery: info.snapshot.discovery,
|
||||||
|
featuresPath: featureSnapshot.cachePath,
|
||||||
|
features: featureSnapshot.overrides,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -62,5 +97,7 @@ export function registerQueryIdsCommand(program: Command, ctx: CliContext): void
|
|||||||
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
|
console.log(`fetched_at: ${info.snapshot.fetchedAt}`);
|
||||||
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
|
console.log(`fresh: ${info.isFresh ? 'yes' : 'no'}`);
|
||||||
console.log(`ops: ${Object.keys(info.snapshot.ids).length}`);
|
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')
|
.description('Read/fetch a tweet by ID or URL')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL to read')
|
||||||
.option('--json', 'Output as JSON')
|
.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 opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(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 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 (result.success && result.tweet) {
|
||||||
if (cmdOpts.json) {
|
if (cmdOpts.json || cmdOpts.jsonFull) {
|
||||||
console.log(JSON.stringify(result.tweet, null, 2));
|
console.log(JSON.stringify(result.tweet, null, 2));
|
||||||
} else {
|
} else {
|
||||||
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
|
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)')
|
.description('List replies to a tweet (by ID or URL)')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('--json', 'Output as JSON')
|
.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 opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(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 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) {
|
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 {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -85,7 +89,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void {
|
|||||||
.description('Show the full conversation thread containing the tweet')
|
.description('Show the full conversation thread containing the tweet')
|
||||||
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('--json', 'Output as JSON')
|
.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 opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(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 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) {
|
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 {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`);
|
||||||
process.exit(1);
|
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")')
|
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
||||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
.option('--json', 'Output as JSON')
|
.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 opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(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 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) {
|
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 {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
|
console.error(`${ctx.p('err')}Search failed: ${result.error}`);
|
||||||
process.exit(1);
|
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('-u, --user <handle>', 'User handle (e.g. @steipete)')
|
||||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
.option('--json', 'Output as JSON')
|
.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 opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(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) {
|
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 {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch mentions: ${result.error}`);
|
||||||
process.exit(1);
|
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')
|
.description('Get your liked tweets')
|
||||||
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
.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 opts = program.opts();
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
||||||
const quoteDepth = ctx.resolveQuoteDepthFromOptions(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 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) {
|
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 {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`);
|
||||||
process.exit(1);
|
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.
|
* 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 {
|
export function extractTweetId(input: string): string {
|
||||||
// If it's a URL, extract the tweet ID
|
// 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 TwitterClientOptions,
|
||||||
type TwitterUser,
|
type TwitterUser,
|
||||||
} from './twitter-client.js';
|
} 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';
|
export type { TweetResult, UploadMediaResult } from './twitter-client-types.js';
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
|
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
|
||||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||||
|
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
|
||||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
||||||
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
|
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
|
||||||
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
|
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
|
||||||
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
||||||
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
|
"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',
|
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
||||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||||
|
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
|
||||||
TweetDetail: '97JF30KziU00483E_8elBA',
|
TweetDetail: '97JF30KziU00483E_8elBA',
|
||||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||||
@@ -25,6 +26,10 @@ export const FALLBACK_QUERY_IDS = {
|
|||||||
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
|
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
|
||||||
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||||
|
ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg',
|
||||||
|
ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg',
|
||||||
|
ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw',
|
||||||
|
ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
export type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { applyFeatureOverrides } from './runtime-features.js';
|
||||||
|
|
||||||
export function buildArticleFeatures(): Record<string, boolean> {
|
export function buildArticleFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('article', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||||
responsive_web_profile_redirect_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,
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||||
|
responsive_web_grok_annotations_enabled: false,
|
||||||
responsive_web_jetfuel_frame: true,
|
responsive_web_jetfuel_frame: true,
|
||||||
responsive_web_grok_share_attachment_enabled: true,
|
responsive_web_grok_share_attachment_enabled: true,
|
||||||
articles_preview_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_imagine_annotation_enabled: true,
|
||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTweetDetailFeatures(): Record<string, boolean> {
|
export function buildTweetDetailFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('tweetDetail', {
|
||||||
...buildArticleFeatures(),
|
...buildArticleFeatures(),
|
||||||
responsive_web_graphql_exclude_directive_enabled: true,
|
responsive_web_graphql_exclude_directive_enabled: true,
|
||||||
communities_web_enable_tweet_community_results_fetch: 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,
|
tweet_awards_web_tipping_enabled: false,
|
||||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||||
verified_phone_label_enabled: false,
|
verified_phone_label_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildArticleFieldToggles(): Record<string, boolean> {
|
export function buildArticleFieldToggles(): Record<string, boolean> {
|
||||||
@@ -66,7 +69,7 @@ export function buildArticleFieldToggles(): Record<string, boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildSearchFeatures(): Record<string, boolean> {
|
export function buildSearchFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('search', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||||
responsive_web_profile_redirect_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,
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||||
|
responsive_web_grok_annotations_enabled: false,
|
||||||
responsive_web_jetfuel_frame: true,
|
responsive_web_jetfuel_frame: true,
|
||||||
responsive_web_grok_share_attachment_enabled: true,
|
responsive_web_grok_share_attachment_enabled: true,
|
||||||
responsive_web_edit_tweet_api_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,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
articles_preview_enabled: true,
|
articles_preview_enabled: true,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTweetCreateFeatures(): Record<string, boolean> {
|
export function buildTweetCreateFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('tweetCreate', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||||
premium_content_api_read_enabled: false,
|
premium_content_api_read_enabled: false,
|
||||||
@@ -115,6 +119,7 @@ export function buildTweetCreateFeatures(): Record<string, boolean> {
|
|||||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||||
|
responsive_web_grok_annotations_enabled: false,
|
||||||
responsive_web_jetfuel_frame: true,
|
responsive_web_jetfuel_frame: true,
|
||||||
responsive_web_grok_share_attachment_enabled: true,
|
responsive_web_grok_share_attachment_enabled: true,
|
||||||
responsive_web_edit_tweet_api_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_grok_imagine_annotation_enabled: true,
|
||||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||||
responsive_web_enhance_cards_enabled: false,
|
responsive_web_enhance_cards_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTimelineFeatures(): Record<string, boolean> {
|
export function buildTimelineFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('timeline', {
|
||||||
...buildSearchFeatures(),
|
...buildSearchFeatures(),
|
||||||
blue_business_profile_image_shape_enabled: true,
|
blue_business_profile_image_shape_enabled: true,
|
||||||
responsive_web_text_conversations_enabled: false,
|
responsive_web_text_conversations_enabled: false,
|
||||||
@@ -156,22 +161,62 @@ export function buildTimelineFeatures(): Record<string, boolean> {
|
|||||||
interactive_text_enabled: true,
|
interactive_text_enabled: true,
|
||||||
longform_notetweets_richtext_consumption_enabled: true,
|
longform_notetweets_richtext_consumption_enabled: true,
|
||||||
responsive_web_media_download_video_enabled: false,
|
responsive_web_media_download_video_enabled: false,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBookmarksFeatures(): Record<string, boolean> {
|
export function buildBookmarksFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('bookmarks', {
|
||||||
...buildTimelineFeatures(),
|
...buildTimelineFeatures(),
|
||||||
graphql_timeline_v2_bookmark_timeline: true,
|
graphql_timeline_v2_bookmark_timeline: true,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLikesFeatures(): Record<string, boolean> {
|
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> {
|
export function buildFollowingFeatures(): Record<string, boolean> {
|
||||||
return {
|
return applyFeatureOverrides('following', {
|
||||||
rweb_video_screen_enabled: true,
|
rweb_video_screen_enabled: true,
|
||||||
profile_label_improvements_pcf_label_in_post_enabled: false,
|
profile_label_improvements_pcf_label_in_post_enabled: false,
|
||||||
responsive_web_profile_redirect_enabled: true,
|
responsive_web_profile_redirect_enabled: true,
|
||||||
@@ -185,6 +230,7 @@ export function buildFollowingFeatures(): Record<string, boolean> {
|
|||||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||||
|
responsive_web_grok_annotations_enabled: false,
|
||||||
responsive_web_jetfuel_frame: false,
|
responsive_web_jetfuel_frame: false,
|
||||||
responsive_web_grok_share_attachment_enabled: false,
|
responsive_web_grok_share_attachment_enabled: false,
|
||||||
articles_preview_enabled: true,
|
articles_preview_enabled: true,
|
||||||
@@ -206,5 +252,5 @@ export function buildFollowingFeatures(): Record<string, boolean> {
|
|||||||
responsive_web_grok_imagine_annotation_enabled: false,
|
responsive_web_grok_imagine_annotation_enabled: false,
|
||||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||||
responsive_web_enhance_cards_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 type { SearchResult, TweetData } from './twitter-client-types.js';
|
||||||
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.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 {
|
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>>(
|
export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -20,7 +26,8 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
|
|||||||
/**
|
/**
|
||||||
* Search for tweets matching a query
|
* 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 features = buildSearchFeatures();
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const seen = new Set<string>();
|
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 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);
|
const nextCursor = extractCursorFromInstructions(instructions);
|
||||||
|
|
||||||
return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 };
|
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 type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
|
||||||
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
||||||
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
|
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
|
||||||
import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
|
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
|
||||||
import { parseTweetsFromInstructions } from './twitter-client-utils.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 {
|
export interface TwitterClientTimelineMethods {
|
||||||
getBookmarks(count?: number): Promise<SearchResult>;
|
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||||
getLikes(count?: number): Promise<SearchResult>;
|
getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult>;
|
||||||
getBookmarkFolderTimeline(folderId: string, count?: number): 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>>(
|
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -19,6 +34,17 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
super(...args);
|
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[]> {
|
private async getBookmarksQueryIds(): Promise<string[]> {
|
||||||
const primary = await this.getQueryId('Bookmarks');
|
const primary = await this.getQueryId('Bookmarks');
|
||||||
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
||||||
@@ -37,105 +63,19 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
/**
|
/**
|
||||||
* Get the authenticated user's bookmarks
|
* Get the authenticated user's bookmarks
|
||||||
*/
|
*/
|
||||||
async getBookmarks(count = 20): Promise<SearchResult> {
|
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
||||||
const variables = {
|
return this.getBookmarksPaged(count, options);
|
||||||
count,
|
}
|
||||||
includePromotedContent: false,
|
|
||||||
withDownvotePerspective: false,
|
|
||||||
withReactionsMetadata: false,
|
|
||||||
withReactionsPerspective: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const features = buildBookmarksFeatures();
|
async getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult> {
|
||||||
|
return this.getBookmarksPaged(Number.POSITIVE_INFINITY, options);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the authenticated user's liked tweets
|
* 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();
|
const userResult = await this.getCurrentUser();
|
||||||
if (!userResult.success || !userResult.user) {
|
if (!userResult.success || !userResult.user) {
|
||||||
return { success: false, error: userResult.error ?? 'Could not determine current 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 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 };
|
return { success: true as const, tweets, had404 };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -243,24 +183,41 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
/**
|
/**
|
||||||
* Get the authenticated user's bookmark folder timeline
|
* Get the authenticated user's bookmark folder timeline
|
||||||
*/
|
*/
|
||||||
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
|
async getBookmarkFolderTimeline(
|
||||||
const variablesWithCount = {
|
folderId: string,
|
||||||
bookmark_collection_id: folderId,
|
count = 20,
|
||||||
includePromotedContent: true,
|
options: TimelineFetchOptions = {},
|
||||||
count,
|
): Promise<SearchResult> {
|
||||||
};
|
return this.getBookmarkFolderTimelinePaged(folderId, count, options);
|
||||||
|
}
|
||||||
|
|
||||||
const variablesWithoutCount = {
|
async getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult> {
|
||||||
bookmark_collection_id: folderId,
|
return this.getBookmarkFolderTimelinePaged(folderId, Number.POSITIVE_INFINITY, options);
|
||||||
includePromotedContent: true,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
private async getBookmarksPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
|
||||||
const features = buildBookmarksFeatures();
|
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 lastError: string | undefined;
|
||||||
let had404 = false;
|
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({
|
const params = new URLSearchParams({
|
||||||
variables: JSON.stringify(variables),
|
variables: JSON.stringify(variables),
|
||||||
@@ -268,10 +225,15 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const queryId of queryIds) {
|
for (const queryId of queryIds) {
|
||||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
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',
|
method: 'GET',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
});
|
});
|
||||||
@@ -279,17 +241,23 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
had404 = true;
|
had404 = true;
|
||||||
lastError = `HTTP ${response.status}`;
|
lastError = `HTTP ${response.status}`;
|
||||||
|
this.logBookmarksDebug('bookmarks 404', { queryId });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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 };
|
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
data?: {
|
data?: {
|
||||||
bookmark_collection_timeline?: {
|
bookmark_timeline_v2?: {
|
||||||
timeline?: {
|
timeline?: {
|
||||||
instructions?: Array<{
|
instructions?: Array<{
|
||||||
entries?: Array<{
|
entries?: Array<{
|
||||||
@@ -308,43 +276,292 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
errors?: Array<{ message: string }>;
|
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) {
|
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;
|
this.logBookmarksDebug('bookmarks page parsed', {
|
||||||
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
|
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) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error.message : String(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);
|
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
|
||||||
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
|
const firstAttempt = await fetchPage(pageCount, pageCursor);
|
||||||
firstAttempt = await tryOnce(variablesWithoutCount);
|
if (firstAttempt.success) {
|
||||||
}
|
return firstAttempt;
|
||||||
if (firstAttempt.success) {
|
}
|
||||||
return { success: true, tweets: firstAttempt.tweets };
|
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) {
|
return { success: true, tweets, nextCursor };
|
||||||
await this.refreshQueryIds();
|
}
|
||||||
let secondAttempt = await tryOnce(variablesWithCount);
|
|
||||||
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
|
private async getBookmarkFolderTimelinePaged(
|
||||||
secondAttempt = await tryOnce(variablesWithoutCount);
|
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,
|
parseTweetsFromInstructions,
|
||||||
} from './twitter-client-utils.js';
|
} 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 {
|
export interface TwitterClientTweetDetailMethods {
|
||||||
getTweet(tweetId: string): Promise<GetTweetResult>;
|
getTweet(tweetId: string, options?: TweetFetchOptions): Promise<GetTweetResult>;
|
||||||
getReplies(tweetId: string): Promise<SearchResult>;
|
getReplies(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
|
||||||
getThread(tweetId: string): Promise<SearchResult>;
|
getThread(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
|
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -246,7 +252,8 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
/**
|
/**
|
||||||
* Get tweet details by ID
|
* 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);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return response;
|
return response;
|
||||||
@@ -271,7 +278,7 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
tweetId,
|
tweetId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const mapped = mapTweetResult(tweetResult, this.quoteDepth);
|
const mapped = mapTweetResult(tweetResult, { quoteDepth: this.quoteDepth, includeRaw });
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
if (tweetResult?.article) {
|
if (tweetResult?.article) {
|
||||||
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
|
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
|
* 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);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
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);
|
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
|
||||||
|
|
||||||
return { success: true, tweets: replies };
|
return { success: true, tweets: replies };
|
||||||
@@ -310,14 +318,15 @@ export function withTweetDetails<TBase extends AbstractConstructor<TwitterClient
|
|||||||
/**
|
/**
|
||||||
* Get full conversation thread for a tweet ID
|
* 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);
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
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 target = tweets.find((t) => t.id === tweetId);
|
||||||
const rootId = target?.conversationId || tweetId;
|
const rootId = target?.conversationId || tweetId;
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
import type { TwitterCookies } from './cookies.js';
|
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 = {
|
export type GraphqlTweetResult = {
|
||||||
__typename?: string;
|
__typename?: string;
|
||||||
rest_id?: string;
|
rest_id?: string;
|
||||||
@@ -11,6 +34,12 @@ export type GraphqlTweetResult = {
|
|||||||
favorite_count?: number;
|
favorite_count?: number;
|
||||||
conversation_id_str?: string;
|
conversation_id_str?: string;
|
||||||
in_reply_to_status_id_str?: string | null;
|
in_reply_to_status_id_str?: string | null;
|
||||||
|
entities?: {
|
||||||
|
media?: GraphqlMediaEntity[];
|
||||||
|
};
|
||||||
|
extended_entities?: {
|
||||||
|
media?: GraphqlMediaEntity[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
core?: {
|
core?: {
|
||||||
user_results?: {
|
user_results?: {
|
||||||
@@ -162,12 +191,33 @@ export type TweetResult =
|
|||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BookmarkMutationResult =
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface UploadMediaResult {
|
export interface UploadMediaResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
error?: 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 {
|
export interface TweetData {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -184,6 +234,10 @@ export interface TweetData {
|
|||||||
inReplyToStatusId?: string;
|
inReplyToStatusId?: string;
|
||||||
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
||||||
quotedTweet?: TweetData;
|
quotedTweet?: TweetData;
|
||||||
|
// Media attachments (photos, videos, GIFs)
|
||||||
|
media?: TweetMedia[];
|
||||||
|
// Raw GraphQL tweet result (only when includeRaw is enabled)
|
||||||
|
_raw?: GraphqlTweetResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetTweetResult {
|
export interface GetTweetResult {
|
||||||
@@ -196,6 +250,8 @@ export interface SearchResult {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
tweets?: TweetData[];
|
tweets?: TweetData[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** Cursor for fetching the next page of results */
|
||||||
|
nextCursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentUserResult {
|
export interface CurrentUserResult {
|
||||||
@@ -234,6 +290,27 @@ export interface TwitterClientOptions {
|
|||||||
quoteDepth?: number;
|
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 {
|
export interface CreateTweetResponse {
|
||||||
data?: {
|
data?: {
|
||||||
create_tweet?: {
|
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 {
|
export function normalizeQuoteDepth(value?: number): number {
|
||||||
if (value === undefined || value === null) {
|
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);
|
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 {
|
export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -163,7 +224,19 @@ export function unwrapTweetResult(result: GraphqlTweetResult | undefined): Graph
|
|||||||
return result;
|
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 userResult = result?.core?.user_results?.result;
|
||||||
const userLegacy = userResult?.legacy;
|
const userLegacy = userResult?.legacy;
|
||||||
const userCore = userResult?.core;
|
const userCore = userResult?.core;
|
||||||
@@ -183,11 +256,13 @@ export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDept
|
|||||||
if (quoteDepth > 0) {
|
if (quoteDepth > 0) {
|
||||||
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
||||||
if (quotedResult) {
|
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,
|
id: result.rest_id,
|
||||||
text,
|
text,
|
||||||
createdAt: result.legacy?.created_at,
|
createdAt: result.legacy?.created_at,
|
||||||
@@ -202,7 +277,14 @@ export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDept
|
|||||||
},
|
},
|
||||||
authorId: userId,
|
authorId: userId,
|
||||||
quotedTweet,
|
quotedTweet,
|
||||||
|
media,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (includeRaw) {
|
||||||
|
(tweetData as TweetData & { _raw: GraphqlTweetResult })._raw = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tweetData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findTweetInInstructions(
|
export function findTweetInInstructions(
|
||||||
@@ -220,7 +302,7 @@ export function findTweetInInstructions(
|
|||||||
}>
|
}>
|
||||||
| undefined,
|
| undefined,
|
||||||
tweetId: string,
|
tweetId: string,
|
||||||
) {
|
): GraphqlTweetResult | undefined {
|
||||||
if (!instructions) {
|
if (!instructions) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -294,6 +376,11 @@ export function collectTweetResultsFromEntry(entry: {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParseTweetsOptions {
|
||||||
|
quoteDepth: number;
|
||||||
|
includeRaw?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTweetsFromInstructions(
|
export function parseTweetsFromInstructions(
|
||||||
instructions:
|
instructions:
|
||||||
| Array<{
|
| Array<{
|
||||||
@@ -336,8 +423,12 @@ export function parseTweetsFromInstructions(
|
|||||||
}>;
|
}>;
|
||||||
}>
|
}>
|
||||||
| undefined,
|
| undefined,
|
||||||
quoteDepth: number,
|
quoteDepthOrOptions: number | ParseTweetsOptions,
|
||||||
): TweetData[] {
|
): TweetData[] {
|
||||||
|
const options: ParseTweetsOptions =
|
||||||
|
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
|
||||||
|
const { quoteDepth, includeRaw = false } = options;
|
||||||
|
|
||||||
const tweets: TweetData[] = [];
|
const tweets: TweetData[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -345,7 +436,7 @@ export function parseTweetsFromInstructions(
|
|||||||
for (const entry of instruction.entries ?? []) {
|
for (const entry of instruction.entries ?? []) {
|
||||||
const results = collectTweetResultsFromEntry(entry);
|
const results = collectTweetResultsFromEntry(entry);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const mapped = mapTweetResult(result, quoteDepth);
|
const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
|
||||||
if (!mapped || seen.has(mapped.id)) {
|
if (!mapped || seen.has(mapped.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { AbstractConstructor } from './twitter-client-base.js';
|
import type { AbstractConstructor } from './twitter-client-base.js';
|
||||||
import { TwitterClientBase } 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 TwitterClientMediaMethods, withMedia } from './twitter-client-media.js';
|
||||||
import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js';
|
import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js';
|
||||||
import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.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';
|
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
|
||||||
|
|
||||||
type TwitterClientInstance = TwitterClientBase &
|
type TwitterClientInstance = TwitterClientBase &
|
||||||
|
TwitterClientBookmarkMethods &
|
||||||
|
TwitterClientListMethods &
|
||||||
TwitterClientMediaMethods &
|
TwitterClientMediaMethods &
|
||||||
TwitterClientPostingMethods &
|
TwitterClientPostingMethods &
|
||||||
TwitterClientSearchMethods &
|
TwitterClientSearchMethods &
|
||||||
@@ -16,19 +20,22 @@ type TwitterClientInstance = TwitterClientBase &
|
|||||||
TwitterClientUserMethods;
|
TwitterClientUserMethods;
|
||||||
|
|
||||||
const MixedTwitterClient = withUsers(
|
const MixedTwitterClient = withUsers(
|
||||||
withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase))))),
|
withLists(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
|
||||||
) as AbstractConstructor<TwitterClientInstance>;
|
) as AbstractConstructor<TwitterClientInstance>;
|
||||||
|
|
||||||
export class TwitterClient extends MixedTwitterClient {}
|
export class TwitterClient extends MixedTwitterClient {}
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
BookmarkMutationResult,
|
||||||
CurrentUserResult,
|
CurrentUserResult,
|
||||||
FollowingResult,
|
FollowingResult,
|
||||||
GetTweetResult,
|
GetTweetResult,
|
||||||
|
ListsResult,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
TweetData,
|
TweetData,
|
||||||
TweetResult,
|
TweetResult,
|
||||||
TwitterClientOptions,
|
TwitterClientOptions,
|
||||||
|
TwitterList,
|
||||||
TwitterUser,
|
TwitterUser,
|
||||||
UploadMediaResult,
|
UploadMediaResult,
|
||||||
} from './twitter-client-types.js';
|
} from './twitter-client-types.js';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
|
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';
|
import { extractTweetId } from '../src/lib/extract-tweet-id.js';
|
||||||
|
|
||||||
describe('CLI utilities', () => {
|
describe('CLI utilities', () => {
|
||||||
@@ -19,6 +20,11 @@ describe('CLI utilities', () => {
|
|||||||
expect(extractTweetId(url)).toBe('1234567890123456789');
|
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', () => {
|
it('should return ID as-is if already an ID', () => {
|
||||||
const id = '1234567890123456789';
|
const id = '1234567890123456789';
|
||||||
expect(extractTweetId(id)).toBe('1234567890123456789');
|
expect(extractTweetId(id)).toBe('1234567890123456789');
|
||||||
@@ -56,4 +62,55 @@ describe('CLI utilities', () => {
|
|||||||
expect(extractBookmarkFolderId(url)).toBeNull();
|
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('bird <tweet-id-or-url>');
|
||||||
expect(help).toContain('--json');
|
expect(help).toContain('--json');
|
||||||
|
expect(help).toContain('--json-full');
|
||||||
|
expect(help).toContain('_raw');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows global options in subcommand help', () => {
|
it('shows global options in subcommand help', () => {
|
||||||
@@ -48,4 +50,51 @@ describe('root help output', () => {
|
|||||||
expect(help).toContain('--timeout');
|
expect(help).toContain('--timeout');
|
||||||
expect(help).toContain('--quote-depth');
|
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 () => {
|
it('returns an error for non-ok responses', async () => {
|
||||||
const mockFetch = vi
|
const mockFetch = vi
|
||||||
.fn()
|
.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' }));
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
|
||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarksQueryIds = async () => ['test'];
|
||||||
const result = await client.getBookmarks(1);
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -174,6 +178,8 @@ describe('TwitterClient API coverage', () => {
|
|||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarksQueryIds = async () => ['test'];
|
||||||
const result = await client.getBookmarks(1);
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -185,6 +191,8 @@ describe('TwitterClient API coverage', () => {
|
|||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarksQueryIds = async () => ['test'];
|
||||||
const result = await client.getBookmarks(1);
|
const result = await client.getBookmarks(1);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -362,10 +370,14 @@ describe('TwitterClient API coverage', () => {
|
|||||||
it('returns an error for non-ok responses', async () => {
|
it('returns an error for non-ok responses', async () => {
|
||||||
const mockFetch = vi
|
const mockFetch = vi
|
||||||
.fn()
|
.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' }));
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
|
||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
|
||||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -381,6 +393,8 @@ describe('TwitterClient API coverage', () => {
|
|||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
|
||||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -392,6 +406,8 @@ describe('TwitterClient API coverage', () => {
|
|||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
|
||||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
|||||||
@@ -14,4 +14,8 @@ export type TwitterClientPrivate = TwitterClient & {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
getLikesQueryIds: () => Promise<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);
|
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
|
||||||
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
|
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', () => {
|
describe('TwitterClient bookmark folders', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user