@jackwener/opencli 1.7.18 → 1.7.20

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 (120) hide show
  1. package/README.md +18 -17
  2. package/README.zh-CN.md +16 -18
  3. package/cli-manifest.json +311 -186
  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 +8 -4
  21. package/clis/twitter/bookmark-folder.test.js +59 -1
  22. package/clis/twitter/bookmarks.js +12 -4
  23. package/clis/twitter/bookmarks.test.js +205 -0
  24. package/clis/twitter/followers.js +20 -5
  25. package/clis/twitter/followers.test.js +44 -0
  26. package/clis/twitter/following.js +36 -20
  27. package/clis/twitter/following.test.js +60 -8
  28. package/clis/twitter/likes.js +28 -13
  29. package/clis/twitter/likes.test.js +111 -1
  30. package/clis/twitter/list-add.js +128 -204
  31. package/clis/twitter/list-add.test.js +97 -1
  32. package/clis/twitter/list-tweets.js +13 -4
  33. package/clis/twitter/list-tweets.test.js +48 -0
  34. package/clis/twitter/lists.js +5 -2
  35. package/clis/twitter/post.js +23 -4
  36. package/clis/twitter/post.test.js +30 -0
  37. package/clis/twitter/profile.js +16 -8
  38. package/clis/twitter/profile.test.js +39 -0
  39. package/clis/twitter/reply.js +133 -10
  40. package/clis/twitter/reply.test.js +55 -0
  41. package/clis/twitter/search.js +188 -170
  42. package/clis/twitter/search.test.js +96 -258
  43. package/clis/twitter/shared.js +167 -16
  44. package/clis/twitter/shared.test.js +102 -1
  45. package/clis/twitter/timeline.js +3 -1
  46. package/clis/twitter/tweets.js +147 -51
  47. package/clis/twitter/tweets.test.js +238 -1
  48. package/clis/xiaohongshu/comments.js +23 -2
  49. package/clis/xiaohongshu/comments.test.js +63 -1
  50. package/clis/xiaohongshu/search.js +168 -13
  51. package/clis/xiaohongshu/search.test.js +82 -8
  52. package/clis/xueqiu/earnings-date.js +2 -2
  53. package/clis/xueqiu/kline.js +2 -2
  54. package/clis/xueqiu/utils.js +19 -0
  55. package/clis/xueqiu/utils.test.js +26 -0
  56. package/clis/zhihu/answer-detail.js +233 -0
  57. package/clis/zhihu/answer-detail.test.js +330 -0
  58. package/clis/zhihu/question.js +44 -10
  59. package/clis/zhihu/question.test.js +78 -1
  60. package/clis/zhihu/recommend.js +103 -0
  61. package/clis/zhihu/recommend.test.js +143 -0
  62. package/dist/src/browser/base-page.d.ts +3 -2
  63. package/dist/src/browser/base-page.test.js +2 -2
  64. package/dist/src/browser/cdp.js +3 -3
  65. package/dist/src/browser/daemon-client.d.ts +1 -0
  66. package/dist/src/browser/daemon-client.js +3 -0
  67. package/dist/src/browser/daemon-client.test.js +20 -0
  68. package/dist/src/browser/page.d.ts +3 -2
  69. package/dist/src/browser/page.js +4 -4
  70. package/dist/src/browser/page.test.js +31 -0
  71. package/dist/src/browser/utils.d.ts +10 -0
  72. package/dist/src/browser/utils.js +37 -0
  73. package/dist/src/browser/utils.test.d.ts +1 -0
  74. package/dist/src/browser/utils.test.js +29 -0
  75. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  76. package/dist/src/cli-argv-preprocess.js +131 -0
  77. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  78. package/dist/src/cli-argv-preprocess.test.js +130 -0
  79. package/dist/src/cli.js +131 -89
  80. package/dist/src/cli.test.js +34 -28
  81. package/dist/src/commands/daemon.js +6 -7
  82. package/dist/src/daemon-utils.d.ts +18 -0
  83. package/dist/src/daemon-utils.js +37 -0
  84. package/dist/src/daemon.d.ts +1 -1
  85. package/dist/src/daemon.js +44 -13
  86. package/dist/src/daemon.test.js +42 -1
  87. package/dist/src/doctor.js +15 -16
  88. package/dist/src/download/progress.js +15 -11
  89. package/dist/src/download/progress.test.d.ts +1 -0
  90. package/dist/src/download/progress.test.js +25 -0
  91. package/dist/src/electron-apps.js +0 -1
  92. package/dist/src/electron-apps.test.js +1 -0
  93. package/dist/src/execution.js +1 -3
  94. package/dist/src/execution.test.js +4 -16
  95. package/dist/src/external-clis.yaml +12 -3
  96. package/dist/src/external.d.ts +4 -0
  97. package/dist/src/external.js +3 -0
  98. package/dist/src/external.test.js +24 -1
  99. package/dist/src/help.d.ts +16 -1
  100. package/dist/src/help.js +50 -8
  101. package/dist/src/help.test.js +5 -1
  102. package/dist/src/logger.js +8 -9
  103. package/dist/src/main.js +16 -0
  104. package/dist/src/output.js +4 -5
  105. package/dist/src/runtime-detect.d.ts +1 -1
  106. package/dist/src/runtime-detect.js +1 -1
  107. package/dist/src/runtime-detect.test.js +3 -2
  108. package/dist/src/tui.d.ts +0 -1
  109. package/dist/src/tui.js +9 -22
  110. package/dist/src/types.d.ts +3 -1
  111. package/dist/src/update-check.js +4 -5
  112. package/package.json +5 -4
  113. package/clis/notion/export.js +0 -32
  114. package/clis/notion/favorites.js +0 -85
  115. package/clis/notion/new.js +0 -35
  116. package/clis/notion/read.js +0 -31
  117. package/clis/notion/search.js +0 -47
  118. package/clis/notion/sidebar.js +0 -42
  119. package/clis/notion/status.js +0 -17
  120. package/clis/notion/write.js +0 -41
