Files
jackwener__opencli/clis/twitter/thread.js
Ocean bcb0fb362f feat(twitter): expose bio on read command
* feat(twitter): 在 read 命令上暴露 bio(用户简介)

`list-tweets` / `timeline` / `search` 三个读命令现在每行多一列 `bio`,从
`user.legacy.description` 抽。匹配 `profile` 命令已有的 `bio` 字段,让下游
消费者展示作者画像时省去"读了推文还要再读作者主页"的 roundtrip。

bio 在 user 对象缺失或没 description 时回落到 `''`。columns 数组同步更新,
`--format columns` 会渲染 bio。完全 additive:既有字段名、顺序、值都不变。

延续 #1660 (card binding_values) 和 #1667 (quoted_tweet) 的同一类
read-side enrichment 模式。

## 验证

- `clis/twitter/list-tweets.test.js` / `clis/twitter/search.test.js` 已有
  shape assertion 补上 `bio: ''` 行
- `timeline.test.js` 用 `toMatchObject`(子集匹配),新增 bio 不会破断言
- `npx vitest run clis/twitter/list-tweets.test.js clis/twitter/timeline.test.js
  clis/twitter/search.test.js --project adapter` → 48/48 通过
- `npx tsc --noEmit` 干净
- `npm run build` 干净
- `node scripts/check-silent-column-drop.mjs` → current=97, baseline=97, new=0

* test(twitter): cover inline bio extraction

* feat(twitter): expose thread author bio

---------

Co-authored-by: huanghe <he.huang@extremevision.mo>
Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-19 18:56:38 +08:00

162 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
import { extractMedia, extractCard, extractQuotedTweet } from './shared.js';
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
// ── Twitter GraphQL constants ──────────────────────────────────────────
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 };
function buildTweetDetailUrl(tweetId, cursor) {
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;
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, seen) {
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';
const bio = u?.legacy?.description || '';
return {
id: tw.rest_id,
author: screenName,
bio,
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}`,
...extractMedia(l),
card: extractCard(tw),
quoted_tweet: extractQuotedTweet(tw),
};
}
function parseTweetDetail(data, seen) {
const tweets = [];
let nextCursor = 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 };
}
export const __test__ = {
parseTweetDetail,
};
// ── CLI definition ────────────────────────────────────────────────────
cli({
site: 'twitter',
name: 'thread',
access: 'read',
description: 'Get a tweet thread (original + all replies)',
domain: 'x.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
{ name: 'limit', type: 'int', default: 50 },
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the thread by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the conversation\'s structural ordering.' },
],
columns: ['id', 'author', 'bio', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls', 'card', 'quoted_tweet'],
func: async (page, kwargs) => {
let tweetId = kwargs['tweet-id'];
const urlMatch = tweetId.match(/\/status\/(\d+)/);
if (urlMatch)
tweetId = urlMatch[1];
// Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
const cookies = await page.getCookies({ url: 'https://x.com' });
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
if (!ct0)
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
// Build auth headers in TypeScript
const headers = JSON.stringify({
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
'X-Csrf-Token': ct0,
'X-Twitter-Auth-Type': 'OAuth2Session',
'X-Twitter-Active-User': 'yes',
});
// Paginate — fetch in browser, parse in TypeScript
const allTweets = [];
const seen = new Set();
let cursor = null;
for (let i = 0; i < 5; i++) {
const apiUrl = buildTweetDetailUrl(tweetId, cursor);
// 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 };
}`);
if (data?.error) {
if (allTweets.length === 0)
throw new CommandExecutionError(`HTTP ${data.error}: Tweet not found or queryId expired`);
break;
}
// TypeScript-side: type-safe parsing + cursor extraction
const { tweets, nextCursor } = parseTweetDetail(data, seen);
allTweets.push(...tweets);
if (!nextCursor || nextCursor === cursor)
break;
cursor = nextCursor;
}
const trimmed = allTweets.slice(0, kwargs.limit);
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
},
});