Skip to content

Commit 4cd0409

Browse files
authored
fix: harden twitter timeline review findings (#236)
1 parent ea113a6 commit 4cd0409

File tree

3 files changed

+136
-9
lines changed

3 files changed

+136
-9
lines changed

docs/.vitepress/config.mts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,6 @@ export default defineConfig({
102102
{ text: 'ChatWise', link: '/adapters/desktop/chatwise' },
103103
{ text: 'Notion', link: '/adapters/desktop/notion' },
104104
{ text: 'Discord', link: '/adapters/desktop/discord' },
105-
{ text: 'Feishu', link: '/adapters/desktop/feishu' },
106-
{ text: 'WeChat', link: '/adapters/desktop/wechat' },
107-
{ text: 'NeteaseMusic', link: '/adapters/desktop/neteasemusic' },
108105
],
109106
},
110107
],

src/clis/twitter/timeline.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { __test__ } from './timeline.js';
3+
4+
describe('twitter timeline helpers', () => {
5+
it('builds for-you variables with withCommunity', () => {
6+
expect(__test__.buildTimelineVariables('for-you', 20)).toEqual({
7+
count: 20,
8+
includePromotedContent: false,
9+
latestControlAvailable: true,
10+
requestContext: 'launch',
11+
withCommunity: true,
12+
});
13+
});
14+
15+
it('builds following variables with seenTweetIds instead of withCommunity', () => {
16+
expect(__test__.buildTimelineVariables('following', 20, 'cursor-1')).toEqual({
17+
count: 20,
18+
includePromotedContent: false,
19+
latestControlAvailable: true,
20+
requestContext: 'launch',
21+
seenTweetIds: [],
22+
cursor: 'cursor-1',
23+
});
24+
});
25+
26+
it('encodes variables into timeline url', () => {
27+
const url = __test__.buildHomeTimelineUrl('query123', 'HomeLatestTimeline', {
28+
count: 20,
29+
seenTweetIds: [],
30+
});
31+
32+
expect(url).toContain('/i/api/graphql/query123/HomeLatestTimeline');
33+
expect(url).toContain('variables=');
34+
expect(url).toContain('features=');
35+
expect(decodeURIComponent(url)).toContain('"seenTweetIds":[]');
36+
});
37+
38+
it('parses tweets and bottom cursor from home timeline payload', () => {
39+
const payload = {
40+
data: {
41+
home: {
42+
home_timeline_urt: {
43+
instructions: [
44+
{
45+
entries: [
46+
{
47+
entryId: 'tweet-1',
48+
content: {
49+
itemContent: {
50+
tweet_results: {
51+
result: {
52+
rest_id: '1',
53+
legacy: {
54+
full_text: 'hello',
55+
favorite_count: 3,
56+
retweet_count: 2,
57+
reply_count: 1,
58+
created_at: 'now',
59+
},
60+
core: {
61+
user_results: {
62+
result: {
63+
legacy: {
64+
screen_name: 'alice',
65+
},
66+
},
67+
},
68+
},
69+
views: {
70+
count: '9',
71+
},
72+
},
73+
},
74+
},
75+
},
76+
},
77+
{
78+
entryId: 'cursor-bottom-1',
79+
content: {
80+
entryType: 'TimelineTimelineCursor',
81+
cursorType: 'Bottom',
82+
value: 'cursor-next',
83+
},
84+
},
85+
],
86+
},
87+
],
88+
},
89+
},
90+
},
91+
};
92+
93+
const result = __test__.parseHomeTimeline(payload, new Set());
94+
95+
expect(result.nextCursor).toBe('cursor-next');
96+
expect(result.tweets).toHaveLength(1);
97+
expect(result.tweets[0]).toMatchObject({
98+
id: '1',
99+
author: 'alice',
100+
text: 'hello',
101+
likes: 3,
102+
retweets: 2,
103+
replies: 1,
104+
views: 9,
105+
created_at: 'now',
106+
url: 'https://x.com/alice/status/1',
107+
});
108+
});
109+
});

src/clis/twitter/timeline.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@ const BEARER_TOKEN =
77
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
88
const HOME_LATEST_TIMELINE_QUERY_ID = 'BKB7oi212Fi7kQtCBGE4zA';
99

10+
type TimelineType = 'for-you' | 'following';
11+
12+
interface TimelineEndpointConfig {
13+
endpoint: string;
14+
method: 'GET' | 'POST';
15+
fallbackQueryId: string;
16+
}
17+
1018
// Endpoint config: for-you uses GET HomeTimeline, following uses POST HomeLatestTimeline
11-
const TIMELINE_ENDPOINTS: Record<string, { endpoint: string; method: string; fallbackQueryId: string }> = {
19+
const TIMELINE_ENDPOINTS: Record<TimelineType, TimelineEndpointConfig> = {
1220
'for-you': { endpoint: 'HomeTimeline', method: 'GET', fallbackQueryId: HOME_TIMELINE_QUERY_ID },
1321
following: { endpoint: 'HomeLatestTimeline', method: 'POST', fallbackQueryId: HOME_LATEST_TIMELINE_QUERY_ID },
1422
};
@@ -61,15 +69,20 @@ interface TimelineTweet {
6169
url: string;
6270
}
6371

64-
function buildHomeTimelineUrl(queryId: string, endpoint: string, count: number, cursor?: string | null): string {
65-
const vars: Record<string, any> = {
72+
function buildTimelineVariables(type: TimelineType, count: number, cursor?: string | null): Record<string, unknown> {
73+
const vars: Record<string, unknown> = {
6674
count,
6775
includePromotedContent: false,
6876
latestControlAvailable: true,
6977
requestContext: 'launch',
70-
withCommunity: true,
7178
};
79+
if (type === 'for-you') vars.withCommunity = true;
80+
if (type === 'following') vars.seenTweetIds = [];
7281
if (cursor) vars.cursor = cursor;
82+
return vars;
83+
}
84+
85+
function buildHomeTimelineUrl(queryId: string, endpoint: string, vars: Record<string, unknown>): string {
7386

7487
return (
7588
`/i/api/graphql/${queryId}/${endpoint}` +
@@ -170,7 +183,8 @@ cli({
170183
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
171184
func: async (page, kwargs) => {
172185
const limit = kwargs.limit || 20;
173-
const { endpoint, method, fallbackQueryId } = TIMELINE_ENDPOINTS[kwargs.type || 'for-you'];
186+
const timelineType: TimelineType = kwargs.type === 'following' ? 'following' : 'for-you';
187+
const { endpoint, method, fallbackQueryId } = TIMELINE_ENDPOINTS[timelineType];
174188

175189
// Navigate to x.com for cookie context
176190
await page.goto('https://x.com');
@@ -212,7 +226,8 @@ cli({
212226

213227
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
214228
const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
215-
const apiUrl = buildHomeTimelineUrl(queryId, endpoint, fetchCount, cursor);
229+
const variables = buildTimelineVariables(timelineType, fetchCount, cursor);
230+
const apiUrl = buildHomeTimelineUrl(queryId, endpoint, variables);
216231

217232
const data = await page.evaluate(`async () => {
218233
const r = await fetch("${apiUrl}", { method: "${method}", headers: ${headers}, credentials: 'include' });
@@ -235,3 +250,9 @@ cli({
235250
return allTweets.slice(0, limit);
236251
},
237252
});
253+
254+
export const __test__ = {
255+
buildTimelineVariables,
256+
buildHomeTimelineUrl,
257+
parseHomeTimeline,
258+
};

0 commit comments

Comments
 (0)