@@ -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)]);
@@ -1,9 +1,10 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
- import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { normalizeTwitterScreenName, resolveTwitterQueryId, sanitizeQueryId, extractMedia, unwrapBrowserResult } from './shared.js';
4
4
  import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
  const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
6
6
  const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
7
+ const MAX_PAGINATION_PAGES = 100;
7
8
  const FEATURES = {
8
9
  rweb_video_screen_enabled: false,
9
10
  profile_label_improvements_pcf_label_in_post_enabled: true,
@@ -151,20 +152,33 @@ cli({
151
152
  columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls'],
152
153
  func: async (page, kwargs) => {
153
154
  const limit = kwargs.limit || 20;
154
- let username = (kwargs.username || '').replace(/^@/, '');
155
+ const rawUsername = String(kwargs.username ?? '').trim();
156
+ let username = normalizeTwitterScreenName(rawUsername);
157
+ if (rawUsername && !username) {
158
+ throw new ArgumentError('twitter likes username must be a valid Twitter/X handle', 'Example: opencli twitter likes @jack --limit 20');
159
+ }
155
160
  const cookies = await page.getCookies({ url: 'https://x.com' });
156
161
  const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
157
162
  if (!ct0)
158
163
  throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
159
- // If no username provided, detect the logged-in user
164
+ // If no username provided, detect the logged-in user.
165
+ // Bridge wraps primitive page.evaluate returns as { session, data:<value> };
166
+ // unwrap so the href string is usable downstream.
160
167
  if (!username) {
161
- const href = await page.evaluate(`() => {
168
+ // Force a navigation to the home surface so the AppTabBar sidebar
169
+ // is rendered; the framework pre-nav lands on bare x.com which
170
+ // does not always expose AppTabBar_Profile_Link.
171
+ await page.goto('https://x.com/home');
172
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
173
+ const href = unwrapBrowserResult(await page.evaluate(`() => {
162
174
  const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
163
175
  return link ? link.getAttribute('href') : null;
164
- }`);
165
- if (!href)
176
+ }`));
177
+ if (!href || typeof href !== 'string')
178
+ throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
179
+ username = normalizeTwitterScreenName(href);
180
+ if (!username)
166
181
  throw new AuthRequiredError('x.com', 'Could not detect logged-in user. Are you logged in?');
167
- username = href.replace('/', '');
168
182
  }
169
183
  const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
170
184
  const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
@@ -175,27 +189,28 @@ cli({
175
189
  'X-Twitter-Active-User': 'yes',
176
190
  });
177
191
  // Get userId from screen_name
178
- const userId = await page.evaluate(`async () => {
192
+ const userId = unwrapBrowserResult(await page.evaluate(`async () => {
179
193
  const screenName = ${JSON.stringify(username)};
180
194
  const url = ${JSON.stringify(buildUserByScreenNameUrl(userByScreenNameQueryId, username))};
181
195
  const resp = await fetch(url, { headers: ${headers}, credentials: 'include' });
182
196
  if (!resp.ok) return null;
183
197
  const d = await resp.json();
184
198
  return d.data?.user?.result?.rest_id || null;
185
- }`);
199
+ }`));
186
200
  if (!userId) {
187
201
  throw new CommandExecutionError(`Could not find user @${username}`);
188
202
  }
189
203
  const allTweets = [];
190
204
  const seen = new Set();
191
205
  let cursor = null;
192
- for (let i = 0; i < 5 && allTweets.length < limit; i++) {
206
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
207
+ for (let i = 0; i < MAX_PAGINATION_PAGES && allTweets.length < limit; i++) {
193
208
  const fetchCount = Math.min(100, limit - allTweets.length + 10);
194
209
  const apiUrl = buildLikesUrl(likesQueryId, userId, fetchCount, cursor);
195
- const data = await page.evaluate(`async () => {
210
+ const data = unwrapBrowserResult(await page.evaluate(`async () => {
196
211
  const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
197
212
  return r.ok ? await r.json() : { error: r.status };
198
- }`);
213
+ }`));
199
214
  if (data?.error) {
200
215
  if (allTweets.length === 0)
201
216
  throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch likes. queryId may have expired.`);
@@ -1,5 +1,50 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
2
4
  import { __test__ } from './likes.js';
5
+
6
+ function likesPayload() {
7
+ return {
8
+ data: {
9
+ user: {
10
+ result: {
11
+ timeline_v2: {
12
+ timeline: {
13
+ instructions: [{
14
+ entries: [{
15
+ entryId: 'tweet-1',
16
+ content: {
17
+ itemContent: {
18
+ tweet_results: {
19
+ result: {
20
+ rest_id: '1',
21
+ legacy: {
22
+ full_text: 'liked post',
23
+ favorite_count: 7,
24
+ retweet_count: 2,
25
+ created_at: 'now',
26
+ },
27
+ core: {
28
+ user_results: {
29
+ result: {
30
+ legacy: { screen_name: 'alice', name: 'Alice' },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ }],
39
+ }],
40
+ },
41
+ },
42
+ },
43
+ },
44
+ },
45
+ };
46
+ }
47
+
3
48
  describe('twitter likes helpers', () => {
4
49
  it('falls back when queryId contains unsafe characters', () => {
5
50
  expect(__test__.sanitizeQueryId('safe_Query-123', 'fallback')).toBe('safe_Query-123');
@@ -83,3 +128,68 @@ describe('twitter likes helpers', () => {
83
128
  });
84
129
  });
85
130
  });
131
+
132
+ describe('twitter likes command', () => {
133
+ it('rejects invalid explicit username before cookies or navigation', async () => {
134
+ const command = getRegistry().get('twitter/likes');
135
+ const page = {
136
+ goto: vi.fn(),
137
+ wait: vi.fn(),
138
+ getCookies: vi.fn(),
139
+ evaluate: vi.fn(),
140
+ };
141
+
142
+ await expect(command.func(page, { username: 'viewer/extra', limit: 10 })).rejects.toBeInstanceOf(ArgumentError);
143
+ expect(page.getCookies).not.toHaveBeenCalled();
144
+ expect(page.goto).not.toHaveBeenCalled();
145
+ expect(page.evaluate).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('rejects route-like AppTabBar hrefs as AuthRequiredError', async () => {
149
+ const command = getRegistry().get('twitter/likes');
150
+ const page = {
151
+ goto: vi.fn().mockResolvedValue(undefined),
152
+ wait: vi.fn().mockResolvedValue(undefined),
153
+ getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
154
+ evaluate: vi.fn(async (script) => {
155
+ if (String(script).includes('AppTabBar_Profile_Link')) return '/home';
156
+ throw new Error(`Unexpected evaluate: ${String(script).slice(0, 80)}`);
157
+ }),
158
+ };
159
+
160
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
161
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/home');
162
+ expect(page.evaluate).toHaveBeenCalledTimes(1);
163
+ });
164
+
165
+ it('unwraps Browser Bridge envelopes for default-self user lookup and likes payload', async () => {
166
+ const command = getRegistry().get('twitter/likes');
167
+ const page = {
168
+ goto: vi.fn().mockResolvedValue(undefined),
169
+ wait: vi.fn().mockResolvedValue(undefined),
170
+ getCookies: vi.fn(async () => [{ name: 'ct0', value: 'token' }]),
171
+ evaluate: vi.fn(async (script) => {
172
+ const text = String(script);
173
+ if (text.includes('AppTabBar_Profile_Link')) {
174
+ return { session: 'site:twitter', data: '/viewer' };
175
+ }
176
+ if (text.includes('operationName')) return null;
177
+ if (text.includes('/UserByScreenName')) {
178
+ return { session: 'site:twitter', data: '42' };
179
+ }
180
+ if (text.includes('/Likes')) {
181
+ return { session: 'site:twitter', data: likesPayload() };
182
+ }
183
+ throw new Error(`Unexpected evaluate: ${text.slice(0, 80)}`);
184
+ }),
185
+ };
186
+
187
+ const rows = await command.func(page, { limit: 1 });
188
+
189
+ expect(rows).toHaveLength(1);
190
+ expect(rows[0]).toMatchObject({ id: '1', author: 'alice', text: 'liked post' });
191
+ const likesCall = page.evaluate.mock.calls.find(([script]) => String(script).includes('/Likes')) || [];
192
+ expect(decodeURIComponent(String(likesCall[0]))).toContain('"userId":"42"');
193
+ expect(decodeURIComponent(String(likesCall[0]))).not.toContain('[object Object]');
194
+ });
195
+ });