Add engine switch and Sweetistics endpoints for tweet reading
This commit is contained in:
@@ -16,10 +16,15 @@ pnpm run binary # Creates the 'bird' executable
|
|||||||
- `bird tweet "<text>"` — post a new tweet.
|
- `bird tweet "<text>"` — post a new tweet.
|
||||||
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
|
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
|
||||||
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
|
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
|
||||||
|
- `bird replies <tweet-id-or-url> [--json]` — list replies to a tweet.
|
||||||
|
- `bird thread <tweet-id-or-url> [--json]` — show the full conversation thread.
|
||||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
||||||
- `bird mentions [-n count] [--json]` — find tweets mentioning @clawdbot.
|
- `bird mentions [-n count] [--json]` — find tweets mentioning @clawdbot.
|
||||||
- `bird check` — show which credentials are available and where they were sourced from.
|
- `bird check` — show which credentials are available and where they were sourced from.
|
||||||
|
|
||||||
|
Global engine switch:
|
||||||
|
- `--engine graphql|sweetistics|auto` (default `auto`). `auto` uses Sweetistics when an API key is provided, otherwise falls back to direct GraphQL. `sweetistics` requires `--sweetistics-api-key` (or env) and uses Sweetistics for all commands. `graphql` forces direct Twitter cookies even if an API key is present.
|
||||||
|
|
||||||
### Post a tweet
|
### Post a tweet
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -132,4 +137,4 @@ pnpm run lint:fix
|
|||||||
|
|
||||||
- Chrome cookie extraction requires macOS (uses `sqlite3` and `security` CLI tools).
|
- Chrome cookie extraction requires macOS (uses `sqlite3` and `security` CLI tools).
|
||||||
- The keychain access may block when running over SSH; use environment variables instead.
|
- The keychain access may block when running over SSH; use environment variables instead.
|
||||||
- Twitter/X may rotate GraphQL query IDs; update `src/lib/twitter-client.ts` if requests start failing.
|
- Twitter/X rotates GraphQL query IDs; refresh them with `pnpm run graphql:update` (writes `src/lib/query-ids.json`).
|
||||||
|
|||||||
+2
-1
@@ -15,7 +15,8 @@
|
|||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"binary": "bun build --compile --minify src/index.ts --outfile bird"
|
"binary": "bun build --compile --minify src/index.ts --outfile bird",
|
||||||
|
"graphql:update": "tsx scripts/update-query-ids.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"commander": "^12.1.0"
|
"commander": "^12.1.0"
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Fetches current Twitter/X GraphQL query IDs from public client bundles and
|
||||||
|
* updates src/lib/query-ids.json. Inspired by Sweetistics' extractor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const TARGET_OPERATIONS = [
|
||||||
|
'CreateTweet',
|
||||||
|
'CreateRetweet',
|
||||||
|
'FavoriteTweet',
|
||||||
|
'TweetDetail',
|
||||||
|
'SearchTimeline',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type OperationName = (typeof TARGET_OPERATIONS)[number];
|
||||||
|
|
||||||
|
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 OPERATION_PATTERNS = [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const QUERY_IDS_PATH = path.resolve(process.cwd(), 'src/lib/query-ids.json');
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DiscoveredOperation {
|
||||||
|
queryId: string;
|
||||||
|
bundle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readExistingIds(): Promise<Record<OperationName, string>> {
|
||||||
|
try {
|
||||||
|
const contents = await fs.readFile(QUERY_IDS_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(contents) as Record<string, string>;
|
||||||
|
const result: Partial<Record<OperationName, string>> = {};
|
||||||
|
for (const op of TARGET_OPERATIONS) {
|
||||||
|
if (typeof parsed[op] === 'string' && parsed[op].trim().length > 0) {
|
||||||
|
result[op] = parsed[op].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result as Record<OperationName, string>;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.warn('[warn] Failed to read existing query IDs:', error);
|
||||||
|
}
|
||||||
|
return {} as Record<OperationName, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchText(url: string): Promise<string> {
|
||||||
|
const response = await fetch(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverBundles(): Promise<string[]> {
|
||||||
|
const bundles = new Set<string>();
|
||||||
|
for (const page of DISCOVERY_PAGES) {
|
||||||
|
try {
|
||||||
|
const html = await fetchText(page);
|
||||||
|
for (const match of html.matchAll(BUNDLE_URL_REGEX)) {
|
||||||
|
bundles.add(match[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[warn] Could not fetch ${page}:`, error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const discovered = Array.from(bundles);
|
||||||
|
if (discovered.length === 0) {
|
||||||
|
throw new Error('No client bundles discovered; x.com layout may have changed.');
|
||||||
|
}
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOperations(
|
||||||
|
bundleContents: string,
|
||||||
|
bundleLabel: string,
|
||||||
|
targets: Set<OperationName>,
|
||||||
|
discovered: Map<OperationName, DiscoveredOperation>,
|
||||||
|
): void {
|
||||||
|
for (const pattern of OPERATION_PATTERNS) {
|
||||||
|
pattern.regex.lastIndex = 0; // reset stateful regex
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.regex.exec(bundleContents)) !== null) {
|
||||||
|
const operationName = match[pattern.operationGroup];
|
||||||
|
const queryId = match[pattern.queryIdGroup];
|
||||||
|
if (!operationName || !queryId) continue;
|
||||||
|
|
||||||
|
if (!targets.has(operationName as OperationName)) continue;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(queryId)) continue;
|
||||||
|
const op = operationName as OperationName;
|
||||||
|
if (discovered.has(op)) continue;
|
||||||
|
discovered.set(op, { queryId, bundle: bundleLabel });
|
||||||
|
if (discovered.size === targets.size) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndExtract(
|
||||||
|
bundleUrls: string[],
|
||||||
|
targets: Set<OperationName>,
|
||||||
|
): Promise<Map<OperationName, DiscoveredOperation>> {
|
||||||
|
const discovered = new Map<OperationName, DiscoveredOperation>();
|
||||||
|
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(url);
|
||||||
|
extractOperations(js, label, targets, discovered);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[warn] Failed to scan ${label}:`, error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (discovered.size === targets.size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeIds(ids: Record<OperationName, string>): Promise<void> {
|
||||||
|
const ordered: Record<OperationName, string> = {} as Record<OperationName, string>;
|
||||||
|
for (const op of TARGET_OPERATIONS) {
|
||||||
|
if (ids[op]) {
|
||||||
|
ordered[op] = ids[op];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const json = `${JSON.stringify(ordered, null, 2)}\n`;
|
||||||
|
await fs.mkdir(path.dirname(QUERY_IDS_PATH), { recursive: true });
|
||||||
|
await fs.writeFile(QUERY_IDS_PATH, json, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('[info] Discovering Twitter/X client bundles…');
|
||||||
|
const bundleUrls = await discoverBundles();
|
||||||
|
console.log(`[info] Found ${bundleUrls.length} bundles`);
|
||||||
|
|
||||||
|
const targets = new Set<OperationName>(TARGET_OPERATIONS);
|
||||||
|
const existing = await readExistingIds();
|
||||||
|
|
||||||
|
const discovered = await fetchAndExtract(bundleUrls, targets);
|
||||||
|
if (discovered.size === 0) {
|
||||||
|
throw new Error('No query IDs discovered; extraction patterns may need an update.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIds: Record<OperationName, string> = { ...existing };
|
||||||
|
for (const op of TARGET_OPERATIONS) {
|
||||||
|
const found = discovered.get(op);
|
||||||
|
if (found?.queryId) {
|
||||||
|
nextIds[op] = found.queryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeIds(nextIds);
|
||||||
|
|
||||||
|
for (const op of TARGET_OPERATIONS) {
|
||||||
|
const previous = existing[op];
|
||||||
|
const current = nextIds[op];
|
||||||
|
const source = discovered.get(op)?.bundle ?? 'existing file';
|
||||||
|
if (previous && current && previous !== current) {
|
||||||
|
console.log(`✅ ${op}: ${previous} → ${current} (${source})`);
|
||||||
|
} else if (current) {
|
||||||
|
console.log(`✅ ${op}: ${current} (${source})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ ${op}: not found (kept previous value if present)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[info] Updated ${QUERY_IDS_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('[error]', error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+246
-47
@@ -12,7 +12,7 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { resolveCredentials } from './lib/cookies.js';
|
import { resolveCredentials } from './lib/cookies.js';
|
||||||
import { extractTweetId } from './lib/extract-tweet-id.js';
|
import { extractTweetId } from './lib/extract-tweet-id.js';
|
||||||
import { TwitterClient } from './lib/twitter-client.js';
|
import { TwitterClient, type TweetData } from './lib/twitter-client.js';
|
||||||
import { SweetisticsClient } from './lib/sweetistics-client.js';
|
import { SweetisticsClient } from './lib/sweetistics-client.js';
|
||||||
|
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -25,7 +25,14 @@ program
|
|||||||
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
.option('--ct0 <token>', 'Twitter ct0 cookie')
|
||||||
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction')
|
.option('--chrome-profile <name>', 'Chrome profile name for cookie extraction')
|
||||||
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
|
.option('--sweetistics-api-key <key>', 'Sweetistics API key (or set SWEETISTICS_API_KEY)')
|
||||||
.option('--sweetistics-base-url <url>', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com');
|
.option('--sweetistics-base-url <url>', 'Sweetistics base URL', process.env.SWEETISTICS_BASE_URL || 'https://sweetistics.com')
|
||||||
|
.option(
|
||||||
|
'--engine <engine>',
|
||||||
|
'Engine: graphql | sweetistics | auto',
|
||||||
|
process.env.BIRD_ENGINE || 'auto',
|
||||||
|
);
|
||||||
|
|
||||||
|
type EngineMode = 'graphql' | 'sweetistics' | 'auto';
|
||||||
|
|
||||||
function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) {
|
function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetisticsBaseUrl?: string }) {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
@@ -39,6 +46,45 @@ function resolveSweetisticsConfig(options: { sweetisticsApiKey?: string; sweetis
|
|||||||
return { apiKey, baseUrl };
|
return { apiKey, baseUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEngineMode(value?: string): EngineMode {
|
||||||
|
const normalized = (value || 'auto').toLowerCase();
|
||||||
|
if (normalized === 'graphql' || normalized === 'sweetistics' || normalized === 'auto') {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseSweetistics(engine: EngineMode, hasApiKey: boolean): boolean {
|
||||||
|
if (engine === 'sweetistics') return true;
|
||||||
|
if (engine === 'graphql') return false;
|
||||||
|
return hasApiKey; // auto
|
||||||
|
}
|
||||||
|
|
||||||
|
function printTweets(
|
||||||
|
tweets: TweetData[],
|
||||||
|
opts: { json?: boolean; emptyMessage?: string; showSeparator?: boolean } = {},
|
||||||
|
) {
|
||||||
|
if (opts.json) {
|
||||||
|
console.log(JSON.stringify(tweets, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tweets.length === 0) {
|
||||||
|
console.log(opts.emptyMessage ?? 'No tweets found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const tweet of tweets) {
|
||||||
|
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
|
||||||
|
console.log(tweet.text);
|
||||||
|
if (tweet.createdAt) {
|
||||||
|
console.log(`📅 ${tweet.createdAt}`);
|
||||||
|
}
|
||||||
|
console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`);
|
||||||
|
if (opts.showSeparator ?? true) {
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tweet command
|
// Tweet command
|
||||||
program
|
program
|
||||||
.command('tweet')
|
.command('tweet')
|
||||||
@@ -47,8 +93,14 @@ program
|
|||||||
.action(async (text: string) => {
|
.action(async (text: string) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const sweetistics = resolveSweetisticsConfig(opts);
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
|
|
||||||
if (sweetistics.apiKey) {
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const client = new SweetisticsClient({
|
const client = new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
@@ -110,9 +162,15 @@ program
|
|||||||
.action(async (tweetIdOrUrl: string, text: string) => {
|
.action(async (tweetIdOrUrl: string, text: string) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
const sweetistics = resolveSweetisticsConfig(opts);
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
|
||||||
if (sweetistics.apiKey) {
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const client = new SweetisticsClient({
|
const client = new SweetisticsClient({
|
||||||
baseUrl: sweetistics.baseUrl,
|
baseUrl: sweetistics.baseUrl,
|
||||||
@@ -175,6 +233,37 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
|
|
||||||
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
|
||||||
|
const result = await client.read(tweetId);
|
||||||
|
if (result.success && result.tweet) {
|
||||||
|
if (cmdOpts.json) {
|
||||||
|
console.log(JSON.stringify(result.tweet, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`);
|
||||||
|
console.log(result.tweet.text);
|
||||||
|
if (result.tweet.createdAt) {
|
||||||
|
console.log(`\n📅 ${result.tweet.createdAt}`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`❤️ ${result.tweet.likeCount ?? 0} 🔁 ${result.tweet.retweetCount ?? 0} 💬 ${result.tweet.replyCount ?? 0}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ Failed to read tweet via Sweetistics: ${result.error ?? 'Unknown error'}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentials({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -190,7 +279,6 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tweetId = extractTweetId(tweetIdOrUrl);
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies });
|
||||||
const result = await client.getTweet(tweetId);
|
const result = await client.getTweet(tweetId);
|
||||||
|
|
||||||
@@ -213,15 +301,33 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search command - find tweets
|
// Replies command - list replies to a tweet
|
||||||
program
|
program
|
||||||
.command('search')
|
.command('replies')
|
||||||
.description('Search for tweets')
|
.description('List replies to a tweet (by ID or URL)')
|
||||||
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
|
||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
|
||||||
|
const result = await client.replies(tweetId);
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ Failed to fetch replies via Sweetistics: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentials({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -238,27 +344,118 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies });
|
||||||
|
const result = await client.getReplies(tweetId);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No replies found.' });
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Failed to fetch replies: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Thread command - show full conversation thread
|
||||||
|
program
|
||||||
|
.command('thread')
|
||||||
|
.description('Show the full conversation thread containing the tweet')
|
||||||
|
.argument('<tweet-id-or-url>', 'Tweet ID or URL')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
|
const tweetId = extractTweetId(tweetIdOrUrl);
|
||||||
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
|
||||||
|
const result = await client.thread(tweetId);
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ Failed to fetch thread via Sweetistics: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
|
authToken: opts.authToken,
|
||||||
|
ct0: opts.ct0,
|
||||||
|
chromeProfile: opts.chromeProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.error(`⚠️ ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
console.error('❌ Missing required credentials');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies });
|
||||||
|
const result = await client.getThread(tweetId);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No thread tweets found.' });
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Failed to fetch thread: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search command - find tweets
|
||||||
|
program
|
||||||
|
.command('search')
|
||||||
|
.description('Search for tweets')
|
||||||
|
.argument('<query>', 'Search query (e.g., "@clawdbot" or "from:clawdbot")')
|
||||||
|
.option('-n, --count <number>', 'Number of tweets to fetch', '10')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
|
const opts = program.opts();
|
||||||
const count = parseInt(cmdOpts.count || '10', 10);
|
const count = parseInt(cmdOpts.count || '10', 10);
|
||||||
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
|
|
||||||
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
|
||||||
|
const result = await client.search(query, count);
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ Search failed via Sweetistics: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
|
authToken: opts.authToken,
|
||||||
|
ct0: opts.ct0,
|
||||||
|
chromeProfile: opts.chromeProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.error(`⚠️ ${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
console.error('❌ Missing required credentials');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies });
|
||||||
const result = await client.search(query, count);
|
const result = await client.search(query, count);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
if (cmdOpts.json) {
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No tweets found.' });
|
||||||
console.log(JSON.stringify(result.tweets, null, 2));
|
|
||||||
} else {
|
|
||||||
if (result.tweets.length === 0) {
|
|
||||||
console.log('No tweets found.');
|
|
||||||
} else {
|
|
||||||
for (const tweet of result.tweets) {
|
|
||||||
console.log(`\n@${tweet.author.username} (${tweet.author.name}):`);
|
|
||||||
console.log(tweet.text);
|
|
||||||
if (tweet.createdAt) {
|
|
||||||
console.log(`📅 ${tweet.createdAt}`);
|
|
||||||
}
|
|
||||||
console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`);
|
|
||||||
console.log('─'.repeat(50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`❌ Search failed: ${result.error}`);
|
console.error(`❌ Search failed: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -273,6 +470,26 @@ program
|
|||||||
.option('--json', 'Output as JSON')
|
.option('--json', 'Output as JSON')
|
||||||
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
|
const count = parseInt(cmdOpts.count || '10', 10);
|
||||||
|
const sweetistics = resolveSweetisticsConfig(opts);
|
||||||
|
const engine = resolveEngineMode(opts.engine);
|
||||||
|
const useSweetistics = shouldUseSweetistics(engine, Boolean(sweetistics.apiKey));
|
||||||
|
|
||||||
|
if (useSweetistics) {
|
||||||
|
if (!sweetistics.apiKey) {
|
||||||
|
console.error('❌ Sweetistics engine selected but no API key provided.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new SweetisticsClient({ baseUrl: sweetistics.baseUrl, apiKey: sweetistics.apiKey });
|
||||||
|
const result = await client.search('@clawdbot', count);
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(`❌ Failed to fetch mentions via Sweetistics: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentials({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -289,28 +506,10 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies });
|
const client = new TwitterClient({ cookies });
|
||||||
const count = parseInt(cmdOpts.count || '10', 10);
|
|
||||||
const result = await client.search('@clawdbot', count);
|
const result = await client.search('@clawdbot', count);
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
if (cmdOpts.json) {
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No mentions found.' });
|
||||||
console.log(JSON.stringify(result.tweets, null, 2));
|
|
||||||
} else {
|
|
||||||
if (result.tweets.length === 0) {
|
|
||||||
console.log('No mentions found.');
|
|
||||||
} else {
|
|
||||||
console.log(`Found ${result.tweets.length} mentions:\n`);
|
|
||||||
for (const tweet of result.tweets) {
|
|
||||||
console.log(`@${tweet.author.username} (${tweet.author.name}):`);
|
|
||||||
console.log(tweet.text);
|
|
||||||
if (tweet.createdAt) {
|
|
||||||
console.log(`📅 ${tweet.createdAt}`);
|
|
||||||
}
|
|
||||||
console.log(`🔗 https://x.com/${tweet.author.username}/status/${tweet.id}`);
|
|
||||||
console.log('─'.repeat(50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`❌ Failed to fetch mentions: ${result.error}`);
|
console.error(`❌ Failed to fetch mentions: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"CreateTweet": "TAJw1rBsjAtdNgTdlo2oeg",
|
||||||
|
"CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
|
||||||
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||||
|
"TweetDetail": "nBS-WpgA6ZG0CyNHD517JQ",
|
||||||
|
"SearchTimeline": "Tp1sewRU1AsZpBWhqCZicQ"
|
||||||
|
}
|
||||||
@@ -10,6 +10,33 @@ export interface SweetisticsTweetResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SweetisticsTweet {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
author: {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
createdAt?: string;
|
||||||
|
replyCount?: number;
|
||||||
|
retweetCount?: number;
|
||||||
|
likeCount?: number;
|
||||||
|
conversationId?: string;
|
||||||
|
inReplyToStatusId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SweetisticsTimelineResult {
|
||||||
|
success: boolean;
|
||||||
|
tweets?: SweetisticsTweet[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SweetisticsReadResult {
|
||||||
|
success: boolean;
|
||||||
|
tweet?: SweetisticsTweet;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBaseUrl(value: string): string {
|
function normalizeBaseUrl(value: string): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return 'https://sweetistics.com';
|
if (!trimmed) return 'https://sweetistics.com';
|
||||||
@@ -67,4 +94,57 @@ export class SweetisticsClient {
|
|||||||
|
|
||||||
return { success: true, tweetId };
|
return { success: true, tweetId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async read(tweetId: string): Promise<SweetisticsReadResult> {
|
||||||
|
return this.fetchJson<SweetisticsReadResult>(`${this.baseUrl}/api/twitter/tweet/${tweetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async replies(tweetId: string): Promise<SweetisticsTimelineResult> {
|
||||||
|
return this.fetchJson<SweetisticsTimelineResult>(`${this.baseUrl}/api/twitter/tweet/${tweetId}/replies`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async thread(tweetId: string): Promise<SweetisticsTimelineResult> {
|
||||||
|
return this.fetchJson<SweetisticsTimelineResult>(`${this.baseUrl}/api/twitter/tweet/${tweetId}/thread`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query: string, count: number): Promise<SweetisticsTimelineResult> {
|
||||||
|
const url = new URL(`${this.baseUrl}/api/twitter/search`);
|
||||||
|
url.searchParams.set('q', query);
|
||||||
|
url.searchParams.set('count', String(count));
|
||||||
|
return this.fetchJson<SweetisticsTimelineResult>(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T extends { success: boolean; error?: string }>(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${this.apiKey}`,
|
||||||
|
...(this.userAgent ? { 'user-agent': this.userAgent } : {}),
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Sweetistics response parse failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !(data as { success?: unknown })?.success) {
|
||||||
|
const message =
|
||||||
|
(typeof (data as { error?: unknown })?.error === 'string'
|
||||||
|
? (data as { error?: string }).error
|
||||||
|
: undefined) || `HTTP ${response.status}`;
|
||||||
|
return { ...(data as object), success: false, error: message } as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+184
-100
@@ -3,18 +3,49 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TwitterCookies } from './cookies.js';
|
import type { TwitterCookies } from './cookies.js';
|
||||||
|
import queryIds from './query-ids.json' with { type: 'json' };
|
||||||
|
|
||||||
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
const TWITTER_API_BASE = 'https://x.com/i/api/graphql';
|
||||||
|
|
||||||
// Twitter GraphQL query IDs - these may need to be updated periodically
|
// Query IDs rotate frequently; the values in query-ids.json are refreshed by
|
||||||
// as Twitter rotates them. Check browser network tab for current values.
|
// scripts/update-query-ids.ts. The fallback values keep the client usable if
|
||||||
// Source: https://github.com/fa0311/TwitterInternalAPIDocument
|
// the file is missing or incomplete.
|
||||||
const QUERY_IDS = {
|
const FALLBACK_QUERY_IDS = {
|
||||||
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
|
||||||
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
|
||||||
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
|
||||||
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
|
||||||
SearchTimeline: 'Tp1sewRU1AsZpBWhqCZicQ',
|
SearchTimeline: 'Tp1sewRU1AsZpBWhqCZicQ',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||||
|
|
||||||
|
const QUERY_IDS: Record<OperationName, string> = {
|
||||||
|
...FALLBACK_QUERY_IDS,
|
||||||
|
...(queryIds as Partial<Record<OperationName, string>>),
|
||||||
|
};
|
||||||
|
|
||||||
|
type GraphqlTweetResult = {
|
||||||
|
rest_id?: string;
|
||||||
|
legacy?: {
|
||||||
|
full_text?: string;
|
||||||
|
created_at?: string;
|
||||||
|
reply_count?: number;
|
||||||
|
retweet_count?: number;
|
||||||
|
favorite_count?: number;
|
||||||
|
conversation_id_str?: string;
|
||||||
|
in_reply_to_status_id_str?: string | null;
|
||||||
|
};
|
||||||
|
core?: {
|
||||||
|
user_results?: {
|
||||||
|
result?: {
|
||||||
|
legacy?: {
|
||||||
|
screen_name?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TweetResult {
|
export interface TweetResult {
|
||||||
@@ -34,6 +65,8 @@ export interface TweetData {
|
|||||||
replyCount?: number;
|
replyCount?: number;
|
||||||
retweetCount?: number;
|
retweetCount?: number;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
|
conversationId?: string;
|
||||||
|
inReplyToStatusId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetTweetResult {
|
export interface GetTweetResult {
|
||||||
@@ -150,10 +183,74 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined {
|
||||||
* Get tweet details by ID
|
if (!result?.legacy || !result.core?.user_results?.result?.legacy?.screen_name) return undefined;
|
||||||
*/
|
return {
|
||||||
async getTweet(tweetId: string): Promise<GetTweetResult> {
|
id: result.rest_id || '',
|
||||||
|
text: result.legacy.full_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: result.core.user_results.result.legacy.screen_name,
|
||||||
|
name: result.core.user_results.result.legacy.name || result.core.user_results.result.legacy.screen_name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTweetsFromInstructions(
|
||||||
|
instructions:
|
||||||
|
| Array<{
|
||||||
|
entries?: Array<{
|
||||||
|
content?: {
|
||||||
|
itemContent?: {
|
||||||
|
tweet_results?: {
|
||||||
|
result?: GraphqlTweetResult;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
): TweetData[] {
|
||||||
|
const tweets: TweetData[] = [];
|
||||||
|
for (const instruction of instructions ?? []) {
|
||||||
|
for (const entry of instruction.entries ?? []) {
|
||||||
|
const result = entry.content?.itemContent?.tweet_results?.result;
|
||||||
|
const mapped = this.mapTweetResult(result);
|
||||||
|
if (mapped) tweets.push(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tweets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTweetDetail(
|
||||||
|
tweetId: string,
|
||||||
|
): Promise<
|
||||||
|
| {
|
||||||
|
success: true;
|
||||||
|
data: {
|
||||||
|
tweetResult?: { result?: GraphqlTweetResult };
|
||||||
|
threaded_conversation_with_injections_v2?: {
|
||||||
|
instructions?: Array<{
|
||||||
|
entries?: Array<{
|
||||||
|
content?: {
|
||||||
|
itemContent?: {
|
||||||
|
tweet_results?: {
|
||||||
|
result?: GraphqlTweetResult;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { success: false; error: string }
|
||||||
|
> {
|
||||||
const variables = {
|
const variables = {
|
||||||
focalTweetId: tweetId,
|
focalTweetId: tweetId,
|
||||||
with_rux_injections: false,
|
with_rux_injections: false,
|
||||||
@@ -206,62 +303,19 @@ export class TwitterClient {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
return {
|
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||||
success: false,
|
|
||||||
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
data?: {
|
data?: {
|
||||||
tweetResult?: {
|
tweetResult?: { result?: GraphqlTweetResult };
|
||||||
result?: {
|
|
||||||
rest_id?: string;
|
|
||||||
legacy?: {
|
|
||||||
full_text?: string;
|
|
||||||
created_at?: string;
|
|
||||||
reply_count?: number;
|
|
||||||
retweet_count?: number;
|
|
||||||
favorite_count?: number;
|
|
||||||
};
|
|
||||||
core?: {
|
|
||||||
user_results?: {
|
|
||||||
result?: {
|
|
||||||
legacy?: {
|
|
||||||
screen_name?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
threaded_conversation_with_injections_v2?: {
|
threaded_conversation_with_injections_v2?: {
|
||||||
instructions?: Array<{
|
instructions?: Array<{
|
||||||
entries?: Array<{
|
entries?: Array<{
|
||||||
content?: {
|
content?: {
|
||||||
itemContent?: {
|
itemContent?: {
|
||||||
tweet_results?: {
|
tweet_results?: {
|
||||||
result?: {
|
result?: GraphqlTweetResult;
|
||||||
rest_id?: string;
|
|
||||||
legacy?: {
|
|
||||||
full_text?: string;
|
|
||||||
created_at?: string;
|
|
||||||
reply_count?: number;
|
|
||||||
retweet_count?: number;
|
|
||||||
favorite_count?: number;
|
|
||||||
};
|
|
||||||
core?: {
|
|
||||||
user_results?: {
|
|
||||||
result?: {
|
|
||||||
legacy?: {
|
|
||||||
screen_name?: string;
|
|
||||||
name?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -269,61 +323,54 @@ export class TwitterClient {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
errors?: Array<{ message: string }>;
|
errors?: Array<{ message: string; code?: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.errors && data.errors.length > 0) {
|
if (data.errors && data.errors.length > 0) {
|
||||||
return {
|
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||||
success: false,
|
|
||||||
error: data.errors.map((e) => e.message).join(', '),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer direct tweetResult if present, otherwise search the conversation thread
|
return { success: true, data: data.data ?? {} };
|
||||||
const tweetResult =
|
|
||||||
data.data?.tweetResult?.result ??
|
|
||||||
this.findTweetInInstructions(data.data?.threaded_conversation_with_injections_v2?.instructions, tweetId);
|
|
||||||
|
|
||||||
if (!tweetResult) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Tweet not found in response',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacy = tweetResult.legacy;
|
|
||||||
const userLegacy = tweetResult.core?.user_results?.result?.legacy;
|
|
||||||
|
|
||||||
if (!legacy?.full_text || !userLegacy?.screen_name) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Incomplete tweet data',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
tweet: {
|
|
||||||
id: tweetResult.rest_id || tweetId,
|
|
||||||
text: legacy.full_text,
|
|
||||||
author: {
|
|
||||||
username: userLegacy.screen_name,
|
|
||||||
name: userLegacy.name || userLegacy.screen_name,
|
|
||||||
},
|
|
||||||
createdAt: legacy.created_at,
|
|
||||||
replyCount: legacy.reply_count,
|
|
||||||
retweetCount: legacy.retweet_count,
|
|
||||||
likeCount: legacy.favorite_count,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tweet details by ID
|
||||||
|
*/
|
||||||
|
async getTweet(tweetId: string): Promise<GetTweetResult> {
|
||||||
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
|
if (!response.success) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tweetResult =
|
||||||
|
(response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ??
|
||||||
|
this.findTweetInInstructions(
|
||||||
|
response.data.threaded_conversation_with_injections_v2?.instructions as
|
||||||
|
| Array<{
|
||||||
|
entries?: Array<{
|
||||||
|
content?: {
|
||||||
|
itemContent?: {
|
||||||
|
tweet_results?: {
|
||||||
|
result?: GraphqlTweetResult;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
tweetId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapped = this.mapTweetResult(tweetResult);
|
||||||
|
if (mapped) {
|
||||||
|
return { success: true, tweet: mapped };
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Tweet not found in response' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post a new tweet
|
* Post a new tweet
|
||||||
*/
|
*/
|
||||||
@@ -635,4 +682,41 @@ export class TwitterClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get replies to a tweet by ID
|
||||||
|
*/
|
||||||
|
async getReplies(tweetId: string): Promise<SearchResult> {
|
||||||
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
|
if (!response.success) return response;
|
||||||
|
|
||||||
|
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||||
|
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||||
|
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
|
||||||
|
|
||||||
|
return { success: true, tweets: replies };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full conversation thread for a tweet ID
|
||||||
|
*/
|
||||||
|
async getThread(tweetId: string): Promise<SearchResult> {
|
||||||
|
const response = await this.fetchTweetDetail(tweetId);
|
||||||
|
if (!response.success) return response;
|
||||||
|
|
||||||
|
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
|
||||||
|
const tweets = this.parseTweetsFromInstructions(instructions);
|
||||||
|
|
||||||
|
const target = tweets.find((t) => t.id === tweetId);
|
||||||
|
const rootId = target?.conversationId || tweetId;
|
||||||
|
const thread = tweets.filter((tweet) => tweet.conversationId === rootId);
|
||||||
|
|
||||||
|
thread.sort((a, b) => {
|
||||||
|
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
|
||||||
|
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
|
||||||
|
return aTime - bTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, tweets: thread };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user