@jackwener/opencli 1.7.13 → 1.7.15
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/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './retweet.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter retweet command', () => {
|
|
8
|
+
it('clicks the retweet button then the confirm menu item and reports success', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully retweeted.' },
|
|
13
|
+
]);
|
|
14
|
+
const result = await cmd.func(page, {
|
|
15
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
16
|
+
});
|
|
17
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
|
|
18
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
19
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
20
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
21
|
+
// Two-step UI flow must be present:
|
|
22
|
+
// 1) click the retweet button
|
|
23
|
+
// 2) wait for and click the confirm menu item (data-testid="retweetConfirm")
|
|
24
|
+
expect(script).toContain('retweetBtn.click()');
|
|
25
|
+
expect(script).toContain("document.querySelector('[data-testid=\"retweetConfirm\"]')");
|
|
26
|
+
expect(script).toContain('confirmBtn.click()');
|
|
27
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
28
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
29
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
30
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
31
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
32
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
33
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"retweet\"]')");
|
|
34
|
+
// Idempotency probe: when already retweeted ([data-testid="unretweet"] present),
|
|
35
|
+
// the script returns ok:true with an "already retweeted" message.
|
|
36
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
|
|
37
|
+
expect(result).toEqual([
|
|
38
|
+
{ status: 'success', message: 'Tweet successfully retweeted.' },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns a failed row when the confirm menu item never appears', async () => {
|
|
43
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
44
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
45
|
+
const page = createPageMock([
|
|
46
|
+
{ ok: false, message: 'Retweet menu opened but the confirm option did not appear.' },
|
|
47
|
+
]);
|
|
48
|
+
const result = await cmd.func(page, {
|
|
49
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
50
|
+
});
|
|
51
|
+
expect(result).toEqual([
|
|
52
|
+
{ status: 'failed', message: 'Retweet menu opened but the confirm option did not appear.' },
|
|
53
|
+
]);
|
|
54
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
58
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
59
|
+
await expect(cmd.func(undefined, {
|
|
60
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
61
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
65
|
+
const cmd = getRegistry().get('twitter/retweet');
|
|
66
|
+
const page = createPageMock([]);
|
|
67
|
+
await expect(cmd.func(page, {
|
|
68
|
+
url: 'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
69
|
+
})).rejects.toThrow(ArgumentError);
|
|
70
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
71
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
package/clis/twitter/search.js
CHANGED
|
@@ -1,6 +1,104 @@
|
|
|
1
|
-
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
1
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { extractMedia } from './shared.js';
|
|
4
|
+
import { applyTopByEngagement } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// ── Public-search operator surface ─────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// X's web search supports a small set of inline operators (from:, filter:,
|
|
9
|
+
// -filter:, etc.) plus a tab-selector URL param `f=`. We expose the most
|
|
10
|
+
// useful subset as flags so callers don't have to memorise the operator
|
|
11
|
+
// strings, while still letting power users append raw operators in <query>.
|
|
12
|
+
|
|
13
|
+
/** Operands accepted by `--has`. Map 1:1 to Twitter's `filter:<x>` operator. */
|
|
14
|
+
const HAS_CHOICES = Object.freeze(['media', 'images', 'videos', 'links', 'replies']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Operands accepted by `--exclude`. Note that `retweets` is exposed as the
|
|
18
|
+
* friendlier name but X's actual operator stays as `-filter:nativeretweets`
|
|
19
|
+
* (the historical "native" prefix is preserved by their backend).
|
|
20
|
+
*/
|
|
21
|
+
const EXCLUDE_CHOICES = Object.freeze(['replies', 'retweets', 'media', 'links']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Operands accepted by `--product`. `photos`/`videos` are the human-friendly
|
|
25
|
+
* forms used by the X UI tabs; the URL param uses the singular forms (image,
|
|
26
|
+
* video). `people` is intentionally NOT supported here because that tab
|
|
27
|
+
* returns User objects, not tweets, and would need a different output schema.
|
|
28
|
+
*/
|
|
29
|
+
const PRODUCT_CHOICES = Object.freeze(['top', 'live', 'photos', 'videos']);
|
|
30
|
+
|
|
31
|
+
const PRODUCT_TO_F_PARAM = Object.freeze({
|
|
32
|
+
top: 'top',
|
|
33
|
+
live: 'live',
|
|
34
|
+
photos: 'image',
|
|
35
|
+
videos: 'video',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const FROM_USER_PATTERN = /^[A-Za-z0-9_]{1,15}$/;
|
|
39
|
+
|
|
40
|
+
const EXCLUDE_TO_OPERATOR = Object.freeze({
|
|
41
|
+
replies: '-filter:replies',
|
|
42
|
+
// `retweets` is a CLI-friendly alias for X's actual `-filter:nativeretweets`.
|
|
43
|
+
retweets: '-filter:nativeretweets',
|
|
44
|
+
media: '-filter:media',
|
|
45
|
+
links: '-filter:links',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compose the final search query string by appending operator clauses for
|
|
50
|
+
* --from / --has / --exclude. Pure synchronous — exported via __test__ for
|
|
51
|
+
* unit coverage.
|
|
52
|
+
*
|
|
53
|
+
* Behaviour notes:
|
|
54
|
+
* - Trims leading `@` from --from so callers can pass `@alice` or `alice`.
|
|
55
|
+
* - Order is `<query> from:X filter:Y -filter:Z` (matches what X's own search
|
|
56
|
+
* bar emits when you click the suggestions UI).
|
|
57
|
+
* - Empty <query> with non-empty filters is allowed — the resulting string
|
|
58
|
+
* is just the operator clauses joined; X handles that fine.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} rawQuery
|
|
61
|
+
* @param {{ from?: string, has?: string, exclude?: string }} kwargs
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
function buildSearchQuery(rawQuery, kwargs) {
|
|
65
|
+
const parts = [String(rawQuery ?? '').trim()];
|
|
66
|
+
if (kwargs.from) {
|
|
67
|
+
const fromUser = String(kwargs.from).trim().replace(/^@+/, '');
|
|
68
|
+
if (fromUser && !FROM_USER_PATTERN.test(fromUser)) {
|
|
69
|
+
throw new ArgumentError(
|
|
70
|
+
`Invalid --from username: ${JSON.stringify(kwargs.from)}`,
|
|
71
|
+
'Use a Twitter/X handle with 1-15 letters, numbers, or underscores; omit @ or pass @handle.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (fromUser) parts.push(`from:${fromUser}`);
|
|
75
|
+
}
|
|
76
|
+
if (kwargs.has) {
|
|
77
|
+
parts.push(`filter:${kwargs.has}`);
|
|
78
|
+
}
|
|
79
|
+
if (kwargs.exclude) {
|
|
80
|
+
const op = EXCLUDE_TO_OPERATOR[kwargs.exclude];
|
|
81
|
+
if (op) parts.push(op);
|
|
82
|
+
}
|
|
83
|
+
return parts.filter(Boolean).join(' ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve which X search tab (`f=` URL param) to land on. `--product` wins
|
|
88
|
+
* over the legacy `--filter` so adding `--product` doesn't break callers that
|
|
89
|
+
* were already setting `--filter top|live`.
|
|
90
|
+
*
|
|
91
|
+
* @param {{ product?: string, filter?: string }} kwargs
|
|
92
|
+
* @returns {string} URL `f=` value: top|live|image|video
|
|
93
|
+
*/
|
|
94
|
+
function resolveSearchFParam(kwargs) {
|
|
95
|
+
if (kwargs.product) {
|
|
96
|
+
const mapped = PRODUCT_TO_F_PARAM[kwargs.product];
|
|
97
|
+
if (mapped) return mapped;
|
|
98
|
+
}
|
|
99
|
+
return kwargs.filter === 'live' ? 'live' : 'top';
|
|
100
|
+
}
|
|
101
|
+
|
|
4
102
|
/**
|
|
5
103
|
* Trigger Twitter search SPA navigation with fallback strategies.
|
|
6
104
|
*
|
|
@@ -9,9 +107,13 @@ import { extractMedia } from './shared.js';
|
|
|
9
107
|
* intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
|
|
10
108
|
*
|
|
11
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)
|
|
12
114
|
*/
|
|
13
|
-
async function navigateToSearch(page, query,
|
|
14
|
-
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${
|
|
115
|
+
async function navigateToSearch(page, query, fParam) {
|
|
116
|
+
const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${fParam}`);
|
|
15
117
|
let lastPath = '';
|
|
16
118
|
// Strategy 1 (primary): pushState + popstate with retry
|
|
17
119
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
@@ -74,40 +176,78 @@ async function navigateToSearch(page, query, filter) {
|
|
|
74
176
|
}
|
|
75
177
|
lastPath = String(await page.evaluate('() => window.location.pathname') || '');
|
|
76
178
|
if (lastPath.startsWith('/search')) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
})()`);
|
|
87
|
-
await page.wait(2);
|
|
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}`);
|
|
88
186
|
}
|
|
89
187
|
return;
|
|
90
188
|
}
|
|
91
189
|
}
|
|
92
190
|
throw new CommandExecutionError(`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`);
|
|
93
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;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
})()`);
|
|
218
|
+
if (!clicked) return false;
|
|
219
|
+
await page.wait(2);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
94
223
|
cli({
|
|
95
224
|
site: 'twitter',
|
|
96
225
|
name: 'search',
|
|
97
226
|
access: 'read',
|
|
98
|
-
description: 'Search Twitter/X for tweets',
|
|
227
|
+
description: 'Search Twitter/X for tweets, with optional --from / --has / --exclude / --product filters mapped to X\'s search operators',
|
|
99
228
|
domain: 'x.com',
|
|
100
229
|
strategy: Strategy.INTERCEPT, // Use intercept strategy
|
|
101
230
|
browser: true,
|
|
102
231
|
args: [
|
|
103
|
-
{ name: 'query', type: 'string', required: true, positional: true },
|
|
104
|
-
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'] },
|
|
105
|
-
{ name: '
|
|
232
|
+
{ name: 'query', type: 'string', required: true, positional: true, help: 'Search query. Raw X operators (e.g. "exact phrase", #tag, OR, lang:en, since:YYYY-MM-DD, from:, since:) are passed through unchanged.' },
|
|
233
|
+
{ name: 'filter', type: 'string', default: 'top', choices: ['top', 'live'], help: 'Legacy alias for --product. Kept for backwards compatibility; if --product is set it wins.' },
|
|
234
|
+
{ name: 'product', type: 'string', choices: PRODUCT_CHOICES, help: 'Which X search tab to read: top (default), live (Latest), photos, videos. Maps to the f= URL param.' },
|
|
235
|
+
{ name: 'from', type: 'string', help: 'Restrict to tweets authored by <user>. Leading @ is stripped. Equivalent to appending `from:<user>` to the query.' },
|
|
236
|
+
{ name: 'has', type: 'string', choices: HAS_CHOICES, help: 'Restrict to tweets that have media|images|videos|links|replies. Maps to X\'s `filter:<has>` operator.' },
|
|
237
|
+
{ name: 'exclude', type: 'string', choices: EXCLUDE_CHOICES, help: 'Exclude tweets matching <type>: replies|retweets|media|links. Maps to X\'s `-filter:<x>` operator (retweets → -filter:nativeretweets).' },
|
|
238
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Maximum number of tweets to return (default 15). Result count after server-side filtering.' },
|
|
239
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the results by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps X\'s native ordering.' },
|
|
106
240
|
],
|
|
107
241
|
columns: ['id', 'author', 'text', 'created_at', 'likes', 'views', 'url', 'has_media', 'media_urls'],
|
|
108
242
|
func: async (page, kwargs) => {
|
|
109
|
-
const
|
|
110
|
-
|
|
243
|
+
const finalQuery = buildSearchQuery(kwargs.query, kwargs);
|
|
244
|
+
if (!finalQuery) {
|
|
245
|
+
throw new ArgumentError('twitter search query is empty', 'Provide a non-empty <query>, or use at least one of --from / --has / --exclude.');
|
|
246
|
+
}
|
|
247
|
+
if (!Number.isInteger(Number(kwargs.limit)) || Number(kwargs.limit) <= 0) {
|
|
248
|
+
throw new ArgumentError('twitter search --limit must be a positive integer', 'Example: opencli twitter search opencli --limit 15');
|
|
249
|
+
}
|
|
250
|
+
const fParam = resolveSearchFParam(kwargs);
|
|
111
251
|
// 1. Navigate to x.com/explore (has a search input at the top)
|
|
112
252
|
await page.goto('https://x.com/explore');
|
|
113
253
|
await page.wait(3);
|
|
@@ -120,10 +260,10 @@ cli({
|
|
|
120
260
|
// a full page reload, so the interceptor stays alive.
|
|
121
261
|
// Note: the previous approach (nativeSetter + Enter keydown on the
|
|
122
262
|
// search input) does not reliably trigger Twitter's form submission.
|
|
123
|
-
await navigateToSearch(page,
|
|
263
|
+
await navigateToSearch(page, finalQuery, fParam);
|
|
124
264
|
// 4. Scroll to trigger additional pagination
|
|
125
265
|
await page.autoScroll({ times: 3, delayMs: 2000 });
|
|
126
|
-
//
|
|
266
|
+
// 5. Retrieve captured data
|
|
127
267
|
const requests = await page.getInterceptedRequests();
|
|
128
268
|
if (!requests || requests.length === 0)
|
|
129
269
|
return [];
|
|
@@ -167,6 +307,18 @@ cli({
|
|
|
167
307
|
// ignore parsing errors for individual payloads
|
|
168
308
|
}
|
|
169
309
|
}
|
|
170
|
-
|
|
310
|
+
const trimmed = results.slice(0, kwargs.limit);
|
|
311
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
171
312
|
}
|
|
172
313
|
});
|
|
314
|
+
|
|
315
|
+
export const __test__ = {
|
|
316
|
+
buildSearchQuery,
|
|
317
|
+
resolveSearchFParam,
|
|
318
|
+
HAS_CHOICES,
|
|
319
|
+
EXCLUDE_CHOICES,
|
|
320
|
+
PRODUCT_CHOICES,
|
|
321
|
+
EXCLUDE_TO_OPERATOR,
|
|
322
|
+
PRODUCT_TO_F_PARAM,
|
|
323
|
+
FROM_USER_PATTERN,
|
|
324
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
-
import './search.js';
|
|
3
|
+
import { __test__ } from './search.js';
|
|
4
|
+
|
|
5
|
+
const { buildSearchQuery, resolveSearchFParam, HAS_CHOICES, EXCLUDE_CHOICES, PRODUCT_CHOICES, EXCLUDE_TO_OPERATOR, PRODUCT_TO_F_PARAM, FROM_USER_PATTERN } = __test__;
|
|
4
6
|
describe('twitter search command', () => {
|
|
5
7
|
it('retries transient SPA navigation failures before giving up', async () => {
|
|
6
8
|
const command = getRegistry().get('twitter/search');
|
|
@@ -213,6 +215,56 @@ describe('twitter search command', () => {
|
|
|
213
215
|
expect(evaluate).toHaveBeenCalledTimes(6);
|
|
214
216
|
expect(page.autoScroll).toHaveBeenCalled();
|
|
215
217
|
});
|
|
218
|
+
it('clicks the requested product tab after fallback navigation when f= param is absent', async () => {
|
|
219
|
+
const command = getRegistry().get('twitter/search');
|
|
220
|
+
expect(command?.func).toBeTypeOf('function');
|
|
221
|
+
const evaluate = vi.fn()
|
|
222
|
+
.mockResolvedValueOnce(undefined) // pushState attempt 1
|
|
223
|
+
.mockResolvedValueOnce('/explore')
|
|
224
|
+
.mockResolvedValueOnce(undefined) // pushState attempt 2
|
|
225
|
+
.mockResolvedValueOnce('/explore')
|
|
226
|
+
.mockResolvedValueOnce({ ok: true }) // search input fallback
|
|
227
|
+
.mockResolvedValueOnce('/search')
|
|
228
|
+
.mockResolvedValueOnce(true); // product tab click
|
|
229
|
+
const page = {
|
|
230
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
231
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
232
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
233
|
+
evaluate,
|
|
234
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
235
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
236
|
+
};
|
|
237
|
+
const result = await command.func(page, { query: 'cats', product: 'photos', limit: 5 });
|
|
238
|
+
expect(result).toEqual([]);
|
|
239
|
+
expect(evaluate).toHaveBeenCalledTimes(7);
|
|
240
|
+
expect(evaluate.mock.calls[6][0]).toContain('Photos');
|
|
241
|
+
expect(page.autoScroll).toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
it('throws when fallback navigation cannot select the requested product tab', async () => {
|
|
244
|
+
const command = getRegistry().get('twitter/search');
|
|
245
|
+
expect(command?.func).toBeTypeOf('function');
|
|
246
|
+
const evaluate = vi.fn()
|
|
247
|
+
.mockResolvedValueOnce(undefined) // pushState attempt 1
|
|
248
|
+
.mockResolvedValueOnce('/explore')
|
|
249
|
+
.mockResolvedValueOnce(undefined) // pushState attempt 2
|
|
250
|
+
.mockResolvedValueOnce('/explore')
|
|
251
|
+
.mockResolvedValueOnce({ ok: true }) // search input fallback
|
|
252
|
+
.mockResolvedValueOnce('/search')
|
|
253
|
+
.mockResolvedValueOnce(false); // requested tab missing
|
|
254
|
+
const page = {
|
|
255
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
256
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
257
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
258
|
+
evaluate,
|
|
259
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
260
|
+
getInterceptedRequests: vi.fn(),
|
|
261
|
+
};
|
|
262
|
+
await expect(command.func(page, { query: 'cats', product: 'videos', limit: 5 }))
|
|
263
|
+
.rejects
|
|
264
|
+
.toThrow(/could not select the requested product tab: video/);
|
|
265
|
+
expect(page.autoScroll).not.toHaveBeenCalled();
|
|
266
|
+
expect(page.getInterceptedRequests).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
216
268
|
it('throws with the final path after both attempts fail', async () => {
|
|
217
269
|
const command = getRegistry().get('twitter/search');
|
|
218
270
|
expect(command?.func).toBeTypeOf('function');
|
|
@@ -238,3 +290,216 @@ describe('twitter search command', () => {
|
|
|
238
290
|
expect(evaluate).toHaveBeenCalledTimes(5);
|
|
239
291
|
});
|
|
240
292
|
});
|
|
293
|
+
|
|
294
|
+
describe('twitter search filter helpers', () => {
|
|
295
|
+
describe('buildSearchQuery', () => {
|
|
296
|
+
it('returns the trimmed raw query when no filters are set', () => {
|
|
297
|
+
expect(buildSearchQuery(' hello world ', {})).toBe('hello world');
|
|
298
|
+
});
|
|
299
|
+
it('appends from: with leading @ stripped', () => {
|
|
300
|
+
expect(buildSearchQuery('hello', { from: '@alice' })).toBe('hello from:alice');
|
|
301
|
+
});
|
|
302
|
+
it('preserves from: when caller passes a bare username', () => {
|
|
303
|
+
expect(buildSearchQuery('hello', { from: 'alice' })).toBe('hello from:alice');
|
|
304
|
+
});
|
|
305
|
+
it('strips multiple leading @ characters from --from', () => {
|
|
306
|
+
expect(buildSearchQuery('hi', { from: '@@bob' })).toBe('hi from:bob');
|
|
307
|
+
});
|
|
308
|
+
it('drops --from when it is whitespace-only', () => {
|
|
309
|
+
expect(buildSearchQuery('hi', { from: ' ' })).toBe('hi');
|
|
310
|
+
});
|
|
311
|
+
it('rejects invalid --from usernames instead of injecting raw operators', () => {
|
|
312
|
+
expect(() => buildSearchQuery('hi', { from: 'alice filter:links' })).toThrow(/Invalid --from/);
|
|
313
|
+
expect(() => buildSearchQuery('hi', { from: 'alice/bob' })).toThrow(/Invalid --from/);
|
|
314
|
+
expect(() => buildSearchQuery('hi', { from: '@' + 'a'.repeat(16) })).toThrow(/Invalid --from/);
|
|
315
|
+
});
|
|
316
|
+
it('appends filter:<has> for --has', () => {
|
|
317
|
+
expect(buildSearchQuery('q', { has: 'images' })).toBe('q filter:images');
|
|
318
|
+
});
|
|
319
|
+
it('maps --exclude retweets to -filter:nativeretweets', () => {
|
|
320
|
+
expect(buildSearchQuery('q', { exclude: 'retweets' })).toBe('q -filter:nativeretweets');
|
|
321
|
+
});
|
|
322
|
+
it('maps --exclude replies/media/links to their -filter operators', () => {
|
|
323
|
+
expect(buildSearchQuery('q', { exclude: 'replies' })).toBe('q -filter:replies');
|
|
324
|
+
expect(buildSearchQuery('q', { exclude: 'media' })).toBe('q -filter:media');
|
|
325
|
+
expect(buildSearchQuery('q', { exclude: 'links' })).toBe('q -filter:links');
|
|
326
|
+
});
|
|
327
|
+
it('silently ignores unknown --exclude values', () => {
|
|
328
|
+
// choices: ['replies','retweets','media','links'] — unknowns shouldn't appear
|
|
329
|
+
// in real CLI use because the validator rejects them, but the helper still
|
|
330
|
+
// guards via the EXCLUDE_TO_OPERATOR map lookup.
|
|
331
|
+
expect(buildSearchQuery('q', { exclude: 'bogus' })).toBe('q');
|
|
332
|
+
});
|
|
333
|
+
it('composes multiple filter clauses in stable order: query → from → has → exclude', () => {
|
|
334
|
+
expect(buildSearchQuery('hot take', {
|
|
335
|
+
from: '@alice',
|
|
336
|
+
has: 'media',
|
|
337
|
+
exclude: 'retweets',
|
|
338
|
+
})).toBe('hot take from:alice filter:media -filter:nativeretweets');
|
|
339
|
+
});
|
|
340
|
+
it('allows an empty raw query when filters are present', () => {
|
|
341
|
+
expect(buildSearchQuery('', { from: 'alice' })).toBe('from:alice');
|
|
342
|
+
});
|
|
343
|
+
it('returns empty string when nothing useful is supplied', () => {
|
|
344
|
+
expect(buildSearchQuery('', {})).toBe('');
|
|
345
|
+
expect(buildSearchQuery(' ', {})).toBe('');
|
|
346
|
+
});
|
|
347
|
+
it('coerces nullish raw query into empty string', () => {
|
|
348
|
+
expect(buildSearchQuery(null, { from: 'alice' })).toBe('from:alice');
|
|
349
|
+
expect(buildSearchQuery(undefined, { from: 'alice' })).toBe('from:alice');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('resolveSearchFParam', () => {
|
|
354
|
+
it('defaults to top when neither product nor filter is set', () => {
|
|
355
|
+
expect(resolveSearchFParam({})).toBe('top');
|
|
356
|
+
});
|
|
357
|
+
it('returns top when filter=top', () => {
|
|
358
|
+
expect(resolveSearchFParam({ filter: 'top' })).toBe('top');
|
|
359
|
+
});
|
|
360
|
+
it('returns live when filter=live', () => {
|
|
361
|
+
expect(resolveSearchFParam({ filter: 'live' })).toBe('live');
|
|
362
|
+
});
|
|
363
|
+
it('maps --product photos to image (Twitter URL singular form)', () => {
|
|
364
|
+
expect(resolveSearchFParam({ product: 'photos' })).toBe('image');
|
|
365
|
+
});
|
|
366
|
+
it('maps --product videos to video (Twitter URL singular form)', () => {
|
|
367
|
+
expect(resolveSearchFParam({ product: 'videos' })).toBe('video');
|
|
368
|
+
});
|
|
369
|
+
it('maps --product top|live straight through', () => {
|
|
370
|
+
expect(resolveSearchFParam({ product: 'top' })).toBe('top');
|
|
371
|
+
expect(resolveSearchFParam({ product: 'live' })).toBe('live');
|
|
372
|
+
});
|
|
373
|
+
it('lets --product win when both --product and --filter are set', () => {
|
|
374
|
+
expect(resolveSearchFParam({ product: 'photos', filter: 'live' })).toBe('image');
|
|
375
|
+
expect(resolveSearchFParam({ product: 'top', filter: 'live' })).toBe('top');
|
|
376
|
+
});
|
|
377
|
+
it('falls back to filter when --product is unknown', () => {
|
|
378
|
+
// unknowns are blocked at the CLI validator layer; this is just defence
|
|
379
|
+
expect(resolveSearchFParam({ product: 'bogus', filter: 'live' })).toBe('live');
|
|
380
|
+
expect(resolveSearchFParam({ product: 'bogus' })).toBe('top');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
describe('choice surface', () => {
|
|
385
|
+
it('exposes the documented HAS_CHOICES set', () => {
|
|
386
|
+
expect(HAS_CHOICES).toEqual(['media', 'images', 'videos', 'links', 'replies']);
|
|
387
|
+
});
|
|
388
|
+
it('exposes the documented EXCLUDE_CHOICES set', () => {
|
|
389
|
+
expect(EXCLUDE_CHOICES).toEqual(['replies', 'retweets', 'media', 'links']);
|
|
390
|
+
});
|
|
391
|
+
it('exposes the documented PRODUCT_CHOICES set', () => {
|
|
392
|
+
expect(PRODUCT_CHOICES).toEqual(['top', 'live', 'photos', 'videos']);
|
|
393
|
+
});
|
|
394
|
+
it('keeps PRODUCT_TO_F_PARAM domain a strict subset of PRODUCT_CHOICES', () => {
|
|
395
|
+
for (const choice of PRODUCT_CHOICES) {
|
|
396
|
+
expect(PRODUCT_TO_F_PARAM[choice]).toBeTypeOf('string');
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
it('keeps EXCLUDE_TO_OPERATOR domain a strict subset of EXCLUDE_CHOICES', () => {
|
|
400
|
+
for (const choice of EXCLUDE_CHOICES) {
|
|
401
|
+
expect(EXCLUDE_TO_OPERATOR[choice]).toMatch(/^-filter:/);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
it('keeps FROM_USER_PATTERN aligned with X handle syntax', () => {
|
|
405
|
+
expect(FROM_USER_PATTERN.test('alice_123')).toBe(true);
|
|
406
|
+
expect(FROM_USER_PATTERN.test('a'.repeat(15))).toBe(true);
|
|
407
|
+
expect(FROM_USER_PATTERN.test('a'.repeat(16))).toBe(false);
|
|
408
|
+
expect(FROM_USER_PATTERN.test('alice/bob')).toBe(false);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('twitter search end-to-end with new filters', () => {
|
|
414
|
+
it('encodes the composed query and product=live into the f= URL param', async () => {
|
|
415
|
+
const command = getRegistry().get('twitter/search');
|
|
416
|
+
const evaluate = vi.fn()
|
|
417
|
+
.mockResolvedValueOnce(undefined)
|
|
418
|
+
.mockResolvedValueOnce('/search');
|
|
419
|
+
const page = {
|
|
420
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
421
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
422
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
423
|
+
evaluate,
|
|
424
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
425
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
426
|
+
};
|
|
427
|
+
await command.func(page, {
|
|
428
|
+
query: 'breaking news',
|
|
429
|
+
from: '@alice',
|
|
430
|
+
has: 'images',
|
|
431
|
+
exclude: 'retweets',
|
|
432
|
+
product: 'live',
|
|
433
|
+
limit: 5,
|
|
434
|
+
});
|
|
435
|
+
const pushStateCall = evaluate.mock.calls[0][0];
|
|
436
|
+
// f=live wins because --product=live trumps the default --filter
|
|
437
|
+
expect(pushStateCall).toContain('f=live');
|
|
438
|
+
// composed query should be percent-encoded inside the URL
|
|
439
|
+
const encoded = encodeURIComponent('breaking news from:alice filter:images -filter:nativeretweets');
|
|
440
|
+
expect(pushStateCall).toContain(encoded);
|
|
441
|
+
});
|
|
442
|
+
it('throws ArgumentError when query and all filters are empty', async () => {
|
|
443
|
+
const command = getRegistry().get('twitter/search');
|
|
444
|
+
const page = {
|
|
445
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
446
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
447
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
448
|
+
evaluate: vi.fn(),
|
|
449
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
450
|
+
getInterceptedRequests: vi.fn(),
|
|
451
|
+
};
|
|
452
|
+
await expect(command.func(page, { query: ' ', limit: 5 }))
|
|
453
|
+
.rejects
|
|
454
|
+
.toThrow(/empty/i);
|
|
455
|
+
expect(page.installInterceptor).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
it('throws ArgumentError for invalid --from before navigation', async () => {
|
|
458
|
+
const command = getRegistry().get('twitter/search');
|
|
459
|
+
const page = {
|
|
460
|
+
goto: vi.fn(),
|
|
461
|
+
wait: vi.fn(),
|
|
462
|
+
installInterceptor: vi.fn(),
|
|
463
|
+
evaluate: vi.fn(),
|
|
464
|
+
autoScroll: vi.fn(),
|
|
465
|
+
getInterceptedRequests: vi.fn(),
|
|
466
|
+
};
|
|
467
|
+
await expect(command.func(page, { query: 'hi', from: 'alice filter:links', limit: 5 }))
|
|
468
|
+
.rejects
|
|
469
|
+
.toThrow(/Invalid --from/);
|
|
470
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
it('throws ArgumentError for invalid --limit before navigation', async () => {
|
|
473
|
+
const command = getRegistry().get('twitter/search');
|
|
474
|
+
const page = {
|
|
475
|
+
goto: vi.fn(),
|
|
476
|
+
wait: vi.fn(),
|
|
477
|
+
installInterceptor: vi.fn(),
|
|
478
|
+
evaluate: vi.fn(),
|
|
479
|
+
autoScroll: vi.fn(),
|
|
480
|
+
getInterceptedRequests: vi.fn(),
|
|
481
|
+
};
|
|
482
|
+
await expect(command.func(page, { query: 'hi', limit: 0 }))
|
|
483
|
+
.rejects
|
|
484
|
+
.toThrow(/--limit/);
|
|
485
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
486
|
+
});
|
|
487
|
+
it('runs with only filters set (empty <query>)', async () => {
|
|
488
|
+
const command = getRegistry().get('twitter/search');
|
|
489
|
+
const evaluate = vi.fn()
|
|
490
|
+
.mockResolvedValueOnce(undefined)
|
|
491
|
+
.mockResolvedValueOnce('/search');
|
|
492
|
+
const page = {
|
|
493
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
494
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
495
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
496
|
+
evaluate,
|
|
497
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
498
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
499
|
+
};
|
|
500
|
+
const result = await command.func(page, { query: '', from: 'alice', limit: 5 });
|
|
501
|
+
expect(result).toEqual([]);
|
|
502
|
+
const pushStateCall = evaluate.mock.calls[0][0];
|
|
503
|
+
expect(pushStateCall).toContain(encodeURIComponent('from:alice'));
|
|
504
|
+
});
|
|
505
|
+
});
|