@jackwener/opencli 1.7.14 → 1.7.16
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 +9 -6
- package/README.zh-CN.md +9 -6
- package/cli-manifest.json +374 -74
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/chatgpt/ask.js +2 -1
- package/clis/chatgpt/detail.js +6 -1
- package/clis/chatgpt/read.js +2 -1
- package/clis/chatgpt/send.js +2 -1
- package/clis/chatgpt/utils.js +54 -12
- package/clis/chatgpt/utils.test.js +36 -1
- package/clis/claude/ask.js +22 -7
- package/clis/claude/detail.js +9 -2
- package/clis/claude/new.js +8 -2
- package/clis/claude/read.js +2 -1
- package/clis/claude/send.js +8 -3
- package/clis/claude/utils.js +27 -4
- package/clis/deepseek/ask.js +21 -8
- package/clis/deepseek/detail.js +9 -1
- package/clis/deepseek/new.js +13 -2
- package/clis/deepseek/read.js +2 -1
- package/clis/deepseek/utils.js +8 -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/linkedin/search.js +8 -11
- package/clis/maimai/search-talents.js +10 -6
- package/clis/openreview/author.js +58 -0
- package/clis/openreview/openreview.test.js +83 -1
- package/clis/openreview/utils.js +14 -0
- package/clis/reddit/comment.js +1 -0
- package/clis/reddit/frontpage.js +1 -0
- package/clis/reddit/popular.js +1 -0
- package/clis/reddit/read.js +2 -0
- package/clis/reddit/read.test.js +4 -0
- package/clis/reddit/save.js +1 -0
- package/clis/reddit/saved.js +1 -0
- package/clis/reddit/search.js +2 -1
- package/clis/reddit/subreddit.js +2 -1
- package/clis/reddit/subscribe.js +1 -0
- package/clis/reddit/upvote.js +1 -0
- package/clis/reddit/upvoted.js +1 -0
- package/clis/reddit/user-comments.js +2 -1
- package/clis/reddit/user-posts.js +2 -1
- package/clis/reddit/user.js +2 -1
- package/clis/twitter/article.js +9 -5
- package/clis/twitter/bookmark-folder.js +187 -0
- package/clis/twitter/bookmark-folder.test.js +337 -0
- package/clis/twitter/bookmark-folders.js +115 -0
- package/clis/twitter/bookmark-folders.test.js +152 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +10 -10
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +6 -5
- package/clis/twitter/followers.js +10 -3
- package/clis/twitter/following.js +14 -11
- package/clis/twitter/following.test.js +2 -1
- 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 +11 -11
- package/clis/twitter/list-add.js +8 -7
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +8 -7
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +9 -9
- package/clis/twitter/lists.js +6 -8
- package/clis/twitter/notifications.js +3 -2
- package/clis/twitter/profile.js +11 -7
- package/clis/twitter/quote.js +60 -32
- package/clis/twitter/quote.test.js +96 -8
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +9 -14
- package/clis/twitter/retweet.test.js +5 -1
- package/clis/twitter/search.js +176 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +43 -0
- package/clis/twitter/shared.test.js +107 -1
- package/clis/twitter/thread.js +11 -11
- package/clis/twitter/timeline.js +13 -13
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +8 -9
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +6 -13
- package/clis/twitter/unlike.test.js +5 -2
- package/clis/twitter/unretweet.js +9 -14
- package/clis/twitter/unretweet.test.js +5 -1
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/clis/youtube/like.js +6 -2
- package/clis/youtube/subscribe.js +6 -2
- package/clis/youtube/unlike.js +6 -2
- package/clis/youtube/unsubscribe.js +6 -2
- package/clis/youtube/utils.js +19 -13
- package/clis/youtube/utils.test.js +17 -1
- 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.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- 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.d.ts +1 -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 +5 -3
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- 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 +4 -2
- package/dist/src/browser/page.js +18 -1
- 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/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 +630 -60
- package/dist/src/cli.test.js +731 -1
- package/dist/src/commanderAdapter.js +7 -0
- package/dist/src/doctor.js +2 -2
- package/dist/src/doctor.test.js +4 -4
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +31 -6
- package/dist/src/execution.test.js +43 -16
- package/dist/src/external-clis.yaml +24 -0
- package/dist/src/help.d.ts +33 -0
- package/dist/src/help.js +174 -0
- package/dist/src/main.js +4 -14
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +83 -1
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './bookmark.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter bookmark command', () => {
|
|
8
|
+
it('navigates to the tweet URL and reports success when the bookmark script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/bookmark');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully bookmarked.' },
|
|
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
|
+
// Idempotency probe: when already bookmarked ([data-testid="removeBookmark"] present),
|
|
22
|
+
// the script returns ok:true with an "already bookmarked" message.
|
|
23
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"removeBookmark\"]')");
|
|
24
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"bookmark\"]')");
|
|
25
|
+
expect(script).toContain('bookmarkBtn.click()');
|
|
26
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
27
|
+
// critical here because conversation pages render multiple
|
|
28
|
+
// bookmark/removeBookmark buttons and a bare querySelector would
|
|
29
|
+
// silently bookmark a different tweet.
|
|
30
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
31
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
32
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
33
|
+
expect(result).toEqual([
|
|
34
|
+
{ status: 'success', message: 'Tweet successfully bookmarked.' },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns a failed row without re-waiting when the bookmark script reports a UI mismatch', async () => {
|
|
39
|
+
const cmd = getRegistry().get('twitter/bookmark');
|
|
40
|
+
const page = createPageMock([
|
|
41
|
+
{
|
|
42
|
+
ok: false,
|
|
43
|
+
message: 'Could not find Bookmark button on the requested tweet. Are you logged in?',
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
const result = await cmd.func(page, {
|
|
47
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
48
|
+
});
|
|
49
|
+
expect(result).toEqual([
|
|
50
|
+
{
|
|
51
|
+
status: 'failed',
|
|
52
|
+
message: 'Could not find Bookmark button on the requested tweet. Are you logged in?',
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
59
|
+
const cmd = getRegistry().get('twitter/bookmark');
|
|
60
|
+
await expect(cmd.func(undefined, {
|
|
61
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
62
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
66
|
+
const cmd = getRegistry().get('twitter/bookmark');
|
|
67
|
+
const page = createPageMock([]);
|
|
68
|
+
await expect(cmd.func(page, {
|
|
69
|
+
url: 'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
70
|
+
})).rejects.toThrow(ArgumentError);
|
|
71
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
72
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
|
|
3
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
4
4
|
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
|
|
5
5
|
const FEATURES = {
|
|
6
6
|
rweb_video_screen_enabled: false,
|
|
@@ -101,21 +101,20 @@ cli({
|
|
|
101
101
|
site: 'twitter',
|
|
102
102
|
name: 'bookmarks',
|
|
103
103
|
access: 'read',
|
|
104
|
-
description: 'Fetch Twitter/X bookmarks',
|
|
104
|
+
description: 'Fetch your Twitter/X bookmarks (the logged-in user\'s saved tweets, newest first)',
|
|
105
105
|
domain: 'x.com',
|
|
106
106
|
strategy: Strategy.COOKIE,
|
|
107
107
|
browser: true,
|
|
108
|
+
browserSession: { reuse: 'site' },
|
|
108
109
|
args: [
|
|
109
|
-
{ name: 'limit', type: 'int', default: 20 },
|
|
110
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
|
|
111
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
|
|
110
112
|
],
|
|
111
113
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
|
|
112
114
|
func: async (page, kwargs) => {
|
|
113
115
|
const limit = kwargs.limit || 20;
|
|
114
|
-
await page.
|
|
115
|
-
|
|
116
|
-
const ct0 = await page.evaluate(`() => {
|
|
117
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
118
|
-
}`);
|
|
116
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
117
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
119
118
|
if (!ct0)
|
|
120
119
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
121
120
|
const queryId = await page.evaluate(`async () => {
|
|
@@ -143,7 +142,7 @@ cli({
|
|
|
143
142
|
return null;
|
|
144
143
|
}`) || BOOKMARKS_QUERY_ID;
|
|
145
144
|
const headers = JSON.stringify({
|
|
146
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
145
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
147
146
|
'X-Csrf-Token': ct0,
|
|
148
147
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
149
148
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -169,6 +168,7 @@ cli({
|
|
|
169
168
|
break;
|
|
170
169
|
cursor = nextCursor;
|
|
171
170
|
}
|
|
172
|
-
|
|
171
|
+
const trimmed = allTweets.slice(0, limit);
|
|
172
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
173
173
|
},
|
|
174
174
|
});
|
package/clis/twitter/delete.js
CHANGED
|
@@ -1,33 +1,13 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
try {
|
|
6
|
-
pathname = new URL(url).pathname;
|
|
7
|
-
}
|
|
8
|
-
catch {
|
|
9
|
-
throw new Error(`Invalid tweet URL: ${url}`);
|
|
10
|
-
}
|
|
11
|
-
const match = pathname.match(/\/status\/(\d+)/);
|
|
12
|
-
if (!match?.[1]) {
|
|
13
|
-
throw new Error(`Could not extract tweet ID from URL: ${url}`);
|
|
14
|
-
}
|
|
15
|
-
return match[1];
|
|
16
|
-
}
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
17
5
|
function buildDeleteScript(tweetId) {
|
|
18
6
|
return `(async () => {
|
|
19
7
|
try {
|
|
20
8
|
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
21
|
-
|
|
22
|
-
const targetArticle =
|
|
23
|
-
Array.from(article.querySelectorAll('a[href*="/status/"]')).some((link) => {
|
|
24
|
-
try {
|
|
25
|
-
return new URL(link.href, window.location.origin).pathname.includes('/status/' + tweetId);
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
})
|
|
30
|
-
);
|
|
9
|
+
${buildTwitterArticleScopeSource(tweetId)}
|
|
10
|
+
const targetArticle = findTargetArticle();
|
|
31
11
|
|
|
32
12
|
if (!targetArticle) {
|
|
33
13
|
return { ok: false, message: 'Could not find the tweet card matching the requested URL.' };
|
|
@@ -82,17 +62,14 @@ cli({
|
|
|
82
62
|
func: async (page, kwargs) => {
|
|
83
63
|
if (!page)
|
|
84
64
|
throw new CommandExecutionError('Browser session required for twitter delete');
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
throw new CommandExecutionError(message);
|
|
92
|
-
}
|
|
93
|
-
await page.goto(kwargs.url);
|
|
65
|
+
// parseTweetUrl throws ArgumentError on malformed/off-domain inputs —
|
|
66
|
+
// this replaces the ad-hoc local extractTweetId which only checked
|
|
67
|
+
// the path shape and accepted any host (silent: would try to act on
|
|
68
|
+
// attacker-controlled redirect URLs).
|
|
69
|
+
const target = parseTweetUrl(kwargs.url);
|
|
70
|
+
await page.goto(target.url);
|
|
94
71
|
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
95
|
-
const result = await page.evaluate(buildDeleteScript(
|
|
72
|
+
const result = await page.evaluate(buildDeleteScript(target.id));
|
|
96
73
|
if (result.ok) {
|
|
97
74
|
// Wait for the deletion request to be processed
|
|
98
75
|
await page.wait(2);
|
|
@@ -105,5 +82,4 @@ cli({
|
|
|
105
82
|
});
|
|
106
83
|
export const __test__ = {
|
|
107
84
|
buildDeleteScript,
|
|
108
|
-
extractTweetId,
|
|
109
85
|
};
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
-
import { __test__ } from './delete.js';
|
|
5
4
|
import './delete.js';
|
|
6
5
|
describe('twitter delete command', () => {
|
|
7
|
-
it('extracts tweet ids from both user and i/status URLs', () => {
|
|
8
|
-
expect(__test__.extractTweetId('https://x.com/alice/status/2040254679301718161?s=20')).toBe('2040254679301718161');
|
|
9
|
-
expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143');
|
|
10
|
-
});
|
|
11
6
|
it('targets the matched tweet article instead of the first More button on the page', async () => {
|
|
12
7
|
const cmd = getRegistry().get('twitter/delete');
|
|
13
8
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -23,9 +18,17 @@ describe('twitter delete command', () => {
|
|
|
23
18
|
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
24
19
|
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
25
20
|
const script = page.evaluate.mock.calls[0][0];
|
|
21
|
+
// Article-scoping must come from the shared helper (not an inline
|
|
22
|
+
// `pathname.includes('/status/' + tweetId)` substring match — see
|
|
23
|
+
// codex-mini0 #1400 catch where `/status/123` would match
|
|
24
|
+
// `/status/1234567`). The helper emits `__twHasLinkToTarget` and
|
|
25
|
+
// `__twGetStatusIdFromHref` plus the canonical anchored regex.
|
|
26
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
27
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
26
28
|
expect(script).toContain("document.querySelectorAll('article')");
|
|
27
|
-
expect(script).toContain("'/status/' + tweetId");
|
|
28
29
|
expect(script).toContain("targetArticle.querySelectorAll('button,[role=\"button\"]')");
|
|
30
|
+
// Substring match must NOT appear — exact-id match only.
|
|
31
|
+
expect(script).not.toContain("'/status/' + tweetId");
|
|
29
32
|
expect(result).toEqual([
|
|
30
33
|
{
|
|
31
34
|
status: 'success',
|
|
@@ -55,7 +58,7 @@ describe('twitter delete command', () => {
|
|
|
55
58
|
]);
|
|
56
59
|
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
57
60
|
});
|
|
58
|
-
it('
|
|
61
|
+
it('rejects malformed or off-domain URLs with ArgumentError before navigation', async () => {
|
|
59
62
|
const cmd = getRegistry().get('twitter/delete');
|
|
60
63
|
expect(cmd?.func).toBeTypeOf('function');
|
|
61
64
|
const page = {
|
|
@@ -63,11 +66,20 @@ describe('twitter delete command', () => {
|
|
|
63
66
|
wait: vi.fn(),
|
|
64
67
|
evaluate: vi.fn(),
|
|
65
68
|
};
|
|
69
|
+
// parseTweetUrl bubbles ArgumentError directly (no CommandExecutionError
|
|
70
|
+
// wrapping); replaces the previous local extractTweetId path that hid
|
|
71
|
+
// typed-input failures behind a generic CliError.
|
|
66
72
|
await expect(cmd.func(page, {
|
|
67
73
|
url: 'https://x.com/alice/home',
|
|
68
|
-
})).rejects.toThrow(
|
|
74
|
+
})).rejects.toThrow(ArgumentError);
|
|
69
75
|
expect(page.goto).not.toHaveBeenCalled();
|
|
70
76
|
expect(page.wait).not.toHaveBeenCalled();
|
|
71
77
|
expect(page.evaluate).not.toHaveBeenCalled();
|
|
72
78
|
});
|
|
79
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
80
|
+
const cmd = getRegistry().get('twitter/delete');
|
|
81
|
+
await expect(cmd.func(undefined, {
|
|
82
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
83
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
84
|
+
});
|
|
73
85
|
});
|
package/clis/twitter/download.js
CHANGED
|
@@ -12,14 +12,15 @@ cli({
|
|
|
12
12
|
site: 'twitter',
|
|
13
13
|
name: 'download',
|
|
14
14
|
access: 'read',
|
|
15
|
-
description: '
|
|
15
|
+
description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
|
|
16
16
|
domain: 'x.com',
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
|
+
browserSession: { reuse: 'site' },
|
|
18
19
|
args: [
|
|
19
|
-
{ name: 'username', positional: true, help: 'Twitter username (
|
|
20
|
-
{ name: 'tweet-url', help: 'Single tweet URL to download' },
|
|
21
|
-
{ name: 'limit', type: 'int', default: 10, help: '
|
|
22
|
-
{ name: 'output', default: './twitter-downloads', help: 'Output directory' },
|
|
20
|
+
{ name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
|
|
21
|
+
{ name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
|
|
22
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Maximum number of media items to download when scanning a profile (default 10). Ignored when --tweet-url is used.' },
|
|
23
|
+
{ name: 'output', default: './twitter-downloads', help: 'Output directory (default ./twitter-downloads). A per-source subdir is created inside.' },
|
|
23
24
|
],
|
|
24
25
|
columns: ['index', 'type', 'status', 'size'],
|
|
25
26
|
func: async (page, kwargs) => {
|
|
@@ -79,13 +79,20 @@ cli({
|
|
|
79
79
|
site: 'twitter',
|
|
80
80
|
name: 'followers',
|
|
81
81
|
access: 'read',
|
|
82
|
-
description: 'Get accounts following a Twitter/X user',
|
|
82
|
+
description: 'Get accounts following a Twitter/X user (defaults to the logged-in user when no user is given)',
|
|
83
83
|
domain: 'x.com',
|
|
84
84
|
strategy: Strategy.UI,
|
|
85
85
|
browser: true,
|
|
86
|
+
browserSession: { reuse: 'site' },
|
|
86
87
|
args: [
|
|
87
|
-
{
|
|
88
|
-
|
|
88
|
+
{
|
|
89
|
+
name: 'user',
|
|
90
|
+
positional: true,
|
|
91
|
+
type: 'string',
|
|
92
|
+
required: false,
|
|
93
|
+
help: 'Twitter/X handle (with or without @). Omit to fetch followers of the currently logged-in account.',
|
|
94
|
+
},
|
|
95
|
+
{ name: 'limit', type: 'int', default: 50, help: 'Maximum number of follower rows to return (default 50). Must be a positive integer.' },
|
|
89
96
|
],
|
|
90
97
|
// `followers` (count) is NOT exposed: the SPA followers-list view does not
|
|
91
98
|
// render it. Use `twitter profile <user>` for per-user follower counts.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { resolveTwitterQueryId, sanitizeQueryId } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
4
5
|
|
|
5
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
6
|
const FOLLOWING_QUERY_ID = 'zx6e-TLzRkeDO_a7p4b3JQ'; // Following fallback
|
|
7
7
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
8
8
|
|
|
@@ -135,13 +135,20 @@ cli({
|
|
|
135
135
|
site: 'twitter',
|
|
136
136
|
name: 'following',
|
|
137
137
|
access: 'read',
|
|
138
|
-
description: 'Get accounts a Twitter/X user is following',
|
|
138
|
+
description: 'Get accounts a Twitter/X user is following (defaults to the logged-in user when no user is given)',
|
|
139
139
|
domain: 'x.com',
|
|
140
140
|
strategy: Strategy.COOKIE,
|
|
141
141
|
browser: true,
|
|
142
|
+
browserSession: { reuse: 'site' },
|
|
142
143
|
args: [
|
|
143
|
-
{
|
|
144
|
-
|
|
144
|
+
{
|
|
145
|
+
name: 'user',
|
|
146
|
+
positional: true,
|
|
147
|
+
type: 'string',
|
|
148
|
+
required: false,
|
|
149
|
+
help: 'Twitter/X handle (with or without @). Omit to fetch the accounts the currently logged-in user follows.',
|
|
150
|
+
},
|
|
151
|
+
{ name: 'limit', type: 'int', default: 50, help: 'Maximum number of following rows to return (default 50). Must be a positive integer.' },
|
|
145
152
|
],
|
|
146
153
|
columns: ['screen_name', 'name', 'bio', 'followers'],
|
|
147
154
|
func: async (page, kwargs) => {
|
|
@@ -151,12 +158,8 @@ cli({
|
|
|
151
158
|
}
|
|
152
159
|
let targetUser = normalizeScreenName(kwargs.user);
|
|
153
160
|
|
|
154
|
-
await page.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const ct0 = await page.evaluate(`() => {
|
|
158
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
159
|
-
}`);
|
|
161
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
162
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
160
163
|
if (!ct0)
|
|
161
164
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
162
165
|
|
|
@@ -176,7 +179,7 @@ cli({
|
|
|
176
179
|
const followingQueryId = await resolveTwitterQueryId(page, 'Following', FOLLOWING_QUERY_ID);
|
|
177
180
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
178
181
|
const headers = JSON.stringify({
|
|
179
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
182
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
180
183
|
'X-Csrf-Token': ct0,
|
|
181
184
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
182
185
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -205,8 +205,8 @@ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = {
|
|
|
205
205
|
const page = {
|
|
206
206
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
207
207
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
208
|
+
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
|
|
208
209
|
evaluate: vi.fn(async (script) => {
|
|
209
|
-
if (script.includes('document.cookie')) return ct0;
|
|
210
210
|
if (script.includes('operationName')) return null;
|
|
211
211
|
if (script.includes('/UserByScreenName')) return userLookup;
|
|
212
212
|
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
|
|
@@ -228,6 +228,7 @@ describe('twitter following command', () => {
|
|
|
228
228
|
const rows = await command.func(page, { user: '@elonmusk', limit: 3 });
|
|
229
229
|
|
|
230
230
|
expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
|
|
231
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
231
232
|
const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
|
|
232
233
|
expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
|
|
233
234
|
expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
3
5
|
cli({
|
|
4
6
|
site: 'twitter',
|
|
5
7
|
name: 'hide-reply',
|
|
@@ -15,28 +17,45 @@ cli({
|
|
|
15
17
|
func: async (page, kwargs) => {
|
|
16
18
|
if (!page)
|
|
17
19
|
throw new CommandExecutionError('Browser session required for twitter hide-reply');
|
|
18
|
-
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
19
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
20
23
|
const result = await page.evaluate(`(async () => {
|
|
21
24
|
try {
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
27
|
+
// Locate the article matching the requested status id, then find
|
|
28
|
+
// its More menu. Without article scoping we'd grab whatever the
|
|
29
|
+
// first "More" button on the page is — usually the parent tweet
|
|
30
|
+
// (silent: hide the wrong reply, or fail silently if the parent
|
|
31
|
+
// is not a reply you authored).
|
|
22
32
|
let attempts = 0;
|
|
33
|
+
let targetArticle = null;
|
|
23
34
|
let moreMenu = null;
|
|
24
35
|
|
|
25
36
|
while (attempts < 20) {
|
|
26
|
-
|
|
27
|
-
if (
|
|
37
|
+
targetArticle = findTargetArticle();
|
|
38
|
+
if (targetArticle) {
|
|
39
|
+
const buttons = Array.from(targetArticle.querySelectorAll('button,[role="button"]'));
|
|
40
|
+
moreMenu = buttons.find((el) => visible(el) && (el.getAttribute('aria-label') || '').trim() === 'More');
|
|
41
|
+
if (moreMenu) break;
|
|
42
|
+
}
|
|
28
43
|
await new Promise(r => setTimeout(r, 500));
|
|
29
44
|
attempts++;
|
|
30
45
|
}
|
|
31
46
|
|
|
47
|
+
if (!targetArticle) {
|
|
48
|
+
return { ok: false, message: 'Could not find the requested reply article on this page.' };
|
|
49
|
+
}
|
|
32
50
|
if (!moreMenu) {
|
|
33
|
-
return { ok: false, message: 'Could not find the "More" menu on
|
|
51
|
+
return { ok: false, message: 'Could not find the "More" menu on the requested reply. Are you logged in?' };
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
moreMenu.click();
|
|
37
55
|
await new Promise(r => setTimeout(r, 1000));
|
|
38
56
|
|
|
39
|
-
// Look for the "Hide reply" menu item
|
|
57
|
+
// Look for the "Hide reply" menu item. Menu items render at the
|
|
58
|
+
// document root, not inside the article — scope is the open menu.
|
|
40
59
|
const items = document.querySelectorAll('[role="menuitem"]');
|
|
41
60
|
let hideItem = null;
|
|
42
61
|
for (const item of items) {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './hide-reply.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter hide-reply command', () => {
|
|
8
|
+
it('navigates to the reply URL and reports success when the hide-reply script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Reply successfully hidden.' },
|
|
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
|
+
// Article-scoped More menu lookup — without scoping, the bare
|
|
22
|
+
// [aria-label="More"] selector grabs the parent tweet's More menu and
|
|
23
|
+
// silently hides the wrong reply (or fails because the parent is not a
|
|
24
|
+
// reply you authored).
|
|
25
|
+
expect(script).toContain('moreMenu.click()');
|
|
26
|
+
expect(script).toContain('[role="menuitem"]');
|
|
27
|
+
expect(script).toContain("'Hide reply'");
|
|
28
|
+
expect(script).toContain('hideItem.click()');
|
|
29
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
30
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
31
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
32
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
33
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
34
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
35
|
+
expect(result).toEqual([
|
|
36
|
+
{ status: 'success', message: 'Reply successfully hidden.' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns a failed row without re-waiting when the hide-reply script reports a UI mismatch', async () => {
|
|
41
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
42
|
+
const page = createPageMock([
|
|
43
|
+
{
|
|
44
|
+
ok: false,
|
|
45
|
+
message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.',
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
const result = await cmd.func(page, {
|
|
49
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
50
|
+
});
|
|
51
|
+
expect(result).toEqual([
|
|
52
|
+
{
|
|
53
|
+
status: 'failed',
|
|
54
|
+
message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.',
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
61
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
62
|
+
await expect(cmd.func(undefined, {
|
|
63
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
64
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
68
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
69
|
+
const page = createPageMock([]);
|
|
70
|
+
await expect(cmd.func(page, {
|
|
71
|
+
url: 'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
72
|
+
})).rejects.toThrow(ArgumentError);
|
|
73
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
74
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/clis/twitter/like.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
3
5
|
cli({
|
|
4
6
|
site: 'twitter',
|
|
5
7
|
name: 'like',
|
|
@@ -15,21 +17,28 @@ cli({
|
|
|
15
17
|
func: async (page, kwargs) => {
|
|
16
18
|
if (!page)
|
|
17
19
|
throw new CommandExecutionError('Browser session required for twitter like');
|
|
18
|
-
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
19
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
20
23
|
const result = await page.evaluate(`(async () => {
|
|
21
24
|
try {
|
|
22
|
-
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
// Poll for the tweet to render. We scope state probes to the
|
|
27
|
+
// article matching the requested status id — on conversation
|
|
28
|
+
// pages multiple articles render and a bare querySelector would
|
|
29
|
+
// grab the first one (silent: like the wrong tweet).
|
|
23
30
|
let attempts = 0;
|
|
24
31
|
let likeBtn = null;
|
|
25
32
|
let unlikeBtn = null;
|
|
26
|
-
|
|
33
|
+
let targetArticle = null;
|
|
34
|
+
|
|
27
35
|
while (attempts < 20) {
|
|
28
|
-
|
|
29
|
-
likeBtn =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
targetArticle = findTargetArticle();
|
|
37
|
+
likeBtn = targetArticle?.querySelector('[data-testid="like"]') || null;
|
|
38
|
+
unlikeBtn = targetArticle?.querySelector('[data-testid="unlike"]') || null;
|
|
39
|
+
|
|
40
|
+
if (likeBtn || unlikeBtn) break;
|
|
41
|
+
|
|
33
42
|
await new Promise(r => setTimeout(r, 500));
|
|
34
43
|
attempts++;
|
|
35
44
|
}
|
|
@@ -46,9 +55,10 @@ cli({
|
|
|
46
55
|
// Click Like
|
|
47
56
|
likeBtn.click();
|
|
48
57
|
await new Promise(r => setTimeout(r, 1000));
|
|
49
|
-
|
|
50
|
-
// Verify success by checking if the 'unlike' button
|
|
51
|
-
const
|
|
58
|
+
|
|
59
|
+
// Verify success by checking if the 'unlike' button reappeared
|
|
60
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
61
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="unlike"]');
|
|
52
62
|
if (verifyBtn) {
|
|
53
63
|
return { ok: true, message: 'Tweet successfully liked.' };
|
|
54
64
|
} else {
|