Skip to content

Commit 7c5bafd

Browse files
ppop123wangyanjackwener
authored
fix(twitter): repair list-add / list-tweets / lists / following after 2026-05 changes (#1503)
* fix(twitter): unwrap page.evaluate primitive returns in lists/list-tweets/following The opencli >=1.7.x browser bridge wraps page.evaluate's primitive return values as { session, data: <value> }. Adapters that destructure .data inline (e.g. data.queryId, data.viewer) keep working because the wrapper spreads object-typed responses to the top level, but ones that consume the return value as a bare string broke: - twitter list-tweets: the dynamically resolved queryId (a string) became {session, data:"..."}. Interpolating that into the GraphQL URL produced /i/api/graphql/[object Object]/ListLatestTweetsTimeline, giving "HTTP 400: queryId may have expired". - twitter lists: same on ListsManagementPageTimeline queryId. - twitter following: same shape bug on the href read from the profile link, producing "TypeError: href.replace is not a function" when no --user is given. Add a small unwrap() helper at each call site so primitive returns are extracted from the wrapper before use. Object-typed GraphQL responses are left as-is since they rely on spread semantics. * fix(twitter): rewrite list-add to use ListAddMember GraphQL mutation In 2026-05 X replaced the "Add/remove from Lists" modal dialog with a full-page route (/i/lists/add_member). The previous UI flow no longer works: Save button not found in dialog (X expected text Save/Done). Dialog structure may have changed. The mutation that the dialog used to fire (ListAddMember) is still the right primitive — and the surrounding adapter already calls X GraphQL APIs directly to resolve userId and verify member_count. Drop the UI flow entirely and call ListAddMember directly via fetch in the page context. Wins: - Works again on current X UI (verified 2026-05-12 on x.com). - ~10x faster: no goto-profile + click-caret + scroll-dialog round trips. - One less moving piece — no dependency on Chrome extension's nativeClick for this command. Implementation notes: - LIST_ADD_MEMBER_QUERY_ID is a 2026-05 fallback; resolveTwitterQueryId does live lookup from the loaded client-web bundle, matching the pattern already used elsewhere in the twitter clis. - X's ListAddMember response routinely contains a non-fatal partial decode error on default_banner_media_results (code 214, Validation / BadRequestError) alongside a fully populated data.list. We treat the call as failed only when data.list / member_count is missing, and ignore decode-flavored errors confined to banner fields. - Same opencli >=1.7.x { session, data } primitive-wrap behavior that the previous commit addressed applies here: userId from the UserByScreenName call needs unwrap before being interpolated into the mutation body, otherwise X parses "[object Object]" as user_id and returns "strconv.ParseInt ... invalid syntax". Verified flows: - noop (already a member) → status: noop, member_count unchanged. - new add (e.g. @AnthropicAI on a fresh list) → status: success, member_count incremented. Trade-off: rejection signals (e.g. X declining to add @deepseek_ai) look indistinguishable from noop at the response level, since X returns HTTP 200 with member_count unchanged. Documented in the success message. * fix(twitter): integrate list media and harden list-add --------- Co-authored-by: wangyan <wy@wang-yan-Air.local> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent f66996a commit 7c5bafd

7 files changed

Lines changed: 297 additions & 214 deletions

File tree

cli-manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23164,7 +23164,9 @@
2316423164
"retweets",
2316523165
"replies",
2316623166
"created_at",
23167-
"url"
23167+
"url",
23168+
"has_media",
23169+
"media_urls"
2316823170
],
2316923171
"type": "js",
2317023172
"modulePath": "twitter/list-tweets.js",

