@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
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { JSDOM } from 'jsdom';
|
|
2
3
|
import { __test__ } from './shared.js';
|
|
3
4
|
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
4
5
|
|
|
5
|
-
const { extractMedia, parseTweetUrl } = __test__;
|
|
6
|
+
const { extractMedia, parseTweetUrl, buildTwitterArticleScopeSource } = __test__;
|
|
6
7
|
|
|
7
8
|
describe('twitter parseTweetUrl', () => {
|
|
8
9
|
it('accepts exact Twitter/X tweet URLs and preserves query parameters', () => {
|
|
@@ -30,6 +31,111 @@ describe('twitter parseTweetUrl', () => {
|
|
|
30
31
|
});
|
|
31
32
|
});
|
|
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
|
+
});
|
|
138
|
+
|
|
33
139
|
describe('twitter extractMedia', () => {
|
|
34
140
|
it('returns false + empty list when legacy has no media', () => {
|
|
35
141
|
expect(extractMedia({})).toEqual({ has_media: false, media_urls: [] });
|
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,
|
|
@@ -100,9 +100,11 @@ cli({
|
|
|
100
100
|
domain: 'x.com',
|
|
101
101
|
strategy: Strategy.COOKIE,
|
|
102
102
|
browser: true,
|
|
103
|
+
browserSession: { reuse: 'site' },
|
|
103
104
|
args: [
|
|
104
|
-
{ name: 'tweet-id', positional: true, type: 'string', required: true },
|
|
105
|
+
{ name: 'tweet-id', positional: true, type: 'string', required: true, help: 'Tweet numeric ID (e.g. 1234567890) or full status URL' },
|
|
105
106
|
{ name: 'limit', type: 'int', default: 50 },
|
|
107
|
+
{ 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
108
|
],
|
|
107
109
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'url', 'has_media', 'media_urls'],
|
|
108
110
|
func: async (page, kwargs) => {
|
|
@@ -110,18 +112,15 @@ cli({
|
|
|
110
112
|
const urlMatch = tweetId.match(/\/status\/(\d+)/);
|
|
111
113
|
if (urlMatch)
|
|
112
114
|
tweetId = urlMatch[1];
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
await page.
|
|
116
|
-
|
|
117
|
-
const ct0 = await page.evaluate(`() => {
|
|
118
|
-
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
119
|
-
}`);
|
|
115
|
+
// Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
|
|
116
|
+
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
|
|
117
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
118
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
120
119
|
if (!ct0)
|
|
121
120
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
122
121
|
// Build auth headers in TypeScript
|
|
123
122
|
const headers = JSON.stringify({
|
|
124
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
123
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
125
124
|
'X-Csrf-Token': ct0,
|
|
126
125
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
127
126
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -149,6 +148,7 @@ cli({
|
|
|
149
148
|
break;
|
|
150
149
|
cursor = nextCursor;
|
|
151
150
|
}
|
|
152
|
-
|
|
151
|
+
const trimmed = allTweets.slice(0, kwargs.limit);
|
|
152
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
153
153
|
},
|
|
154
154
|
});
|
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,38 +137,37 @@ 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,
|
|
144
|
+
browserSession: { reuse: 'site' },
|
|
144
145
|
args: [
|
|
145
146
|
{
|
|
146
147
|
name: 'type',
|
|
147
148
|
default: 'for-you',
|
|
148
149
|
choices: ['for-you', 'following'],
|
|
149
|
-
help: '
|
|
150
|
+
help: 'Which home-timeline feed to read. Default for-you (algorithmic). Use following for the chronological feed of accounts you follow.',
|
|
150
151
|
},
|
|
151
|
-
{ name: 'limit', type: 'int', default: 20 },
|
|
152
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of tweets to return (default 20).' },
|
|
153
|
+
{ 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
154
|
],
|
|
153
155
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
154
156
|
func: async (page, kwargs) => {
|
|
155
157
|
const limit = kwargs.limit || 20;
|
|
156
158
|
const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
|
|
157
159
|
const { endpoint, method, fallbackQueryId } = TIMELINE_ENDPOINTS[timelineType];
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
await page.
|
|
161
|
-
|
|
162
|
-
const ct0 = await page.evaluate(`() => {
|
|
163
|
-
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
164
|
-
}`);
|
|
160
|
+
// Cookie context auto-established by framework pre-nav (Strategy.COOKIE + domain).
|
|
161
|
+
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip.
|
|
162
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
163
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
165
164
|
if (!ct0)
|
|
166
165
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
167
166
|
// Dynamically resolve queryId for the selected endpoint
|
|
168
167
|
const queryId = await resolveTwitterQueryId(page, endpoint, fallbackQueryId);
|
|
169
168
|
// Build auth headers
|
|
170
169
|
const headers = JSON.stringify({
|
|
171
|
-
Authorization: `Bearer ${decodeURIComponent(
|
|
170
|
+
Authorization: `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
172
171
|
'X-Csrf-Token': ct0,
|
|
173
172
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
174
173
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -196,7 +195,8 @@ cli({
|
|
|
196
195
|
break;
|
|
197
196
|
cursor = nextCursor;
|
|
198
197
|
}
|
|
199
|
-
|
|
198
|
+
const trimmed = allTweets.slice(0, limit);
|
|
199
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
200
200
|
},
|
|
201
201
|
});
|
|
202
202
|
export const __test__ = {
|
package/clis/twitter/trending.js
CHANGED
|
@@ -17,6 +17,7 @@ cli({
|
|
|
17
17
|
domain: 'x.com',
|
|
18
18
|
strategy: Strategy.COOKIE,
|
|
19
19
|
browser: true,
|
|
20
|
+
browserSession: { reuse: 'site' },
|
|
20
21
|
args: [
|
|
21
22
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of trends to show' },
|
|
22
23
|
],
|
|
@@ -26,10 +27,9 @@ cli({
|
|
|
26
27
|
// Navigate to trending page
|
|
27
28
|
await page.goto('https://x.com/explore/tabs/trending');
|
|
28
29
|
await page.wait(3);
|
|
29
|
-
// Verify login via CSRF cookie
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
})()`);
|
|
30
|
+
// Verify login via CSRF cookie (read directly from cookie store via CDP)
|
|
31
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
32
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
33
33
|
if (!ct0)
|
|
34
34
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
35
35
|
await page.wait(2);
|
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
|
|
|
@@ -149,9 +149,11 @@ cli({
|
|
|
149
149
|
domain: 'x.com',
|
|
150
150
|
strategy: Strategy.COOKIE,
|
|
151
151
|
browser: true,
|
|
152
|
+
browserSession: { reuse: 'site' },
|
|
152
153
|
args: [
|
|
153
154
|
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' },
|
|
154
155
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
156
|
+
{ 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
157
|
],
|
|
156
158
|
columns: ['id', 'author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls'],
|
|
157
159
|
func: async (page, kwargs) => {
|
|
@@ -159,19 +161,15 @@ cli({
|
|
|
159
161
|
const username = String(kwargs.username || '').replace(/^@/, '').trim();
|
|
160
162
|
if (!username) throw new CommandExecutionError('username is required');
|
|
161
163
|
|
|
162
|
-
await page.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const ct0 = await page.evaluate(`() => {
|
|
166
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
167
|
-
}`);
|
|
164
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
165
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
168
166
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
169
167
|
|
|
170
168
|
const userTweetsQueryId = await resolveTwitterQueryId(page, 'UserTweets', USER_TWEETS_QUERY_ID);
|
|
171
169
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
172
170
|
|
|
173
171
|
const headers = JSON.stringify({
|
|
174
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
172
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
175
173
|
'X-Csrf-Token': ct0,
|
|
176
174
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
177
175
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -207,7 +205,8 @@ cli({
|
|
|
207
205
|
}
|
|
208
206
|
|
|
209
207
|
if (all.length === 0) throw new EmptyResultError(`@${username} has no recent tweets`, 'Account may be private or suspended');
|
|
210
|
-
|
|
208
|
+
const trimmed = all.slice(0, limit);
|
|
209
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
211
210
|
},
|
|
212
211
|
});
|
|
213
212
|
|
|
@@ -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
|
+
});
|
package/clis/twitter/unlike.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { parseTweetUrl } from './shared.js';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
4
|
|
|
5
5
|
cli({
|
|
6
6
|
site: 'twitter',
|
|
@@ -22,18 +22,11 @@ cli({
|
|
|
22
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return match?.[1] === tweetId;
|
|
31
|
-
} catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
);
|
|
36
|
-
// Poll for the tweet to render
|
|
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.
|
|
37
30
|
let attempts = 0;
|
|
38
31
|
let likeBtn = null;
|
|
39
32
|
let unlikeBtn = null;
|
|
@@ -23,9 +23,12 @@ describe('twitter unlike command', () => {
|
|
|
23
23
|
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
|
|
24
24
|
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
25
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');
|
|
26
31
|
expect(script).toContain("document.querySelectorAll('article')");
|
|
27
|
-
expect(script).toContain('match?.[1] === tweetId');
|
|
28
|
-
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
29
32
|
expect(result).toEqual([
|
|
30
33
|
{ status: 'success', message: 'Tweet successfully unliked.' },
|
|
31
34
|
]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
-
import { parseTweetUrl } from './shared.js';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
4
|
|
|
5
5
|
cli({
|
|
6
6
|
site: 'twitter',
|
|
@@ -22,18 +22,11 @@ cli({
|
|
|
22
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
23
23
|
const result = await page.evaluate(`(async () => {
|
|
24
24
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return match?.[1] === tweetId;
|
|
31
|
-
} catch {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
);
|
|
36
|
-
// Poll for the tweet to render
|
|
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 unretweet the wrong one.
|
|
37
30
|
let attempts = 0;
|
|
38
31
|
let retweetBtn = null;
|
|
39
32
|
let unretweetBtn = null;
|
|
@@ -62,7 +55,9 @@ cli({
|
|
|
62
55
|
// Step 1: click Unretweet button → opens menu
|
|
63
56
|
unretweetBtn.click();
|
|
64
57
|
|
|
65
|
-
// Step 2: wait for the confirm menu item
|
|
58
|
+
// Step 2: wait for and click the confirm menu item. The confirm
|
|
59
|
+
// popover renders at the document root, not inside the article,
|
|
60
|
+
// so this lookup is intentionally document-scoped.
|
|
66
61
|
let confirmBtn = null;
|
|
67
62
|
for (let i = 0; i < 20; i++) {
|
|
68
63
|
await new Promise(r => setTimeout(r, 250));
|
|
@@ -24,8 +24,12 @@ describe('twitter unretweet command', () => {
|
|
|
24
24
|
expect(script).toContain('unretweetBtn.click()');
|
|
25
25
|
expect(script).toContain("document.querySelector('[data-testid=\"unretweetConfirm\"]')");
|
|
26
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');
|
|
27
32
|
expect(script).toContain("document.querySelectorAll('article')");
|
|
28
|
-
expect(script).toContain('match?.[1] === tweetId');
|
|
29
33
|
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unretweet\"]')");
|
|
30
34
|
// Idempotency probe: when already not retweeted ([data-testid="retweet"] present),
|
|
31
35
|
// the script returns ok:true with an "already removed" message.
|