chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-01 11:31:29 +01:00
10 changed files with 935 additions and 18 deletions
+4 -1
View File
@@ -3,15 +3,18 @@
## 0.4.2 — Unreleased ## 0.4.2 — Unreleased
### Added ### Added
- `likes` command to list your liked tweets (thanks @swairshah).
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
- `following`/`followers` commands to list users (thanks @lockmeister). - `following`/`followers` commands to list users (thanks @lockmeister).
### Changed ### Changed
- Query ID updater now tracks the Likes GraphQL operation.
- Query ID updater now tracks Following/Followers GraphQL operations. - Query ID updater now tracks Following/Followers GraphQL operations.
## 0.4.1 — 2025-12-31 ## 0.4.1 — 2025-12-31
### Added ### Added
- `bookmarks` command to list your bookmarked tweets. - `bookmarks` command to list your bookmarked tweets.
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
### Changed ### Changed
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`). - Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
+28 -2
View File
@@ -50,6 +50,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>
# Likes
bird likes -n 5
# Following (who you follow) # Following (who you follow)
bird following -n 20 bird following -n 20
@@ -75,7 +79,8 @@ bird query-ids --fresh
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread. - `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query. - `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
- `bird bookmarks [-n count] [--json]` — list your bookmarked tweets. - `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
- `bird likes [-n count] [--json]` — list your liked tweets.
- `bird 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).
- `bird whoami` — print which Twitter account your cookies belong to. - `bird whoami` — print which Twitter account your cookies belong to.
@@ -83,6 +88,7 @@ bird query-ids --fresh
Global options: Global options:
- `--timeout <ms>`: abort requests after the given timeout (milliseconds). - `--timeout <ms>`: abort requests after the given timeout (milliseconds).
- `--quote-depth <n>`: max quoted tweet depth in JSON output (default: 1; 0 disables).
- `--plain`: stable output (no emoji, no color). - `--plain`: stable output (no emoji, no color).
- `--no-emoji`: disable emoji output. - `--no-emoji`: disable emoji output.
- `--no-color`: disable ANSI colors (or set `NO_COLOR=1`). - `--no-color`: disable ANSI colors (or set `NO_COLOR=1`).
@@ -122,12 +128,14 @@ Example `~/.config/bird/config.json5`:
// Cookie source order for browser extraction (string or array) // Cookie source order for browser extraction (string or array)
cookieSource: ["firefox", "safari"], cookieSource: ["firefox", "safari"],
firefoxProfile: "default-release", firefoxProfile: "default-release",
timeoutMs: 20000 timeoutMs: 20000,
quoteDepth: 1
} }
``` ```
Environment shortcuts: Environment shortcuts:
- `BIRD_TIMEOUT_MS` - `BIRD_TIMEOUT_MS`
- `BIRD_QUOTE_DEPTH`
## Output ## Output
@@ -135,6 +143,24 @@ Environment shortcuts:
- `read` returns full text for Notes and Articles when present. - `read` returns full text for Notes and Articles when present.
- Use `--plain` for stable, script-friendly output (no emoji, no color). - Use `--plain` for stable, script-friendly output (no emoji, no color).
### JSON Schema
When using `--json`, tweet objects include:
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Tweet ID |
| `text` | string | Full tweet text (includes Note/Article content when present) |
| `author` | object | `{ username, name }` |
| `authorId` | string? | Author's user ID |
| `createdAt` | string | Timestamp |
| `replyCount` | number | Number of replies |
| `retweetCount` | number | Number of retweets |
| `likeCount` | number | Number of likes |
| `conversationId` | string | Thread conversation ID |
| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) |
| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) |
## Query IDs (GraphQL) ## Query IDs (GraphQL)
X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as: X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as:
+1
View File
@@ -16,6 +16,7 @@ const TARGET_OPERATIONS = [
'Bookmarks', 'Bookmarks',
'Following', 'Following',
'Followers', 'Followers',
'Likes',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
+82 -11
View File
@@ -17,6 +17,7 @@ import JSON5 from 'json5';
import kleur from 'kleur'; import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js'; import { resolveCliInvocation } from './lib/cli-args.js';
import { type CookieSource, resolveCredentials } from './lib/cookies.js'; import { type CookieSource, resolveCredentials } from './lib/cookies.js';
import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js';
import { extractTweetId } from './lib/extract-tweet-id.js'; import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import { import {
@@ -108,6 +109,7 @@ type BirdConfig = {
firefoxProfile?: string; firefoxProfile?: string;
cookieSource?: CookieSource | CookieSource[]; cookieSource?: CookieSource | CookieSource[];
timeoutMs?: number; timeoutMs?: number;
quoteDepth?: number;
}; };
function readConfigFile(path: string): Partial<BirdConfig> { function readConfigFile(path: string): Partial<BirdConfig> {
@@ -152,6 +154,7 @@ const KNOWN_COMMANDS = new Set([
'bookmarks', 'bookmarks',
'following', 'following',
'followers', 'followers',
'likes',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
@@ -193,6 +196,7 @@ program
.option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--media <path>', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, [])
.option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--alt <text>', 'Alt text for the corresponding --media (repeatable)', collect, [])
.option('--timeout <ms>', 'Request timeout in milliseconds') .option('--timeout <ms>', 'Request timeout in milliseconds')
.option('--quote-depth <depth>', 'Max quoted tweet depth (default: 1; 0 disables)')
.option('--plain', 'Plain output (stable, no emoji, no color)') .option('--plain', 'Plain output (stable, no emoji, no color)')
.option('--no-emoji', 'Disable emoji output') .option('--no-emoji', 'Disable emoji output')
.option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)');
@@ -241,6 +245,23 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb
return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS); return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS);
} }
function resolveQuoteDepth(...values: Array<string | number | undefined | null>): number | undefined {
for (const value of values) {
if (value === undefined || value === null || value === '') {
continue;
}
const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.floor(parsed);
}
}
return undefined;
}
function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined {
return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, process.env.BIRD_QUOTE_DEPTH);
}
function detectMime(path: string): string | null { function detectMime(path: string): string | null {
const ext = path.toLowerCase(); const ext = path.toLowerCase();
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) { if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
@@ -404,6 +425,7 @@ program
.action(async (text: string) => { .action(async (text: string) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = []; let media: MediaSpec[] = [];
try { try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
@@ -427,7 +449,7 @@ program
console.error(`${l('source')}${cookies.source}`); console.error(`${l('source')}${cookies.source}`);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
let mediaIds: string[] | undefined; let mediaIds: string[] | undefined;
if (media.length > 0) { if (media.length > 0) {
const uploaded: string[] = []; const uploaded: string[] = [];
@@ -462,6 +484,7 @@ program
.action(async (tweetIdOrUrl: string, text: string) => { .action(async (tweetIdOrUrl: string, text: string) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
let media: MediaSpec[] = []; let media: MediaSpec[] = [];
try { try {
media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] });
@@ -488,7 +511,7 @@ program
console.error(`${p('info')}Replying to tweet: ${tweetId}`); console.error(`${p('info')}Replying to tweet: ${tweetId}`);
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
let mediaIds: string[] | undefined; let mediaIds: string[] | undefined;
if (media.length > 0) { if (media.length > 0) {
const uploaded: string[] = []; const uploaded: string[] = [];
@@ -523,6 +546,7 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
@@ -537,7 +561,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getTweet(tweetId); const result = await client.getTweet(tweetId);
if (result.success && result.tweet) { if (result.success && result.tweet) {
@@ -566,6 +590,7 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -579,7 +604,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getReplies(tweetId); const result = await client.getReplies(tweetId);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -599,6 +624,7 @@ program
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const tweetId = extractTweetId(tweetIdOrUrl); const tweetId = extractTweetId(tweetIdOrUrl);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -612,7 +638,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getThread(tweetId); const result = await client.getThread(tweetId);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -633,6 +659,7 @@ program
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -646,7 +673,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.search(query, count); const result = await client.search(query, count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
@@ -667,6 +694,7 @@ program
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '10', 10); const count = Number.parseInt(cmdOpts.count || '10', 10);
const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user);
@@ -688,7 +716,7 @@ program
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
if (!query) { if (!query) {
const who = await client.getCurrentUser(); const who = await client.getCurrentUser();
@@ -718,8 +746,9 @@ program
.command('bookmarks') .command('bookmarks')
.description('Get your bookmarked tweets') .description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20') .option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => { .action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10); const count = Number.parseInt(cmdOpts.count || '20', 10);
@@ -736,10 +765,18 @@ program
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getBookmarks(count); const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
if (result.success && result.tweets) { if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' }); const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
} else { } else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`); console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1); process.exit(1);
@@ -872,6 +909,39 @@ program
} }
}); });
// Likes command - get user's liked tweets
program
.command('likes')
.description('Get your liked tweets')
.option('-n, --count <number>', 'Number of likes to fetch', '20')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getLikes(count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
} else {
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
process.exit(1);
}
});
// Whoami command - show the logged-in account // Whoami command - show the logged-in account
program program
.command('whoami') .command('whoami')
@@ -879,6 +949,7 @@ program
.action(async () => { .action(async () => {
const opts = program.opts(); const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts); const timeoutMs = resolveTimeoutFromOptions(opts);
const quoteDepth = resolveQuoteDepthFromOptions(opts);
const { cookies, warnings } = await resolveCredentialsFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
@@ -895,7 +966,7 @@ program
console.error(`${l('source')}${cookies.source}`); console.error(`${l('source')}${cookies.source}`);
} }
const client = new TwitterClient({ cookies, timeoutMs }); const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
const result = await client.getCurrentUser(); const result = await client.getCurrentUser();
const credentialSource = cookies.source ?? 'env/auto-detected cookies'; const credentialSource = cookies.source ?? 'env/auto-detected cookies';
+20
View File
@@ -0,0 +1,20 @@
/**
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
*/
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
export function extractBookmarkFolderId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+1
View File
@@ -4,6 +4,7 @@
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "97JF30KziU00483E_8elBA", "TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Likes": "JR2gceKucIKcVNB_9JkhsA",
"Following": "BEkNpEt5pNETESoqMsTEGA", "Following": "BEkNpEt5pNETESoqMsTEGA",
"Followers": "kuFUYP9eV1FPoEy4N-pi7w" "Followers": "kuFUYP9eV1FPoEy4N-pi7w"
} }
+280 -3
View File
@@ -30,6 +30,8 @@ const FALLBACK_QUERY_IDS = {
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Following: 'BEkNpEt5pNETESoqMsTEGA', Following: 'BEkNpEt5pNETESoqMsTEGA',
Followers: 'kuFUYP9eV1FPoEy4N-pi7w', Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
} as const; } as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS; type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -41,7 +43,18 @@ const QUERY_IDS: Record<OperationName, string> = {
const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>; const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array<OperationName>;
function normalizeQuoteDepth(value?: number): number {
if (value === undefined || value === null) {
return 1;
}
if (!Number.isFinite(value)) {
return 1;
}
return Math.max(0, Math.floor(value));
}
type GraphqlTweetResult = { type GraphqlTweetResult = {
__typename?: string;
rest_id?: string; rest_id?: string;
legacy?: { legacy?: {
full_text?: string; full_text?: string;
@@ -186,6 +199,10 @@ type GraphqlTweetResult = {
}>; }>;
}>; }>;
}; };
tweet?: GraphqlTweetResult;
quoted_status_result?: {
result?: GraphqlTweetResult;
};
}; };
export type TweetResult = export type TweetResult =
@@ -218,6 +235,8 @@ export interface TweetData {
likeCount?: number; likeCount?: number;
conversationId?: string; conversationId?: string;
inReplyToStatusId?: string; inReplyToStatusId?: string;
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
quotedTweet?: TweetData;
} }
export interface GetTweetResult { export interface GetTweetResult {
@@ -264,6 +283,8 @@ export interface TwitterClientOptions {
cookies: TwitterCookies; cookies: TwitterCookies;
userAgent?: string; userAgent?: string;
timeoutMs?: number; timeoutMs?: number;
// Max depth for quoted tweets (0 disables). Defaults to 1.
quoteDepth?: number;
} }
interface CreateTweetResponse { interface CreateTweetResponse {
@@ -288,6 +309,7 @@ export class TwitterClient {
private cookieHeader: string; private cookieHeader: string;
private userAgent: string; private userAgent: string;
private timeoutMs?: number; private timeoutMs?: number;
private quoteDepth: number;
private clientUuid: string; private clientUuid: string;
private clientDeviceId: string; private clientDeviceId: string;
private clientUserId?: string; private clientUserId?: string;
@@ -303,6 +325,7 @@ export class TwitterClient {
options.userAgent || options.userAgent ||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
this.timeoutMs = options.timeoutMs; this.timeoutMs = options.timeoutMs;
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
this.clientUuid = randomUUID(); this.clientUuid = randomUUID();
this.clientDeviceId = randomUUID(); this.clientDeviceId = randomUUID();
} }
@@ -729,7 +752,17 @@ export class TwitterClient {
); );
} }
private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined { private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
if (!result) {
return undefined;
}
if (result.tweet) {
return result.tweet;
}
return result;
}
private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined {
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;
@@ -745,6 +778,14 @@ export class TwitterClient {
return undefined; return undefined;
} }
let quotedTweet: TweetData | undefined;
if (quoteDepth > 0) {
const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result);
if (quotedResult) {
quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1);
}
}
return { return {
id: result.rest_id, id: result.rest_id,
text, text,
@@ -759,6 +800,7 @@ export class TwitterClient {
name: name || username, name: name || username,
}, },
authorId: userId, authorId: userId,
quotedTweet,
}; };
} }
@@ -1890,10 +1932,10 @@ export class TwitterClient {
return { success: true, tweets: thread }; return { success: true, tweets: thread };
} }
private buildBookmarksFeatures(): Record<string, boolean> { // Shared timeline features for likes/bookmarks-style timelines.
private buildTimelineFeatures(): Record<string, boolean> {
return { return {
...this.buildSearchFeatures(), ...this.buildSearchFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
blue_business_profile_image_shape_enabled: true, blue_business_profile_image_shape_enabled: true,
responsive_web_text_conversations_enabled: false, responsive_web_text_conversations_enabled: false,
tweetypie_unmention_optimization_enabled: true, tweetypie_unmention_optimization_enabled: true,
@@ -1905,11 +1947,27 @@ export class TwitterClient {
}; };
} }
private buildBookmarksFeatures(): Record<string, boolean> {
return {
...this.buildTimelineFeatures(),
graphql_timeline_v2_bookmark_timeline: true,
};
}
private buildLikesFeatures(): Record<string, boolean> {
return this.buildTimelineFeatures();
}
private async getBookmarksQueryIds(): Promise<string[]> { private async getBookmarksQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Bookmarks'); const primary = await this.getQueryId('Bookmarks');
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
} }
private async getBookmarkFolderQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('BookmarkFolderTimeline');
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
}
/** /**
* Get the authenticated user's bookmarks * Get the authenticated user's bookmarks
*/ */
@@ -2309,6 +2367,225 @@ export class TwitterClient {
return { success: false, error: secondAttempt.error }; return { success: false, error: secondAttempt.error };
} }
return { success: false, error: firstAttempt.error };
}
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
}
/**
* Get the authenticated user's liked tweets
*/
async getLikes(count = 20): Promise<SearchResult> {
const userResult = await this.getCurrentUser();
if (!userResult.success || !userResult.user) {
return { success: false, error: userResult.error ?? 'Could not determine current user' };
}
const variables = {
userId: userResult.user.id,
count,
includePromotedContent: false,
withClientEventToken: false,
withBirdwatchNotes: false,
withVoice: true,
};
const features = this.buildLikesFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
const tryOnce = async () => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getLikesQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
/**
* Get the authenticated user's bookmark folder timeline
*/
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
const variablesWithCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
count,
};
const variablesWithoutCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
};
const features = this.buildBookmarksFeatures();
const tryOnce = async (variables: Record<string, unknown>) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status === 404) {
had404 = true;
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
}
const data = (await response.json()) as {
data?: {
bookmark_collection_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true as const, tweets, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
};
let firstAttempt = await tryOnce(variablesWithCount);
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
firstAttempt = await tryOnce(variablesWithoutCount);
}
if (firstAttempt.success) {
return { success: true, tweets: firstAttempt.tweets };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
let secondAttempt = await tryOnce(variablesWithCount);
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
secondAttempt = await tryOnce(variablesWithoutCount);
}
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error }; return { success: false, error: firstAttempt.error };
} }
} }
+27
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
import { extractTweetId } from '../src/lib/extract-tweet-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js';
describe('CLI utilities', () => { describe('CLI utilities', () => {
@@ -29,4 +30,30 @@ describe('CLI utilities', () => {
expect(extractTweetId(url)).toBe('1234567890123456789'); expect(extractTweetId(url)).toBe('1234567890123456789');
}); });
}); });
describe('extractBookmarkFolderId', () => {
it('should extract ID from x.com bookmarks URL', () => {
const url = 'https://x.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should extract ID from twitter.com bookmarks URL', () => {
const url = 'https://twitter.com/i/bookmarks/1976792203235119344';
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
});
it('should accept a numeric ID as-is', () => {
const id = '1976792203235119344';
expect(extractBookmarkFolderId(id)).toBe('1976792203235119344');
});
it('should return null for invalid values', () => {
expect(extractBookmarkFolderId('not-an-id')).toBeNull();
});
it('should return null for folder_id query URLs', () => {
const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344';
expect(extractBookmarkFolderId(url)).toBeNull();
});
});
}); });
+153
View File
@@ -19,6 +19,13 @@ type TwitterClientApiPrivate = TwitterClient & {
getBookmarksQueryIds: () => Promise<string[]>; getBookmarksQueryIds: () => Promise<string[]>;
getFollowingQueryIds: () => Promise<string[]>; getFollowingQueryIds: () => Promise<string[]>;
getFollowersQueryIds: () => Promise<string[]>; getFollowersQueryIds: () => Promise<string[]>;
getLikesQueryIds: () => Promise<string[]>;
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getBookmarkFolderQueryIds: () => Promise<string[]>;
}; };
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({ const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
@@ -263,6 +270,152 @@ describe('TwitterClient API coverage', () => {
const result = await client.getFollowers('123', 1); const result = await client.getFollowers('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('likes error paths', () => {
const stubCurrentUser = async () => ({
success: true,
user: { id: '123', username: 'tester', name: 'Tester' },
});
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => [];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching likes');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getCurrentUser = stubCurrentUser;
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404');
});
});
describe('bookmark folder error paths', () => {
it('returns an error for non-ok responses', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
it('returns API errors from payloads', async () => {
const mockFetch = vi.fn().mockResolvedValueOnce(
makeResponse({
json: async () => ({ errors: [{ message: 'bad' }] }),
}),
);
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('bad');
});
it('returns an error when fetching throws', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toContain('boom');
});
it('returns unknown error when no query ids are available', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientApiPrivate;
clientPrivate.getBookmarkFolderQueryIds = async () => [];
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false);
expect(result.error).toBe('Unknown error fetching bookmark folder');
});
it('returns the second attempt error after 404s', async () => {
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
global.fetch = mockFetch as unknown as typeof fetch;
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 1);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toBe('HTTP 404'); expect(result.error).toBe('HTTP 404');
}); });
+339 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js'; import { type TweetData, TwitterClient } from '../src/lib/twitter-client.js';
describe('TwitterClient', () => { describe('TwitterClient', () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -9,6 +9,14 @@ describe('TwitterClient', () => {
cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token',
source: 'test', source: 'test',
}; };
type TwitterClientPrivate = TwitterClient & {
getCurrentUser: () => Promise<{
success: boolean;
user?: { id: string; username: string; name: string };
error?: string;
}>;
getLikesQueryIds: () => Promise<string[]>;
};
afterEach(() => { afterEach(() => {
global.fetch = originalFetch; global.fetch = originalFetch;
@@ -952,6 +960,95 @@ describe('TwitterClient', () => {
}); });
}); });
describe('quoted tweets', () => {
const makeTweetResult = (
id: string,
text: string,
username = `user${id}`,
name = `User ${id}`,
): Record<string, unknown> => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: `u${id}`,
legacy: { screen_name: username, name },
},
},
},
});
it('includes one level of quoted tweet by default', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet?.id).toBe('2');
expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined();
});
it('honors quoteDepth = 0', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet).toBeUndefined();
});
it('recurses when quoteDepth > 1', () => {
const quoted2 = makeTweetResult('3', 'quoted2');
const quoted1 = makeTweetResult('2', 'quoted1');
quoted1.quoted_status_result = { result: quoted2 };
const root = makeTweetResult('1', 'root');
root.quoted_status_result = { result: quoted1 };
const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet?.id).toBe('2');
expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3');
expect(mapped?.quotedTweet?.quotedTweet?.quotedTweet).toBeUndefined();
});
it('unwraps quoted tweet visibility wrappers', () => {
const quoted = makeTweetResult('2', 'quoted');
const root = makeTweetResult('1', 'root');
root.quoted_status_result = {
result: {
__typename: 'TweetWithVisibilityResults',
tweet: quoted,
},
};
const client = new TwitterClient({ cookies: validCookies });
const mapped = (
client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }
).mapTweetResult(root);
expect(mapped?.quotedTweet?.id).toBe('2');
});
});
describe('search', () => { describe('search', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;
@@ -1332,7 +1429,248 @@ describe('TwitterClient', () => {
expect(mockFetch).toHaveBeenCalledTimes(2); expect(mockFetch).toHaveBeenCalledTimes(2);
}); });
}); });
describe('likes', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches likes and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('2');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/Likes?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.userId).toBe('42');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
});
it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' });
const result = await client.getLikes(1);
expect(result.success).toBe(false);
expect(result.error).toBe('no user');
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('bookmark folders', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches bookmark folder timeline and parses tweet results', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('9');
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(String(url)).toContain('/BookmarkFolderTimeline?');
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
expect(parsedVars.bookmark_collection_id).toBe('123');
expect(parsedVars.count).toBe(2);
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
it('retries without count when API rejects the count variable', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Variable "$count" is not defined by operation' }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_collection_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '9',
legacy: {
full_text: 'saved in folder',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '9',
},
core: {
user_results: {
result: {
rest_id: 'u9',
legacy: { screen_name: 'folder', name: 'Folder' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> };
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
const result = await client.getBookmarkFolderTimeline('123', 2);
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
const firstVars = JSON.parse(
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string,
);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(firstVars.count).toBe(2);
expect(secondVars.count).toBeUndefined();
});
});
describe('conversation helpers', () => { describe('conversation helpers', () => {
let mockFetch: ReturnType<typeof vi.fn>; let mockFetch: ReturnType<typeof vi.fn>;