@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
package/clis/twitter/shared.js
CHANGED
|
@@ -1,4 +1,83 @@
|
|
|
1
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
2
|
+
|
|
1
3
|
const QUERY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
4
|
+
const TWEET_PATH_PATTERN = /^\/(?:[^/]+|i)\/status\/(\d+)\/?$/;
|
|
5
|
+
const TWEET_HOSTS = new Set(['x.com', 'twitter.com']);
|
|
6
|
+
|
|
7
|
+
function isTwitterHost(hostname) {
|
|
8
|
+
return TWEET_HOSTS.has(hostname)
|
|
9
|
+
|| hostname.endsWith('.x.com')
|
|
10
|
+
|| hostname.endsWith('.twitter.com');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseTweetUrl(rawUrl) {
|
|
14
|
+
const value = String(rawUrl ?? '').trim();
|
|
15
|
+
if (!value) {
|
|
16
|
+
throw new ArgumentError('twitter tweet URL cannot be empty', 'Example: opencli twitter retweet https://x.com/jack/status/20');
|
|
17
|
+
}
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = new URL(value);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new ArgumentError(`Invalid tweet URL: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
24
|
+
}
|
|
25
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
26
|
+
if (parsed.protocol !== 'https:' || !isTwitterHost(hostname)) {
|
|
27
|
+
throw new ArgumentError(`Invalid tweet URL host: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
28
|
+
}
|
|
29
|
+
const match = parsed.pathname.match(TWEET_PATH_PATTERN);
|
|
30
|
+
if (!match?.[1]) {
|
|
31
|
+
throw new ArgumentError(`Could not extract tweet ID from URL: ${value}`, 'Use a full https://x.com/<user>/status/<id> URL');
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
id: match[1],
|
|
35
|
+
url: parsed.toString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a JS source fragment that, when embedded inside a `page.evaluate(...)`
|
|
41
|
+
* IIFE, declares browser-side helpers for scoping operations to a specific
|
|
42
|
+
* tweet by status id. Sibling adapters historically inlined ad-hoc article
|
|
43
|
+
* lookups that either (a) skipped scoping entirely (silent: act on first
|
|
44
|
+
* matching button on a conversation page) or (b) used substring matches like
|
|
45
|
+
* `pathname.includes('/status/' + tweetId)` (silent: `/status/123` matches
|
|
46
|
+
* `/status/1234567`). This helper centralises the canonical pattern so all
|
|
47
|
+
* write-actions reuse the same exact-match guard.
|
|
48
|
+
*
|
|
49
|
+
* Declared bindings (available to the embedding IIFE):
|
|
50
|
+
* - `tweetId` : the requested status id (string)
|
|
51
|
+
* - `__twGetStatusIdFromHref(href)` : extract status id from a link href, or null
|
|
52
|
+
* - `__twHasLinkToTarget(root)` : true iff `root` contains any link to tweetId
|
|
53
|
+
* - `findTargetArticle()` : the <article> matching tweetId, or undefined
|
|
54
|
+
*/
|
|
55
|
+
export function buildTwitterArticleScopeSource(tweetId) {
|
|
56
|
+
return `
|
|
57
|
+
const tweetId = ${JSON.stringify(tweetId)};
|
|
58
|
+
const __twTweetPathRe = /^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/;
|
|
59
|
+
const __twIsTwitterHost = (hostname) => hostname === 'x.com'
|
|
60
|
+
|| hostname === 'twitter.com'
|
|
61
|
+
|| hostname.endsWith('.x.com')
|
|
62
|
+
|| hostname.endsWith('.twitter.com');
|
|
63
|
+
const __twGetStatusIdFromHref = (href) => {
|
|
64
|
+
try {
|
|
65
|
+
const parsed = new URL(href, window.location.origin);
|
|
66
|
+
if (parsed.protocol !== 'https:' || !__twIsTwitterHost(parsed.hostname.toLowerCase())) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return parsed.pathname.match(__twTweetPathRe)?.[1] || null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const __twHasLinkToTarget = (root) => Array.from(root.querySelectorAll('a[href*="/status/"]'))
|
|
75
|
+
.some((link) => __twGetStatusIdFromHref(link.href) === tweetId);
|
|
76
|
+
const findTargetArticle = () => Array.from(document.querySelectorAll('article'))
|
|
77
|
+
.find(__twHasLinkToTarget);
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
2
81
|
export function sanitizeQueryId(resolved, fallbackId) {
|
|
3
82
|
return typeof resolved === 'string' && QUERY_ID_PATTERN.test(resolved) ? resolved : fallbackId;
|
|
4
83
|
}
|
|
@@ -65,4 +144,6 @@ export function extractMedia(legacy) {
|
|
|
65
144
|
export const __test__ = {
|
|
66
145
|
sanitizeQueryId,
|
|
67
146
|
extractMedia,
|
|
147
|
+
parseTweetUrl,
|
|
148
|
+
buildTwitterArticleScopeSource,
|
|
68
149
|
};
|
|
@@ -1,7 +1,140 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
2
3
|
import { __test__ } from './shared.js';
|
|
4
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
5
|
|
|
4
|
-
const { extractMedia } = __test__;
|
|
6
|
+
const { extractMedia, parseTweetUrl, buildTwitterArticleScopeSource } = __test__;
|
|
7
|
+
|
|
8
|
+
describe('twitter parseTweetUrl', () => {
|
|
9
|
+
it('accepts exact Twitter/X tweet URLs and preserves query parameters', () => {
|
|
10
|
+
expect(parseTweetUrl('https://x.com/alice/status/2040254679301718161?s=20')).toEqual({
|
|
11
|
+
id: '2040254679301718161',
|
|
12
|
+
url: 'https://x.com/alice/status/2040254679301718161?s=20',
|
|
13
|
+
});
|
|
14
|
+
expect(parseTweetUrl('https://mobile.twitter.com/i/status/2040318731105313143')).toEqual({
|
|
15
|
+
id: '2040318731105313143',
|
|
16
|
+
url: 'https://mobile.twitter.com/i/status/2040318731105313143',
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('rejects non-https, off-domain, host-suffix, embedded, and path-suffix URLs', () => {
|
|
21
|
+
const invalid = [
|
|
22
|
+
'http://x.com/alice/status/2040254679301718161',
|
|
23
|
+
'https://evil.com/alice/status/2040254679301718161',
|
|
24
|
+
'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
25
|
+
'https://evil.com/?next=https://x.com/alice/status/2040254679301718161',
|
|
26
|
+
'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
27
|
+
];
|
|
28
|
+
for (const url of invalid) {
|
|
29
|
+
expect(() => parseTweetUrl(url)).toThrow(ArgumentError);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('twitter buildTwitterArticleScopeSource', () => {
|
|
35
|
+
// JSDOM-based tests prove the returned source actually works on real DOM —
|
|
36
|
+
// mocked `evaluate` tests in adapter specs only verify the script string
|
|
37
|
+
// contains expected tokens, but cannot catch silent matching bugs (cf.
|
|
38
|
+
// dianping #1312: mocked-evaluate single tests miss in-browser logic bugs).
|
|
39
|
+
function loadHelpers(tweetId, dom) {
|
|
40
|
+
const source = buildTwitterArticleScopeSource(tweetId);
|
|
41
|
+
const probe = new Function(
|
|
42
|
+
'document',
|
|
43
|
+
'window',
|
|
44
|
+
'URL',
|
|
45
|
+
`${source}\nreturn { findTargetArticle, __twHasLinkToTarget, __twGetStatusIdFromHref };`,
|
|
46
|
+
);
|
|
47
|
+
return probe(dom.window.document, dom.window, dom.window.URL);
|
|
48
|
+
}
|
|
49
|
+
function makeDom(html) {
|
|
50
|
+
return new JSDOM(`<html><body>${html}</body></html>`, { url: 'https://x.com/alice/status/2040254679301718161' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('finds the article whose link exactly matches the requested status id', () => {
|
|
54
|
+
const dom = makeDom(`
|
|
55
|
+
<article id="a"><a href="https://x.com/alice/status/2040254679301718161">link</a></article>
|
|
56
|
+
<article id="b"><a href="https://x.com/bob/status/9999999999999999999">link</a></article>
|
|
57
|
+
`);
|
|
58
|
+
const helpers = loadHelpers('2040254679301718161', dom);
|
|
59
|
+
const article = helpers.findTargetArticle();
|
|
60
|
+
expect(article?.id).toBe('a');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects substring matches — tweet id 123 must not match /status/1234567', () => {
|
|
64
|
+
// This is the codex-mini0 #1400 catch (substring vulnerability):
|
|
65
|
+
// `/status/123` was accepted as a substring of `/status/1234567`.
|
|
66
|
+
const dom = makeDom('<article><a href="https://x.com/alice/status/1234567">link</a></article>');
|
|
67
|
+
const helpers = loadHelpers('123', dom);
|
|
68
|
+
expect(helpers.findTargetArticle()).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects path-suffix attack — /status/<id>/photo/1 must not match status <id>', () => {
|
|
72
|
+
// Same regex anchor that parseTweetUrl uses — guards against attached
|
|
73
|
+
// paths like `/photo/1` that would otherwise pass with a loose suffix.
|
|
74
|
+
const dom = makeDom('<article><a href="https://x.com/alice/status/2040254679301718161/photo/1">link</a></article>');
|
|
75
|
+
const helpers = loadHelpers('2040254679301718161', dom);
|
|
76
|
+
expect(helpers.findTargetArticle()).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects off-domain links even when the path has the requested status id', () => {
|
|
80
|
+
const dom = makeDom('<article><a href="https://evil.com/alice/status/2040254679301718161">link</a></article>');
|
|
81
|
+
const helpers = loadHelpers('2040254679301718161', dom);
|
|
82
|
+
expect(helpers.findTargetArticle()).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('rejects host-suffix and non-https status links', () => {
|
|
86
|
+
const dom = makeDom(`
|
|
87
|
+
<article id="suffix"><a href="https://x.com.evil.com/alice/status/2040254679301718161">link</a></article>
|
|
88
|
+
<article id="http"><a href="http://x.com/alice/status/2040254679301718161">link</a></article>
|
|
89
|
+
`);
|
|
90
|
+
const helpers = loadHelpers('2040254679301718161', dom);
|
|
91
|
+
expect(helpers.findTargetArticle()).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('accepts exact Twitter/X status links with query and hash suffixes', () => {
|
|
95
|
+
const dom = makeDom('<article id="ok"><a href="https://mobile.twitter.com/alice/status/2040254679301718161?s=20#fragment">link</a></article>');
|
|
96
|
+
const helpers = loadHelpers('2040254679301718161', dom);
|
|
97
|
+
expect(helpers.findTargetArticle()?.id).toBe('ok');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('matches /i/status/<id> URL form', () => {
|
|
101
|
+
const dom = makeDom('<article><a href="https://x.com/i/status/2040318731105313143">link</a></article>');
|
|
102
|
+
const helpers = loadHelpers('2040318731105313143', dom);
|
|
103
|
+
expect(helpers.findTargetArticle()).toBeTruthy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('__twHasLinkToTarget reports true on any descendant <a> matching tweet id', () => {
|
|
107
|
+
// Used by quote-card guard in quote.js — the quoted tweet card is not
|
|
108
|
+
// inside an <article>, but somewhere on the compose page.
|
|
109
|
+
const dom = makeDom(`
|
|
110
|
+
<div data-testid="card.wrapper">
|
|
111
|
+
<a href="https://x.com/alice/status/2040254679301718161">quoted card</a>
|
|
112
|
+
</div>
|
|
113
|
+
`);
|
|
114
|
+
const helpers = loadHelpers('2040254679301718161', dom);
|
|
115
|
+
expect(helpers.__twHasLinkToTarget(dom.window.document)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('__twGetStatusIdFromHref returns null on non-status URLs', () => {
|
|
119
|
+
const dom = makeDom('');
|
|
120
|
+
const helpers = loadHelpers('123', dom);
|
|
121
|
+
expect(helpers.__twGetStatusIdFromHref('https://x.com/alice/home')).toBeNull();
|
|
122
|
+
expect(helpers.__twGetStatusIdFromHref('https://x.com/alice/status/123/photo/1')).toBeNull();
|
|
123
|
+
expect(helpers.__twGetStatusIdFromHref('https://evil.com/alice/status/123')).toBeNull();
|
|
124
|
+
expect(helpers.__twGetStatusIdFromHref('https://x.com.evil.com/alice/status/123')).toBeNull();
|
|
125
|
+
expect(helpers.__twGetStatusIdFromHref('http://x.com/alice/status/123')).toBeNull();
|
|
126
|
+
expect(helpers.__twGetStatusIdFromHref('not a url')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('emits the canonical regex anchor — guards future maintainers from dropping ^ or $', () => {
|
|
130
|
+
const source = buildTwitterArticleScopeSource('123');
|
|
131
|
+
// Source-level assertion complements the JSDOM behavioural tests above.
|
|
132
|
+
// If a future refactor relaxes the anchor (e.g. drops ^ or $), the
|
|
133
|
+
// JSDOM tests would still pass on benign inputs but fail on adversarial
|
|
134
|
+
// cases. This token check ensures the regex shape itself is preserved.
|
|
135
|
+
expect(source).toContain('/^\\/(?:[^/]+|i)\\/status\\/(\\d+)\\/?$/');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
5
138
|
|
|
6
139
|
describe('twitter extractMedia', () => {
|
|
7
140
|
it('returns false + empty list when legacy has no media', () => {
|
package/clis/twitter/thread.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { extractMedia } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
4
5
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
5
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
6
|
const TWEET_DETAIL_QUERY_ID = 'nBS-WpgA6ZG0CyNHD517JQ';
|
|
7
7
|
const FEATURES = {
|
|
8
8
|
responsive_web_graphql_exclude_directive_enabled: true,
|
|
@@ -101,8 +101,9 @@ cli({
|
|
|
101
101
|
strategy: Strategy.COOKIE,
|
|
102
102
|
browser: true,
|
|
103
103
|
args: [
|
|
104
|
-
{ name: 'tweet-id', positional: true, type: 'string', required: true },
|
|
104
|
+
{ name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
|
|
105
105
|
{ name: 'limit', type: 'int', default: 50 },
|
|
106
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the thread 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 conversation\'s structural ordering.' },
|
|
106
107
|
],
|
|
107
108
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls'],
|
|
108
109
|
func: async (page, kwargs) => {
|
|
@@ -121,7 +122,7 @@ cli({
|
|
|
121
122
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
122
123
|
// Build auth headers in TypeScript
|
|
123
124
|
const headers = JSON.stringify({
|
|
124
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
125
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
125
126
|
'X-Csrf-Token': ct0,
|
|
126
127
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
127
128
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -149,6 +150,7 @@ cli({
|
|
|
149
150
|
break;
|
|
150
151
|
cursor = nextCursor;
|
|
151
152
|
}
|
|
152
|
-
|
|
153
|
+
const trimmed = allTweets.slice(0, kwargs.limit);
|
|
154
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
153
155
|
},
|
|
154
156
|
});
|
package/clis/twitter/timeline.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { resolveTwitterQueryId, extractMedia } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
4
5
|
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
5
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
6
|
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
|
7
7
|
const HOME_LATEST_TIMELINE_QUERY_ID = 'BKB7oi212Fi7kQtCBGE4zA';
|
|
8
8
|
// Endpoint config: for-you uses GET HomeTimeline, following uses POST HomeLatestTimeline
|
|
@@ -137,7 +137,7 @@ cli({
|
|
|
137
137
|
site: 'twitter',
|
|
138
138
|
name: 'timeline',
|
|
139
139
|
access: 'read',
|
|
140
|
-
description: 'Fetch
|
|
140
|
+
description: 'Fetch the logged-in user\'s home timeline (for-you algorithmic feed by default; pass --type following for the chronological feed of accounts you follow)',
|
|
141
141
|
domain: 'x.com',
|
|
142
142
|
strategy: Strategy.COOKIE,
|
|
143
143
|
browser: true,
|
|
@@ -146,9 +146,10 @@ cli({
|
|
|
146
146
|
name: 'type',
|
|
147
147
|
default: 'for-you',
|
|
148
148
|
choices: ['for-you', 'following'],
|
|
149
|
-
help: '
|
|
149
|
+
help: 'Which home-timeline feed to read. Default for-you (algorithmic). Use following for the chronological feed of accounts you follow.',
|
|
150
150
|
},
|
|
151
|
-
{ name: 'limit', type: 'int', default: 20 },
|
|
151
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
|
|
152
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the timeline 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.' },
|
|
152
153
|
],
|
|
153
154
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
154
155
|
func: async (page, kwargs) => {
|
|
@@ -168,7 +169,7 @@ cli({
|
|
|
168
169
|
const queryId = await resolveTwitterQueryId(page, endpoint, fallbackQueryId);
|
|
169
170
|
// Build auth headers
|
|
170
171
|
const headers = JSON.stringify({
|
|
171
|
-
Authorization: `Bearer ${decodeURIComponent(
|
|
172
|
+
Authorization: `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
172
173
|
'X-Csrf-Token': ct0,
|
|
173
174
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
174
175
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -196,7 +197,8 @@ cli({
|
|
|
196
197
|
break;
|
|
197
198
|
cursor = nextCursor;
|
|
198
199
|
}
|
|
199
|
-
|
|
200
|
+
const trimmed = allTweets.slice(0, limit);
|
|
201
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
200
202
|
},
|
|
201
203
|
});
|
|
202
204
|
export const __test__ = {
|
package/clis/twitter/tweets.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
4
5
|
|
|
5
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
6
6
|
const USER_TWEETS_QUERY_ID = '6fWQaBPK51aGyC_VC7t9GQ';
|
|
7
7
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ';
|
|
8
8
|
|
|
@@ -152,6 +152,7 @@ cli({
|
|
|
152
152
|
args: [
|
|
153
153
|
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
|
|
154
154
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
155
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the tweets 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 chronological ordering.' },
|
|
155
156
|
],
|
|
156
157
|
columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls'],
|
|
157
158
|
func: async (page, kwargs) => {
|
|
@@ -171,7 +172,7 @@ cli({
|
|
|
171
172
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
172
173
|
|
|
173
174
|
const headers = JSON.stringify({
|
|
174
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
175
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
175
176
|
'X-Csrf-Token': ct0,
|
|
176
177
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
177
178
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -207,7 +208,8 @@ cli({
|
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
if (all.length === 0) throw new EmptyResultError(`@${username} has no recent tweets`, 'Account may be private or suspended');
|
|
210
|
-
|
|
211
|
+
const trimmed = all.slice(0, limit);
|
|
212
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
211
213
|
},
|
|
212
214
|
});
|
|
213
215
|
|
|
@@ -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: 'unbookmark',
|
|
@@ -15,21 +17,25 @@ cli({
|
|
|
15
17
|
func: async (page, kwargs) => {
|
|
16
18
|
if (!page)
|
|
17
19
|
throw new CommandExecutionError('Browser session required for twitter unbookmark');
|
|
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)}
|
|
22
26
|
let attempts = 0;
|
|
23
27
|
let removeBtn = null;
|
|
28
|
+
let targetArticle = null;
|
|
24
29
|
|
|
25
30
|
while (attempts < 20) {
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
targetArticle = findTargetArticle();
|
|
32
|
+
// Check if not bookmarked (already removed)
|
|
33
|
+
const bookmarkBtn = targetArticle?.querySelector('[data-testid="bookmark"]');
|
|
28
34
|
if (bookmarkBtn) {
|
|
29
35
|
return { ok: true, message: 'Tweet is not bookmarked (already removed).' };
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
removeBtn =
|
|
38
|
+
removeBtn = targetArticle?.querySelector('[data-testid="removeBookmark"]') || null;
|
|
33
39
|
if (removeBtn) break;
|
|
34
40
|
|
|
35
41
|
await new Promise(r => setTimeout(r, 500));
|
|
@@ -37,14 +43,15 @@ cli({
|
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
if (!removeBtn) {
|
|
40
|
-
return { ok: false, message: 'Could not find Remove Bookmark button. Are you logged in?' };
|
|
46
|
+
return { ok: false, message: 'Could not find Remove Bookmark button on the requested tweet. Are you logged in?' };
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
removeBtn.click();
|
|
44
50
|
await new Promise(r => setTimeout(r, 1000));
|
|
45
51
|
|
|
46
52
|
// Verify
|
|
47
|
-
const
|
|
53
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
54
|
+
const verify = verifyArticle?.querySelector('[data-testid="bookmark"]');
|
|
48
55
|
if (verify) {
|
|
49
56
|
return { ok: true, message: 'Tweet successfully removed from bookmarks.' };
|
|
50
57
|
} else {
|
|
@@ -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 './unbookmark.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter unbookmark command', () => {
|
|
8
|
+
it('navigates to the tweet URL and reports success when the unbookmark script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/unbookmark');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully removed from bookmarks.' },
|
|
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 not bookmarked ([data-testid="bookmark"] present),
|
|
22
|
+
// the script returns ok:true with an "already removed" message.
|
|
23
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"bookmark\"]')");
|
|
24
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"removeBookmark\"]')");
|
|
25
|
+
expect(script).toContain('removeBtn.click()');
|
|
26
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
27
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
28
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
29
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
30
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
31
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
32
|
+
expect(result).toEqual([
|
|
33
|
+
{ status: 'success', message: 'Tweet successfully removed from bookmarks.' },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a failed row without re-waiting when the unbookmark script reports a UI mismatch', async () => {
|
|
38
|
+
const cmd = getRegistry().get('twitter/unbookmark');
|
|
39
|
+
const page = createPageMock([
|
|
40
|
+
{
|
|
41
|
+
ok: false,
|
|
42
|
+
message: 'Could not find Remove Bookmark button on the requested tweet. Are you logged in?',
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
const result = await cmd.func(page, {
|
|
46
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
47
|
+
});
|
|
48
|
+
expect(result).toEqual([
|
|
49
|
+
{
|
|
50
|
+
status: 'failed',
|
|
51
|
+
message: 'Could not find Remove Bookmark button on the requested tweet. Are you logged in?',
|
|
52
|
+
},
|
|
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/unbookmark');
|
|
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/unbookmark');
|
|
66
|
+
const page = createPageMock([]);
|
|
67
|
+
await expect(cmd.func(page, {
|
|
68
|
+
url: 'http://x.com/alice/status/2040254679301718161',
|
|
69
|
+
})).rejects.toThrow(ArgumentError);
|
|
70
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
71
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'unlike',
|
|
8
|
+
access: 'write',
|
|
9
|
+
description: 'Remove a like from a specific tweet',
|
|
10
|
+
domain: 'x.com',
|
|
11
|
+
strategy: Strategy.UI,
|
|
12
|
+
browser: true,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to unlike' },
|
|
15
|
+
],
|
|
16
|
+
columns: ['status', 'message'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
if (!page)
|
|
19
|
+
throw new CommandExecutionError('Browser session required for twitter unlike');
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
22
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
|
+
const result = await page.evaluate(`(async () => {
|
|
24
|
+
try {
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
// Poll for the tweet to render. State probes scoped to the article
|
|
27
|
+
// matching the requested status id — bare querySelector on a
|
|
28
|
+
// conversation page would silently grab the first article (e.g.
|
|
29
|
+
// the parent tweet) and unlike the wrong one.
|
|
30
|
+
let attempts = 0;
|
|
31
|
+
let likeBtn = null;
|
|
32
|
+
let unlikeBtn = null;
|
|
33
|
+
let targetArticle = null;
|
|
34
|
+
|
|
35
|
+
while (attempts < 20) {
|
|
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
|
+
|
|
42
|
+
await new Promise(r => setTimeout(r, 500));
|
|
43
|
+
attempts++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check if it's already not liked
|
|
47
|
+
if (likeBtn) {
|
|
48
|
+
return { ok: true, message: 'Tweet is not liked (already unliked).' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!unlikeBtn) {
|
|
52
|
+
return { ok: false, message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. Are you logged in?' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Click Unlike
|
|
56
|
+
unlikeBtn.click();
|
|
57
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
58
|
+
|
|
59
|
+
// Verify success by checking if the 'like' button reappeared
|
|
60
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
61
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="like"]');
|
|
62
|
+
if (verifyBtn) {
|
|
63
|
+
return { ok: true, message: 'Tweet successfully unliked.' };
|
|
64
|
+
} else {
|
|
65
|
+
return { ok: false, message: 'Unlike action was initiated but UI did not update as expected.' };
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { ok: false, message: e.toString() };
|
|
69
|
+
}
|
|
70
|
+
})()`);
|
|
71
|
+
if (result.ok) {
|
|
72
|
+
// Wait for the unlike network request to be processed
|
|
73
|
+
await page.wait(2);
|
|
74
|
+
}
|
|
75
|
+
return [{
|
|
76
|
+
status: result.ok ? 'success' : 'failed',
|
|
77
|
+
message: result.message
|
|
78
|
+
}];
|
|
79
|
+
}
|
|
80
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './unlike.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter unlike command', () => {
|
|
8
|
+
it('navigates to the tweet URL and reports success when the unlike script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully unliked.' },
|
|
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
|
+
// After ok:true the adapter waits an extra 2s for the network round-trip.
|
|
20
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
21
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
22
|
+
// Idempotency check: looks for the like button (already-not-liked path) before clicking unlike.
|
|
23
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
|
|
24
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
25
|
+
expect(script).toContain('unlikeBtn.click()');
|
|
26
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
27
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
28
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
29
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
30
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
31
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
32
|
+
expect(result).toEqual([
|
|
33
|
+
{ status: 'success', message: 'Tweet successfully unliked.' },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('returns a failed row without re-waiting when the unlike script reports a UI mismatch', async () => {
|
|
38
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
39
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
40
|
+
const page = createPageMock([
|
|
41
|
+
{
|
|
42
|
+
ok: false,
|
|
43
|
+
message: 'Could not find the Unlike button on this tweet after waiting 10 seconds. 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 the Unlike button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
// Only the primaryColumn wait should run when ok is false.
|
|
56
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
60
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
61
|
+
await expect(cmd.func(undefined, {
|
|
62
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
63
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
67
|
+
const cmd = getRegistry().get('twitter/unlike');
|
|
68
|
+
const page = createPageMock([]);
|
|
69
|
+
await expect(cmd.func(page, {
|
|
70
|
+
url: 'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
71
|
+
})).rejects.toThrow(ArgumentError);
|
|
72
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
73
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
});
|