@jackwener/opencli 1.7.18 → 1.7.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +7 -8
  2. package/README.zh-CN.md +7 -8
  3. package/cli-manifest.json +305 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/google/search.js +16 -6
  9. package/clis/google-scholar/search.js +20 -5
  10. package/clis/google-scholar/search.test.js +35 -2
  11. package/clis/reddit/home.js +117 -0
  12. package/clis/reddit/home.test.js +127 -0
  13. package/clis/reddit/read.js +400 -54
  14. package/clis/reddit/read.test.js +315 -12
  15. package/clis/reddit/subreddit-info.js +117 -0
  16. package/clis/reddit/subreddit-info.test.js +163 -0
  17. package/clis/reddit/whoami.js +84 -0
  18. package/clis/reddit/whoami.test.js +105 -0
  19. package/clis/rednote/search.js +6 -2
  20. package/clis/twitter/bookmark-folder.js +3 -1
  21. package/clis/twitter/bookmarks.js +3 -1
  22. package/clis/twitter/followers.js +20 -5
  23. package/clis/twitter/followers.test.js +44 -0
  24. package/clis/twitter/following.js +36 -20
  25. package/clis/twitter/following.test.js +60 -8
  26. package/clis/twitter/likes.js +28 -13
  27. package/clis/twitter/likes.test.js +111 -1
  28. package/clis/twitter/list-add.js +128 -204
  29. package/clis/twitter/list-add.test.js +97 -1
  30. package/clis/twitter/list-tweets.js +13 -4
  31. package/clis/twitter/list-tweets.test.js +48 -0
  32. package/clis/twitter/lists.js +5 -2
  33. package/clis/twitter/post.js +23 -4
  34. package/clis/twitter/post.test.js +30 -0
  35. package/clis/twitter/profile.js +16 -8
  36. package/clis/twitter/profile.test.js +39 -0
  37. package/clis/twitter/reply.js +133 -10
  38. package/clis/twitter/reply.test.js +55 -0
  39. package/clis/twitter/search.js +188 -170
  40. package/clis/twitter/search.test.js +96 -258
  41. package/clis/twitter/shared.js +167 -16
  42. package/clis/twitter/shared.test.js +102 -1
  43. package/clis/twitter/timeline.js +3 -1
  44. package/clis/twitter/tweets.js +147 -51
  45. package/clis/twitter/tweets.test.js +238 -1
  46. package/clis/xiaohongshu/comments.js +23 -2
  47. package/clis/xiaohongshu/comments.test.js +63 -1
  48. package/clis/xiaohongshu/search.js +168 -13
  49. package/clis/xiaohongshu/search.test.js +82 -8
  50. package/clis/xueqiu/earnings-date.js +2 -2
  51. package/clis/xueqiu/kline.js +2 -2
  52. package/clis/xueqiu/utils.js +19 -0
  53. package/clis/xueqiu/utils.test.js +26 -0
  54. package/clis/zhihu/answer-detail.js +233 -0
  55. package/clis/zhihu/answer-detail.test.js +330 -0
  56. package/clis/zhihu/question.js +44 -10
  57. package/clis/zhihu/question.test.js +78 -1
  58. package/clis/zhihu/recommend.js +103 -0
  59. package/clis/zhihu/recommend.test.js +143 -0
  60. package/dist/src/browser/base-page.d.ts +3 -2
  61. package/dist/src/browser/base-page.test.js +2 -2
  62. package/dist/src/browser/cdp.js +3 -3
  63. package/dist/src/browser/page.d.ts +3 -2
  64. package/dist/src/browser/page.js +4 -4
  65. package/dist/src/browser/page.test.js +31 -0
  66. package/dist/src/browser/utils.d.ts +10 -0
  67. package/dist/src/browser/utils.js +37 -0
  68. package/dist/src/browser/utils.test.d.ts +1 -0
  69. package/dist/src/browser/utils.test.js +29 -0
  70. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  71. package/dist/src/cli-argv-preprocess.js +131 -0
  72. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  73. package/dist/src/cli-argv-preprocess.test.js +130 -0
  74. package/dist/src/cli.js +123 -86
  75. package/dist/src/cli.test.js +33 -28
  76. package/dist/src/commands/daemon.js +6 -7
  77. package/dist/src/doctor.js +15 -16
  78. package/dist/src/download/progress.js +15 -11
  79. package/dist/src/download/progress.test.d.ts +1 -0
  80. package/dist/src/download/progress.test.js +25 -0
  81. package/dist/src/execution.js +1 -3
  82. package/dist/src/execution.test.js +4 -16
  83. package/dist/src/help.d.ts +11 -0
  84. package/dist/src/help.js +46 -5
  85. package/dist/src/logger.js +8 -9
  86. package/dist/src/main.js +16 -0
  87. package/dist/src/output.js +4 -5
  88. package/dist/src/runtime-detect.d.ts +1 -1
  89. package/dist/src/runtime-detect.js +1 -1
  90. package/dist/src/runtime-detect.test.js +3 -2
  91. package/dist/src/tui.d.ts +0 -1
  92. package/dist/src/tui.js +9 -22
  93. package/dist/src/types.d.ts +3 -1
  94. package/dist/src/update-check.js +4 -5
  95. package/package.json +5 -4
