chore: merge origin/main
This commit is contained in:
+114
-34
@@ -17,6 +17,7 @@ import JSON5 from 'json5';
|
||||
import kleur from 'kleur';
|
||||
import { resolveCliInvocation } from './lib/cli-args.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 { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
|
||||
import {
|
||||
@@ -32,16 +33,19 @@ import { runtimeQueryIds } from './lib/runtime-query-ids.js';
|
||||
import { type TweetData, TwitterClient } from './lib/twitter-client.js';
|
||||
import { getCliVersion } from './lib/version.js';
|
||||
|
||||
const program = new Command();
|
||||
const program: Command = new Command();
|
||||
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const normalizedArgs = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
|
||||
const isTty = process.stdout.isTTY;
|
||||
const rawArgs: string[] = process.argv.slice(2);
|
||||
const normalizedArgs: string[] = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs;
|
||||
const isTty: boolean = process.stdout.isTTY;
|
||||
let output: OutputConfig = resolveOutputConfigFromArgv(normalizedArgs, process.env, isTty);
|
||||
kleur.enabled = output.color;
|
||||
|
||||
const wrap = (styler: (text: string) => string) => (text: string) => (isTty ? styler(text) : text);
|
||||
const collect = (value: string, previous: string[] = []) => {
|
||||
const wrap =
|
||||
(styler: (text: string) => string): ((text: string) => string) =>
|
||||
(text: string): string =>
|
||||
isTty ? styler(text) : text;
|
||||
const collect = (value: string, previous: string[] = []): string[] => {
|
||||
previous.push(value);
|
||||
return previous;
|
||||
};
|
||||
@@ -50,16 +54,22 @@ const COOKIE_SOURCES: CookieSource[] = ['safari', 'chrome', 'firefox'];
|
||||
|
||||
function parseCookieSource(value: string): CookieSource {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') return normalized;
|
||||
if (normalized === 'safari' || normalized === 'chrome' || normalized === 'firefox') {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(`Invalid --cookie-source "${value}". Allowed: safari, chrome, firefox.`);
|
||||
}
|
||||
|
||||
function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
|
||||
if (typeof input === 'string') return [parseCookieSource(input)];
|
||||
if (typeof input === 'string') {
|
||||
return [parseCookieSource(input)];
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
const result: CookieSource[] = [];
|
||||
for (const entry of input) {
|
||||
if (typeof entry !== 'string') continue;
|
||||
if (typeof entry !== 'string') {
|
||||
continue;
|
||||
}
|
||||
result.push(parseCookieSource(entry));
|
||||
}
|
||||
return result.length > 0 ? result : undefined;
|
||||
@@ -67,15 +77,15 @@ function resolveCookieSourceOrder(input: unknown): CookieSource[] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const collectCookieSource = (value: string, previous: CookieSource[] = []) => {
|
||||
const collectCookieSource = (value: string, previous: CookieSource[] = []): CookieSource[] => {
|
||||
previous.push(parseCookieSource(value));
|
||||
return previous;
|
||||
};
|
||||
|
||||
const p = (kind: Parameters<typeof statusPrefix>[0]) => statusPrefix(kind, output);
|
||||
const l = (kind: Parameters<typeof labelPrefix>[0]) => labelPrefix(kind, output);
|
||||
const p = (kind: Parameters<typeof statusPrefix>[0]): string => statusPrefix(kind, output);
|
||||
const l = (kind: Parameters<typeof labelPrefix>[0]): string => labelPrefix(kind, output);
|
||||
|
||||
function applyOutputFromCommand(command: Command) {
|
||||
function applyOutputFromCommand(command: Command): void {
|
||||
const opts = command.optsWithGlobals() as { plain?: boolean; emoji?: boolean; color?: boolean };
|
||||
output = resolveOutputConfigFromCommander(opts, process.env, isTty);
|
||||
kleur.enabled = output.color;
|
||||
@@ -103,7 +113,9 @@ type BirdConfig = {
|
||||
};
|
||||
|
||||
function readConfigFile(path: string): Partial<BirdConfig> {
|
||||
if (!existsSync(path)) return {};
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const parsed = JSON5.parse(raw) as Partial<BirdConfig>;
|
||||
@@ -140,6 +152,7 @@ const KNOWN_COMMANDS = new Set([
|
||||
'search',
|
||||
'mentions',
|
||||
'bookmarks',
|
||||
'likes',
|
||||
'help',
|
||||
'whoami',
|
||||
'check',
|
||||
@@ -152,7 +165,7 @@ program.addHelpText(
|
||||
|
||||
program.name('bird').description('Post tweets and replies via Twitter/X GraphQL API').version(getCliVersion());
|
||||
|
||||
const formatExample = (command: string, description: string) =>
|
||||
const formatExample = (command: string, description: string): string =>
|
||||
`${colors.command(` ${command}`)}\n${colors.muted(` ${description}`)}`;
|
||||
|
||||
program.addHelpText(
|
||||
@@ -194,7 +207,7 @@ type CredentialsOptions = {
|
||||
cookieSource?: CookieSource[];
|
||||
};
|
||||
|
||||
function resolveCredentialsFromOptions(opts: CredentialsOptions) {
|
||||
function resolveCredentialsFromOptions(opts: CredentialsOptions): ReturnType<typeof resolveCredentials> {
|
||||
const cookieSource = opts.cookieSource?.length
|
||||
? opts.cookieSource
|
||||
: (resolveCookieSourceOrder(config.cookieSource) ?? COOKIE_SOURCES);
|
||||
@@ -215,9 +228,13 @@ type MediaSpec = { path: string; alt?: string; mime: string; buffer: Buffer };
|
||||
|
||||
function resolveTimeoutMs(...values: Array<string | number | undefined | null>): number | undefined {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (value === undefined || value === null || value === '') {
|
||||
continue;
|
||||
}
|
||||
const parsed = typeof value === 'number' ? value : Number(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -241,17 +258,31 @@ function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number })
|
||||
|
||||
function detectMime(path: string): string | null {
|
||||
const ext = path.toLowerCase();
|
||||
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (ext.endsWith('.png')) return 'image/png';
|
||||
if (ext.endsWith('.webp')) return 'image/webp';
|
||||
if (ext.endsWith('.gif')) return 'image/gif';
|
||||
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) return 'video/mp4';
|
||||
if (ext.endsWith('.mov')) return 'video/quicktime';
|
||||
if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (ext.endsWith('.png')) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (ext.endsWith('.webp')) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (ext.endsWith('.gif')) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (ext.endsWith('.mp4') || ext.endsWith('.m4v')) {
|
||||
return 'video/mp4';
|
||||
}
|
||||
if (ext.endsWith('.mov')) {
|
||||
return 'video/quicktime';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
|
||||
if (opts.media.length === 0) return [];
|
||||
if (opts.media.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const specs: MediaSpec[] = [];
|
||||
for (const [index, path] of opts.media.entries()) {
|
||||
const mime = detectMime(path);
|
||||
@@ -263,9 +294,15 @@ function loadMedia(opts: { media: string[]; alts: string[] }): MediaSpec[] {
|
||||
}
|
||||
|
||||
const videoCount = specs.filter((m) => m.mime.startsWith('video/')).length;
|
||||
if (videoCount > 1) throw new Error('Only one video can be attached');
|
||||
if (videoCount === 1 && specs.length > 1) throw new Error('Video cannot be combined with other media');
|
||||
if (specs.length > 4) throw new Error('Maximum 4 media attachments');
|
||||
if (videoCount > 1) {
|
||||
throw new Error('Only one video can be attached');
|
||||
}
|
||||
if (videoCount === 1 && specs.length > 1) {
|
||||
throw new Error('Video cannot be combined with other media');
|
||||
}
|
||||
if (specs.length > 4) {
|
||||
throw new Error('Maximum 4 media attachments');
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
@@ -327,6 +364,7 @@ program
|
||||
'SearchTimeline',
|
||||
'UserArticlesTweets',
|
||||
'Bookmarks',
|
||||
'Likes',
|
||||
];
|
||||
|
||||
if (cmdOpts.fresh) {
|
||||
@@ -675,13 +713,13 @@ program
|
||||
if (!query) {
|
||||
const who = await client.getCurrentUser();
|
||||
const handle = normalizeHandle(who.user?.username);
|
||||
if (!handle) {
|
||||
if (handle) {
|
||||
query = `@${handle}`;
|
||||
} else {
|
||||
console.error(
|
||||
`${p('err')}Could not determine current user (${who.error ?? 'Unknown error'}). Use --user <handle>.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
query = `@${handle}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,6 +738,48 @@ program
|
||||
.command('bookmarks')
|
||||
.description('Get your bookmarked tweets')
|
||||
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
||||
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.error(`${p('warn')}${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
||||
if (cmdOpts.folderId && !folderId) {
|
||||
console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const result = folderId
|
||||
? await client.getBookmarkFolderTimeline(folderId, count)
|
||||
: await client.getBookmarks(count);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
|
||||
} else {
|
||||
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Likes command - get user's liked tweets
|
||||
program
|
||||
.command('likes')
|
||||
.description('Get your liked tweets')
|
||||
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
@@ -719,12 +799,12 @@ program
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
|
||||
const result = await client.getBookmarks(count);
|
||||
const result = await client.getLikes(count);
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' });
|
||||
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
|
||||
} else {
|
||||
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@ const TWEET_ID_REGEX = /^\d{8,}$/;
|
||||
|
||||
export function looksLikeTweetInput(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return TWEET_URL_REGEX.test(trimmed) || TWEET_ID_REGEX.test(trimmed);
|
||||
}
|
||||
|
||||
|
||||
+40
-14
@@ -21,10 +21,12 @@ export type CookieSource = 'safari' | 'chrome' | 'firefox';
|
||||
|
||||
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const;
|
||||
const TWITTER_URL = 'https://x.com/';
|
||||
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
|
||||
const TWITTER_ORIGINS: string[] = ['https://x.com/', 'https://twitter.com/'];
|
||||
|
||||
function normalizeValue(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
@@ -38,24 +40,36 @@ function buildEmpty(): TwitterCookies {
|
||||
}
|
||||
|
||||
function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void {
|
||||
if (cookies[field]) return;
|
||||
if (cookies[field]) {
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = normalizeValue(process.env[key]);
|
||||
if (!value) continue;
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
cookies[field] = value;
|
||||
if (!cookies.source) cookies.source = `env ${key}`;
|
||||
if (!cookies.source) {
|
||||
cookies.source = `env ${key}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] {
|
||||
if (Array.isArray(cookieSource)) return cookieSource;
|
||||
if (cookieSource) return [cookieSource];
|
||||
if (Array.isArray(cookieSource)) {
|
||||
return cookieSource;
|
||||
}
|
||||
if (cookieSource) {
|
||||
return [cookieSource];
|
||||
}
|
||||
return ['safari', 'chrome', 'firefox'];
|
||||
}
|
||||
|
||||
function labelForSource(source: CookieSource, profile?: string): string {
|
||||
if (source === 'safari') return 'Safari';
|
||||
if (source === 'safari') {
|
||||
return 'Safari';
|
||||
}
|
||||
if (source === 'chrome') {
|
||||
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
||||
}
|
||||
@@ -67,13 +81,19 @@ function pickCookieValue(
|
||||
name: (typeof TWITTER_COOKIE_NAMES)[number],
|
||||
): string | null {
|
||||
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
|
||||
if (preferred?.value) return preferred.value;
|
||||
if (preferred?.value) {
|
||||
return preferred.value;
|
||||
}
|
||||
|
||||
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
|
||||
if (twitter?.value) return twitter.value;
|
||||
if (twitter?.value) {
|
||||
return twitter.value;
|
||||
}
|
||||
|
||||
return matches[0]?.value ?? null;
|
||||
}
|
||||
@@ -99,8 +119,12 @@ async function readTwitterCookiesFromBrowser(options: {
|
||||
|
||||
const authToken = pickCookieValue(cookies, 'auth_token');
|
||||
const ct0 = pickCookieValue(cookies, 'ct0');
|
||||
if (authToken) out.authToken = authToken;
|
||||
if (ct0) out.ct0 = ct0;
|
||||
if (authToken) {
|
||||
out.authToken = authToken;
|
||||
}
|
||||
if (ct0) {
|
||||
out.ct0 = ct0;
|
||||
}
|
||||
|
||||
if (out.authToken && out.ct0) {
|
||||
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
|
||||
@@ -156,7 +180,9 @@ export async function resolveCredentials(options: {
|
||||
}
|
||||
if (options.ct0) {
|
||||
cookies.ct0 = options.ct0;
|
||||
if (!cookies.source) cookies.source = 'CLI argument';
|
||||
if (!cookies.source) {
|
||||
cookies.source = 'CLI argument';
|
||||
}
|
||||
}
|
||||
|
||||
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
|
||||
|
||||
@@ -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,9 +1,11 @@
|
||||
/**
|
||||
* Extract tweet ID from a Twitter/X URL or return the input unchanged if it's already an ID.
|
||||
*/
|
||||
const TWEET_URL_REGEX = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;
|
||||
|
||||
export function extractTweetId(input: string): string {
|
||||
// If it's a URL, extract the tweet ID
|
||||
const urlMatch = input.match(/(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/);
|
||||
const urlMatch = TWEET_URL_REGEX.exec(input);
|
||||
if (urlMatch) {
|
||||
return urlMatch[1];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
const HANDLE_REGEX = /^[A-Za-z0-9_]{1,15}$/;
|
||||
|
||||
export function normalizeHandle(input?: string | null): string | null {
|
||||
const raw = (input ?? '').trim();
|
||||
if (!raw) return null;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||
const handle = withoutAt.trim();
|
||||
if (!handle) return null;
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// X/Twitter handles are traditionally max 15 chars; keep strict to avoid surprising queries.
|
||||
if (!/^[A-Za-z0-9_]{1,15}$/.test(handle)) return null;
|
||||
if (!HANDLE_REGEX.test(handle)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
@@ -16,7 +24,9 @@ export function mentionsQueryFromUserOption(userOption?: string | null): {
|
||||
query: string | null;
|
||||
error: string | null;
|
||||
} {
|
||||
if (typeof userOption === 'undefined') return { query: null, error: null };
|
||||
if (typeof userOption === 'undefined') {
|
||||
return { query: null, error: null };
|
||||
}
|
||||
|
||||
const handle = normalizeHandle(userOption);
|
||||
if (!handle) {
|
||||
|
||||
+18
-6
@@ -53,14 +53,22 @@ export function resolveOutputConfigFromCommander(
|
||||
}
|
||||
|
||||
export function statusPrefix(kind: StatusKind, cfg: OutputConfig): string {
|
||||
if (cfg.plain) return `${STATUS[kind].plain} `;
|
||||
if (cfg.emoji) return `${STATUS[kind].emoji} `;
|
||||
if (cfg.plain) {
|
||||
return `${STATUS[kind].plain} `;
|
||||
}
|
||||
if (cfg.emoji) {
|
||||
return `${STATUS[kind].emoji} `;
|
||||
}
|
||||
return `${STATUS[kind].text} `;
|
||||
}
|
||||
|
||||
export function labelPrefix(kind: LabelKind, cfg: OutputConfig): string {
|
||||
if (cfg.plain) return `${LABELS[kind].plain} `;
|
||||
if (cfg.emoji) return `${LABELS[kind].emoji} `;
|
||||
if (cfg.plain) {
|
||||
return `${LABELS[kind].plain} `;
|
||||
}
|
||||
if (cfg.emoji) {
|
||||
return `${LABELS[kind].emoji} `;
|
||||
}
|
||||
return `${LABELS[kind].text} `;
|
||||
}
|
||||
|
||||
@@ -72,8 +80,12 @@ export function formatStatsLine(
|
||||
const retweetCount = stats.retweetCount ?? 0;
|
||||
const replyCount = stats.replyCount ?? 0;
|
||||
|
||||
if (cfg.plain) return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
|
||||
if (!cfg.emoji) return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
|
||||
if (cfg.plain) {
|
||||
return `likes: ${likeCount} retweets: ${retweetCount} replies: ${replyCount}`;
|
||||
}
|
||||
if (!cfg.emoji) {
|
||||
return `Likes ${likeCount} Retweets ${retweetCount} Replies ${replyCount}`;
|
||||
}
|
||||
return `❤️ ${likeCount} 🔁 ${retweetCount} 💬 ${replyCount}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
|
||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
||||
"Likes": "JR2gceKucIKcVNB_9JkhsA"
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_CACHE_FILENAME = 'query-ids-cache.json';
|
||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_TTL_MS: number = 24 * 60 * 60 * 1000;
|
||||
|
||||
const DISCOVERY_PAGES = [
|
||||
const DISCOVERY_PAGES: string[] = [
|
||||
'https://x.com/?lang=en',
|
||||
'https://x.com/explore',
|
||||
'https://x.com/notifications',
|
||||
@@ -13,6 +13,7 @@ const DISCOVERY_PAGES = [
|
||||
];
|
||||
|
||||
const BUNDLE_URL_REGEX = /https:\/\/abs\.twimg\.com\/responsive-web\/client-web(?:-legacy)?\/[A-Za-z0-9.-]+\.js/g;
|
||||
const QUERY_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
const OPERATION_PATTERNS = [
|
||||
{
|
||||
@@ -67,6 +68,15 @@ export type RuntimeQueryIdsOptions = {
|
||||
fetchImpl?: typeof fetch;
|
||||
};
|
||||
|
||||
export type RuntimeQueryIdStore = {
|
||||
cachePath: string;
|
||||
ttlMs: number;
|
||||
getSnapshotInfo: () => Promise<RuntimeQueryIdSnapshotInfo | null>;
|
||||
getQueryId: (operationName: string) => Promise<string | null>;
|
||||
refresh: (operationNames: string[], opts?: { force?: boolean }) => Promise<RuntimeQueryIdSnapshotInfo | null>;
|
||||
clearMemory: () => void;
|
||||
};
|
||||
|
||||
async function fetchText(fetchImpl: typeof fetch, url: string): Promise<string> {
|
||||
const response = await fetchImpl(url, { headers: HEADERS });
|
||||
if (!response.ok) {
|
||||
@@ -85,7 +95,9 @@ function resolveDefaultCachePath(): string {
|
||||
}
|
||||
|
||||
function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
||||
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
||||
@@ -93,10 +105,14 @@ function parseSnapshot(raw: unknown): RuntimeQueryIdSnapshot | null {
|
||||
const discovery =
|
||||
record.discovery && typeof record.discovery === 'object' ? (record.discovery as Record<string, unknown>) : null;
|
||||
|
||||
if (!fetchedAt || !ttlMs || !ids || !discovery) return null;
|
||||
if (!fetchedAt || !ttlMs || !ids || !discovery) {
|
||||
return null;
|
||||
}
|
||||
const pages = Array.isArray(discovery.pages) ? discovery.pages : null;
|
||||
const bundles = Array.isArray(discovery.bundles) ? discovery.bundles : null;
|
||||
if (!pages || !bundles) return null;
|
||||
if (!pages || !bundles) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedIds: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(ids)) {
|
||||
@@ -120,9 +136,7 @@ async function readSnapshotFromDisk(cachePath: string): Promise<RuntimeQueryIdSn
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8');
|
||||
return parseSnapshot(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? String((error as { code?: unknown }).code ?? '') : '';
|
||||
if (code !== 'ENOENT') return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -161,15 +175,27 @@ function extractOperations(
|
||||
pattern.regex.lastIndex = 0;
|
||||
while (true) {
|
||||
const match = pattern.regex.exec(bundleContents);
|
||||
if (match === null) break;
|
||||
if (match === null) {
|
||||
break;
|
||||
}
|
||||
const operationName = match[pattern.operationGroup];
|
||||
const queryId = match[pattern.queryIdGroup];
|
||||
if (!operationName || !queryId) continue;
|
||||
if (!targets.has(operationName)) continue;
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
|
||||
if (discovered.has(operationName)) continue;
|
||||
if (!operationName || !queryId) {
|
||||
continue;
|
||||
}
|
||||
if (!targets.has(operationName)) {
|
||||
continue;
|
||||
}
|
||||
if (!QUERY_ID_REGEX.test(queryId)) {
|
||||
continue;
|
||||
}
|
||||
if (discovered.has(operationName)) {
|
||||
continue;
|
||||
}
|
||||
discovered.set(operationName, { queryId, bundle: bundleLabel });
|
||||
if (discovered.size === targets.size) return;
|
||||
if (discovered.size === targets.size) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +212,9 @@ async function fetchAndExtract(
|
||||
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(
|
||||
chunk.map(async (url) => {
|
||||
if (discovered.size === targets.size) return;
|
||||
if (discovered.size === targets.size) {
|
||||
return;
|
||||
}
|
||||
const label = url.split('/').at(-1) ?? url;
|
||||
try {
|
||||
const js = await fetchText(fetchImpl, url);
|
||||
@@ -196,13 +224,15 @@ async function fetchAndExtract(
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (discovered.size === targets.size) break;
|
||||
if (discovered.size === targets.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}) {
|
||||
export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {}): RuntimeQueryIdStore {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
||||
@@ -212,7 +242,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
||||
let refreshInFlight: Promise<RuntimeQueryIdSnapshotInfo | null> | null = null;
|
||||
|
||||
const loadSnapshot = async (): Promise<RuntimeQueryIdSnapshot | null> => {
|
||||
if (memorySnapshot) return memorySnapshot;
|
||||
if (memorySnapshot) {
|
||||
return memorySnapshot;
|
||||
}
|
||||
if (!loadOnce) {
|
||||
loadOnce = (async () => {
|
||||
const fromDisk = await readSnapshotFromDisk(cachePath);
|
||||
@@ -225,7 +257,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
||||
|
||||
const getSnapshotInfo = async (): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||
const snapshot = await loadSnapshot();
|
||||
if (!snapshot) return null;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const fetchedAtMs = new Date(snapshot.fetchedAt).getTime();
|
||||
const ageMs = Number.isFinite(fetchedAtMs) ? Math.max(0, Date.now() - fetchedAtMs) : Number.POSITIVE_INFINITY;
|
||||
const effectiveTtl = Number.isFinite(snapshot.ttlMs) ? snapshot.ttlMs : ttlMs;
|
||||
@@ -235,7 +269,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
||||
|
||||
const getQueryId = async (operationName: string): Promise<string | null> => {
|
||||
const info = await getSnapshotInfo();
|
||||
if (!info) return null;
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
return info.snapshot.ids[operationName] ?? null;
|
||||
};
|
||||
|
||||
@@ -243,7 +279,9 @@ export function createRuntimeQueryIdStore(options: RuntimeQueryIdsOptions = {})
|
||||
operationNames: string[],
|
||||
opts: { force?: boolean } = {},
|
||||
): Promise<RuntimeQueryIdSnapshotInfo | null> => {
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
if (refreshInFlight) {
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
refreshInFlight = (async () => {
|
||||
const current = await getSnapshotInfo();
|
||||
|
||||
+359
-45
@@ -4,6 +4,7 @@
|
||||
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { TwitterCookies } from './cookies.js';
|
||||
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
|
||||
import queryIds from './query-ids.json' with { type: 'json' };
|
||||
import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||
|
||||
@@ -12,6 +13,9 @@ const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
||||
const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
||||
const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
||||
const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
|
||||
const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
|
||||
const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
|
||||
const SETTINGS_NAME_REGEX = /"name":"([^"\\]*(?:\\.[^"\\]*)*)"/;
|
||||
|
||||
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||
@@ -24,6 +28,8 @@ const FALLBACK_QUERY_IDS = {
|
||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||
} as const;
|
||||
|
||||
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||
@@ -356,7 +362,9 @@ export class TwitterClient {
|
||||
| undefined,
|
||||
tweetId: string,
|
||||
) {
|
||||
if (!instructions) return undefined;
|
||||
if (!instructions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const instruction of instructions) {
|
||||
for (const entry of instruction.entries || []) {
|
||||
@@ -418,10 +426,14 @@ export class TwitterClient {
|
||||
|
||||
private mediaCategoryForMime(mimeType: string): string | null {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
if (mimeType === 'image/gif') return 'tweet_gif';
|
||||
if (mimeType === 'image/gif') {
|
||||
return 'tweet_gif';
|
||||
}
|
||||
return 'tweet_image';
|
||||
}
|
||||
if (mimeType.startsWith('video/')) return 'tweet_video';
|
||||
if (mimeType.startsWith('video/')) {
|
||||
return 'tweet_video';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -431,7 +443,9 @@ export class TwitterClient {
|
||||
|
||||
async uploadMedia(input: { data: Uint8Array; mimeType: string; alt?: string }): Promise<UploadMediaResult> {
|
||||
const category = this.mediaCategoryForMime(input.mimeType);
|
||||
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` };
|
||||
if (!category) {
|
||||
return { success: false, error: `Unsupported media type: ${input.mimeType}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const initParams = new URLSearchParams({
|
||||
@@ -459,7 +473,9 @@ export class TwitterClient {
|
||||
: initBody.media_id !== undefined
|
||||
? String(initBody.media_id)
|
||||
: undefined;
|
||||
if (!mediaId) return { success: false, error: 'Media upload INIT did not return media_id' };
|
||||
if (!mediaId) {
|
||||
return { success: false, error: 'Media upload INIT did not return media_id' };
|
||||
}
|
||||
|
||||
const chunkSize = 5 * 1024 * 1024;
|
||||
let segmentIndex = 0;
|
||||
@@ -515,7 +531,10 @@ export class TwitterClient {
|
||||
const delaySecs = Number.isFinite(info.check_after_secs) ? Math.max(1, info.check_after_secs as number) : 2;
|
||||
await this.sleep(delaySecs * 1000);
|
||||
|
||||
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({ command: 'STATUS', media_id: mediaId })}`;
|
||||
const statusUrl = `${TWITTER_UPLOAD_URL}?${new URLSearchParams({
|
||||
command: 'STATUS',
|
||||
media_id: mediaId,
|
||||
}).toString()}`;
|
||||
const statusResp = await this.fetchWithTimeout(statusUrl, {
|
||||
method: 'GET',
|
||||
headers: this.getUploadHeaders(),
|
||||
@@ -533,11 +552,15 @@ export class TwitterClient {
|
||||
error?: { message?: string; name?: string };
|
||||
};
|
||||
};
|
||||
if (!statusBody.processing_info) break;
|
||||
if (!statusBody.processing_info) {
|
||||
break;
|
||||
}
|
||||
info.state = statusBody.processing_info.state;
|
||||
info.check_after_secs = statusBody.processing_info.check_after_secs;
|
||||
info.error = statusBody.processing_info.error;
|
||||
if (info.state === 'succeeded') break;
|
||||
if (info.state === 'succeeded') {
|
||||
break;
|
||||
}
|
||||
attempts += 1;
|
||||
}
|
||||
}
|
||||
@@ -564,15 +587,21 @@ export class TwitterClient {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') return;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
@@ -586,7 +615,9 @@ export class TwitterClient {
|
||||
if (keys.has(key)) {
|
||||
if (typeof nested === 'string') {
|
||||
const trimmed = nested.trim();
|
||||
if (trimmed) output.push(trimmed);
|
||||
if (trimmed) {
|
||||
output.push(trimmed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -599,7 +630,9 @@ export class TwitterClient {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) continue;
|
||||
if (seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
@@ -608,7 +641,9 @@ export class TwitterClient {
|
||||
|
||||
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
|
||||
const article = result?.article;
|
||||
if (!article) return undefined;
|
||||
if (!article) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const articleResult = article.article_results?.result ?? article;
|
||||
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
|
||||
@@ -673,7 +708,9 @@ export class TwitterClient {
|
||||
|
||||
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
|
||||
const note = result?.note_tweet?.note_tweet_results?.result;
|
||||
if (!note) return undefined;
|
||||
if (!note) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.firstText(
|
||||
note.text,
|
||||
@@ -704,10 +741,14 @@ export class TwitterClient {
|
||||
const username = userLegacy?.screen_name ?? userCore?.screen_name;
|
||||
const name = userLegacy?.name ?? userCore?.name ?? username;
|
||||
const userId = userResult?.rest_id;
|
||||
if (!result?.rest_id || !username) return undefined;
|
||||
if (!result?.rest_id || !username) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const text = this.extractTweetText(result);
|
||||
if (!text) return undefined;
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let quotedTweet: TweetData | undefined;
|
||||
if (quoteDepth > 0) {
|
||||
@@ -774,7 +815,9 @@ export class TwitterClient {
|
||||
}): GraphqlTweetResult[] {
|
||||
const results: GraphqlTweetResult[] = [];
|
||||
const pushResult = (result?: GraphqlTweetResult) => {
|
||||
if (result?.rest_id) results.push(result);
|
||||
if (result?.rest_id) {
|
||||
results.push(result);
|
||||
}
|
||||
};
|
||||
|
||||
const content = entry.content;
|
||||
@@ -841,7 +884,9 @@ export class TwitterClient {
|
||||
const results = this.collectTweetResultsFromEntry(entry);
|
||||
for (const result of results) {
|
||||
const mapped = this.mapTweetResult(result);
|
||||
if (!mapped || seen.has(mapped.id)) continue;
|
||||
if (!mapped || seen.has(mapped.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(mapped.id);
|
||||
tweets.push(mapped);
|
||||
}
|
||||
@@ -988,7 +1033,7 @@ export class TwitterClient {
|
||||
});
|
||||
|
||||
const queryId = await this.getQueryId('UserArticlesTweets');
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params}`;
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
|
||||
@@ -1022,7 +1067,9 @@ export class TwitterClient {
|
||||
for (const instruction of instructions) {
|
||||
for (const entry of instruction.entries ?? []) {
|
||||
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (result?.rest_id !== tweetId) continue;
|
||||
if (result?.rest_id !== tweetId) {
|
||||
continue;
|
||||
}
|
||||
const articleResult = result.article?.article_results?.result;
|
||||
const title = this.firstText(articleResult?.title, result.article?.title);
|
||||
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
|
||||
@@ -1131,7 +1178,7 @@ export class TwitterClient {
|
||||
const queryIds = await this.getTweetDetailQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params}`;
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
@@ -1384,12 +1431,16 @@ export class TwitterClient {
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||
if (fallback) return fallback;
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return { success: false, error: this.formatErrors(data.errors) };
|
||||
}
|
||||
|
||||
const tweetId = data.data?.create_tweet?.tweet_results?.result?.rest_id;
|
||||
if (tweetId) return { success: true, tweetId };
|
||||
if (tweetId) {
|
||||
return { success: true, tweetId };
|
||||
}
|
||||
|
||||
return { success: false, error: 'Tweet created but no ID returned' };
|
||||
}
|
||||
@@ -1407,7 +1458,9 @@ export class TwitterClient {
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const fallback = await this.tryStatusUpdateFallback(data.errors, variables);
|
||||
if (fallback) return fallback;
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: this.formatErrors(data.errors),
|
||||
@@ -1446,7 +1499,9 @@ export class TwitterClient {
|
||||
mediaIds?: string[];
|
||||
} | null {
|
||||
const text = typeof variables.tweet_text === 'string' ? variables.tweet_text : null;
|
||||
if (!text) return null;
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reply = variables.reply;
|
||||
const inReplyToTweetId =
|
||||
@@ -1518,7 +1573,9 @@ export class TwitterClient {
|
||||
const tweetId =
|
||||
typeof data.id_str === 'string' ? data.id_str : data.id !== undefined ? String(data.id) : undefined;
|
||||
|
||||
if (tweetId) return { success: true, tweetId };
|
||||
if (tweetId) {
|
||||
return { success: true, tweetId };
|
||||
}
|
||||
return { success: false, error: 'Tweet created but no ID returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -1529,12 +1586,18 @@ export class TwitterClient {
|
||||
errors: Array<{ message: string; code?: number }>,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<TweetResult | null> {
|
||||
if (!errors.some((error) => error.code === 226)) return null;
|
||||
if (!errors.some((error) => error.code === 226)) {
|
||||
return null;
|
||||
}
|
||||
const input = this.statusUpdateInputFromCreateTweetVariables(variables);
|
||||
if (!input) return null;
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = await this.postStatusUpdate(input);
|
||||
if (fallback.success) return fallback;
|
||||
if (fallback.success) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
@@ -1543,8 +1606,12 @@ export class TwitterClient {
|
||||
}
|
||||
|
||||
private async ensureClientUserId(): Promise<void> {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
if (this.clientUserId) return;
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
if (this.clientUserId) {
|
||||
return;
|
||||
}
|
||||
const result = await this.getCurrentUser();
|
||||
if (result.success && result.user?.id) {
|
||||
this.clientUserId = result.user.id;
|
||||
@@ -1574,7 +1641,7 @@ export class TwitterClient {
|
||||
const queryIds = await this.getSearchTimelineQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
@@ -1654,12 +1721,16 @@ export class TwitterClient {
|
||||
};
|
||||
|
||||
const firstAttempt = await tryOnce();
|
||||
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
|
||||
if (firstAttempt.success) {
|
||||
return { success: true, tweets: firstAttempt.tweets };
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await tryOnce();
|
||||
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
@@ -1761,9 +1832,9 @@ export class TwitterClient {
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const usernameMatch = html.match(/"screen_name":"([^"]+)"/);
|
||||
const idMatch = html.match(/"user_id"\s*:\s*"(\d+)"/);
|
||||
const nameMatch = html.match(/"name":"([^"\\]*(?:\\.[^"\\]*)*)"/);
|
||||
const usernameMatch = SETTINGS_SCREEN_NAME_REGEX.exec(html);
|
||||
const idMatch = SETTINGS_USER_ID_REGEX.exec(html);
|
||||
const nameMatch = SETTINGS_NAME_REGEX.exec(html);
|
||||
|
||||
const username = usernameMatch?.[1];
|
||||
const userId = idMatch?.[1];
|
||||
@@ -1797,7 +1868,9 @@ export class TwitterClient {
|
||||
*/
|
||||
async getReplies(tweetId: string): Promise<SearchResult> {
|
||||
const response = await this.fetchTweetDetail(tweetId);
|
||||
if (!response.success) return response;
|
||||
if (!response.success) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||
@@ -1811,7 +1884,9 @@ export class TwitterClient {
|
||||
*/
|
||||
async getThread(tweetId: string): Promise<SearchResult> {
|
||||
const response = await this.fetchTweetDetail(tweetId);
|
||||
if (!response.success) return response;
|
||||
if (!response.success) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||
@@ -1829,10 +1904,10 @@ export class TwitterClient {
|
||||
return { success: true, tweets: thread };
|
||||
}
|
||||
|
||||
private buildBookmarksFeatures(): Record<string, boolean> {
|
||||
// Shared timeline features for likes/bookmarks-style timelines.
|
||||
private buildTimelineFeatures(): Record<string, boolean> {
|
||||
return {
|
||||
...this.buildSearchFeatures(),
|
||||
graphql_timeline_v2_bookmark_timeline: true,
|
||||
blue_business_profile_image_shape_enabled: true,
|
||||
responsive_web_text_conversations_enabled: false,
|
||||
tweetypie_unmention_optimization_enabled: true,
|
||||
@@ -1844,11 +1919,27 @@ export class TwitterClient {
|
||||
};
|
||||
}
|
||||
|
||||
private buildBookmarksFeatures(): Record<string, boolean> {
|
||||
return {
|
||||
...this.buildTimelineFeatures(),
|
||||
graphql_timeline_v2_bookmark_timeline: true,
|
||||
};
|
||||
}
|
||||
|
||||
private buildLikesFeatures(): Record<string, boolean> {
|
||||
return this.buildTimelineFeatures();
|
||||
}
|
||||
|
||||
private async getBookmarksQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('Bookmarks');
|
||||
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
|
||||
*/
|
||||
@@ -1874,7 +1965,7 @@ export class TwitterClient {
|
||||
const queryIds = await this.getBookmarksQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params}`;
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
@@ -1931,12 +2022,235 @@ export class TwitterClient {
|
||||
};
|
||||
|
||||
const firstAttempt = await tryOnce();
|
||||
if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets };
|
||||
if (firstAttempt.success) {
|
||||
return { success: true, tweets: firstAttempt.tweets };
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await tryOnce();
|
||||
if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets };
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
}
|
||||
private async getLikesQueryIds(): Promise<string[]> {
|
||||
const primary = await this.getQueryId('Likes');
|
||||
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's liked tweets
|
||||
*/
|
||||
async getLikes(count = 20): Promise<SearchResult> {
|
||||
const userResult = await this.getCurrentUser();
|
||||
if (!userResult.success || !userResult.user) {
|
||||
return { success: false, error: userResult.error ?? 'Could not determine current user' };
|
||||
}
|
||||
|
||||
const variables = {
|
||||
userId: userResult.user.id,
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
withClientEventToken: false,
|
||||
withBirdwatchNotes: false,
|
||||
withVoice: true,
|
||||
};
|
||||
|
||||
const features = this.buildLikesFeatures();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
const tryOnce = async () => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getLikesQueryIds();
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
user?: {
|
||||
result?: {
|
||||
timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||
}
|
||||
|
||||
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||
|
||||
return { success: true as const, tweets, had404 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching likes', had404 };
|
||||
};
|
||||
|
||||
const firstAttempt = await tryOnce();
|
||||
if (firstAttempt.success) {
|
||||
return { success: true, tweets: firstAttempt.tweets };
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await tryOnce();
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authenticated user's bookmark folder timeline
|
||||
*/
|
||||
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
|
||||
const variablesWithCount = {
|
||||
bookmark_collection_id: folderId,
|
||||
includePromotedContent: true,
|
||||
count,
|
||||
};
|
||||
|
||||
const variablesWithoutCount = {
|
||||
bookmark_collection_id: folderId,
|
||||
includePromotedContent: true,
|
||||
};
|
||||
|
||||
const features = this.buildBookmarksFeatures();
|
||||
|
||||
const tryOnce = async (variables: Record<string, unknown>) => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getBookmarkFolderQueryIds();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
bookmark_collection_timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||
}
|
||||
|
||||
const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions;
|
||||
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||
|
||||
return { success: true as const, tweets, had404 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 };
|
||||
};
|
||||
|
||||
let firstAttempt = await tryOnce(variablesWithCount);
|
||||
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
|
||||
firstAttempt = await tryOnce(variablesWithoutCount);
|
||||
}
|
||||
if (firstAttempt.success) {
|
||||
return { success: true, tweets: firstAttempt.tweets };
|
||||
}
|
||||
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
let secondAttempt = await tryOnce(variablesWithCount);
|
||||
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
|
||||
secondAttempt = await tryOnce(variablesWithoutCount);
|
||||
}
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
|
||||
+46
-17
@@ -5,6 +5,9 @@ import { fileURLToPath } from 'node:url';
|
||||
declare const __dirname: string | undefined;
|
||||
|
||||
export const FALLBACK_VERSION = 'unknown';
|
||||
const REF_PREFIX_REGEX = /^ref:\s*/i;
|
||||
const LINE_SPLIT_REGEX = /\r?\n/;
|
||||
const GITDIR_REGEX = /gitdir:\s*(.+)\s*$/i;
|
||||
|
||||
function readPackageVersionFromJsonFile(candidate: string): string | null {
|
||||
try {
|
||||
@@ -37,7 +40,9 @@ function resolveStartDir(importMetaUrl?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof __dirname === 'string' && __dirname.length > 0) return __dirname;
|
||||
if (typeof __dirname === 'string' && __dirname.length > 0) {
|
||||
return __dirname;
|
||||
}
|
||||
|
||||
return process.cwd();
|
||||
}
|
||||
@@ -47,27 +52,37 @@ export function resolvePackageVersion(importMetaUrl?: string): string {
|
||||
typeof process !== 'undefined' && typeof process.env.BIRD_VERSION === 'string'
|
||||
? process.env.BIRD_VERSION.trim()
|
||||
: '';
|
||||
if (injected.length > 0) return injected;
|
||||
if (injected.length > 0) {
|
||||
return injected;
|
||||
}
|
||||
|
||||
let dir = resolveStartDir(importMetaUrl);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const version =
|
||||
readPackageVersionFromJsonFile(path.join(dir, 'package.json')) ??
|
||||
readVersionFromTextFile(path.join(dir, 'VERSION'));
|
||||
if (version) return version;
|
||||
if (version) {
|
||||
return version;
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
if (parent === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
return FALLBACK_VERSION;
|
||||
}
|
||||
|
||||
function truncateSha(sha: string, length = 8): string {
|
||||
function truncateSha(sha: string, length: number = 8): string {
|
||||
const trimmed = sha.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.length <= length) return trimmed;
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (trimmed.length <= length) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, length);
|
||||
}
|
||||
|
||||
@@ -80,14 +95,18 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!head) return null;
|
||||
if (!head) {
|
||||
return null;
|
||||
}
|
||||
if (!head.startsWith('ref:')) {
|
||||
const sha = truncateSha(head);
|
||||
return sha.length > 0 ? sha : null;
|
||||
}
|
||||
|
||||
const ref = head.replace(/^ref:\s*/i, '').trim();
|
||||
if (!ref) return null;
|
||||
const ref = head.replace(REF_PREFIX_REGEX, '').trim();
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refPath = path.join(gitDir, ref);
|
||||
try {
|
||||
@@ -100,9 +119,11 @@ function resolveGitShaFromGitDir(gitDir: string): string | null {
|
||||
const packedRefsPath = path.join(gitDir, 'packed-refs');
|
||||
try {
|
||||
const packed = fs.readFileSync(packedRefsPath, 'utf8');
|
||||
const lines = packed.split(/\r?\n/);
|
||||
const lines = packed.split(LINE_SPLIT_REGEX);
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith('#') || line.startsWith('^')) continue;
|
||||
if (!line || line.startsWith('#') || line.startsWith('^')) {
|
||||
continue;
|
||||
}
|
||||
const [shaRaw, refName] = line.split(' ');
|
||||
if (refName?.trim() === ref) {
|
||||
const sha = truncateSha(shaRaw ?? '');
|
||||
@@ -121,7 +142,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
|
||||
typeof process !== 'undefined' && typeof process.env.BIRD_GIT_SHA === 'string'
|
||||
? process.env.BIRD_GIT_SHA.trim()
|
||||
: '';
|
||||
if (injected.length > 0) return truncateSha(injected);
|
||||
if (injected.length > 0) {
|
||||
return truncateSha(injected);
|
||||
}
|
||||
|
||||
let dir = resolveStartDir(importMetaUrl);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
@@ -130,15 +153,19 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
|
||||
const stat = fs.statSync(dotGit);
|
||||
if (stat.isDirectory()) {
|
||||
const sha = resolveGitShaFromGitDir(dotGit);
|
||||
if (sha) return sha;
|
||||
if (sha) {
|
||||
return sha;
|
||||
}
|
||||
} else if (stat.isFile()) {
|
||||
const txt = fs.readFileSync(dotGit, 'utf8');
|
||||
const match = txt.match(/gitdir:\s*(.+)\s*$/i);
|
||||
const match = GITDIR_REGEX.exec(txt);
|
||||
const gitDir = match?.[1]?.trim();
|
||||
if (gitDir) {
|
||||
const resolved = path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir);
|
||||
const sha = resolveGitShaFromGitDir(resolved);
|
||||
if (sha) return sha;
|
||||
if (sha) {
|
||||
return sha;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -146,7 +173,9 @@ export function resolveGitSha(importMetaUrl?: string): string | null {
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
if (parent === dir) {
|
||||
break;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user