clis/twitter/following.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,15 @@ cli({
164164
if (!ct0)
165165
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
166166

167+
// opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
168+
const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
167169
if (!targetUser) {
168-
const href = await page.evaluate(() => {
170+
const hrefRaw = await page.evaluate(`() => {
169171
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
170172
return link ? link.getAttribute('href') : null;
171-
});
172-
if (!href)
173+
}`);
174+
const href = unwrap(hrefRaw);
175+
if (!href || typeof href !== 'string')
173176
throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
174177
targetUser = normalizeScreenName(href.replace('/', ''));
175178
}

clis/twitter/list-add.js

Lines changed: 128 additions & 204 deletions
Large diffs are not rendered by default.

clis/twitter/list-add.test.js

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it, vi } from 'vitest';
22
import { getRegistry } from '@jackwener/opencli/registry';
3-
import './list-add.js';
3+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4+
import { buildListAddMemberRow } from './list-add.js';
45

56
describe('twitter list-add registration', () => {
67
it('registers the list-add command with the expected shape', () => {
@@ -34,4 +35,99 @@ describe('twitter list-add registration', () => {
3435
expect(page.wait).toHaveBeenCalledWith(3);
3536
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
3637
});
38+
39+
it('rejects invalid user input before navigation', async () => {
40+
const cmd = getRegistry().get('twitter/list-add');
41+
const page = {
42+
goto: vi.fn(),
43+
wait: vi.fn(),
44+
getCookies: vi.fn(),
45+
evaluate: vi.fn(),
46+
};
47+
48+
await expect(cmd.func(page, { listId: 'abc', username: 'alice' })).rejects.toBeInstanceOf(ArgumentError);
49+
await expect(cmd.func(page, { listId: '123', username: '' })).rejects.toBeInstanceOf(ArgumentError);
50+
expect(page.goto).not.toHaveBeenCalled();
51+
});
52+
53+
it('builds success rows when member_count increases despite non-fatal decode errors', () => {
54+
const row = buildListAddMemberRow({
55+
addResult: {
56+
httpOk: true,
57+
status: 200,
58+
mc: 11,
59+
isMember: true,
60+
errors: [{ path: ['data', 'list', 'default_banner_media_results'], message: 'decode failed' }],
61+
},
62+
memberCountBefore: 10,
63+
listId: '123',
64+
username: 'alice',
65+
userId: '42',
66+
});
67+
68+
expect(row).toMatchObject({
69+
listId: '123',
70+
username: 'alice',
71+
userId: '42',
72+
status: 'success',
73+
});
74+
expect(row.message).toContain('member_count 10 → 11');
75+
});
76+
77+
it('treats unchanged member_count as noop only when membership is confirmed', () => {
78+
const row = buildListAddMemberRow({
79+
addResult: { httpOk: true, status: 200, mc: 10, isMember: true, errors: null },
80+
memberCountBefore: 10,
81+
listId: '123',
82+
username: 'alice',
83+
userId: '42',
84+
});
85+
86+
expect(row.status).toBe('noop');
87+
expect(row.message).toBe('@alice is already a member of list 123');
88+
});
89+
90+
it('fails typed when unchanged member_count does not confirm membership', () => {
91+
expect(() => buildListAddMemberRow({
92+
addResult: { httpOk: true, status: 200, mc: 10, isMember: false, errors: null },
93+
memberCountBefore: 10,
94+
listId: '123',
95+
username: 'alice',
96+
userId: '42',
97+
})).toThrow(CommandExecutionError);
98+
});
99+
100+
it('fails typed when member_count decreases unexpectedly', () => {
101+
expect(() => buildListAddMemberRow({
102+
addResult: { httpOk: true, status: 200, mc: 9, isMember: true, errors: null },
103+
memberCountBefore: 10,
104+
listId: '123',
105+
username: 'alice',
106+
userId: '42',
107+
})).toThrow(/decreased unexpectedly/);
108+
});
109+
110+
it('fails typed when GraphQL response has no usable member_count', () => {
111+
expect(() => buildListAddMemberRow({
112+
addResult: {
113+
httpOk: true,
114+
status: 200,
115+
mc: undefined,
116+
isMember: null,
117+
errors: [{ message: 'List is unavailable', path: ['data', 'list'] }],
118+
},
119+
memberCountBefore: 10,
120+
listId: '123',
121+
username: 'alice',
122+
userId: '42',
123+
})).toThrow(/List is unavailable/);
124+
125+
expect(() => buildListAddMemberRow({
126+
addResult: { httpOk: true, status: 200, mc: null, isMember: null, errors: { message: 'not an array' } },
127+
memberCountBefore: 10,
128+
listId: '123',
129+
username: 'alice',
130+
userId: '42',
131+
})).toThrow(/no member_count/);
132+
});
37133
});

clis/twitter/list-tweets.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
22
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3+
import { extractMedia } from './shared.js';
34
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
45

56
const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
@@ -71,6 +72,7 @@ export function extractTimelineTweet(result, seen) {
7172
replies: legacy.reply_count || 0,
7273
created_at: legacy.created_at || '',
7374
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
75+
...extractMedia(legacy),
7476
};
7577
}
7678

@@ -119,7 +121,7 @@ cli({
119121
{ name: 'limit', type: 'int', default: 50 },
120122
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list timeline 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 list\'s native (recency) ordering.' },
121123
],
122-
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
124+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url', 'has_media', 'media_urls'],
123125
func: async (page, kwargs) => {
124126
const listId = String(kwargs.listId || '').trim();
125127
if (!listId || !/^\d+$/.test(listId)) {
@@ -130,7 +132,11 @@ cli({
130132
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
131133
if (!ct0)
132134
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
133-
const queryId = await page.evaluate(`async () => {
135+
// opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
136+
// Without unwrap, the string queryId becomes "[object Object]" when interpolated into the URL,
137+
// causing HTTP 400 "queryId may have expired".
138+
const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
139+
const queryIdRaw = await page.evaluate(`async () => {
134140
try {
135141
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
136142
if (ghResp.ok) {
@@ -153,7 +159,8 @@ cli({
153159
}
154160
} catch {}
155161
return null;
156-
}`) || LIST_TWEETS_QUERY_ID;
162+
}`);
163+
const queryId = unwrap(queryIdRaw) || LIST_TWEETS_QUERY_ID;
157164
const headers = JSON.stringify({
158165
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
159166
'X-Csrf-Token': ct0,

clis/twitter/list-tweets.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,57 @@ describe('twitter list-tweets parser', () => {
3030
replies: 2,
3131
created_at: 'Wed Apr 16 10:00:00 +0000 2026',
3232
url: 'https://x.com/bob/status/99',
33+
has_media: false,
34+
media_urls: [],
3335
});
3436
});
3537

38+
it('includes photo media URLs from extended_entities', () => {
39+
const tweet = extractTimelineTweet({
40+
rest_id: '101',
41+
legacy: {
42+
full_text: 'pic post',
43+
extended_entities: {
44+
media: [
45+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/abc.jpg' },
46+
{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/def.jpg' },
47+
],
48+
},
49+
},
50+
core: { user_results: { result: { legacy: { screen_name: 'dave' } } } },
51+
}, new Set());
52+
expect(tweet?.has_media).toBe(true);
53+
expect(tweet?.media_urls).toEqual([
54+
'https://pbs.twimg.com/media/abc.jpg',
55+
'https://pbs.twimg.com/media/def.jpg',
56+
]);
57+
});
58+
59+
it('extracts mp4 variant URL for video media', () => {
60+
const tweet = extractTimelineTweet({
61+
rest_id: '102',
62+
legacy: {
63+
full_text: 'video post',
64+
extended_entities: {
65+
media: [{
66+
type: 'video',
67+
media_url_https: 'https://pbs.twimg.com/amplify_video_thumb/thumb.jpg',
68+
video_info: {
69+
variants: [
70+
{ content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/playlist.m3u8' },
71+
{ content_type: 'video/mp4', bitrate: 832000, url: 'https://video.twimg.com/low.mp4' },
72+
{ content_type: 'video/mp4', bitrate: 2176000, url: 'https://video.twimg.com/high.mp4' },
73+
],
74+
},
75+
}],
76+
},
77+
},
78+
core: { user_results: { result: { legacy: { screen_name: 'erin' } } } },
79+
}, new Set());
80+
expect(tweet?.has_media).toBe(true);
81+
expect(tweet?.media_urls?.[0]).toMatch(/\.mp4$/);
82+
});
83+
3684
it('prefers long-form note_tweet text over truncated legacy full_text', () => {
3785
const tweet = extractTimelineTweet({
3886
rest_id: '100',

clis/twitter/lists.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ export const command = cli({
103103
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
104104
if (!ct0)
105105
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
106-
const queryId = await page.evaluate(`async () => {
106+
// opencli >=1.7.x wraps primitive page.evaluate returns as { session, data: <value> }.
107+
const unwrap = (v) => (v && typeof v === 'object' && 'session' in v && 'data' in v ? v.data : v);
108+
const queryIdRaw = await page.evaluate(`async () => {
107109
try {
108110
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
109111
if (ghResp.ok) {
@@ -126,7 +128,8 @@ export const command = cli({
126128
}
127129
} catch {}
128130
return null;
129-
}`) || LISTS_QUERY_ID;
131+
}`);
132+
const queryId = unwrap(queryIdRaw) || LISTS_QUERY_ID;
130133
const headers = JSON.stringify({
131134
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
132135
'X-Csrf-Token': ct0,

0 commit comments

Comments
 (0)