@@ -0,0 +1,84 @@
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ cli({
5
+ site: 'reddit',
6
+ name: 'whoami',
7
+ access: 'read',
8
+ description: 'Show the currently logged-in Reddit user',
9
+ domain: 'reddit.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ siteSession: 'persistent',
13
+ args: [],
14
+ columns: ['field', 'value'],
15
+ func: async (page) => {
16
+ await page.goto('https://www.reddit.com');
17
+ // Probe identity via /api/me.json. Reddit returns 200 with an empty
18
+ // body for stale anonymous sessions, so 401/403 alone is not a
19
+ // sufficient logged-out signal — we also verify `data.name` exists
20
+ // (two-pronged auth detection from PR #1428).
21
+ //
22
+ // Intermediate object keys deliberately avoid `field` / `value` to
23
+ // sidestep the silent-column-drop audit (columns are ['field',
24
+ // 'value']) — see PR #1329 sediment "中间解析对象 key 不能跟 columns
25
+ // 任一项重叠".
26
+ const result = await page.evaluate(`(async () => {
27
+ try {
28
+ const res = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
29
+ if (res.status === 401 || res.status === 403) {
30
+ return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + res.status };
31
+ }
32
+ if (!res.ok) {
33
+ return { kind: 'http', httpStatus: res.status, where: '/api/me.json' };
34
+ }
35
+ const d = await res.json();
36
+ const me = d?.data;
37
+ if (!me?.name) {
38
+ return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
39
+ }
40
+ return { kind: 'ok', identity: me };
41
+ } catch (e) {
42
+ return { kind: 'exception', detail: String(e && e.message || e) };
43
+ }
44
+ })()`);
45
+
46
+ if (result?.kind === 'auth') {
47
+ throw new AuthRequiredError('reddit.com', result.detail);
48
+ }
49
+ if (result?.kind === 'http') {
50
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
51
+ }
52
+ if (result?.kind === 'exception') {
53
+ throw new CommandExecutionError(`whoami failed: ${result.detail}`);
54
+ }
55
+ if (result?.kind !== 'ok') {
56
+ throw new CommandExecutionError(`Unexpected result from reddit whoami: ${JSON.stringify(result)}`);
57
+ }
58
+
59
+ const u = result.identity;
60
+ const created = u.created_utc
61
+ ? new Date(u.created_utc * 1000).toISOString().split('T')[0]
62
+ : null;
63
+ const linkKarma = typeof u.link_karma === 'number' ? u.link_karma : null;
64
+ const commentKarma = typeof u.comment_karma === 'number' ? u.comment_karma : null;
65
+ const totalKarma = typeof u.total_karma === 'number'
66
+ ? u.total_karma
67
+ : (linkKarma != null && commentKarma != null ? linkKarma + commentKarma : null);
68
+ const inboxCount = typeof u.inbox_count === 'number' ? u.inbox_count : null;
69
+
70
+ return [
71
+ { field: 'Username', value: 'u/' + u.name },
72
+ { field: 'ID', value: u.id ? 't2_' + u.id : null },
73
+ { field: 'Post Karma', value: linkKarma != null ? String(linkKarma) : null },
74
+ { field: 'Comment Karma', value: commentKarma != null ? String(commentKarma) : null },
75
+ { field: 'Total Karma', value: totalKarma != null ? String(totalKarma) : null },
76
+ { field: 'Account Created', value: created },
77
+ { field: 'Gold', value: u.is_gold ? 'Yes' : 'No' },
78
+ { field: 'Mod', value: u.is_mod ? 'Yes' : 'No' },
79
+ { field: 'Verified Email', value: u.has_verified_email ? 'Yes' : 'No' },
80
+ { field: 'Has Mail', value: u.has_mail ? 'Yes' : 'No' },
81
+ { field: 'Inbox Count', value: inboxCount != null ? String(inboxCount) : null },
82
+ ];
83
+ },
84
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './whoami.js';
5
+
6
+ function makePage(result) {
7
+ return {
8
+ goto: vi.fn().mockResolvedValue(undefined),
9
+ evaluate: vi.fn().mockResolvedValue(result),
10
+ };
11
+ }
12
+
13
+ describe('reddit whoami command', () => {
14
+ const command = getRegistry().get('reddit/whoami');
15
+
16
+ it('registers with the expected shape', () => {
17
+ expect(command).toBeDefined();
18
+ expect(command.access).toBe('read');
19
+ expect(command.browser).toBe(true);
20
+ expect(command.columns).toEqual(['field', 'value']);
21
+ expect(command.args).toEqual([]);
22
+ });
23
+
24
+ it('throws AuthRequiredError on 401/403 from /api/me.json', async () => {
25
+ const page = makePage({ kind: 'auth', detail: 'Reddit /api/me.json returned HTTP 401' });
26
+ await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
27
+ expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
28
+ });
29
+
30
+ it('throws AuthRequiredError on 200 with missing data.name (stale anon session)', async () => {
31
+ const page = makePage({ kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' });
32
+ await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
33
+ });
34
+
35
+ it('throws CommandExecutionError on HTTP / exception failure modes', async () => {
36
+ await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: '/api/me.json' }), {}))
37
+ .rejects.toBeInstanceOf(CommandExecutionError);
38
+ await expect(command.func(makePage({ kind: 'exception', detail: 'bad json' }), {}))
39
+ .rejects.toBeInstanceOf(CommandExecutionError);
40
+ });
41
+
42
+ it('maps a full identity payload into the field/value rows', async () => {
43
+ const identity = {
44
+ name: 'alice',
45
+ id: 'abcdef',
46
+ link_karma: 1234,
47
+ comment_karma: 5678,
48
+ total_karma: 6912,
49
+ created_utc: 1577836800, // 2020-01-01
50
+ is_gold: true,
51
+ is_mod: false,
52
+ has_verified_email: true,
53
+ has_mail: false,
54
+ inbox_count: 0,
55
+ };
56
+ const page = makePage({ kind: 'ok', identity });
57
+ const rows = await command.func(page, {});
58
+
59
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
60
+ expect(byField.Username).toBe('u/alice');
61
+ expect(byField.ID).toBe('t2_abcdef');
62
+ expect(byField['Post Karma']).toBe('1234');
63
+ expect(byField['Comment Karma']).toBe('5678');
64
+ expect(byField['Total Karma']).toBe('6912');
65
+ expect(byField['Account Created']).toBe('2020-01-01');
66
+ expect(byField.Gold).toBe('Yes');
67
+ expect(byField.Mod).toBe('No');
68
+ expect(byField['Verified Email']).toBe('Yes');
69
+ expect(byField['Has Mail']).toBe('No');
70
+ expect(byField['Inbox Count']).toBe('0');
71
+
72
+ // Row shape must match the declared columns exactly so the
73
+ // silent-column-drop audit can't be triggered.
74
+ for (const row of rows) {
75
+ expect(Object.keys(row).sort()).toEqual(['field', 'value']);
76
+ }
77
+ });
78
+
79
+ it('falls back to null for missing numeric karma fields rather than 0 sentinels', async () => {
80
+ const identity = {
81
+ name: 'bob',
82
+ id: 'xyz',
83
+ created_utc: null,
84
+ is_gold: false,
85
+ is_mod: false,
86
+ has_verified_email: false,
87
+ has_mail: false,
88
+ };
89
+ const page = makePage({ kind: 'ok', identity });
90
+ const rows = await command.func(page, {});
91
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
92
+ expect(byField['Post Karma']).toBeNull();
93
+ expect(byField['Comment Karma']).toBeNull();
94
+ expect(byField['Total Karma']).toBeNull();
95
+ expect(byField['Account Created']).toBeNull();
96
+ expect(byField['Inbox Count']).toBeNull();
97
+ });
98
+
99
+ it('does not throw on `data.name` present even if optional booleans are missing', async () => {
100
+ const identity = { name: 'carol', id: 'i1' };
101
+ const page = makePage({ kind: 'ok', identity });
102
+ const rows = await command.func(page, {});
103
+ expect(rows[0]).toEqual({ field: 'Username', value: 'u/carol' });
104
+ });
105
+ });
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { cli, Strategy } from '@jackwener/opencli/registry';
9
9
  import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
