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:
jackwener committed 2026-03-16 20:17:38 +08:00
1 parent 7f55950fed
commit 0c845d58c8
10 files changed
+729 -162

No files matched your search

+3
View File
@@ -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] ?? '',
});
}
+161
View File
@@ -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 || [];
}
});
+67
View File
@@ -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
}];
}
});
+69
View File
@@ -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
View File
@@ -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
View File
@@ -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);
},
});
+66
View File
@@ -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
}];
}
});
+75
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -17,6 +17,7 @@ export interface Arg {
type?: string;
default?: any;
required?: boolean;
positional?: boolean;
help?: string;
choices?: string[];
}