@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.
- package/README.md +7 -8
- package/README.zh-CN.md +7 -8
- package/cli-manifest.json +305 -9
- package/clis/ctrip/ctrip.test.js +486 -1
- package/clis/ctrip/flight.js +136 -0
- package/clis/ctrip/hotel-search.js +132 -0
- package/clis/ctrip/utils.js +298 -0
- package/clis/google/search.js +16 -6
- package/clis/google-scholar/search.js +20 -5
- package/clis/google-scholar/search.test.js +35 -2
- package/clis/reddit/home.js +117 -0
- package/clis/reddit/home.test.js +127 -0
- package/clis/reddit/read.js +400 -54
- package/clis/reddit/read.test.js +315 -12
- package/clis/reddit/subreddit-info.js +117 -0
- package/clis/reddit/subreddit-info.test.js +163 -0
- package/clis/reddit/whoami.js +84 -0
- package/clis/reddit/whoami.test.js +105 -0
- package/clis/rednote/search.js +6 -2
- package/clis/twitter/bookmark-folder.js +3 -1
- package/clis/twitter/bookmarks.js +3 -1
- package/clis/twitter/followers.js +20 -5
- package/clis/twitter/followers.test.js +44 -0
- package/clis/twitter/following.js +36 -20
- package/clis/twitter/following.test.js +60 -8
- package/clis/twitter/likes.js +28 -13
- package/clis/twitter/likes.test.js +111 -1
- package/clis/twitter/list-add.js +128 -204
- package/clis/twitter/list-add.test.js +97 -1
- package/clis/twitter/list-tweets.js +13 -4
- package/clis/twitter/list-tweets.test.js +48 -0
- package/clis/twitter/lists.js +5 -2
- package/clis/twitter/post.js +23 -4
- package/clis/twitter/post.test.js +30 -0
- package/clis/twitter/profile.js +16 -8
- package/clis/twitter/profile.test.js +39 -0
- package/clis/twitter/reply.js +133 -10
- package/clis/twitter/reply.test.js +55 -0
- package/clis/twitter/search.js +188 -170
- package/clis/twitter/search.test.js +96 -258
- package/clis/twitter/shared.js +167 -16
- package/clis/twitter/shared.test.js +102 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/tweets.js +147 -51
- package/clis/twitter/tweets.test.js +238 -1
- package/clis/xiaohongshu/comments.js +23 -2
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/search.js +168 -13
- package/clis/xiaohongshu/search.test.js +82 -8
- package/clis/xueqiu/earnings-date.js +2 -2
- package/clis/xueqiu/kline.js +2 -2
- package/clis/xueqiu/utils.js +19 -0
- package/clis/xueqiu/utils.test.js +26 -0
- package/clis/zhihu/answer-detail.js +233 -0
- package/clis/zhihu/answer-detail.test.js +330 -0
- package/clis/zhihu/question.js +44 -10
- package/clis/zhihu/question.test.js +78 -1
- package/clis/zhihu/recommend.js +103 -0
- package/clis/zhihu/recommend.test.js +143 -0
- package/dist/src/browser/base-page.d.ts +3 -2
- package/dist/src/browser/base-page.test.js +2 -2
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/page.d.ts +3 -2
- package/dist/src/browser/page.js +4 -4
- package/dist/src/browser/page.test.js +31 -0
- package/dist/src/browser/utils.d.ts +10 -0
- package/dist/src/browser/utils.js +37 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/cli-argv-preprocess.d.ts +37 -0
- package/dist/src/cli-argv-preprocess.js +131 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +130 -0
- package/dist/src/cli.js +123 -86
- package/dist/src/cli.test.js +33 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +15 -16
- package/dist/src/download/progress.js +15 -11
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +25 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/help.d.ts +11 -0
- package/dist/src/help.js +46 -5
- package/dist/src/logger.js +8 -9
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +4 -5
- package/dist/src/runtime-detect.d.ts +1 -1
- package/dist/src/runtime-detect.js +1 -1
- package/dist/src/runtime-detect.test.js +3 -2
- package/dist/src/tui.d.ts +0 -1
- package/dist/src/tui.js +9 -22
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.js +4 -5
- 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');
|
package/clis/twitter/search.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await page.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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,
|