10
- import { buildSearchExtractJs, noteIdToDate } from '../xiaohongshu/search.js';
10
+ import { buildScrollUntilJs, buildSearchExtractJs, noteIdToDate } from '../xiaohongshu/search.js';
11
11
 
12
12
  function parseLimit(raw) {
13
13
  const parsed = Number(raw);
@@ -82,7 +82,11 @@ cli({
82
82
  if (waitResult === 'login_wall') {
83
83
  throw new AuthRequiredError('www.rednote.com', 'Rednote search results are blocked behind a login wall');
84
84
  }
85
- await page.autoScroll({ times: 2 });
85
+ // Scroll until enough rows are rendered or the lazy-load plateaus.
86
+ // Same fix as xiaohongshu/search (#1471): the previous fixed
87
+ // `autoScroll({ times: 2 })` capped extraction at ~13 notes regardless
88
+ // of `--limit`.
89
+ await page.evaluate(buildScrollUntilJs(limit));
86
90
  const payload = await page.evaluate(buildSearchExtractJs('www.rednote.com'));
87
91
  const data = Array.isArray(payload) ? payload : [];
88
92
  return data
@@ -11,6 +11,7 @@ import { resolveTwitterQueryId } from './shared.js';
11
11
  const OPERATION_NAME = 'BookmarkFolderTimeline';
12
12
  const FALLBACK_QUERY_ID = '13H7EUATwethsj_jZ6QQAQ';
13
13
  const FOLDER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
14
+ const MAX_PAGINATION_PAGES = 100;
14
15
 
15
16
  const FEATURES = {
16
17
  rweb_video_screen_enabled: false,
@@ -158,7 +159,8 @@ cli({
158
159
  const allTweets = [];
159
160
  const seen = new Set();
160
161
  let cursor = null;
161
- for (let i = 0; i < 5 && allTweets.length < limit; i++) {
162
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
163
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
162
164
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
163
165
  const apiUrl = buildFolderTimelineUrl(queryId, folderId, fetchCount, cursor);
164
166
  const data = await page.evaluate(`async () => {
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
3
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
4
4
  const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
5
+ const MAX_PAGINATION_PAGES = 100;
5
6
  const FEATURES = {
6
7
  rweb_video_screen_enabled: false,
7
8
  profile_label_improvements_pcf_label_in_post_enabled: true,
@@ -150,7 +151,8 @@ cli({
150
151
  const allTweets = [];
151
152
  const seen = new Set();
152
153
  let cursor = null;
153
- for (let i = 0; i < 5 && allTweets.length < limit; i++) {
154
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
155
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
154
156
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
155
157
  const apiUrl = buildBookmarksUrl(fetchCount, cursor).replace(BOOKMARKS_QUERY_ID, queryId);
156
158
  const data = await page.evaluate(`async () => {
@@ -1,5 +1,6 @@
1
1
  import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { normalizeTwitterScreenName, unwrapBrowserResult } from './shared.js';
3
4
 
4
5
  /**
5
6
  * Extract follower rows from Twitter/X follower-list SPA cells.
@@ -72,7 +73,7 @@ async function extractFollowersFromDOM(page) {
72
73
  }
73
74
 
74
75
  function normalizeScreenName(value) {
75
- return String(value ?? '').trim().replace(/^\/+/, '').replace(/^@+/, '');
76
+ return normalizeTwitterScreenName(value);
76
77
  }
77
78
 
78
79
  cli({
@@ -103,18 +104,27 @@ cli({
103
104
  throw new ArgumentError('limit must be a positive integer');
104
105
  }
105
106
 
106
- let targetUser = normalizeScreenName(kwargs.user);
107
+ const rawUser = String(kwargs.user ?? '').trim();
108
+ let targetUser = normalizeScreenName(rawUser);
109
+ if (rawUser && !targetUser) {
110
+ throw new ArgumentError('twitter followers user must be a valid Twitter/X handle', 'Example: opencli twitter followers @elonmusk --limit 100');
111
+ }
107
112
  if (!targetUser) {
108
113
  await page.goto('https://x.com/home');
109
114
  await page.wait({ selector: '[data-testid="primaryColumn"]' });
110
- const href = await page.evaluate(`() => {
115
+ // Bridge wraps primitive page.evaluate returns as { session, data:<value> };
116
+ // unwrap so the href string is usable downstream.
117
+ const href = unwrapBrowserResult(await page.evaluate(`() => {
111
118
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
112
119
  return link ? link.getAttribute('href') : null;
113
- }`);
114
- if (!href) {
120
+ }`));
121
+ if (!href || typeof href !== 'string') {
115
122
  throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
116
123
  }
117
124
  targetUser = normalizeScreenName(href);
125
+ if (!targetUser) {
126
+ throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
127
+ }
118
128
  }
119
129
  if (!targetUser) {
120
130
  throw new ArgumentError('twitter followers user cannot be empty', 'Example: opencli twitter followers @elonmusk --limit 100');
@@ -173,3 +183,8 @@ cli({
173
183
  return allFollowers.slice(0, limit);
174
184
  }
175
185
  });
186
+
187
+ export const __test__ = {
188
+ extractFollowersFromDOM,
189
+ normalizeScreenName,
190
+ };
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
4
+ import { __test__ } from './followers.js';
5
+
6
+ describe('twitter followers command', () => {
7
+ it('normalizes exact profile handles and rejects route-like hrefs', () => {
8
+ expect(__test__.normalizeScreenName('@viewer')).toBe('viewer');
9
+ expect(__test__.normalizeScreenName('/viewer')).toBe('viewer');
10
+ expect(__test__.normalizeScreenName('https://x.com/viewer')).toBe('viewer');
11
+ expect(__test__.normalizeScreenName('/home')).toBe('');
12
+ expect(__test__.normalizeScreenName('/viewer/extra')).toBe('');
13
+ });
14
+
15
+ it('rejects invalid explicit users before navigation', async () => {
16
+ const command = getRegistry().get('twitter/followers');
17
+ const page = {
18
+ goto: vi.fn(),
19
+ wait: vi.fn(),
20
+ evaluate: vi.fn(),
21
+ };
22
+
23
+ await expect(command.func(page, { user: 'viewer/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
24
+ expect(page.goto).not.toHaveBeenCalled();
25
+ expect(page.wait).not.toHaveBeenCalled();
26
+ expect(page.evaluate).not.toHaveBeenCalled();
27
+ });
28
+
29
+ it('rejects non-profile AppTabBar hrefs instead of navigating to route followers', async () => {
30
+ const command = getRegistry().get('twitter/followers');
31
+ const page = {
32
+ goto: vi.fn().mockResolvedValue(undefined),
33
+ wait: vi.fn().mockResolvedValue(undefined),
34
+ evaluate: vi.fn(async (script) => {
35
+ if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
36
+ throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
37
+ }),
38
+ };
39
+
40
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
41
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
42
+ expect(page.goto).not.toHaveBeenCalledWith('https://x.com/home/followers');
43
+ });
44
+ });
@@ -1,10 +1,11 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
- import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
3
+ import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN } from './utils.js';
5
5
 
6
6
  const FOLLOWING_QUERY_ID = 'zx6e-TLzRkeDO_a7p4b3JQ'; // Following fallback
7
7
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
8
+ const MAX_PAGINATION_PAGES = 100;
8
9
 
9
10
  const FEATURES = {
10
11
  rweb_video_screen_enabled: false,
@@ -128,7 +129,7 @@ function parseFollowing(data) {
128
129
  }
129
130
 
130
131
  function normalizeScreenName(value) {
131
- return String(value || '').trim().replace(/^\/+/, '').replace(/^@+/, '');
132
+ return normalizeTwitterScreenName(value);
132
133
  }
133
134
 
134
135
  cli({
@@ -156,7 +157,11 @@ cli({
156
157
  if (!Number.isInteger(limit) || limit <= 0) {
157
158
  throw new ArgumentError('twitter following --limit must be a positive integer', 'Example: opencli twitter following @elonmusk --limit 200');
158
159
  }
159
- let targetUser = normalizeScreenName(kwargs.user);
160
+ const rawUser = String(kwargs.user ?? '').trim();
161
+ let targetUser = normalizeScreenName(rawUser);
162
+ if (rawUser && !targetUser) {
163
+ throw new ArgumentError('twitter following user must be a valid Twitter/X handle', 'Example: opencli twitter following @elonmusk --limit 200');
164
+ }
160
165
 
161
166
  const cookies = await page.getCookies({ url: 'https://x.com' });
162
167
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
@@ -164,13 +169,25 @@ cli({
164
169
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
165
170
 
166
171
  if (!targetUser) {
167
- const href = await page.evaluate(`() => {
168
- const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
169
- return link ? link.getAttribute('href') : null;
170
- }`);
171
- if (!href)
172
+ // Force a navigation to the home surface so the AppTabBar sidebar
173
+ // is rendered; the framework pre-nav lands on bare x.com which
174
+ // does not always expose AppTabBar_Profile_Link.
175
+ await page.goto('https://x.com/home');
176
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
177
+ // Bridge wraps primitive page.evaluate returns as { session, data:<value> };
178
+ // unwrap so the href string is usable downstream.
179
+ // NOTE: the function-literal form `() => ...` silently drops
180
+ // primitive return values through the bridge — only the template
181
+ // string form preserves the `data` field.
182
+ const href = unwrapBrowserResult(await page.evaluate(`() => {
183
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
184
+ return link ? link.getAttribute('href') : null;
185
+ }`));
186
+ if (!href || typeof href !== 'string')
187
+ throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
188
+ targetUser = normalizeScreenName(href);
189
+ if (!targetUser)
172
190
  throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
173
- targetUser = normalizeScreenName(href.replace('/', ''));
174
191
  }
175
192
  if (!targetUser) {
176
193
  throw new ArgumentError('twitter following user cannot be empty', 'Example: opencli twitter following @elonmusk --limit 200');
@@ -178,21 +195,20 @@ cli({
178
195
 
179
196
  const followingQueryId = await resolveTwitterQueryId(page, 'Following', FOLLOWING_QUERY_ID);
180
197
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
181
- const headers = JSON.stringify({
198
+ const headers = {
182
199
  'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
183
200
  'X-Csrf-Token': ct0,
184
201
  'X-Twitter-Auth-Type': 'OAuth2Session',
185
202
  'X-Twitter-Active-User': 'yes',
186
- });
203
+ };
187
204
 
188
205
  // Get userId from screen_name
189
- const userLookup = await page.evaluate(`async () => {
190
- const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser))};
191
- const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
206
+ const userLookup = unwrapBrowserResult(await page.evaluate(async (url, headers) => {
207
+ const resp = await fetch(url, { headers, credentials: 'include' });
192
208
  if (!resp.ok) return { error: resp.status };
193
209
  const d = await resp.json();
194
210
  return { userId: d.data?.user?.result?.rest_id || null };
195
- }`);
211
+ }, buildUserByScreenNameUrl(userByScreenNameQueryId, targetUser), headers));
196
212
  if (userLookup?.error === 401 || userLookup?.error === 403) {
197
213
  throw new AuthRequiredError('x.com', `Twitter user lookup failed (HTTP ${userLookup.error})`);
198
214
  }
@@ -207,14 +223,14 @@ cli({
207
223
  const seen = new Set();
208
224
  let cursor = null;
209
225
 
210
- const maxPages = Math.ceil(limit / 50) + 2;
211
- for (let i = 0; i < maxPages && allUsers.length < limit; i++) {
226
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
227
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allUsers.length < limit; i++) {
212
228
  const fetchCount = Math.min(50, limit - allUsers.length + 10);
213
229
  const apiUrl = buildFollowingUrl(followingQueryId, userId, fetchCount, cursor);
214
- const data = await page.evaluate(`async () => {
215
- const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
230
+ const data = unwrapBrowserResult(await page.evaluate(async (url, headers) => {
231
+ const r = await fetch(url, { headers, credentials: 'include' });
216
232
  return r.ok ? await r.json() : { error: r.status };
217
- }`);
233
+ }, apiUrl, headers));
218
234
  if (data?.error) {
219
235
  if (data.error === 401 || data.error === 403)
220
236
  throw new AuthRequiredError('x.com', `Twitter following request failed (HTTP ${data.error})`);
@@ -157,6 +157,8 @@ describe('twitter following helpers', () => {
157
157
  expect(__test__.normalizeScreenName('@elonmusk')).toBe('elonmusk');
158
158
  expect(__test__.normalizeScreenName('/elonmusk')).toBe('elonmusk');
159
159
  expect(__test__.normalizeScreenName(' @@alice ')).toBe('alice');
160
+ expect(__test__.normalizeScreenName('/home')).toBe('');
161
+ expect(__test__.normalizeScreenName('/elonmusk/extra')).toBe('');
160
162
  });
161
163
  });
162
164
 
@@ -201,16 +203,28 @@ function followingPayload(users, cursor) {
201
203
  };
202
204
  }
203
205
 
204
- function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' } } = {}) {
206
+ function bridgeEnvelope(data) {
207
+ return { session: 'site:twitter', data };
208
+ }
209
+
210
+ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = { userId: '42' }, envelope = false } = {}) {
205
211
  const page = {
206
212
  goto: vi.fn().mockResolvedValue(undefined),
207
213
  wait: vi.fn().mockResolvedValue(undefined),
208
214
  getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
209
- evaluate: vi.fn(async (script) => {
215
+ evaluate: vi.fn(async (script, ...args) => {
216
+ const wrap = (value) => envelope ? bridgeEnvelope(value) : value;
217
+ if (typeof script === 'function') {
218
+ const haystack = [script.toString(), ...args.map((arg) => String(arg))].join('\n');
219
+ if (haystack.includes('/UserByScreenName')) return wrap(userLookup);
220
+ if (haystack.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
221
+ if (haystack.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
222
+ throw new Error(`Unexpected evaluate function: ${haystack.slice(0, 80)}`);
223
+ }
210
224
  if (script.includes('operationName')) return null;
211
- if (script.includes('/UserByScreenName')) return userLookup;
212
- if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
213
- if (script.includes('AppTabBar_Profile_Link')) return '/viewer';
225
+ if (script.includes('/UserByScreenName')) return wrap(userLookup);
226
+ if (script.includes('/Following')) return wrap(followingResponses.shift() || followingPayload([], null));
227
+ if (script.includes('AppTabBar_Profile_Link')) return wrap('/viewer');
214
228
  throw new Error(`Unexpected evaluate script: ${script.slice(0, 80)}`);
215
229
  }),
216
230
  };
@@ -229,12 +243,13 @@ describe('twitter following command', () => {
229
243
 
230
244
  expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
231
245
  expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
232
- const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
246
+ const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
247
+ const userLookupScript = callText(page.evaluate.mock.calls.find((call) => callText(call).includes('/UserByScreenName')) || []);
233
248
  expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
234
249
  expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
235
- const followingCalls = page.evaluate.mock.calls.filter(([script]) => script.includes('/Following'));
250
+ const followingCalls = page.evaluate.mock.calls.filter((call) => callText(call).includes('/Following'));
236
251
  expect(followingCalls).toHaveLength(2);
237
- expect(decodeURIComponent(followingCalls[1][0])).toContain('"cursor":"cursor-1"');
252
+ expect(decodeURIComponent(callText(followingCalls[1]))).toContain('"cursor":"cursor-1"');
238
253
  });
239
254
 
240
255
  it('rejects invalid limits before navigating', async () => {
@@ -245,6 +260,29 @@ describe('twitter following command', () => {
245
260
  expect(page.goto).not.toHaveBeenCalled();
246
261
  });
247
262
 
263
+ it('rejects invalid explicit users before cookies or navigation', async () => {
264
+ const command = getRegistry().get('twitter/following');
265
+ const page = createFollowingPage([]);
266
+
267
+ await expect(command.func(page, { user: 'elonmusk/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
268
+ expect(page.getCookies).not.toHaveBeenCalled();
269
+ expect(page.goto).not.toHaveBeenCalled();
270
+ expect(page.evaluate).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
274
+ const command = getRegistry().get('twitter/following');
275
+ const page = createFollowingPage([]);
276
+ page.evaluate.mockImplementation(async (script, ...args) => {
277
+ const haystack = [typeof script === 'function' ? script.toString() : String(script), ...args.map((arg) => String(arg))].join('\n');
278
+ if (haystack.includes('AppTabBar_Profile_Link')) return '/home';
279
+ throw new Error(`Unexpected evaluate: ${haystack.slice(0, 80)}`);
280
+ });
281
+
282
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
283
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
284
+ });
285
+
248
286
  it('maps first-page auth failures to AuthRequiredError', async () => {
249
287
  const command = getRegistry().get('twitter/following');
250
288
  const page = createFollowingPage([{ error: 401 }]);
@@ -269,6 +307,20 @@ describe('twitter following command', () => {
269
307
  await expect(command.func(page, { user: 'elonmusk', limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
270
308
  });
271
309
 
310
+ it('unwraps Browser Bridge envelopes for user lookup and following payloads', async () => {
311
+ const command = getRegistry().get('twitter/following');
312
+ const page = createFollowingPage([followingPayload(['alice'], null)], { envelope: true });
313
+
314
+ const rows = await command.func(page, { user: 'elonmusk', limit: 10 });
315
+
316
+ expect(rows.map((row) => row.screen_name)).toEqual(['alice']);
317
+ const callText = (call) => call.map((part) => typeof part === 'function' ? part.toString() : String(part)).join('\n');
318
+ const followingCall = page.evaluate.mock.calls.find((call) => callText(call).includes('/Following')) || [];
319
+ const followingUrl = String(followingCall[1] || '');
320
+ expect(decodeURIComponent(followingUrl)).toContain('"userId":"42"');
321
+ expect(decodeURIComponent(followingUrl)).not.toContain('[object Object]');
322
+ });
323
+
272
324
  it('fails fast when the following timeline is empty', async () => {
273
325
  const command = getRegistry().get('twitter/following');
274
326
  const page = createFollowingPage([followingPayload([], null)]);