From 0c845d58c807fbb1135e14b83730c730c8cc0c7c Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 16 Mar 2026 20:17:38 +0800 Subject: [PATCH] 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. --- src/build-manifest.ts | 3 + src/clis/twitter/article.ts | 161 ++++++++++++++++++++ src/clis/twitter/bookmark.ts | 67 +++++++++ src/clis/twitter/follow.ts | 69 +++++++++ src/clis/twitter/profile.ts | 160 ++++++++++++++------ src/clis/twitter/thread.ts | 260 +++++++++++++++++++-------------- src/clis/twitter/unbookmark.ts | 66 +++++++++ src/clis/twitter/unfollow.ts | 75 ++++++++++ src/main.ts | 29 +++- src/registry.ts | 1 + 10 files changed, 729 insertions(+), 162 deletions(-) create mode 100644 src/clis/twitter/article.ts create mode 100644 src/clis/twitter/bookmark.ts create mode 100644 src/clis/twitter/follow.ts create mode 100644 src/clis/twitter/unbookmark.ts create mode 100644 src/clis/twitter/unfollow.ts diff --git a/src/build-manifest.ts b/src/build-manifest.ts index 882fb30..bd2d6b1 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -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] ?? '', }); } diff --git a/src/clis/twitter/article.ts b/src/clis/twitter/article.ts new file mode 100644 index 0000000..52c20ad --- /dev/null +++ b/src/clis/twitter/article.ts @@ -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 || []; + } +}); diff --git a/src/clis/twitter/bookmark.ts b/src/clis/twitter/bookmark.ts new file mode 100644 index 0000000..0d725aa --- /dev/null +++ b/src/clis/twitter/bookmark.ts @@ -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 + }]; + } +}); diff --git a/src/clis/twitter/follow.ts b/src/clis/twitter/follow.ts new file mode 100644 index 0000000..f5baf1d --- /dev/null +++ b/src/clis/twitter/follow.ts @@ -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 + }]; + } +}); diff --git a/src/clis/twitter/profile.ts b/src/clis/twitter/profile.ts index be9768d..2c42150 100644 --- a/src/clis/twitter/profile.ts +++ b/src/clis/twitter/profile.ts @@ -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 || []; } }); diff --git a/src/clis/twitter/thread.ts b/src/clis/twitter/thread.ts index e84d208..2fe402f 100644 --- a/src/clis/twitter/thread.ts +++ b/src/clis/twitter/thread.ts @@ -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 = { + 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): 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): { 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(); + 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); + }, }); diff --git a/src/clis/twitter/unbookmark.ts b/src/clis/twitter/unbookmark.ts new file mode 100644 index 0000000..e55c449 --- /dev/null +++ b/src/clis/twitter/unbookmark.ts @@ -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 + }]; + } +}); diff --git a/src/clis/twitter/unfollow.ts b/src/clis/twitter/unfollow.ts new file mode 100644 index 0000000..7798007 --- /dev/null +++ b/src/clis/twitter/unfollow.ts @@ -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 + }]; + } +}); diff --git a/src/main.ts b/src/main.ts index d425e0a..9fd76cb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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} ` : `--${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} ` : `--${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 ', '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 = {}; + // 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; } diff --git a/src/registry.ts b/src/registry.ts index 7af05a3..067d85a 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -17,6 +17,7 @@ export interface Arg { type?: string; default?: any; required?: boolean; + positional?: boolean; help?: string; choices?: string[]; }