Restructure as Codex plugin
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* bird-search.mjs - Vendored Bird CLI search wrapper for /last30days.
|
||||
* Subset of @steipete/bird v0.8.0 (MIT License, Peter Steinberger).
|
||||
*
|
||||
* Usage:
|
||||
* node bird-search.mjs <query> [--count N] [--json]
|
||||
* node bird-search.mjs --whoami
|
||||
* node bird-search.mjs --check
|
||||
*/
|
||||
|
||||
import { resolveCredentials } from './lib/cookies.js';
|
||||
import { TwitterClientBase } from './lib/twitter-client-base.js';
|
||||
import { withSearch } from './lib/twitter-client-search.js';
|
||||
|
||||
// Build a search-only client (no posting, bookmarks, etc.)
|
||||
const SearchClient = withSearch(TwitterClientBase);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function writeStdout(text) {
|
||||
if (text) process.stdout.write(text);
|
||||
}
|
||||
|
||||
function writeStderr(text) {
|
||||
if (text) process.stderr.write(text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// --check: verify that credentials can be resolved
|
||||
if (args.includes('--check')) {
|
||||
try {
|
||||
const { cookies, warnings } = await resolveCredentials({});
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
writeStdout(JSON.stringify({ authenticated: true, source: cookies.source }));
|
||||
return 0;
|
||||
}
|
||||
writeStdout(JSON.stringify({ authenticated: false, warnings }));
|
||||
return 1;
|
||||
} catch (err) {
|
||||
writeStdout(JSON.stringify({ authenticated: false, error: err.message }));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --whoami: check auth and output source
|
||||
if (args.includes('--whoami')) {
|
||||
try {
|
||||
const { cookies } = await resolveCredentials({});
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
writeStdout(cookies.source || 'authenticated');
|
||||
return 0;
|
||||
}
|
||||
writeStderr('Not authenticated\n');
|
||||
return 1;
|
||||
} catch (err) {
|
||||
writeStderr(`Auth check failed: ${err.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse search args
|
||||
let query = null;
|
||||
let count = 20;
|
||||
let jsonOutput = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--count' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '-n' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonOutput = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
query = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve credentials (env vars, then browser cookies)
|
||||
const { cookies, warnings } = await resolveCredentials({});
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify({ error: msg, items: [] }));
|
||||
} else {
|
||||
writeStderr(`Error: ${msg}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const client = new SearchClient({
|
||||
cookies: {
|
||||
authToken: cookies.authToken,
|
||||
ct0: cookies.ct0,
|
||||
cookieHeader: cookies.cookieHeader,
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const result = await client.search(query, count);
|
||||
|
||||
if (!result.success) {
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify({ error: result.error, items: [] }));
|
||||
} else {
|
||||
writeStderr(`Search failed: ${result.error}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const tweets = result.tweets || [];
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify(tweets));
|
||||
} else {
|
||||
for (const tweet of tweets) {
|
||||
const author = tweet.author?.username || 'unknown';
|
||||
writeStdout(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify({ error: err.message, items: [] }));
|
||||
} else {
|
||||
writeStderr(`Error: ${err.message}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await main();
|
||||
process.exitCode = Number.isInteger(code) ? code : 1;
|
||||
} catch (err) {
|
||||
writeStderr(`Fatal error: ${err?.message || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Browser cookie extraction for Twitter authentication.
|
||||
* Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
|
||||
*/
|
||||
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'];
|
||||
const TWITTER_URL = 'https://x.com/';
|
||||
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
|
||||
const DEFAULT_COOKIE_TIMEOUT_MS = 30_000;
|
||||
async function loadSweetCookie() {
|
||||
return import('@steipete/sweet-cookie');
|
||||
}
|
||||
function normalizeValue(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
function envFlagEnabled(name) {
|
||||
const value = normalizeValue(process.env[name]);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
|
||||
}
|
||||
function cookieHeader(authToken, ct0) {
|
||||
return `auth_token=${authToken}; ct0=${ct0}`;
|
||||
}
|
||||
function buildEmpty() {
|
||||
return { authToken: null, ct0: null, cookieHeader: null, source: null };
|
||||
}
|
||||
function readEnvCookie(cookies, keys, field) {
|
||||
if (cookies[field]) {
|
||||
return;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = normalizeValue(process.env[key]);
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
cookies[field] = value;
|
||||
if (!cookies.source) {
|
||||
cookies.source = `env ${key}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
function resolveSources(cookieSource) {
|
||||
if (Array.isArray(cookieSource)) {
|
||||
return cookieSource;
|
||||
}
|
||||
if (cookieSource) {
|
||||
return [cookieSource];
|
||||
}
|
||||
return ['safari', 'chrome', 'firefox'];
|
||||
}
|
||||
function labelForSource(source, profile) {
|
||||
if (source === 'safari') {
|
||||
return 'Safari';
|
||||
}
|
||||
if (source === 'chrome') {
|
||||
return profile ? `Chrome profile "${profile}"` : 'Chrome default profile';
|
||||
}
|
||||
return profile ? `Firefox profile "${profile}"` : 'Firefox default profile';
|
||||
}
|
||||
function pickCookieValue(cookies, name) {
|
||||
const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string');
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com'));
|
||||
if (preferred?.value) {
|
||||
return preferred.value;
|
||||
}
|
||||
const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com'));
|
||||
if (twitter?.value) {
|
||||
return twitter.value;
|
||||
}
|
||||
return matches[0]?.value ?? null;
|
||||
}
|
||||
async function readTwitterCookiesFromBrowser(options) {
|
||||
const warnings = [];
|
||||
const out = buildEmpty();
|
||||
const { getCookies } = options;
|
||||
const { cookies, warnings: providerWarnings } = await getCookies({
|
||||
url: TWITTER_URL,
|
||||
origins: TWITTER_ORIGINS,
|
||||
names: [...TWITTER_COOKIE_NAMES],
|
||||
browsers: [options.source],
|
||||
mode: 'merge',
|
||||
chromeProfile: options.chromeProfile,
|
||||
firefoxProfile: options.firefoxProfile,
|
||||
timeoutMs: options.cookieTimeoutMs,
|
||||
});
|
||||
warnings.push(...providerWarnings);
|
||||
const authToken = pickCookieValue(cookies, 'auth_token');
|
||||
const ct0 = pickCookieValue(cookies, 'ct0');
|
||||
if (authToken) {
|
||||
out.authToken = authToken;
|
||||
}
|
||||
if (ct0) {
|
||||
out.ct0 = ct0;
|
||||
}
|
||||
if (out.authToken && out.ct0) {
|
||||
out.cookieHeader = cookieHeader(out.authToken, out.ct0);
|
||||
out.source = labelForSource(options.source, options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile);
|
||||
return { cookies: out, warnings };
|
||||
}
|
||||
if (options.source === 'safari') {
|
||||
warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.');
|
||||
}
|
||||
else if (options.source === 'chrome') {
|
||||
warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.');
|
||||
}
|
||||
else {
|
||||
warnings.push('No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.');
|
||||
}
|
||||
return { cookies: out, warnings };
|
||||
}
|
||||
async function extractCookiesFromBrowser(options) {
|
||||
const { getCookies } = await loadSweetCookie();
|
||||
return readTwitterCookiesFromBrowser({
|
||||
getCookies,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
export async function extractCookiesFromSafari() {
|
||||
return extractCookiesFromBrowser({ source: 'safari' });
|
||||
}
|
||||
export async function extractCookiesFromChrome(profile) {
|
||||
return extractCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
|
||||
}
|
||||
export async function extractCookiesFromFirefox(profile) {
|
||||
return extractCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
|
||||
}
|
||||
/**
|
||||
* Resolve Twitter credentials from multiple sources.
|
||||
* Priority: CLI args > environment variables > browsers (ordered).
|
||||
*/
|
||||
export async function resolveCredentials(options) {
|
||||
const warnings = [];
|
||||
const cookies = buildEmpty();
|
||||
const disableBrowserCookies = envFlagEnabled('BIRD_DISABLE_BROWSER_COOKIES') ||
|
||||
envFlagEnabled('LAST30DAYS_DISABLE_BROWSER_COOKIES');
|
||||
const cookieTimeoutMs = typeof options.cookieTimeoutMs === 'number' &&
|
||||
Number.isFinite(options.cookieTimeoutMs) &&
|
||||
options.cookieTimeoutMs > 0
|
||||
? options.cookieTimeoutMs
|
||||
: process.platform === 'darwin'
|
||||
? DEFAULT_COOKIE_TIMEOUT_MS
|
||||
: undefined;
|
||||
if (options.authToken) {
|
||||
cookies.authToken = options.authToken;
|
||||
cookies.source = 'CLI argument';
|
||||
}
|
||||
if (options.ct0) {
|
||||
cookies.ct0 = options.ct0;
|
||||
if (!cookies.source) {
|
||||
cookies.source = 'CLI argument';
|
||||
}
|
||||
}
|
||||
readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken');
|
||||
readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0');
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
|
||||
return { cookies, warnings };
|
||||
}
|
||||
if (disableBrowserCookies) {
|
||||
if (!cookies.authToken) {
|
||||
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or disable BIRD_DISABLE_BROWSER_COOKIES to allow browser cookie lookup');
|
||||
}
|
||||
if (!cookies.ct0) {
|
||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or disable BIRD_DISABLE_BROWSER_COOKIES to allow browser cookie lookup');
|
||||
}
|
||||
return { cookies, warnings };
|
||||
}
|
||||
const sourcesToTry = resolveSources(options.cookieSource);
|
||||
let getCookies;
|
||||
try {
|
||||
({ getCookies } = await loadSweetCookie());
|
||||
}
|
||||
catch (error) {
|
||||
if (error?.code !== 'ERR_MODULE_NOT_FOUND' ||
|
||||
!String(error.message ?? '').includes('@steipete/sweet-cookie')) {
|
||||
throw error;
|
||||
}
|
||||
warnings.push('Browser cookie lookup unavailable because vendored dependency @steipete/sweet-cookie is not installed.');
|
||||
if (!cookies.authToken) {
|
||||
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox');
|
||||
}
|
||||
if (!cookies.ct0) {
|
||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
|
||||
}
|
||||
return { cookies, warnings };
|
||||
}
|
||||
for (const source of sourcesToTry) {
|
||||
const res = await readTwitterCookiesFromBrowser({
|
||||
getCookies,
|
||||
source,
|
||||
chromeProfile: options.chromeProfile,
|
||||
firefoxProfile: options.firefoxProfile,
|
||||
cookieTimeoutMs,
|
||||
});
|
||||
warnings.push(...res.warnings);
|
||||
if (res.cookies.authToken && res.cookies.ct0) {
|
||||
return { cookies: res.cookies, warnings };
|
||||
}
|
||||
}
|
||||
if (!cookies.authToken) {
|
||||
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox');
|
||||
}
|
||||
if (!cookies.ct0) {
|
||||
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
|
||||
}
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0);
|
||||
}
|
||||
return { cookies, warnings };
|
||||
}
|
||||
//# sourceMappingURL=cookies.js.map
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"global": {
|
||||
"responsive_web_grok_annotations_enabled": false,
|
||||
"post_ctas_fetch_enabled": true,
|
||||
"responsive_web_graphql_exclude_directive_enabled": true
|
||||
},
|
||||
"sets": {
|
||||
"lists": {
|
||||
"blue_business_profile_image_shape_enabled": true,
|
||||
"tweetypie_unmention_optimization_enabled": true,
|
||||
"responsive_web_text_conversations_enabled": false,
|
||||
"interactive_text_enabled": true,
|
||||
"vibe_api_enabled": true,
|
||||
"responsive_web_twitter_blue_verified_badge_is_enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export async function paginateCursor(opts) {
|
||||
const { maxPages, pageDelayMs = 1000 } = opts;
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
let cursor = opts.cursor;
|
||||
let pagesFetched = 0;
|
||||
while (true) {
|
||||
if (pagesFetched > 0 && pageDelayMs > 0) {
|
||||
await opts.sleep(pageDelayMs);
|
||||
}
|
||||
const page = await opts.fetchPage(cursor);
|
||||
if (!page.success) {
|
||||
if (items.length > 0) {
|
||||
return { success: false, error: page.error, items, nextCursor: cursor };
|
||||
}
|
||||
return page;
|
||||
}
|
||||
pagesFetched += 1;
|
||||
for (const item of page.items) {
|
||||
const key = opts.getKey(item);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
items.push(item);
|
||||
}
|
||||
const pageCursor = page.cursor;
|
||||
if (!pageCursor || pageCursor === cursor) {
|
||||
return { success: true, items, nextCursor: undefined };
|
||||
}
|
||||
if (maxPages !== undefined && pagesFetched >= maxPages) {
|
||||
return { success: true, items, nextCursor: pageCursor };
|
||||
}
|
||||
cursor = pageCursor;
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=paginate-cursor.js.map
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"CreateTweet": "nmdAQXJDxw6-0KKF2on7eA",
|
||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||
"CreateFriendship": "8h9JVdV8dlSyqyRDJEPCsA",
|
||||
"DestroyFriendship": "ppXWuagMNXgvzx6WoXBW0Q",
|
||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
|
||||
"TweetDetail": "_NvJCnIjOW__EP5-RF197A",
|
||||
"SearchTimeline": "6AAys3t42mosm_yTI_QENg",
|
||||
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
|
||||
"BookmarkFolderTimeline": "KJIQpsvxrTfRIlbaRIySHQ",
|
||||
"Following": "mWYeougg_ocJS2Vr1Vt28w",
|
||||
"Followers": "SFYY3WsgwjlXSLlfnEUE4A",
|
||||
"Likes": "ETJflBunfqNa1uE1mBPCaw",
|
||||
"ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ",
|
||||
"ExplorePage": "kheAINB_4pzRDqkzG3K-ng",
|
||||
"GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ",
|
||||
"TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ",
|
||||
"AboutAccountQuery": "zs_jFPFT78rBpXv9Z3U2YQ"
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
|
||||
import defaultOverrides from './features.json' with { type: 'json' };
|
||||
const DEFAULT_CACHE_FILENAME = 'features.json';
|
||||
let cachedOverrides = null;
|
||||
function normalizeFeatureMap(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
const result = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry === 'boolean') {
|
||||
result[key] = entry;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function normalizeOverrides(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { global: {}, sets: {} };
|
||||
}
|
||||
const record = value;
|
||||
const global = normalizeFeatureMap(record.global);
|
||||
const sets = {};
|
||||
const rawSets = record.sets && typeof record.sets === 'object' && !Array.isArray(record.sets)
|
||||
? record.sets
|
||||
: {};
|
||||
for (const [setName, setValue] of Object.entries(rawSets)) {
|
||||
const normalized = normalizeFeatureMap(setValue);
|
||||
if (Object.keys(normalized).length > 0) {
|
||||
sets[setName] = normalized;
|
||||
}
|
||||
}
|
||||
return { global, sets };
|
||||
}
|
||||
function mergeOverrides(base, next) {
|
||||
const sets = { ...base.sets };
|
||||
for (const [setName, overrides] of Object.entries(next.sets)) {
|
||||
const existing = sets[setName];
|
||||
sets[setName] = existing ? { ...existing, ...overrides } : { ...overrides };
|
||||
}
|
||||
return {
|
||||
global: { ...base.global, ...next.global },
|
||||
sets,
|
||||
};
|
||||
}
|
||||
function toFeatureOverrides(overrides) {
|
||||
const result = {};
|
||||
if (Object.keys(overrides.global).length > 0) {
|
||||
result.global = overrides.global;
|
||||
}
|
||||
const setEntries = Object.entries(overrides.sets).filter(([, value]) => Object.keys(value).length > 0);
|
||||
if (setEntries.length > 0) {
|
||||
result.sets = Object.fromEntries(setEntries);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function resolveFeaturesCachePath() {
|
||||
const override = process.env.BIRD_FEATURES_CACHE ?? process.env.BIRD_FEATURES_PATH;
|
||||
if (override && override.trim().length > 0) {
|
||||
return path.resolve(override.trim());
|
||||
}
|
||||
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
|
||||
}
|
||||
function readOverridesFromFile(cachePath) {
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(cachePath, 'utf8');
|
||||
return normalizeOverrides(JSON.parse(raw));
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function readOverridesFromEnv() {
|
||||
const raw = process.env.BIRD_FEATURES_JSON;
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeOverrides(JSON.parse(raw));
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function writeOverridesToDisk(cachePath, overrides) {
|
||||
const payload = toFeatureOverrides(overrides);
|
||||
return mkdir(path.dirname(cachePath), { recursive: true }).then(() => writeFile(cachePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'));
|
||||
}
|
||||
export function loadFeatureOverrides() {
|
||||
if (cachedOverrides) {
|
||||
return cachedOverrides;
|
||||
}
|
||||
const base = normalizeOverrides(defaultOverrides);
|
||||
const fromFile = readOverridesFromFile(resolveFeaturesCachePath());
|
||||
const fromEnv = readOverridesFromEnv();
|
||||
let merged = base;
|
||||
if (fromFile) {
|
||||
merged = mergeOverrides(merged, fromFile);
|
||||
}
|
||||
if (fromEnv) {
|
||||
merged = mergeOverrides(merged, fromEnv);
|
||||
}
|
||||
cachedOverrides = merged;
|
||||
return merged;
|
||||
}
|
||||
export function getFeatureOverridesSnapshot() {
|
||||
const overrides = toFeatureOverrides(loadFeatureOverrides());
|
||||
return {
|
||||
cachePath: resolveFeaturesCachePath(),
|
||||
overrides,
|
||||
};
|
||||
}
|
||||
export function applyFeatureOverrides(setName, base) {
|
||||
const overrides = loadFeatureOverrides();
|
||||
const globalOverrides = overrides.global;
|
||||
const setOverrides = overrides.sets[setName];
|
||||
if (Object.keys(globalOverrides).length === 0 && (!setOverrides || Object.keys(setOverrides).length === 0)) {
|
||||
return base;
|
||||
}
|
||||
if (setOverrides) {
|
||||
return {
|
||||
...base,
|
||||
...globalOverrides,
|
||||
...setOverrides,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
...globalOverrides,
|
||||
};
|
||||
}
|
||||
export async function refreshFeatureOverridesCache() {
|
||||
const cachePath = resolveFeaturesCachePath();
|
||||
const base = normalizeOverrides(defaultOverrides);
|
||||
const fromFile = readOverridesFromFile(cachePath);
|
||||
const merged = mergeOverrides(base, fromFile ?? { global: {}, sets: {} });
|
||||
await writeOverridesToDisk(cachePath, merged);
|
||||
cachedOverrides = null;
|
||||
return { cachePath, overrides: toFeatureOverrides(merged) };
|
||||
}
|
||||
export function clearFeatureOverridesCache() {
|
||||
cachedOverrides = null;
|
||||
}
|
||||
//# sourceMappingURL=runtime-features.js.map
|
||||
@@ -0,0 +1,264 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
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 DISCOVERY_PAGES = [
|
||||
'https://x.com/?lang=en',
|
||||
'https://x.com/explore',
|
||||
'https://x.com/notifications',
|
||||
'https://x.com/settings/profile',
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
regex: /e\.exports=\{queryId\s*:\s*["']([^"']+)["']\s*,\s*operationName\s*:\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 2,
|
||||
queryIdGroup: 1,
|
||||
},
|
||||
{
|
||||
regex: /e\.exports=\{operationName\s*:\s*["']([^"']+)["']\s*,\s*queryId\s*:\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 1,
|
||||
queryIdGroup: 2,
|
||||
},
|
||||
{
|
||||
regex: /operationName\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)queryId\s*[:=]\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 1,
|
||||
queryIdGroup: 3,
|
||||
},
|
||||
{
|
||||
regex: /queryId\s*[:=]\s*["']([^"']+)["'](.{0,4000}?)operationName\s*[:=]\s*["']([^"']+)["']/gs,
|
||||
operationGroup: 3,
|
||||
queryIdGroup: 1,
|
||||
},
|
||||
];
|
||||
const HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
|
||||
Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
};
|
||||
async function fetchText(fetchImpl, url) {
|
||||
const response = await fetchImpl(url, { headers: HEADERS });
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status} for ${url}: ${body.slice(0, 120)}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
function resolveDefaultCachePath() {
|
||||
const override = process.env.BIRD_QUERY_IDS_CACHE;
|
||||
if (override && override.trim().length > 0) {
|
||||
return path.resolve(override.trim());
|
||||
}
|
||||
return path.join(homedir(), '.config', 'bird', DEFAULT_CACHE_FILENAME);
|
||||
}
|
||||
function parseSnapshot(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const record = raw;
|
||||
const fetchedAt = typeof record.fetchedAt === 'string' ? record.fetchedAt : null;
|
||||
const ttlMs = typeof record.ttlMs === 'number' && Number.isFinite(record.ttlMs) ? record.ttlMs : null;
|
||||
const ids = record.ids && typeof record.ids === 'object' ? record.ids : null;
|
||||
const discovery = record.discovery && typeof record.discovery === 'object' ? record.discovery : 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;
|
||||
}
|
||||
const normalizedIds = {};
|
||||
for (const [key, value] of Object.entries(ids)) {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
normalizedIds[key] = value.trim();
|
||||
}
|
||||
}
|
||||
return {
|
||||
fetchedAt,
|
||||
ttlMs,
|
||||
ids: normalizedIds,
|
||||
discovery: {
|
||||
pages: pages.filter((p) => typeof p === 'string'),
|
||||
bundles: bundles.filter((b) => typeof b === 'string'),
|
||||
},
|
||||
};
|
||||
}
|
||||
async function readSnapshotFromDisk(cachePath) {
|
||||
try {
|
||||
const raw = await readFile(cachePath, 'utf8');
|
||||
return parseSnapshot(JSON.parse(raw));
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function writeSnapshotToDisk(cachePath, snapshot) {
|
||||
await mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
async function discoverBundles(fetchImpl) {
|
||||
const bundles = new Set();
|
||||
for (const page of DISCOVERY_PAGES) {
|
||||
try {
|
||||
const html = await fetchText(fetchImpl, page);
|
||||
for (const match of html.matchAll(BUNDLE_URL_REGEX)) {
|
||||
bundles.add(match[0]);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// ignore discovery page failures; other pages often work
|
||||
}
|
||||
}
|
||||
const discovered = [...bundles];
|
||||
if (discovered.length === 0) {
|
||||
throw new Error('No client bundles discovered; x.com layout may have changed.');
|
||||
}
|
||||
return discovered;
|
||||
}
|
||||
function extractOperations(bundleContents, bundleLabel, targets, discovered) {
|
||||
for (const pattern of OPERATION_PATTERNS) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
while (true) {
|
||||
const match = pattern.regex.exec(bundleContents);
|
||||
if (match === null) {
|
||||
break;
|
||||
}
|
||||
const operationName = match[pattern.operationGroup];
|
||||
const queryId = match[pattern.queryIdGroup];
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async function fetchAndExtract(fetchImpl, bundleUrls, targets) {
|
||||
const discovered = new Map();
|
||||
const CONCURRENCY = 6;
|
||||
for (let i = 0; i < bundleUrls.length; i += CONCURRENCY) {
|
||||
const chunk = bundleUrls.slice(i, i + CONCURRENCY);
|
||||
await Promise.all(chunk.map(async (url) => {
|
||||
if (discovered.size === targets.size) {
|
||||
return;
|
||||
}
|
||||
const label = url.split('/').at(-1) ?? url;
|
||||
try {
|
||||
const js = await fetchText(fetchImpl, url);
|
||||
extractOperations(js, label, targets, discovered);
|
||||
}
|
||||
catch {
|
||||
// ignore failed bundles
|
||||
}
|
||||
}));
|
||||
if (discovered.size === targets.size) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return discovered;
|
||||
}
|
||||
export function createRuntimeQueryIdStore(options = {}) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||
const cachePath = options.cachePath ? path.resolve(options.cachePath) : resolveDefaultCachePath();
|
||||
let memorySnapshot = null;
|
||||
let loadOnce = null;
|
||||
let refreshInFlight = null;
|
||||
const loadSnapshot = async () => {
|
||||
if (memorySnapshot) {
|
||||
return memorySnapshot;
|
||||
}
|
||||
if (!loadOnce) {
|
||||
loadOnce = (async () => {
|
||||
const fromDisk = await readSnapshotFromDisk(cachePath);
|
||||
memorySnapshot = fromDisk;
|
||||
return fromDisk;
|
||||
})();
|
||||
}
|
||||
return loadOnce;
|
||||
};
|
||||
const getSnapshotInfo = async () => {
|
||||
const snapshot = await loadSnapshot();
|
||||
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;
|
||||
const isFresh = ageMs <= effectiveTtl;
|
||||
return { snapshot, cachePath, ageMs, isFresh };
|
||||
};
|
||||
const getQueryId = async (operationName) => {
|
||||
const info = await getSnapshotInfo();
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
return info.snapshot.ids[operationName] ?? null;
|
||||
};
|
||||
const refresh = async (operationNames, opts = {}) => {
|
||||
if (refreshInFlight) {
|
||||
return refreshInFlight;
|
||||
}
|
||||
refreshInFlight = (async () => {
|
||||
const current = await getSnapshotInfo();
|
||||
if (!opts.force && current?.isFresh) {
|
||||
return current;
|
||||
}
|
||||
const targets = new Set(operationNames);
|
||||
const bundleUrls = await discoverBundles(fetchImpl);
|
||||
const discovered = await fetchAndExtract(fetchImpl, bundleUrls, targets);
|
||||
if (discovered.size === 0) {
|
||||
return current ?? null;
|
||||
}
|
||||
const ids = {};
|
||||
for (const name of operationNames) {
|
||||
const entry = discovered.get(name);
|
||||
if (entry?.queryId) {
|
||||
ids[name] = entry.queryId;
|
||||
}
|
||||
}
|
||||
const snapshot = {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
ttlMs,
|
||||
ids,
|
||||
discovery: {
|
||||
pages: [...DISCOVERY_PAGES],
|
||||
bundles: bundleUrls.map((url) => url.split('/').at(-1) ?? url),
|
||||
},
|
||||
};
|
||||
await writeSnapshotToDisk(cachePath, snapshot);
|
||||
memorySnapshot = snapshot;
|
||||
return getSnapshotInfo();
|
||||
})().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
return refreshInFlight;
|
||||
};
|
||||
return {
|
||||
cachePath,
|
||||
ttlMs,
|
||||
getSnapshotInfo,
|
||||
getQueryId,
|
||||
refresh,
|
||||
clearMemory() {
|
||||
memorySnapshot = null;
|
||||
loadOnce = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
export const runtimeQueryIds = createRuntimeQueryIdStore();
|
||||
//# sourceMappingURL=runtime-query-ids.js.map
|
||||
@@ -0,0 +1,129 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { runtimeQueryIds } from './runtime-query-ids.js';
|
||||
import { QUERY_IDS, TARGET_QUERY_ID_OPERATIONS } from './twitter-client-constants.js';
|
||||
import { normalizeQuoteDepth } from './twitter-client-utils.js';
|
||||
export class TwitterClientBase {
|
||||
authToken;
|
||||
ct0;
|
||||
cookieHeader;
|
||||
userAgent;
|
||||
timeoutMs;
|
||||
quoteDepth;
|
||||
clientUuid;
|
||||
clientDeviceId;
|
||||
clientUserId;
|
||||
constructor(options) {
|
||||
if (!options.cookies.authToken || !options.cookies.ct0) {
|
||||
throw new Error('Both authToken and ct0 cookies are required');
|
||||
}
|
||||
this.authToken = options.cookies.authToken;
|
||||
this.ct0 = options.cookies.ct0;
|
||||
this.cookieHeader = options.cookies.cookieHeader || `auth_token=${this.authToken}; ct0=${this.ct0}`;
|
||||
this.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';
|
||||
this.timeoutMs = options.timeoutMs;
|
||||
this.quoteDepth = normalizeQuoteDepth(options.quoteDepth);
|
||||
this.clientUuid = randomUUID();
|
||||
this.clientDeviceId = randomUUID();
|
||||
}
|
||||
async sleep(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
async getQueryId(operationName) {
|
||||
const cached = await runtimeQueryIds.getQueryId(operationName);
|
||||
return cached ?? QUERY_IDS[operationName];
|
||||
}
|
||||
async refreshQueryIds() {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await runtimeQueryIds.refresh(TARGET_QUERY_ID_OPERATIONS, { force: true });
|
||||
}
|
||||
catch {
|
||||
// ignore refresh failures; callers will fall back to baked-in IDs
|
||||
}
|
||||
}
|
||||
async withRefreshedQueryIdsOn404(attempt) {
|
||||
const firstAttempt = await attempt();
|
||||
if (firstAttempt.success || !firstAttempt.had404) {
|
||||
return { result: firstAttempt, refreshed: false };
|
||||
}
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await attempt();
|
||||
return { result: secondAttempt, refreshed: true };
|
||||
}
|
||||
async getTweetDetailQueryIds() {
|
||||
const primary = await this.getQueryId('TweetDetail');
|
||||
return Array.from(new Set([primary, '97JF30KziU00483E_8elBA', 'aFvUsJm2c-oDkJV75blV6g']));
|
||||
}
|
||||
async getSearchTimelineQueryIds() {
|
||||
const primary = await this.getQueryId('SearchTimeline');
|
||||
return Array.from(new Set([primary, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']));
|
||||
}
|
||||
async fetchWithTimeout(url, init) {
|
||||
if (!this.timeoutMs || this.timeoutMs <= 0) {
|
||||
return fetch(url, init);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
getHeaders() {
|
||||
return this.getJsonHeaders();
|
||||
}
|
||||
createTransactionId() {
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
getBaseHeaders() {
|
||||
const headers = {
|
||||
accept: '*/*',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
|
||||
'x-csrf-token': this.ct0,
|
||||
'x-twitter-auth-type': 'OAuth2Session',
|
||||
'x-twitter-active-user': 'yes',
|
||||
'x-twitter-client-language': 'en',
|
||||
'x-client-uuid': this.clientUuid,
|
||||
'x-twitter-client-deviceid': this.clientDeviceId,
|
||||
'x-client-transaction-id': this.createTransactionId(),
|
||||
cookie: this.cookieHeader,
|
||||
'user-agent': this.userAgent,
|
||||
origin: 'https://x.com',
|
||||
referer: 'https://x.com/',
|
||||
};
|
||||
if (this.clientUserId) {
|
||||
headers['x-twitter-client-user-id'] = this.clientUserId;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
getJsonHeaders() {
|
||||
return {
|
||||
...this.getBaseHeaders(),
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
}
|
||||
getUploadHeaders() {
|
||||
// Note: do not set content-type; URLSearchParams/FormData need to set it (incl boundary) themselves.
|
||||
return this.getBaseHeaders();
|
||||
}
|
||||
async ensureClientUserId() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=twitter-client-base.js.map
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// biome-ignore lint/correctness/useImportExtensions: JSON module import doesn't use .js extension.
|
||||
import queryIds from './query-ids.json' with { type: 'json' };
|
||||
export const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||
export const TWITTER_GRAPHQL_POST_URL = 'https://x.com/i/api/graphql';
|
||||
export const TWITTER_UPLOAD_URL = 'https://upload.twitter.com/i/media/upload.json';
|
||||
export const TWITTER_MEDIA_METADATA_URL = 'https://x.com/i/api/1.1/media/metadata/create.json';
|
||||
export const TWITTER_STATUS_UPDATE_URL = 'https://x.com/i/api/1.1/statuses/update.json';
|
||||
export const SETTINGS_SCREEN_NAME_REGEX = /"screen_name":"([^"]+)"/;
|
||||
export const SETTINGS_USER_ID_REGEX = /"user_id"\s*:\s*"(\d+)"/;
|
||||
export 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
|
||||
// the file is missing or incomplete.
|
||||
export const FALLBACK_QUERY_IDS = {
|
||||
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||
DeleteRetweet: 'iQtK4dl5hBmXewYZuEOKVw',
|
||||
CreateFriendship: '8h9JVdV8dlSyqyRDJEPCsA',
|
||||
DestroyFriendship: 'ppXWuagMNXgvzx6WoXBW0Q',
|
||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||
UnfavoriteTweet: 'ZYKSe-w7KEslx3JhSIk5LA',
|
||||
CreateBookmark: 'aoDbu3RHznuiSkQ9aNM67Q',
|
||||
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
|
||||
TweetDetail: '97JF30KziU00483E_8elBA',
|
||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||
UserTweets: 'Wms1GvIiHXAPBaCr9KblaA',
|
||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||
Following: 'BEkNpEt5pNETESoqMsTEGA',
|
||||
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
|
||||
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||
ListOwnerships: 'wQcOSjSQ8NtgxIwvYl1lMg',
|
||||
ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg',
|
||||
ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw',
|
||||
ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw',
|
||||
HomeTimeline: 'edseUwk9sP5Phz__9TIRnA',
|
||||
HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg',
|
||||
ExploreSidebar: 'lpSN4M6qpimkF4nRFPE3nQ',
|
||||
ExplorePage: 'kheAINB_4pzRDqkzG3K-ng',
|
||||
GenericTimelineById: 'uGSr7alSjR9v6QJAIaqSKQ',
|
||||
TrendHistory: 'Sj4T-jSB9pr0Mxtsc1UKZQ',
|
||||
AboutAccountQuery: 'zs_jFPFT78rBpXv9Z3U2YQ',
|
||||
};
|
||||
export const QUERY_IDS = {
|
||||
...FALLBACK_QUERY_IDS,
|
||||
...queryIds,
|
||||
};
|
||||
export const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS);
|
||||
//# sourceMappingURL=twitter-client-constants.js.map
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
import { applyFeatureOverrides } from './runtime-features.js';
|
||||
export function buildArticleFeatures() {
|
||||
return applyFeatureOverrides('article', {
|
||||
rweb_video_screen_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||
responsive_web_grok_annotations_enabled: false,
|
||||
responsive_web_jetfuel_frame: true,
|
||||
post_ctas_fetch_enabled: true,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: true,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_grok_imagine_annotation_enabled: true,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildTweetDetailFeatures() {
|
||||
return applyFeatureOverrides('tweetDetail', {
|
||||
...buildArticleFeatures(),
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
responsive_web_twitter_article_plain_text_enabled: true,
|
||||
responsive_web_twitter_article_seed_tweet_detail_enabled: true,
|
||||
responsive_web_twitter_article_seed_tweet_summary_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
verified_phone_label_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildArticleFieldToggles() {
|
||||
return {
|
||||
withPayments: false,
|
||||
withAuxiliaryUserLabels: false,
|
||||
withArticleRichContentState: true,
|
||||
withArticlePlainText: true,
|
||||
withGrokAnalyze: false,
|
||||
withDisallowedReplyControls: false,
|
||||
};
|
||||
}
|
||||
export function buildSearchFeatures() {
|
||||
return applyFeatureOverrides('search', {
|
||||
rweb_video_screen_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||
responsive_web_grok_annotations_enabled: false,
|
||||
responsive_web_jetfuel_frame: true,
|
||||
post_ctas_fetch_enabled: true,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: true,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
rweb_video_timestamps_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_grok_imagine_annotation_enabled: true,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildTweetCreateFeatures() {
|
||||
return applyFeatureOverrides('tweetCreate', {
|
||||
rweb_video_screen_enabled: true,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||
responsive_web_grok_annotations_enabled: false,
|
||||
responsive_web_jetfuel_frame: true,
|
||||
post_ctas_fetch_enabled: true,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: true,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: false,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_grok_imagine_annotation_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildTimelineFeatures() {
|
||||
return applyFeatureOverrides('timeline', {
|
||||
...buildSearchFeatures(),
|
||||
blue_business_profile_image_shape_enabled: true,
|
||||
responsive_web_text_conversations_enabled: false,
|
||||
tweetypie_unmention_optimization_enabled: true,
|
||||
vibe_api_enabled: true,
|
||||
responsive_web_twitter_blue_verified_badge_is_enabled: true,
|
||||
interactive_text_enabled: true,
|
||||
longform_notetweets_richtext_consumption_enabled: true,
|
||||
responsive_web_media_download_video_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildBookmarksFeatures() {
|
||||
return applyFeatureOverrides('bookmarks', {
|
||||
...buildTimelineFeatures(),
|
||||
graphql_timeline_v2_bookmark_timeline: true,
|
||||
});
|
||||
}
|
||||
export function buildLikesFeatures() {
|
||||
return applyFeatureOverrides('likes', buildTimelineFeatures());
|
||||
}
|
||||
export function buildListsFeatures() {
|
||||
return applyFeatureOverrides('lists', {
|
||||
rweb_video_screen_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||
responsive_web_grok_annotations_enabled: false,
|
||||
responsive_web_jetfuel_frame: true,
|
||||
post_ctas_fetch_enabled: true,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: true,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_grok_imagine_annotation_enabled: true,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
blue_business_profile_image_shape_enabled: false,
|
||||
responsive_web_text_conversations_enabled: false,
|
||||
tweetypie_unmention_optimization_enabled: true,
|
||||
vibe_api_enabled: false,
|
||||
interactive_text_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildHomeTimelineFeatures() {
|
||||
return applyFeatureOverrides('homeTimeline', {
|
||||
...buildTimelineFeatures(),
|
||||
});
|
||||
}
|
||||
export function buildUserTweetsFeatures() {
|
||||
return applyFeatureOverrides('userTweets', {
|
||||
rweb_video_screen_enabled: false,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: false,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: true,
|
||||
responsive_web_jetfuel_frame: true,
|
||||
post_ctas_fetch_enabled: true,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
responsive_web_grok_annotations_enabled: false,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: true,
|
||||
responsive_web_grok_analysis_button_from_backend: true,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_grok_imagine_annotation_enabled: true,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildFollowingFeatures() {
|
||||
return applyFeatureOverrides('following', {
|
||||
rweb_video_screen_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: false,
|
||||
responsive_web_profile_redirect_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: true,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||
responsive_web_grok_annotations_enabled: false,
|
||||
responsive_web_jetfuel_frame: false,
|
||||
post_ctas_fetch_enabled: true,
|
||||
responsive_web_grok_share_attachment_enabled: false,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: true,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: false,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: false,
|
||||
responsive_web_grok_imagine_annotation_enabled: false,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
});
|
||||
}
|
||||
export function buildExploreFeatures() {
|
||||
return applyFeatureOverrides('explore', {
|
||||
rweb_video_screen_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: true,
|
||||
responsive_web_profile_redirect_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: false,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: true,
|
||||
responsive_web_grok_analyze_post_followups_enabled: true,
|
||||
responsive_web_grok_annotations_enabled: true,
|
||||
responsive_web_jetfuel_frame: true,
|
||||
responsive_web_grok_share_attachment_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: false,
|
||||
responsive_web_grok_show_grok_translated_post: true,
|
||||
responsive_web_grok_analysis_button_from_backend: true,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: true,
|
||||
responsive_web_grok_imagine_annotation_enabled: true,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: true,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
// Additional features required for ExploreSidebar
|
||||
post_ctas_fetch_enabled: true,
|
||||
rweb_video_timestamps_enabled: true,
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=twitter-client-features.js.map
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
||||
import { buildSearchFeatures } from './twitter-client-features.js';
|
||||
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
|
||||
const RAW_QUERY_MISSING_REGEX = /must be defined/i;
|
||||
function isQueryIdMismatch(payload) {
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
return (parsed.errors?.some((error) => {
|
||||
if (error?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED') {
|
||||
return true;
|
||||
}
|
||||
if (error?.path?.includes('rawQuery') && RAW_QUERY_MISSING_REGEX.test(error.message ?? '')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}) ?? false);
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function withSearch(Base) {
|
||||
class TwitterClientSearch extends Base {
|
||||
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
}
|
||||
/**
|
||||
* Search for tweets matching a query
|
||||
*/
|
||||
async search(query, count = 20, options = {}) {
|
||||
return this.searchPaged(query, count, options);
|
||||
}
|
||||
/**
|
||||
* Get all search results (paged)
|
||||
*/
|
||||
async getAllSearchResults(query, options) {
|
||||
return this.searchPaged(query, Number.POSITIVE_INFINITY, options);
|
||||
}
|
||||
async searchPaged(query, limit, options = {}) {
|
||||
const features = buildSearchFeatures();
|
||||
const pageSize = 20;
|
||||
const seen = new Set();
|
||||
const tweets = [];
|
||||
let cursor = options.cursor;
|
||||
let nextCursor;
|
||||
let pagesFetched = 0;
|
||||
const { includeRaw = false, maxPages } = options;
|
||||
const fetchPage = async (pageCount, pageCursor) => {
|
||||
let lastError;
|
||||
let had404 = false;
|
||||
const queryIds = await this.getSearchTimelineQueryIds();
|
||||
for (const queryId of queryIds) {
|
||||
const variables = {
|
||||
rawQuery: query,
|
||||
count: pageCount,
|
||||
querySource: 'typed_query',
|
||||
product: 'Latest',
|
||||
...(pageCursor ? { cursor: pageCursor } : {}),
|
||||
};
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
});
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params.toString()}`;
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ features, queryId }),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const shouldRefreshQueryIds = (response.status === 400 || response.status === 422) && isQueryIdMismatch(text);
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
||||
had404: had404 || shouldRefreshQueryIds,
|
||||
};
|
||||
}
|
||||
const data = (await response.json());
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const shouldRefreshQueryIds = data.errors.some((error) => error?.extensions?.code === 'GRAPHQL_VALIDATION_FAILED');
|
||||
return {
|
||||
success: false,
|
||||
error: data.errors.map((e) => e.message).join(', '),
|
||||
had404: had404 || shouldRefreshQueryIds,
|
||||
};
|
||||
}
|
||||
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
|
||||
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
||||
const nextCursor = extractCursorFromInstructions(instructions);
|
||||
return { success: true, tweets: pageTweets, cursor: nextCursor, had404 };
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
return { success: false, error: lastError ?? 'Unknown error fetching search results', had404 };
|
||||
};
|
||||
const fetchWithRefresh = async (pageCount, pageCursor) => {
|
||||
const firstAttempt = await fetchPage(pageCount, pageCursor);
|
||||
if (firstAttempt.success) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (firstAttempt.had404) {
|
||||
await this.refreshQueryIds();
|
||||
const secondAttempt = await fetchPage(pageCount, pageCursor);
|
||||
if (secondAttempt.success) {
|
||||
return secondAttempt;
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
return { success: false, error: firstAttempt.error };
|
||||
};
|
||||
const unlimited = limit === Number.POSITIVE_INFINITY;
|
||||
while (unlimited || tweets.length < limit) {
|
||||
const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length);
|
||||
const page = await fetchWithRefresh(pageCount, cursor);
|
||||
if (!page.success) {
|
||||
return { success: false, error: page.error };
|
||||
}
|
||||
pagesFetched += 1;
|
||||
let added = 0;
|
||||
for (const tweet of page.tweets) {
|
||||
if (seen.has(tweet.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(tweet.id);
|
||||
tweets.push(tweet);
|
||||
added += 1;
|
||||
if (!unlimited && tweets.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pageCursor = page.cursor;
|
||||
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
|
||||
nextCursor = undefined;
|
||||
break;
|
||||
}
|
||||
if (maxPages && pagesFetched >= maxPages) {
|
||||
nextCursor = pageCursor;
|
||||
break;
|
||||
}
|
||||
cursor = pageCursor;
|
||||
nextCursor = pageCursor;
|
||||
}
|
||||
return { success: true, tweets, nextCursor };
|
||||
}
|
||||
}
|
||||
return TwitterClientSearch;
|
||||
}
|
||||
//# sourceMappingURL=twitter-client-search.js.map
|
||||
@@ -0,0 +1,2 @@
|
||||
export {};
|
||||
//# sourceMappingURL=twitter-client-types.js.map
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
export function normalizeQuoteDepth(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return 1;
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
export function firstText(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export function collectTextFields(value, keys, output) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectTextFields(item, keys, output);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (keys.has(key)) {
|
||||
if (typeof nested === 'string') {
|
||||
const trimmed = nested.trim();
|
||||
if (trimmed) {
|
||||
output.push(trimmed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
collectTextFields(nested, keys, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
export function uniqueOrdered(values) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
result.push(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Renders a Draft.js content_state into readable markdown/text format.
|
||||
* Handles blocks (paragraphs, headers, lists) and entities (code blocks, links, tweets, dividers).
|
||||
*/
|
||||
export function renderContentState(contentState) {
|
||||
if (!contentState?.blocks || contentState.blocks.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Build entity lookup map from array/object formats
|
||||
const entityMap = new Map();
|
||||
const rawEntityMap = contentState.entityMap ?? [];
|
||||
if (Array.isArray(rawEntityMap)) {
|
||||
for (const entry of rawEntityMap) {
|
||||
const key = Number.parseInt(entry.key, 10);
|
||||
if (!Number.isNaN(key)) {
|
||||
entityMap.set(key, entry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const [key, value] of Object.entries(rawEntityMap)) {
|
||||
const keyNumber = Number.parseInt(key, 10);
|
||||
if (!Number.isNaN(keyNumber)) {
|
||||
entityMap.set(keyNumber, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const outputLines = [];
|
||||
let orderedListCounter = 0;
|
||||
let previousBlockType;
|
||||
for (const block of contentState.blocks) {
|
||||
// Reset ordered list counter when leaving ordered list context
|
||||
if (block.type !== 'ordered-list-item' && previousBlockType === 'ordered-list-item') {
|
||||
orderedListCounter = 0;
|
||||
}
|
||||
switch (block.type) {
|
||||
case 'unstyled': {
|
||||
// Plain paragraph - just output text with any inline formatting
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'header-one': {
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(`# ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'header-two': {
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(`## ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'header-three': {
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(`### ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'unordered-list-item': {
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(`- ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'ordered-list-item': {
|
||||
orderedListCounter++;
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(`${orderedListCounter}. ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'blockquote': {
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(`> ${text}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'atomic': {
|
||||
// Atomic blocks are placeholders for embedded entities
|
||||
const entityContent = renderAtomicBlock(block, entityMap);
|
||||
if (entityContent) {
|
||||
outputLines.push(entityContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Fallback: just output the text
|
||||
const text = renderBlockText(block, entityMap);
|
||||
if (text) {
|
||||
outputLines.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
previousBlockType = block.type;
|
||||
}
|
||||
const result = outputLines.join('\n\n');
|
||||
return result.trim() || undefined;
|
||||
}
|
||||
/**
|
||||
* Renders text content of a block, applying inline link entities.
|
||||
*/
|
||||
function renderBlockText(block, entityMap) {
|
||||
let text = block.text;
|
||||
// Handle LINK entities by appending URL in markdown format
|
||||
// Process in reverse order to not mess up offsets
|
||||
const linkRanges = (block.entityRanges ?? [])
|
||||
.filter((range) => {
|
||||
const entity = entityMap.get(range.key);
|
||||
return entity?.type === 'LINK' && entity.data.url;
|
||||
})
|
||||
.sort((a, b) => b.offset - a.offset);
|
||||
for (const range of linkRanges) {
|
||||
const entity = entityMap.get(range.key);
|
||||
if (entity?.data.url) {
|
||||
const linkText = text.slice(range.offset, range.offset + range.length);
|
||||
const markdownLink = `[${linkText}](${entity.data.url})`;
|
||||
text = text.slice(0, range.offset) + markdownLink + text.slice(range.offset + range.length);
|
||||
}
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
/**
|
||||
* Renders an atomic block by looking up its entity and returning appropriate content.
|
||||
*/
|
||||
function renderAtomicBlock(block, entityMap) {
|
||||
const entityRanges = block.entityRanges ?? [];
|
||||
if (entityRanges.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const entityKey = entityRanges[0].key;
|
||||
const entity = entityMap.get(entityKey);
|
||||
if (!entity) {
|
||||
return undefined;
|
||||
}
|
||||
switch (entity.type) {
|
||||
case 'MARKDOWN':
|
||||
// Code blocks and other markdown content - output as-is
|
||||
return entity.data.markdown?.trim();
|
||||
case 'DIVIDER':
|
||||
return '---';
|
||||
case 'TWEET':
|
||||
if (entity.data.tweetId) {
|
||||
return `[Embedded Tweet: https://x.com/i/status/${entity.data.tweetId}]`;
|
||||
}
|
||||
return undefined;
|
||||
case 'LINK':
|
||||
if (entity.data.url) {
|
||||
return `[Link: ${entity.data.url}]`;
|
||||
}
|
||||
return undefined;
|
||||
case 'IMAGE':
|
||||
// Images in atomic blocks - could extract URL if available
|
||||
return '[Image]';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
export function extractArticleText(result) {
|
||||
const article = result?.article;
|
||||
if (!article) {
|
||||
return undefined;
|
||||
}
|
||||
const articleResult = article.article_results?.result ?? article;
|
||||
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
|
||||
console.error('[bird][debug][article] payload:', JSON.stringify({
|
||||
rest_id: result?.rest_id,
|
||||
article: articleResult,
|
||||
note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null,
|
||||
}, null, 2));
|
||||
}
|
||||
const title = firstText(articleResult.title, article.title);
|
||||
// Try to render from rich content_state first (Draft.js format with blocks + entityMap)
|
||||
// This preserves code blocks, embedded tweets, markdown, etc.
|
||||
const contentState = article.article_results?.result?.content_state;
|
||||
const richBody = renderContentState(contentState);
|
||||
if (richBody) {
|
||||
// Rich content found - prepend title if not already included
|
||||
if (title) {
|
||||
const normalizedTitle = title.trim();
|
||||
const trimmedBody = richBody.trimStart();
|
||||
const headingMatches = [`# ${normalizedTitle}`, `## ${normalizedTitle}`, `### ${normalizedTitle}`];
|
||||
const hasTitle = trimmedBody === normalizedTitle ||
|
||||
trimmedBody.startsWith(`${normalizedTitle}\n`) ||
|
||||
headingMatches.some((heading) => trimmedBody.startsWith(heading));
|
||||
if (!hasTitle) {
|
||||
return `${title}\n\n${richBody}`;
|
||||
}
|
||||
}
|
||||
return richBody;
|
||||
}
|
||||
// Fallback to plain text extraction for articles without rich content_state
|
||||
let body = firstText(articleResult.plain_text, article.plain_text, articleResult.body?.text, articleResult.body?.richtext?.text, articleResult.body?.rich_text?.text, articleResult.content?.text, articleResult.content?.richtext?.text, articleResult.content?.rich_text?.text, articleResult.text, articleResult.richtext?.text, articleResult.rich_text?.text, article.body?.text, article.body?.richtext?.text, article.body?.rich_text?.text, article.content?.text, article.content?.richtext?.text, article.content?.rich_text?.text, article.text, article.richtext?.text, article.rich_text?.text);
|
||||
if (body && title && body.trim() === title.trim()) {
|
||||
body = undefined;
|
||||
}
|
||||
if (!body) {
|
||||
const collected = [];
|
||||
collectTextFields(articleResult, new Set(['text', 'title']), collected);
|
||||
collectTextFields(article, new Set(['text', 'title']), collected);
|
||||
const unique = uniqueOrdered(collected);
|
||||
const filtered = title ? unique.filter((value) => value !== title) : unique;
|
||||
if (filtered.length > 0) {
|
||||
body = filtered.join('\n\n');
|
||||
}
|
||||
}
|
||||
if (title && body && !body.startsWith(title)) {
|
||||
return `${title}\n\n${body}`;
|
||||
}
|
||||
return body ?? title;
|
||||
}
|
||||
export function extractNoteTweetText(result) {
|
||||
const note = result?.note_tweet?.note_tweet_results?.result;
|
||||
if (!note) {
|
||||
return undefined;
|
||||
}
|
||||
return firstText(note.text, note.richtext?.text, note.rich_text?.text, note.content?.text, note.content?.richtext?.text, note.content?.rich_text?.text);
|
||||
}
|
||||
export function extractTweetText(result) {
|
||||
return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text);
|
||||
}
|
||||
export function extractArticleMetadata(result) {
|
||||
const article = result?.article;
|
||||
if (!article) {
|
||||
return undefined;
|
||||
}
|
||||
const articleResult = article.article_results?.result ?? article;
|
||||
const title = firstText(articleResult.title, article.title);
|
||||
if (!title) {
|
||||
return undefined;
|
||||
}
|
||||
// preview_text is available in home timeline responses
|
||||
const previewText = firstText(articleResult.preview_text, article.preview_text);
|
||||
return { title, previewText };
|
||||
}
|
||||
export function extractMedia(result) {
|
||||
// Prefer extended_entities (has video info), fall back to entities
|
||||
const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media;
|
||||
if (!rawMedia || rawMedia.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const media = [];
|
||||
for (const item of rawMedia) {
|
||||
if (!item.type || !item.media_url_https) {
|
||||
continue;
|
||||
}
|
||||
const mediaItem = {
|
||||
type: item.type,
|
||||
url: item.media_url_https,
|
||||
};
|
||||
// Get dimensions from largest available size
|
||||
const sizes = item.sizes;
|
||||
if (sizes?.large) {
|
||||
mediaItem.width = sizes.large.w;
|
||||
mediaItem.height = sizes.large.h;
|
||||
}
|
||||
else if (sizes?.medium) {
|
||||
mediaItem.width = sizes.medium.w;
|
||||
mediaItem.height = sizes.medium.h;
|
||||
}
|
||||
// For thumbnails/previews
|
||||
if (sizes?.small) {
|
||||
mediaItem.previewUrl = `${item.media_url_https}:small`;
|
||||
}
|
||||
// Extract video URL for video/animated_gif
|
||||
if ((item.type === 'video' || item.type === 'animated_gif') && item.video_info?.variants) {
|
||||
// Prefer highest bitrate MP4, fall back to first MP4 when bitrate is missing.
|
||||
const mp4Variants = item.video_info.variants.filter((v) => v.content_type === 'video/mp4' && typeof v.url === 'string');
|
||||
const mp4WithBitrate = mp4Variants
|
||||
.filter((v) => typeof v.bitrate === 'number')
|
||||
.sort((a, b) => b.bitrate - a.bitrate);
|
||||
const selectedVariant = mp4WithBitrate[0] ?? mp4Variants[0];
|
||||
if (selectedVariant) {
|
||||
mediaItem.videoUrl = selectedVariant.url;
|
||||
}
|
||||
if (typeof item.video_info.duration_millis === 'number') {
|
||||
mediaItem.durationMs = item.video_info.duration_millis;
|
||||
}
|
||||
}
|
||||
media.push(mediaItem);
|
||||
}
|
||||
return media.length > 0 ? media : undefined;
|
||||
}
|
||||
export function unwrapTweetResult(result) {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
if (result.tweet) {
|
||||
return result.tweet;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function mapTweetResult(result, quoteDepthOrOptions) {
|
||||
const options = typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
|
||||
const { quoteDepth, includeRaw = false } = options;
|
||||
const userResult = result?.core?.user_results?.result;
|
||||
const userLegacy = userResult?.legacy;
|
||||
const userCore = userResult?.core;
|
||||
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;
|
||||
}
|
||||
const text = extractTweetText(result);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
let quotedTweet;
|
||||
if (quoteDepth > 0) {
|
||||
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
||||
if (quotedResult) {
|
||||
quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw });
|
||||
}
|
||||
}
|
||||
const media = extractMedia(result);
|
||||
const article = extractArticleMetadata(result);
|
||||
const tweetData = {
|
||||
id: result.rest_id,
|
||||
text,
|
||||
createdAt: result.legacy?.created_at,
|
||||
replyCount: result.legacy?.reply_count,
|
||||
retweetCount: result.legacy?.retweet_count,
|
||||
likeCount: result.legacy?.favorite_count,
|
||||
conversationId: result.legacy?.conversation_id_str,
|
||||
inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined,
|
||||
author: {
|
||||
username,
|
||||
name: name || username,
|
||||
},
|
||||
authorId: userId,
|
||||
quotedTweet,
|
||||
media,
|
||||
article,
|
||||
};
|
||||
if (includeRaw) {
|
||||
tweetData._raw = result;
|
||||
}
|
||||
return tweetData;
|
||||
}
|
||||
export function findTweetInInstructions(instructions, tweetId) {
|
||||
if (!instructions) {
|
||||
return undefined;
|
||||
}
|
||||
for (const instruction of instructions) {
|
||||
for (const entry of instruction.entries || []) {
|
||||
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (result?.rest_id === tweetId) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export function collectTweetResultsFromEntry(entry) {
|
||||
const results = [];
|
||||
const pushResult = (result) => {
|
||||
if (result?.rest_id) {
|
||||
results.push(result);
|
||||
}
|
||||
};
|
||||
const content = entry.content;
|
||||
pushResult(content?.itemContent?.tweet_results?.result);
|
||||
pushResult(content?.item?.itemContent?.tweet_results?.result);
|
||||
for (const item of content?.items ?? []) {
|
||||
pushResult(item?.item?.itemContent?.tweet_results?.result);
|
||||
pushResult(item?.itemContent?.tweet_results?.result);
|
||||
pushResult(item?.content?.itemContent?.tweet_results?.result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
export function parseTweetsFromInstructions(instructions, quoteDepthOrOptions) {
|
||||
const options = typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
|
||||
const { quoteDepth, includeRaw = false } = options;
|
||||
const tweets = [];
|
||||
const seen = new Set();
|
||||
for (const instruction of instructions ?? []) {
|
||||
for (const entry of instruction.entries ?? []) {
|
||||
const results = collectTweetResultsFromEntry(entry);
|
||||
for (const result of results) {
|
||||
const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
|
||||
if (!mapped || seen.has(mapped.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(mapped.id);
|
||||
tweets.push(mapped);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tweets;
|
||||
}
|
||||
export function extractCursorFromInstructions(instructions, cursorType = 'Bottom') {
|
||||
for (const instruction of instructions ?? []) {
|
||||
for (const entry of instruction.entries ?? []) {
|
||||
const content = entry.content;
|
||||
if (content?.cursorType === cursorType && typeof content.value === 'string' && content.value.length > 0) {
|
||||
return content.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export function parseUsersFromInstructions(instructions) {
|
||||
if (!instructions) {
|
||||
return [];
|
||||
}
|
||||
const users = [];
|
||||
for (const instruction of instructions) {
|
||||
if (!instruction.entries) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of instruction.entries) {
|
||||
const content = entry?.content;
|
||||
const rawUserResult = content?.itemContent?.user_results?.result;
|
||||
const userResult = rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user
|
||||
? rawUserResult.user
|
||||
: rawUserResult;
|
||||
if (!userResult || userResult.__typename !== 'User') {
|
||||
continue;
|
||||
}
|
||||
const legacy = userResult.legacy;
|
||||
const core = userResult.core;
|
||||
const username = legacy?.screen_name ?? core?.screen_name;
|
||||
if (!userResult.rest_id || !username) {
|
||||
continue;
|
||||
}
|
||||
users.push({
|
||||
id: userResult.rest_id,
|
||||
username,
|
||||
name: legacy?.name ?? core?.name ?? username,
|
||||
description: legacy?.description,
|
||||
followersCount: legacy?.followers_count,
|
||||
followingCount: legacy?.friends_count,
|
||||
isBlueVerified: userResult.is_blue_verified,
|
||||
profileImageUrl: legacy?.profile_image_url_https ?? userResult.avatar?.image_url,
|
||||
createdAt: legacy?.created_at ?? core?.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return users;
|
||||
}
|
||||
//# sourceMappingURL=twitter-client-utils.js.map
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "bird-search",
|
||||
"version": "0.8.0",
|
||||
"description": "Vendored Bird CLI search subset for /last30days",
|
||||
"type": "module",
|
||||
"main": "bird-search.mjs",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"license": "MIT",
|
||||
"attribution": "Based on @steipete/bird v0.8.0 by Peter Steinberger (MIT License)"
|
||||
}
|
||||
Reference in New Issue
Block a user