feat(twitter): implement article, profile, follow, unfollow, bookmark, & unbookmark adapters
This commit introduces the long-form Article adapter, a rewritten Profile adapter, and 4 new UI-based Write commands for managing relationships and bookmarks. Also adds support for positional arguments across the dynamic CLI engine.
This commit is contained in:
1 parent
7f55950fed
commit
0c845d58c8
10 files changed
+729
-162
No files matched your search
@@ -30,6 +30,7 @@ interface ManifestEntry {
|
||||
type?: string;
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}>;
|
||||
@@ -140,6 +141,7 @@ function scanTs(filePath: string, site: string): ManifestEntry {
|
||||
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
|
||||
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
|
||||
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
|
||||
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
|
||||
|
||||
let defaultVal: any = undefined;
|
||||
if (defaultMatch) {
|
||||
@@ -156,6 +158,7 @@ function scanTs(filePath: string, site: string): ManifestEntry {
|
||||
type: typeMatch?.[1] ?? 'str',
|
||||
default: defaultVal,
|
||||
required: requiredMatch?.[1] === 'true',
|
||||
positional: positionalMatch?.[1] === 'true' || undefined,
|
||||
help: helpMatch?.[1] ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'article',
|
||||
description: 'Fetch a Twitter Article (long-form content) and export as Markdown',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'tweet_id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
|
||||
],
|
||||
columns: ['title', 'author', 'content', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
// Extract tweet ID from URL if needed
|
||||
let tweetId = kwargs.tweet_id;
|
||||
const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
|
||||
if (urlMatch) tweetId = urlMatch[1];
|
||||
|
||||
// Navigate to the tweet page for cookie context
|
||||
await page.goto(`https://x.com/i/status/${tweetId}`);
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`
|
||||
async () => {
|
||||
const tweetId = "${tweetId}";
|
||||
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
||||
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes'
|
||||
};
|
||||
|
||||
const variables = JSON.stringify({
|
||||
tweetId: tweetId,
|
||||
withCommunity: false,
|
||||
includePromotedContent: false,
|
||||
withVoice: false,
|
||||
});
|
||||
const features = JSON.stringify({
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
});
|
||||
const fieldToggles = JSON.stringify({
|
||||
withArticleRichContentState: true,
|
||||
withArticlePlainText: true,
|
||||
});
|
||||
|
||||
// Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
|
||||
async function resolveQueryId(operationName, fallbackId) {
|
||||
try {
|
||||
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
||||
if (ghResp.ok) {
|
||||
const data = await ghResp.json();
|
||||
const entry = data[operationName];
|
||||
if (entry && entry.queryId) return entry.queryId;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const scripts = performance.getEntriesByType('resource')
|
||||
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
||||
.map(r => r.name);
|
||||
for (const scriptUrl of scripts.slice(0, 15)) {
|
||||
try {
|
||||
const text = await (await fetch(scriptUrl)).text();
|
||||
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
||||
const m = text.match(re);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
const queryId = await resolveQueryId('TweetResultByRestId', '7xflPyRiUxGVbJd4uWmbfg');
|
||||
const url = '/i/api/graphql/' + queryId + '/TweetResultByRestId?variables='
|
||||
+ encodeURIComponent(variables)
|
||||
+ '&features=' + encodeURIComponent(features)
|
||||
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
||||
|
||||
const resp = await fetch(url, {headers, credentials: 'include'});
|
||||
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Tweet may not exist or queryId expired'};
|
||||
const d = await resp.json();
|
||||
|
||||
const result = d.data?.tweetResult?.result;
|
||||
if (!result) return {error: 'Article not found'};
|
||||
|
||||
// Unwrap TweetWithVisibilityResults
|
||||
const tw = result.tweet || result;
|
||||
const legacy = tw.legacy || {};
|
||||
const user = tw.core?.user_results?.result;
|
||||
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
||||
|
||||
// Extract article content
|
||||
const articleResults = tw.article?.article_results?.result;
|
||||
if (!articleResults) {
|
||||
// Fallback: return note_tweet text if present
|
||||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
if (noteText) {
|
||||
return [{
|
||||
title: '(Note Tweet)',
|
||||
author: screenName,
|
||||
content: noteText,
|
||||
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
||||
}];
|
||||
}
|
||||
return {error: 'Tweet ' + tweetId + ' has no article content'};
|
||||
}
|
||||
|
||||
const title = articleResults.title || '(Untitled)';
|
||||
const contentState = articleResults.content_state || {};
|
||||
const blocks = contentState.blocks || [];
|
||||
|
||||
// Convert draft.js blocks to Markdown
|
||||
const parts = [];
|
||||
let orderedCounter = 0;
|
||||
for (const block of blocks) {
|
||||
const blockType = block.type || 'unstyled';
|
||||
if (blockType === 'atomic') continue;
|
||||
const text = block.text || '';
|
||||
if (!text) continue;
|
||||
if (blockType !== 'ordered-list-item') orderedCounter = 0;
|
||||
|
||||
if (blockType === 'header-one') parts.push('# ' + text);
|
||||
else if (blockType === 'header-two') parts.push('## ' + text);
|
||||
else if (blockType === 'header-three') parts.push('### ' + text);
|
||||
else if (blockType === 'blockquote') parts.push('> ' + text);
|
||||
else if (blockType === 'unordered-list-item') parts.push('- ' + text);
|
||||
else if (blockType === 'ordered-list-item') {
|
||||
orderedCounter++;
|
||||
parts.push(orderedCounter + '. ' + text);
|
||||
}
|
||||
else if (blockType === 'code-block') parts.push('\`\`\`\\n' + text + '\\n\`\`\`');
|
||||
else parts.push(text);
|
||||
}
|
||||
|
||||
return [{
|
||||
title,
|
||||
author: screenName,
|
||||
content: parts.join('\\n\\n') || legacy.full_text || '',
|
||||
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
||||
}];
|
||||
}
|
||||
`);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
||||
}
|
||||
|
||||
return result || [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'bookmark',
|
||||
description: 'Bookmark a tweet',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to bookmark' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let bookmarkBtn = null;
|
||||
let removeBtn = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if already bookmarked
|
||||
removeBtn = document.querySelector('[data-testid="removeBookmark"]');
|
||||
if (removeBtn) {
|
||||
return { ok: true, message: 'Tweet is already bookmarked.' };
|
||||
}
|
||||
|
||||
bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
|
||||
if (bookmarkBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!bookmarkBtn) {
|
||||
return { ok: false, message: 'Could not find Bookmark button. Are you logged in?' };
|
||||
}
|
||||
|
||||
bookmarkBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid="removeBookmark"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Tweet successfully bookmarked.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Bookmark action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'follow',
|
||||
description: 'Follow a Twitter user',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let followBtn = null;
|
||||
let unfollowTestId = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if already following (button shows screen_name-unfollow)
|
||||
unfollowTestId = document.querySelector('[data-testid$="-unfollow"]');
|
||||
if (unfollowTestId) {
|
||||
return { ok: true, message: 'Already following @${username}.' };
|
||||
}
|
||||
|
||||
// Look for the Follow button
|
||||
followBtn = document.querySelector('[data-testid$="-follow"]');
|
||||
if (followBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!followBtn) {
|
||||
return { ok: false, message: 'Could not find Follow button. Are you logged in?' };
|
||||
}
|
||||
|
||||
followBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid$="-unfollow"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Successfully followed @${username}.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Follow action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
+114
-46
@@ -3,59 +3,127 @@ import { cli, Strategy } from '../../registry.js';
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'profile',
|
||||
description: 'Fetch tweets from a user profile',
|
||||
description: 'Fetch a Twitter user profile (bio, stats, etc.)',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.INTERCEPT,
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', required: true },
|
||||
{ name: 'limit', type: 'int', default: 15 },
|
||||
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
||||
],
|
||||
columns: ['id', 'text', 'likes', 'views', 'url'],
|
||||
columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
|
||||
func: async (page, kwargs) => {
|
||||
// Navigate to user profile via search for reliability
|
||||
await page.goto(`https://x.com/search?q=from:${kwargs.username}&f=live`);
|
||||
await page.wait(5);
|
||||
let username = (kwargs.username || '').replace(/^@/, '');
|
||||
|
||||
// Inject XHR interceptor
|
||||
await page.installInterceptor('SearchTimeline');
|
||||
|
||||
// Trigger API by scrolling
|
||||
await page.autoScroll({ times: 3, delayMs: 2000 });
|
||||
|
||||
// Retrieve data
|
||||
const requests = await page.getInterceptedRequests();
|
||||
if (!requests || requests.length === 0) return [];
|
||||
|
||||
let results: any[] = [];
|
||||
for (const req of requests) {
|
||||
try {
|
||||
const insts = req.data.data.search_by_raw_query.search_timeline.timeline.instructions;
|
||||
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries');
|
||||
if (!addEntries) continue;
|
||||
|
||||
for (const entry of addEntries.entries) {
|
||||
if (!entry.entryId.startsWith('tweet-')) continue;
|
||||
|
||||
let tweet = entry.content?.itemContent?.tweet_results?.result;
|
||||
if (!tweet) continue;
|
||||
|
||||
if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
|
||||
tweet = tweet.tweet;
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: tweet.rest_id,
|
||||
text: tweet.legacy?.full_text || '',
|
||||
likes: tweet.legacy?.favorite_count || 0,
|
||||
views: tweet.views?.count || '0',
|
||||
url: `https://x.com/i/status/${tweet.rest_id}`
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
// If no username, detect the logged-in user
|
||||
if (!username) {
|
||||
await page.goto('https://x.com/home');
|
||||
await page.wait(5);
|
||||
const href = await page.evaluate(`() => {
|
||||
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
||||
return link ? link.getAttribute('href') : null;
|
||||
}`);
|
||||
if (!href) throw new Error('Could not detect logged-in user. Are you logged in?');
|
||||
username = href.replace('/', '');
|
||||
}
|
||||
|
||||
return results.slice(0, kwargs.limit);
|
||||
// Navigate directly to the user's profile page (gives us cookie context)
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(3);
|
||||
|
||||
const result = await page.evaluate(`
|
||||
async () => {
|
||||
const screenName = "${username}";
|
||||
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
||||
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes'
|
||||
};
|
||||
|
||||
const variables = JSON.stringify({
|
||||
screen_name: screenName,
|
||||
withSafetyModeUserFields: true,
|
||||
});
|
||||
const features = JSON.stringify({
|
||||
hidden_profile_subscriptions_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
responsive_web_graphql_exclude_directive_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
subscriptions_verification_info_is_identity_verified_enabled: true,
|
||||
subscriptions_verification_info_verified_since_enabled: true,
|
||||
highlights_tweets_tab_ui_enabled: true,
|
||||
responsive_web_twitter_article_notes_tab_enabled: true,
|
||||
subscriptions_feature_can_gift_premium: true,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
});
|
||||
|
||||
// Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
|
||||
async function resolveQueryId(operationName, fallbackId) {
|
||||
try {
|
||||
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
||||
if (ghResp.ok) {
|
||||
const data = await ghResp.json();
|
||||
const entry = data[operationName];
|
||||
if (entry && entry.queryId) return entry.queryId;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const scripts = performance.getEntriesByType('resource')
|
||||
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
||||
.map(r => r.name);
|
||||
for (const scriptUrl of scripts.slice(0, 15)) {
|
||||
try {
|
||||
const text = await (await fetch(scriptUrl)).text();
|
||||
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
||||
const m = text.match(re);
|
||||
if (m) return m[1];
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return fallbackId;
|
||||
}
|
||||
|
||||
const queryId = await resolveQueryId('UserByScreenName', 'qRednkZG-rn1P6b48NINmQ');
|
||||
const url = '/i/api/graphql/' + queryId + '/UserByScreenName?variables='
|
||||
+ encodeURIComponent(variables)
|
||||
+ '&features=' + encodeURIComponent(features);
|
||||
|
||||
const resp = await fetch(url, {headers, credentials: 'include'});
|
||||
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'User may not exist or queryId expired'};
|
||||
const d = await resp.json();
|
||||
|
||||
const result = d.data?.user?.result;
|
||||
if (!result) return {error: 'User @' + screenName + ' not found'};
|
||||
|
||||
const legacy = result.legacy || {};
|
||||
const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
|
||||
|
||||
return [{
|
||||
screen_name: legacy.screen_name || screenName,
|
||||
name: legacy.name || '',
|
||||
bio: legacy.description || '',
|
||||
location: legacy.location || '',
|
||||
url: expandedUrl,
|
||||
followers: legacy.followers_count || 0,
|
||||
following: legacy.friends_count || 0,
|
||||
tweets: legacy.statuses_count || 0,
|
||||
likes: legacy.favourites_count || 0,
|
||||
verified: result.is_blue_verified || legacy.verified || false,
|
||||
created_at: legacy.created_at || '',
|
||||
}];
|
||||
}
|
||||
`);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
||||
}
|
||||
|
||||
return result || [];
|
||||
}
|
||||
});
|
||||
+149
-111
@@ -1,5 +1,119 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
||||
|
||||
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
||||
|
||||
const FEATURES = {
|
||||
responsive_web_graphql_exclude_directive_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,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
};
|
||||
|
||||
const FIELD_TOGGLES = { withArticleRichContentState: true, withArticlePlainText: false };
|
||||
|
||||
// ── Pure functions (type-safe, testable) ───────────────────────────────
|
||||
|
||||
interface ThreadTweet {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
likes: number;
|
||||
retweets: number;
|
||||
in_reply_to?: string;
|
||||
created_at?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function buildTweetDetailUrl(tweetId: string, cursor?: string | null): string {
|
||||
const vars: Record<string, any> = {
|
||||
focalTweetId: tweetId,
|
||||
referrer: 'tweet',
|
||||
with_rux_injections: false,
|
||||
includePromotedContent: false,
|
||||
rankingMode: 'Recency',
|
||||
withCommunity: true,
|
||||
withQuickPromoteEligibilityTweetFields: true,
|
||||
withBirdwatchNotes: true,
|
||||
withVoice: true,
|
||||
};
|
||||
if (cursor) vars.cursor = cursor;
|
||||
|
||||
return `/i/api/graphql/${TWEET_DETAIL_QUERY_ID}/TweetDetail`
|
||||
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
||||
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`
|
||||
+ `&fieldToggles=${encodeURIComponent(JSON.stringify(FIELD_TOGGLES))}`;
|
||||
}
|
||||
|
||||
function extractTweet(r: any, seen: Set<string>): ThreadTweet | null {
|
||||
if (!r) return null;
|
||||
const tw = r.tweet || r;
|
||||
const l = tw.legacy || {};
|
||||
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
||||
seen.add(tw.rest_id);
|
||||
|
||||
const u = tw.core?.user_results?.result;
|
||||
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
||||
|
||||
return {
|
||||
id: tw.rest_id,
|
||||
author: screenName,
|
||||
text: noteText || l.full_text || '',
|
||||
likes: l.favorite_count || 0,
|
||||
retweets: l.retweet_count || 0,
|
||||
in_reply_to: l.in_reply_to_status_id_str || undefined,
|
||||
created_at: l.created_at,
|
||||
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTweetDetail(data: any, seen: Set<string>): { tweets: ThreadTweet[]; nextCursor: string | null } {
|
||||
const tweets: ThreadTweet[] = [];
|
||||
let nextCursor: string | null = null;
|
||||
|
||||
const instructions =
|
||||
data?.data?.threaded_conversation_with_injections_v2?.instructions
|
||||
|| data?.data?.tweetResult?.result?.timeline?.instructions
|
||||
|| [];
|
||||
|
||||
for (const inst of instructions) {
|
||||
for (const entry of inst.entries || []) {
|
||||
// Cursor entries
|
||||
const c = entry.content;
|
||||
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
|
||||
if (c.cursorType === 'Bottom' || c.cursorType === 'ShowMore') nextCursor = c.value;
|
||||
continue;
|
||||
}
|
||||
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
||||
nextCursor = c?.itemContent?.value || c?.value || nextCursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct tweet entry
|
||||
const tw = extractTweet(c?.itemContent?.tweet_results?.result, seen);
|
||||
if (tw) tweets.push(tw);
|
||||
|
||||
// Conversation module (nested replies)
|
||||
for (const item of c?.items || []) {
|
||||
const nested = extractTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
||||
if (nested) tweets.push(nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tweets, nextCursor };
|
||||
}
|
||||
|
||||
// ── CLI definition ────────────────────────────────────────────────────
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'thread',
|
||||
@@ -13,131 +127,55 @@ cli({
|
||||
],
|
||||
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
// Extract tweet ID from URL if needed
|
||||
let tweetId = kwargs.tweet_id;
|
||||
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
||||
if (urlMatch) tweetId = urlMatch[1];
|
||||
|
||||
// Navigate to x.com so we have the right cookie context
|
||||
// Navigate to x.com for cookie context
|
||||
await page.goto('https://x.com');
|
||||
await page.wait(3);
|
||||
|
||||
// Use direct GraphQL fetch (like bb-sites) — runs in browser context with cookies
|
||||
const result = await page.evaluate(`
|
||||
async () => {
|
||||
const tweetId = "${tweetId}";
|
||||
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
||||
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
||||
// Extract CSRF token — the only thing we need from the browser
|
||||
const ct0 = await page.evaluate(`() => {
|
||||
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
||||
}`);
|
||||
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
||||
|
||||
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
||||
const headers = {
|
||||
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes'
|
||||
};
|
||||
// Build auth headers in TypeScript
|
||||
const headers = JSON.stringify({
|
||||
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
||||
'X-Csrf-Token': ct0,
|
||||
'X-Twitter-Auth-Type': 'OAuth2Session',
|
||||
'X-Twitter-Active-User': 'yes',
|
||||
});
|
||||
|
||||
const features = JSON.stringify({
|
||||
responsive_web_graphql_exclude_directive_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,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true
|
||||
});
|
||||
const fieldToggles = JSON.stringify({withArticleRichContentState: true, withArticlePlainText: false});
|
||||
// Paginate — fetch in browser, parse in TypeScript
|
||||
const allTweets: ThreadTweet[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
const tweets = [];
|
||||
const seen = new Set();
|
||||
let cursor = null;
|
||||
const maxPages = 5;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const apiUrl = buildTweetDetailUrl(tweetId, cursor);
|
||||
|
||||
function extractTweet(r) {
|
||||
if (!r) return;
|
||||
const tw = r.tweet || r;
|
||||
const l = tw.legacy || {};
|
||||
if (!tw.rest_id || seen.has(tw.rest_id)) return;
|
||||
seen.add(tw.rest_id);
|
||||
const u = tw.core?.user_results?.result;
|
||||
const nt = tw.note_tweet?.note_tweet_results?.result?.text;
|
||||
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
||||
tweets.push({
|
||||
id: tw.rest_id,
|
||||
author: screenName,
|
||||
text: nt || l.full_text || '',
|
||||
likes: l.favorite_count || 0,
|
||||
retweets: l.retweet_count || 0,
|
||||
in_reply_to: l.in_reply_to_status_id_str || undefined,
|
||||
created_at: l.created_at,
|
||||
url: 'https://x.com/' + screenName + '/status/' + tw.rest_id
|
||||
});
|
||||
}
|
||||
// Browser-side: just fetch + return JSON (3 lines)
|
||||
const data = await page.evaluate(`async () => {
|
||||
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
||||
return r.ok ? await r.json() : { error: r.status };
|
||||
}`);
|
||||
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const vars = {
|
||||
focalTweetId: tweetId,
|
||||
referrer: 'tweet',
|
||||
with_rux_injections: false,
|
||||
includePromotedContent: false,
|
||||
rankingMode: 'Recency',
|
||||
withCommunity: true,
|
||||
withQuickPromoteEligibilityTweetFields: true,
|
||||
withBirdwatchNotes: true,
|
||||
withVoice: true
|
||||
};
|
||||
if (cursor) vars.cursor = cursor;
|
||||
|
||||
const url = '/i/api/graphql/nBS-WpgA6ZG0CyNHD517JQ/TweetDetail?variables='
|
||||
+ encodeURIComponent(JSON.stringify(vars))
|
||||
+ '&features=' + encodeURIComponent(features)
|
||||
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
||||
|
||||
const resp = await fetch(url, {headers, credentials: 'include'});
|
||||
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Tweet may not exist or queryId expired'};
|
||||
const d = await resp.json();
|
||||
|
||||
const instructions = d.data?.threaded_conversation_with_injections_v2?.instructions
|
||||
|| d.data?.tweetResult?.result?.timeline?.instructions || [];
|
||||
let nextCursor = null;
|
||||
|
||||
for (const inst of instructions) {
|
||||
for (const entry of (inst.entries || [])) {
|
||||
// Extract cursor for pagination
|
||||
if (entry.content?.entryType === 'TimelineTimelineCursor'
|
||||
|| entry.content?.__typename === 'TimelineTimelineCursor') {
|
||||
if (entry.content.cursorType === 'Bottom' || entry.content.cursorType === 'ShowMore') {
|
||||
nextCursor = entry.content.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
||||
const cv = entry.content?.itemContent?.value || entry.content?.value;
|
||||
if (cv) nextCursor = cv;
|
||||
continue;
|
||||
}
|
||||
|
||||
extractTweet(entry.content?.itemContent?.tweet_results?.result);
|
||||
for (const item of (entry.content?.items || [])) {
|
||||
extractTweet(item.item?.itemContent?.tweet_results?.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return tweets;
|
||||
if (data?.error) {
|
||||
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Tweet not found or queryId expired`);
|
||||
break;
|
||||
}
|
||||
`);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
||||
// TypeScript-side: type-safe parsing + cursor extraction
|
||||
const { tweets, nextCursor } = parseTweetDetail(data, seen);
|
||||
allTweets.push(...tweets);
|
||||
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return (result || []).slice(0, kwargs.limit);
|
||||
}
|
||||
return allTweets.slice(0, kwargs.limit);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'unbookmark',
|
||||
description: 'Remove a tweet from bookmarks',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to unbookmark' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
|
||||
await page.goto(kwargs.url);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let removeBtn = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if not bookmarked
|
||||
const bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
|
||||
if (bookmarkBtn) {
|
||||
return { ok: true, message: 'Tweet is not bookmarked (already removed).' };
|
||||
}
|
||||
|
||||
removeBtn = document.querySelector('[data-testid="removeBookmark"]');
|
||||
if (removeBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!removeBtn) {
|
||||
return { ok: false, message: 'Could not find Remove Bookmark button. Are you logged in?' };
|
||||
}
|
||||
|
||||
removeBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid="bookmark"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Tweet successfully removed from bookmarks.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Unbookmark action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import type { IPage } from '../../types.js';
|
||||
|
||||
cli({
|
||||
site: 'twitter',
|
||||
name: 'unfollow',
|
||||
description: 'Unfollow a Twitter user',
|
||||
domain: 'x.com',
|
||||
strategy: Strategy.UI,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
|
||||
],
|
||||
columns: ['status', 'message'],
|
||||
func: async (page: IPage | null, kwargs: any) => {
|
||||
if (!page) throw new Error('Requires browser');
|
||||
const username = kwargs.username.replace(/^@/, '');
|
||||
|
||||
await page.goto(`https://x.com/${username}`);
|
||||
await page.wait(5);
|
||||
|
||||
const result = await page.evaluate(`(async () => {
|
||||
try {
|
||||
let attempts = 0;
|
||||
let unfollowBtn = null;
|
||||
|
||||
while (attempts < 20) {
|
||||
// Check if already not following
|
||||
const followBtn = document.querySelector('[data-testid$="-follow"]');
|
||||
if (followBtn) {
|
||||
return { ok: true, message: 'Not following @${username} (already unfollowed).' };
|
||||
}
|
||||
|
||||
unfollowBtn = document.querySelector('[data-testid$="-unfollow"]');
|
||||
if (unfollowBtn) break;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!unfollowBtn) {
|
||||
return { ok: false, message: 'Could not find Unfollow button. Are you logged in?' };
|
||||
}
|
||||
|
||||
// Click the unfollow button — this opens a confirmation dialog
|
||||
unfollowBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Confirm the unfollow in the dialog
|
||||
const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.click();
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
// Verify
|
||||
const verify = document.querySelector('[data-testid$="-follow"]');
|
||||
if (verify) {
|
||||
return { ok: true, message: 'Successfully unfollowed @${username}.' };
|
||||
} else {
|
||||
return { ok: false, message: 'Unfollow action initiated but UI did not update.' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: e.toString() };
|
||||
}
|
||||
})()`);
|
||||
|
||||
if (result.ok) await page.wait(2);
|
||||
|
||||
return [{
|
||||
status: result.ok ? 'success' : 'failed',
|
||||
message: result.message
|
||||
}];
|
||||
}
|
||||
});
|
||||
+24
-5
@@ -129,18 +129,37 @@ for (const [, cmd] of registry) {
|
||||
if (!siteCmd) { siteCmd = program.command(cmd.site).description(`${cmd.site} commands`); siteGroups.set(cmd.site, siteCmd); }
|
||||
const subCmd = siteCmd.command(cmd.name).description(cmd.description);
|
||||
|
||||
// Register positional args first, then named options
|
||||
const positionalArgs: typeof cmd.args = [];
|
||||
for (const arg of cmd.args) {
|
||||
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
||||
if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
|
||||
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
|
||||
else subCmd.option(flag, arg.help ?? '');
|
||||
if (arg.positional) {
|
||||
const bracket = arg.required ? `<${arg.name}>` : `[${arg.name}]`;
|
||||
subCmd.argument(bracket, arg.help ?? '');
|
||||
positionalArgs.push(arg);
|
||||
} else {
|
||||
const flag = arg.required ? `--${arg.name} <value>` : `--${arg.name} [value]`;
|
||||
if (arg.required) subCmd.requiredOption(flag, arg.help ?? '');
|
||||
else if (arg.default != null) subCmd.option(flag, arg.help ?? '', String(arg.default));
|
||||
else subCmd.option(flag, arg.help ?? '');
|
||||
}
|
||||
}
|
||||
subCmd.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table').option('-v, --verbose', 'Debug output', false);
|
||||
|
||||
subCmd.action(async (actionOpts) => {
|
||||
subCmd.action(async (...actionArgs: any[]) => {
|
||||
// Commander passes positional args first, then options object, then the Command
|
||||
const actionOpts = actionArgs[positionalArgs.length] ?? {};
|
||||
const startTime = Date.now();
|
||||
const kwargs: Record<string, any> = {};
|
||||
// Collect positional args
|
||||
for (let i = 0; i < positionalArgs.length; i++) {
|
||||
const arg = positionalArgs[i];
|
||||
const v = actionArgs[i];
|
||||
if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
|
||||
else if (arg.default != null) kwargs[arg.name] = arg.default;
|
||||
}
|
||||
// Collect named options
|
||||
for (const arg of cmd.args) {
|
||||
if (arg.positional) continue;
|
||||
const v = actionOpts[arg.name]; if (v !== undefined) kwargs[arg.name] = coerce(v, arg.type ?? 'str');
|
||||
else if (arg.default != null) kwargs[arg.name] = arg.default;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Arg {
|
||||
type?: string;
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
positional?: boolean;
|
||||
help?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user