Skip to content

Commit e98cf75

Browse files
authored
feat(twitter): add --type flag to timeline command (#83) (#232)
Support switching between For You (algorithmic) and Following (chronological) timelines via `--type for-you|following`. Both endpoints share the same response structure; only the GraphQL endpoint name and queryId differ. QueryId is resolved dynamically from fa0311/twitter-openapi with a hardcoded fallback, and validated against /^[A-Za-z0-9_-]+$/ to prevent injection from upstream.
1 parent 387aa0d commit e98cf75

File tree

1 file changed

+36
-17
lines changed

1 file changed

+36
-17
lines changed

src/clis/twitter/timeline.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ import { cli, Strategy } from '../../registry.js';
22

33
// ── Twitter GraphQL constants ──────────────────────────────────────────
44

5-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
5+
const BEARER_TOKEN =
6+
'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
67
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
8+
const HOME_LATEST_TIMELINE_QUERY_ID = 'BKB7oi212Fi7kQtCBGE4zA';
9+
10+
// Endpoint config: for-you uses GET HomeTimeline, following uses POST HomeLatestTimeline
11+
const TIMELINE_ENDPOINTS: Record<string, { endpoint: string; method: string; fallbackQueryId: string }> = {
12+
'for-you': { endpoint: 'HomeTimeline', method: 'GET', fallbackQueryId: HOME_TIMELINE_QUERY_ID },
13+
following: { endpoint: 'HomeLatestTimeline', method: 'POST', fallbackQueryId: HOME_LATEST_TIMELINE_QUERY_ID },
14+
};
715

816
const FEATURES = {
917
rweb_video_screen_enabled: false,
@@ -53,7 +61,7 @@ interface TimelineTweet {
5361
url: string;
5462
}
5563

56-
function buildHomeTimelineUrl(count: number, cursor?: string | null): string {
64+
function buildHomeTimelineUrl(queryId: string, endpoint: string, count: number, cursor?: string | null): string {
5765
const vars: Record<string, any> = {
5866
count,
5967
includePromotedContent: false,
@@ -63,9 +71,11 @@ function buildHomeTimelineUrl(count: number, cursor?: string | null): string {
6371
};
6472
if (cursor) vars.cursor = cursor;
6573

66-
return `/i/api/graphql/${HOME_TIMELINE_QUERY_ID}/HomeTimeline`
67-
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
68-
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
74+
return (
75+
`/i/api/graphql/${queryId}/${endpoint}` +
76+
`?variables=${encodeURIComponent(JSON.stringify(vars))}` +
77+
`&features=${encodeURIComponent(JSON.stringify(FEATURES))}`
78+
);
6979
}
7080

7181
function extractTweet(result: any, seen: Set<string>): TimelineTweet | null {
@@ -97,8 +107,8 @@ function parseHomeTimeline(data: any, seen: Set<string>): { tweets: TimelineTwee
97107
const tweets: TimelineTweet[] = [];
98108
let nextCursor: string | null = null;
99109

100-
const instructions =
101-
data?.data?.home?.home_timeline_urt?.instructions || [];
110+
// Both HomeTimeline and HomeLatestTimeline share the same response envelope
111+
const instructions = data?.data?.home?.home_timeline_urt?.instructions || [];
102112

103113
for (const inst of instructions) {
104114
for (const entry of inst.entries || []) {
@@ -144,16 +154,23 @@ function parseHomeTimeline(data: any, seen: Set<string>): { tweets: TimelineTwee
144154
cli({
145155
site: 'twitter',
146156
name: 'timeline',
147-
description: 'Fetch Twitter Home Timeline',
157+
description: 'Fetch Twitter timeline (for-you or following)',
148158
domain: 'x.com',
149159
strategy: Strategy.COOKIE,
150160
browser: true,
151161
args: [
162+
{
163+
name: 'type',
164+
default: 'for-you',
165+
choices: ['for-you', 'following'],
166+
help: 'Timeline type: for-you (algorithmic) or following (chronological)',
167+
},
152168
{ name: 'limit', type: 'int', default: 20 },
153169
],
154170
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
155171
func: async (page, kwargs) => {
156172
const limit = kwargs.limit || 20;
173+
const { endpoint, method, fallbackQueryId } = TIMELINE_ENDPOINTS[kwargs.type || 'for-you'];
157174

158175
// Navigate to x.com for cookie context
159176
await page.goto('https://x.com');
@@ -165,22 +182,24 @@ cli({
165182
}`);
166183
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
167184

168-
// Dynamically resolve queryId
169-
const queryId = await page.evaluate(`async () => {
185+
// Dynamically resolve queryId for the selected endpoint
186+
const resolved = await page.evaluate(`async () => {
170187
try {
171188
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
172189
if (ghResp.ok) {
173190
const data = await ghResp.json();
174-
const entry = data['HomeTimeline'];
191+
const entry = data['${endpoint}'];
175192
if (entry && entry.queryId) return entry.queryId;
176193
}
177194
} catch {}
178195
return null;
179-
}`) || HOME_TIMELINE_QUERY_ID;
196+
}`);
197+
// Validate queryId format to prevent injection from untrusted upstream
198+
const queryId = typeof resolved === 'string' && /^[A-Za-z0-9_-]+$/.test(resolved) ? resolved : fallbackQueryId;
180199

181200
// Build auth headers
182201
const headers = JSON.stringify({
183-
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
202+
Authorization: `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
184203
'X-Csrf-Token': ct0,
185204
'X-Twitter-Auth-Type': 'OAuth2Session',
186205
'X-Twitter-Active-User': 'yes',
@@ -193,16 +212,16 @@ cli({
193212

194213
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
195214
const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
196-
const apiUrl = buildHomeTimelineUrl(fetchCount, cursor)
197-
.replace(HOME_TIMELINE_QUERY_ID, queryId);
215+
const apiUrl = buildHomeTimelineUrl(queryId, endpoint, fetchCount, cursor);
198216

199217
const data = await page.evaluate(`async () => {
200-
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
218+
const r = await fetch("${apiUrl}", { method: "${method}", headers: ${headers}, credentials: 'include' });
201219
return r.ok ? await r.json() : { error: r.status };
202220
}`);
203221

204222
if (data?.error) {
205-
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
223+
if (allTweets.length === 0)
224+
throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
206225
break;
207226
}
208227

0 commit comments

Comments
 (0)