@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
@@ -13,6 +13,8 @@ describe('twitter reply command', () => {
13
13
  const cmd = getRegistry().get('twitter/reply');
14
14
  expect(cmd?.func).toBeTypeOf('function');
15
15
  const page = createPageMock([
16
+ { ok: true },
17
+ { ok: true },
16
18
  { ok: true, message: 'Reply posted successfully.' },
17
19
  ]);
18
20
  const result = await cmd.func(page, {
@@ -38,6 +40,8 @@ describe('twitter reply command', () => {
38
40
  const setFileInput = vi.fn().mockResolvedValue(undefined);
39
41
  const page = createPageMock([
40
42
  { ok: true, previewCount: 1 },
43
+ { ok: true },
44
+ { ok: true },
41
45
  { ok: true, message: 'Reply posted successfully.' },
42
46
  ], {
43
47
  setFileInput,
@@ -74,6 +78,8 @@ describe('twitter reply command', () => {
74
78
  const setFileInput = vi.fn().mockResolvedValue(undefined);
75
79
  const page = createPageMock([
76
80
  { ok: true, previewCount: 1 },
81
+ { ok: true },
82
+ { ok: true },
77
83
  { ok: true, message: 'Reply posted successfully.' },
78
84
  ], {
79
85
  setFileInput,
@@ -102,6 +108,55 @@ describe('twitter reply command', () => {
102
108
  ]);
103
109
  vi.unstubAllGlobals();
104
110
  });
111
+ it('falls back to the target tweet page when the dedicated composer route does not expose a textarea', async () => {
112
+ const cmd = getRegistry().get('twitter/reply');
113
+ expect(cmd?.func).toBeTypeOf('function');
114
+ const wait = vi.fn()
115
+ .mockRejectedValueOnce(new Error('Selector not found: [data-testid="tweetTextarea_0"]'))
116
+ .mockResolvedValue(undefined);
117
+ const page = createPageMock([
118
+ { ok: true }, // click target tweet page Reply button
119
+ { ok: true }, // insert reply text
120
+ { ok: true }, // click composer Reply button
121
+ { ok: true, message: 'Reply posted successfully.' }, // submit completed
122
+ ], { wait });
123
+
124
+ const url = 'https://x.com/_kop6/status/2040254679301718161?s=20';
125
+ const result = await cmd.func(page, { url, text: 'fallback reply' });
126
+
127
+ expect(page.goto).toHaveBeenNthCalledWith(1, 'https://x.com/compose/post?in_reply_to=2040254679301718161', { waitUntil: 'load', settleMs: 2500 });
128
+ expect(page.goto).toHaveBeenNthCalledWith(2, url, { waitUntil: 'load', settleMs: 2500 });
129
+ expect(page.evaluate.mock.calls[0][0]).toContain('[data-testid="reply"]');
130
+ expect(wait).toHaveBeenLastCalledWith({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
131
+ expect(result).toEqual([{ status: 'success', message: 'Reply posted successfully.', text: 'fallback reply' }]);
132
+ });
133
+ it('treats an X success toast as success after a Promise was collected error', async () => {
134
+ const cmd = getRegistry().get('twitter/reply');
135
+ expect(cmd?.func).toBeTypeOf('function');
136
+ const evaluate = vi.fn()
137
+ .mockResolvedValueOnce({ ok: true }) // insert reply text
138
+ .mockResolvedValueOnce({ ok: true }) // click Reply
139
+ .mockRejectedValueOnce(new Error('{"code":-32000,"message":"Promise was collected"}'))
140
+ .mockResolvedValueOnce({
141
+ ok: true,
142
+ message: 'Reply posted successfully.',
143
+ url: 'https://x.com/me/status/123',
144
+ });
145
+ const page = createPageMock([], { evaluate });
146
+
147
+ const result = await cmd.func(page, {
148
+ url: 'https://x.com/_kop6/status/2040254679301718161?s=20',
149
+ text: 'toast recovery',
150
+ });
151
+
152
+ expect(page.wait).toHaveBeenCalledWith(2);
153
+ expect(result).toEqual([{
154
+ status: 'success',
155
+ message: 'Reply posted successfully.',
156
+ text: 'toast recovery',
157
+ url: 'https://x.com/me/status/123',
158
+ }]);
159
+ });
105
160
  it('rejects using --image and --image-url together', async () => {
106
161
  const cmd = getRegistry().get('twitter/reply');
107
162
  expect(cmd?.func).toBeTypeOf('function');
@@ -1,7 +1,7 @@
1
- import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { extractMedia } from './shared.js';
4
- import { applyTopByEngagement } from './utils.js';
3
+ import { extractMedia, normalizeTwitterGraphqlPayload, resolveTwitterOperationMetadata } from './shared.js';
4
+ import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
5
5
 
6
6
  // ── Public-search operator surface ─────────────────────────────────────
7
7
  //
@@ -35,6 +35,68 @@ const PRODUCT_TO_F_PARAM = Object.freeze({
35
35
  videos: 'video',
36
36
  });
37
37
 
38
+ const PRODUCT_TO_GRAPHQL_PRODUCT = Object.freeze({
39
+ top: 'Top',
40
+ live: 'Latest',
41
+ photos: 'Photos',
42
+ videos: 'Videos',
43
+ });
44
+ const MAX_PAGINATION_PAGES = 100;
45
+
46
+ const SEARCH_TIMELINE_OPERATION = {
47
+ queryId: 'VhUd6vHVmLBcw0uX-6jMLA',
48
+ features: {
49
+ rweb_video_screen_enabled: true,
50
+ rweb_cashtags_enabled: true,
51
+ profile_label_improvements_pcf_label_in_post_enabled: true,
52
+ responsive_web_profile_redirect_enabled: true,
53
+ rweb_tipjar_consumption_enabled: true,
54
+ verified_phone_label_enabled: false,
55
+ creator_subscriptions_tweet_preview_api_enabled: true,
56
+ responsive_web_graphql_timeline_navigation_enabled: true,
57
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
58
+ premium_content_api_read_enabled: false,
59
+ communities_web_enable_tweet_community_results_fetch: true,
60
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
61
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
62
+ responsive_web_grok_analyze_post_followups_enabled: true,
63
+ rweb_cashtags_composer_attachment_enabled: true,
64
+ responsive_web_jetfuel_frame: true,
65
+ responsive_web_grok_share_attachment_enabled: true,
66
+ responsive_web_grok_annotations_enabled: true,
67
+ articles_preview_enabled: true,
68
+ responsive_web_edit_tweet_api_enabled: true,
69
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
70
+ view_counts_everywhere_api_enabled: true,
71
+ longform_notetweets_consumption_enabled: true,
72
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
73
+ content_disclosure_indicator_enabled: true,
74
+ content_disclosure_ai_generated_indicator_enabled: true,
75
+ responsive_web_grok_show_grok_translated_post: false,
76
+ responsive_web_grok_analysis_button_from_backend: true,
77
+ post_ctas_fetch_enabled: false,
78
+ freedom_of_speech_not_reach_fetch_enabled: true,
79
+ standardized_nudges_misinfo: true,
80
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
81
+ longform_notetweets_rich_text_read_enabled: true,
82
+ longform_notetweets_inline_media_enabled: true,
83
+ responsive_web_grok_image_annotation_enabled: true,
84
+ responsive_web_grok_imagine_annotation_enabled: true,
85
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
86
+ responsive_web_enhance_cards_enabled: false,
87
+ },
88
+ fieldToggles: {
89
+ withPayments: true,
90
+ withAuxiliaryUserLabels: true,
91
+ withArticleRichContentState: true,
92
+ withArticlePlainText: true,
93
+ withArticleSummaryText: true,
94
+ withArticleVoiceOver: true,
95
+ withGrokAnalyze: true,
96
+ withDisallowedReplyControls: true,
97
+ },
98
+ };
99
+
38
100
  const FROM_USER_PATTERN = /^[A-Za-z0-9_]{1,15}$/;
39
101
 
40
102
  const EXCLUDE_TO_OPERATOR = Object.freeze({
@@ -99,125 +161,96 @@ function resolveSearchFParam(kwargs) {
99
161
  return kwargs.filter === 'live' ? 'live' : 'top';
100
162
  }
101
163
 
102
- /**
103
- * Trigger Twitter search SPA navigation with fallback strategies.
104
- *
105
- * Primary: pushState + popstate (works in most environments).
106
- * Fallback: Type into the search input and press Enter when pushState fails
107
- * intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
108
- *
109
- * Both strategies preserve the JS context so the fetch interceptor stays alive.
110
- *
111
- * @param {object} page
112
- * @param {string} query — final composed query (already merged with operators)
113
- * @param {string} fParam — Twitter URL `f=` value (top|live|image|video)
114
- */
115
- async function navigateToSearch(page, query, fParam) {
116
- const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${fParam}`);
117
- let lastPath = '';
118
- // Strategy 1 (primary): pushState + popstate with retry
119
- for (let attempt = 1; attempt <= 2; attempt++) {
120
- await page.evaluate(`
121
- (() => {
122
- window.history.pushState({}, '', ${searchUrl});
123
- window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
124
- })()
125
- `);
126
- try {
127
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
128
- }
129
- catch {
130
- // selector timeout — fall through to path check or next attempt
131
- }
132
- lastPath = String(await page.evaluate('() => window.location.pathname') || '');
133
- if (lastPath.startsWith('/search')) {
134
- return;
135
- }
136
- if (attempt < 2) {
137
- await page.wait(1);
138
- }
164
+ function resolveSearchProduct(kwargs) {
165
+ const product = kwargs.product || (kwargs.filter === 'live' ? 'live' : 'top');
166
+ return PRODUCT_TO_GRAPHQL_PRODUCT[product] || 'Top';
167
+ }
168
+
169
+ function normalizeOperation(operation) {
170
+ if (typeof operation === 'string') {
171
+ return {
172
+ queryId: operation,
173
+ features: SEARCH_TIMELINE_OPERATION.features,
174
+ fieldToggles: SEARCH_TIMELINE_OPERATION.fieldToggles,
175
+ };
139
176
  }
140
- // Strategy 2 (fallback): Use the search input on /explore.
141
- // The nativeSetter + Enter approach triggers Twitter's own form handler,
142
- // performing SPA navigation without a full page reload.
143
- const queryStr = JSON.stringify(query);
144
- const navResult = await page.evaluate(`(async () => {
145
- try {
146
- const input = document.querySelector('[data-testid="SearchBox_Search_Input"]');
147
- if (!input) return { ok: false };
177
+ return {
178
+ queryId: operation?.queryId || SEARCH_TIMELINE_OPERATION.queryId,
179
+ features: operation?.features || SEARCH_TIMELINE_OPERATION.features,
180
+ fieldToggles: operation?.fieldToggles || SEARCH_TIMELINE_OPERATION.fieldToggles,
181
+ };
182
+ }
148
183
 
149
- input.focus();
150
- await new Promise(r => setTimeout(r, 300));
184
+ function buildSearchTimelineRequest(operation, rawQuery, product, count, cursor) {
185
+ const normalized = normalizeOperation(operation);
186
+ const vars = {
187
+ rawQuery,
188
+ count,
189
+ querySource: 'typed_query',
190
+ product,
191
+ };
192
+ if (cursor) vars.cursor = cursor;
193
+ return [
194
+ `/i/api/graphql/${normalized.queryId}/SearchTimeline`,
195
+ {
196
+ variables: vars,
197
+ features: normalized.features,
198
+ fieldToggles: normalized.fieldToggles,
199
+ },
200
+ ];
201
+ }
151
202
 
152
- const nativeSetter = Object.getOwnPropertyDescriptor(
153
- window.HTMLInputElement.prototype, 'value'
154
- )?.set;
155
- if (!nativeSetter) return { ok: false };
156
- nativeSetter.call(input, ${queryStr});
157
- input.dispatchEvent(new Event('input', { bubbles: true }));
158
- input.dispatchEvent(new Event('change', { bubbles: true }));
159
- await new Promise(r => setTimeout(r, 500));
203
+ function unwrapTweetResult(result) {
204
+ if (!result) return null;
205
+ if (result.__typename === 'TweetWithVisibilityResults' && result.tweet) return result.tweet;
206
+ if (result.tweet) return result.tweet;
207
+ return result;
208
+ }
160
209
 
161
- input.dispatchEvent(new KeyboardEvent('keydown', {
162
- key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
163
- }));
210
+ function tweetToRow(result, seen) {
211
+ const tweet = unwrapTweetResult(result);
212
+ if (!tweet?.rest_id || seen.has(tweet.rest_id)) return null;
213
+ seen.add(tweet.rest_id);
214
+ const tweetUser = tweet.core?.user_results?.result;
215
+ return {
216
+ id: tweet.rest_id,
217
+ author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
218
+ text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
219
+ created_at: tweet.legacy?.created_at || '',
220
+ likes: tweet.legacy?.favorite_count || 0,
221
+ views: tweet.views?.count || '0',
222
+ url: `https://x.com/i/status/${tweet.rest_id}`,
223
+ ...extractMedia(tweet.legacy),
224
+ };
225
+ }
164
226
 
165
- return { ok: true };
166
- } catch {
167
- return { ok: false };
168
- }
169
- })()`);
170
- if (navResult?.ok) {
171
- try {
172
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
227
+ function parseSearchTimeline(data, seen) {
228
+ const rows = [];
229
+ let nextCursor = null;
230
+ const instructions = data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
231
+ const visit = (value) => {
232
+ if (!value || typeof value !== 'object') return;
233
+ if (value.tweet_results?.result) {
234
+ const row = tweetToRow(value.tweet_results.result, seen);
235
+ if (row) rows.push(row);
173
236
  }
174
- catch {
175
- // fall through to path check
237
+ if (
238
+ (value.entryType === 'TimelineTimelineCursor' || value.__typename === 'TimelineTimelineCursor')
239
+ && (value.cursorType === 'Bottom' || value.cursorType === 'ShowMore')
240
+ && value.value
241
+ ) {
242
+ nextCursor = value.value;
176
243
  }
177
- lastPath = String(await page.evaluate('() => window.location.pathname') || '');
178
- if (lastPath.startsWith('/search')) {
179
- // The fallback path doesn't carry the f= URL param, so click the
180
- // matching tab to align with the requested product. Only `live`
181
- // currently surfaces a distinct tab label — `image`/`video` tabs
182
- // also need an explicit click, so try them all.
183
- const tabClicked = await clickProductTabIfNeeded(page, fParam);
184
- if (!tabClicked) {
185
- throw new CommandExecutionError(`SPA fallback reached /search but could not select the requested product tab: ${fParam}`);
186
- }
244
+ if (Array.isArray(value)) {
245
+ for (const item of value) visit(item);
187
246
  return;
188
247
  }
189
- }
190
- throw new CommandExecutionError(`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`);
191
- }
192
-
193
- /**
194
- * After the search-input fallback lands on /search, the f= param is missing
195
- * from the URL. Click the matching tab in the result page header so the
196
- * SearchTimeline call uses the right filter. No-op for fParam=top (default).
197
- */
198
- async function clickProductTabIfNeeded(page, fParam) {
199
- if (fParam === 'top') return true;
200
- const tabLabels = JSON.stringify({
201
- live: ['Latest', '最新'],
202
- image: ['Photos', 'Images', '照片', '图片'],
203
- video: ['Videos', '视频'],
204
- }[fParam] || []);
205
- if (tabLabels === '[]') return true;
206
- const clicked = await page.evaluate(`(() => {
207
- const labels = ${tabLabels};
208
- const tabs = document.querySelectorAll('[role="tab"]');
209
- for (const tab of tabs) {
210
- const txt = (tab.textContent || '').trim();
211
- if (labels.some(l => txt.includes(l))) {
212
- tab.click();
213
- return true;
248
+ for (const child of Object.values(value)) {
249
+ if (child && typeof child === 'object') visit(child);
214
250
  }
215
- }
216
- return false;
217
- })()`);
218
- if (!clicked) return false;
219
- await page.wait(2);
220
- return true;
251
+ };
252
+ visit(instructions);
253
+ return { rows, nextCursor };
221
254
  }
222
255
 
223
256
  cli({
@@ -226,7 +259,7 @@ cli({
226
259
  access: 'read',
227
260
  description: 'Search Twitter/X for tweets, with optional --from / --has / --exclude / --product filters mapped to X\'s search operators',
228
261
  domain: 'x.com',
229
- strategy: Strategy.INTERCEPT, // Use intercept strategy
262
+ strategy: Strategy.COOKIE,
230
263
  browser: true,
231
264
  siteSession: 'persistent',
232
265
  args: [
@@ -248,65 +281,47 @@ cli({
248
281
  if (!Number.isInteger(Number(kwargs.limit)) || Number(kwargs.limit) <= 0) {
249
282
  throw new ArgumentError('twitter search --limit must be a positive integer', 'Example: opencli twitter search opencli --limit 15');
250
283
  }
251
- const fParam = resolveSearchFParam(kwargs);
252
- // 1. Navigate to x.com/explore (has a search input at the top)
253
- await page.goto('https://x.com/explore');
254
- await page.wait(3);
255
- // 2. Install interceptor BEFORE triggering search.
256
- // SPA navigation preserves the JS context, so the monkey-patched
257
- // fetch will capture the SearchTimeline API call.
258
- await page.installInterceptor('SearchTimeline');
259
- // 3. Trigger SPA navigation to search results via history API.
260
- // pushState + popstate triggers React Router's listener without
261
- // a full page reload, so the interceptor stays alive.
262
- // Note: the previous approach (nativeSetter + Enter keydown on the
263
- // search input) does not reliably trigger Twitter's form submission.
264
- await navigateToSearch(page, finalQuery, fParam);
265
- // 4. Scroll to trigger additional pagination
266
- await page.autoScroll({ times: 3, delayMs: 2000 });
267
- // 5. Retrieve captured data
268
- const requests = await page.getInterceptedRequests();
269
- if (!requests || requests.length === 0)
270
- return [];
271
- let results = [];
284
+ const cookies = await page.getCookies({ url: 'https://x.com' });
285
+ const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
286
+ if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
287
+ await page.goto('https://x.com/home', { waitUntil: 'load', settleMs: 1000 });
288
+ const operation = await resolveTwitterOperationMetadata(page, 'SearchTimeline', SEARCH_TIMELINE_OPERATION);
289
+ const headers = JSON.stringify({
290
+ 'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
291
+ 'X-Csrf-Token': ct0,
292
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
293
+ 'X-Twitter-Active-User': 'yes',
294
+ 'Content-Type': 'application/json',
295
+ });
296
+ const product = resolveSearchProduct(kwargs);
297
+ const results = [];
272
298
  const seen = new Set();
273
- for (const req of requests) {
274
- try {
275
- const insts = req?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
276
- const addEntries = insts.find((i) => i.type === 'TimelineAddEntries')
277
- || insts.find((i) => i.entries && Array.isArray(i.entries));
278
- if (!addEntries?.entries)
279
- continue;
280
- for (const entry of addEntries.entries) {
281
- if (!entry.entryId.startsWith('tweet-'))
282
- continue;
283
- let tweet = entry.content?.itemContent?.tweet_results?.result;
284
- if (!tweet)
285
- continue;
286
- // Handle retweet wrapping
287
- if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
288
- tweet = tweet.tweet;
289
- }
290
- if (!tweet.rest_id || seen.has(tweet.rest_id))
291
- continue;
292
- seen.add(tweet.rest_id);
293
- // Twitter moved screen_name from legacy to core
294
- const tweetUser = tweet.core?.user_results?.result;
295
- results.push({
296
- id: tweet.rest_id,
297
- author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
298
- text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
299
- created_at: tweet.legacy?.created_at || '',
300
- likes: tweet.legacy?.favorite_count || 0,
301
- views: tweet.views?.count || '0',
302
- url: `https://x.com/i/status/${tweet.rest_id}`,
303
- ...extractMedia(tweet.legacy),
304
- });
305
- }
306
- }
307
- catch (e) {
308
- // ignore parsing errors for individual payloads
299
+ let cursor = null;
300
+ // Runaway guard only; --limit and cursor exhaustion control normal pagination.
301
+ for (let i = 0; i < MAX_PAGINATION_PAGES && results.length < kwargs.limit; i++) {
302
+ const fetchCount = Number(kwargs.limit) - results.length + 10;
303
+ const [requestUrl, requestPayload] = buildSearchTimelineRequest(operation, finalQuery, product, fetchCount, cursor);
304
+ const requestBody = JSON.stringify(requestPayload);
305
+ const data = normalizeTwitterGraphqlPayload(await page.evaluate(`async () => {
306
+ const options = {
307
+ method: 'POST',
308
+ headers: ${headers},
309
+ credentials: 'include',
310
+ };
311
+ options['body'] = ${JSON.stringify(requestBody)};
312
+ const r = await fetch(${JSON.stringify(requestUrl)}, {
313
+ ...options,
314
+ });
315
+ return r.ok ? await r.json() : { error: r.status };
316
+ }`));
317
+ if (data?.error) {
318
+ if (results.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: SearchTimeline fetch failed — queryId may have expired`);
319
+ break;
309
320
  }
321
+ const { rows, nextCursor } = parseSearchTimeline(data, seen);
322
+ results.push(...rows);
323
+ if (!nextCursor || nextCursor === cursor) break;
324
+ cursor = nextCursor;
310
325
  }
311
326
  const trimmed = results.slice(0, kwargs.limit);
312
327
  return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
@@ -316,6 +331,9 @@ cli({
316
331
  export const __test__ = {
317
332
  buildSearchQuery,
318
333
  resolveSearchFParam,
334
+ resolveSearchProduct,
335
+ buildSearchTimelineRequest,
336
+ parseSearchTimeline,
319
337
  HAS_CHOICES,
320
338
  EXCLUDE_CHOICES,
321
339
  PRODUCT_CHOICES,