@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.
Files changed (97) hide show
  1. package/cli-manifest.json +326 -44
  2. package/clis/bilibili/subtitle.js +1 -1
  3. package/clis/dianping/cityResolver.js +185 -0
  4. package/clis/dianping/dianping.test.js +154 -0
  5. package/clis/dianping/search.js +6 -3
  6. package/clis/douyin/_shared/browser-fetch.js +14 -2
  7. package/clis/douyin/_shared/browser-fetch.test.js +13 -0
  8. package/clis/douyin/stats.js +1 -1
  9. package/clis/douyin/update.js +1 -1
  10. package/clis/jike/search.js +1 -1
  11. package/clis/reddit/search.js +1 -1
  12. package/clis/reddit/subreddit.js +1 -1
  13. package/clis/reddit/user-comments.js +1 -1
  14. package/clis/reddit/user-posts.js +1 -1
  15. package/clis/reddit/user.js +1 -1
  16. package/clis/twitter/article.js +2 -1
  17. package/clis/twitter/bookmark-folder.js +189 -0
  18. package/clis/twitter/bookmark-folder.test.js +334 -0
  19. package/clis/twitter/bookmark-folders.js +117 -0
  20. package/clis/twitter/bookmark-folders.test.js +150 -0
  21. package/clis/twitter/bookmark.js +15 -6
  22. package/clis/twitter/bookmark.test.js +74 -0
  23. package/clis/twitter/bookmarks.js +7 -5
  24. package/clis/twitter/delete.js +11 -35
  25. package/clis/twitter/delete.test.js +21 -9
  26. package/clis/twitter/download.js +5 -5
  27. package/clis/twitter/followers.js +9 -3
  28. package/clis/twitter/following.js +11 -5
  29. package/clis/twitter/hide-reply.js +24 -5
  30. package/clis/twitter/hide-reply.test.js +76 -0
  31. package/clis/twitter/like.js +21 -11
  32. package/clis/twitter/like.test.js +73 -0
  33. package/clis/twitter/likes.js +8 -6
  34. package/clis/twitter/list-add.js +4 -4
  35. package/clis/twitter/list-remove.js +4 -4
  36. package/clis/twitter/list-tweets.js +6 -4
  37. package/clis/twitter/lists.js +3 -3
  38. package/clis/twitter/notifications.js +2 -2
  39. package/clis/twitter/profile.js +4 -3
  40. package/clis/twitter/quote.js +167 -0
  41. package/clis/twitter/quote.test.js +194 -0
  42. package/clis/twitter/reply.js +24 -178
  43. package/clis/twitter/reply.test.js +29 -11
  44. package/clis/twitter/retweet.js +94 -0
  45. package/clis/twitter/retweet.test.js +73 -0
  46. package/clis/twitter/search.js +175 -23
  47. package/clis/twitter/search.test.js +266 -1
  48. package/clis/twitter/shared.js +81 -0
  49. package/clis/twitter/shared.test.js +134 -1
  50. package/clis/twitter/thread.js +6 -4
  51. package/clis/twitter/timeline.js +8 -6
  52. package/clis/twitter/tweets.js +5 -3
  53. package/clis/twitter/unbookmark.js +13 -6
  54. package/clis/twitter/unbookmark.test.js +73 -0
  55. package/clis/twitter/unlike.js +80 -0
  56. package/clis/twitter/unlike.test.js +75 -0
  57. package/clis/twitter/unretweet.js +94 -0
  58. package/clis/twitter/unretweet.test.js +73 -0
  59. package/clis/twitter/utils.js +286 -0
  60. package/clis/twitter/utils.test.js +169 -0
  61. package/dist/src/browser/ax-snapshot.d.ts +37 -0
  62. package/dist/src/browser/ax-snapshot.js +217 -0
  63. package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
  64. package/dist/src/browser/ax-snapshot.test.js +91 -0
  65. package/dist/src/browser/base-page.d.ts +51 -0
  66. package/dist/src/browser/base-page.js +545 -2
  67. package/dist/src/browser/base-page.test.js +520 -4
  68. package/dist/src/browser/bridge.js +47 -45
  69. package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
  70. package/dist/src/browser/cdp-click-fixture.test.js +87 -0
  71. package/dist/src/browser/cdp.js +5 -0
  72. package/dist/src/browser/cdp.test.js +1 -0
  73. package/dist/src/browser/daemon-client.d.ts +3 -1
  74. package/dist/src/browser/find.d.ts +9 -1
  75. package/dist/src/browser/find.js +219 -0
  76. package/dist/src/browser/find.test.js +61 -1
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +13 -0
  79. package/dist/src/browser/page.test.js +28 -0
  80. package/dist/src/browser/target-errors.d.ts +3 -1
  81. package/dist/src/browser/target-errors.js +2 -0
  82. package/dist/src/browser/target-resolver.d.ts +14 -0
  83. package/dist/src/browser/target-resolver.js +28 -0
  84. package/dist/src/browser/visual-refs.d.ts +11 -0
  85. package/dist/src/browser/visual-refs.js +108 -0
  86. package/dist/src/browser.test.js +18 -0
  87. package/dist/src/build-manifest.d.ts +23 -0
  88. package/dist/src/build-manifest.js +34 -0
  89. package/dist/src/build-manifest.test.js +108 -1
  90. package/dist/src/cli.js +560 -58
  91. package/dist/src/cli.test.js +689 -1
  92. package/dist/src/commanderAdapter.js +23 -4
  93. package/dist/src/help.d.ts +36 -0
  94. package/dist/src/help.js +301 -5
  95. package/dist/src/types.d.ts +82 -0
  96. package/package.json +1 -1
  97. package/scripts/typed-error-lint-baseline.json +18 -18
@@ -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', () => {
@@ -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(BEARER_TOKEN)}`,
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
- return allTweets.slice(0, kwargs.limit);
153
+ const trimmed = allTweets.slice(0, kwargs.limit);
154
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
153
155
  },
154
156
  });
@@ -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 Twitter timeline (for-you or following)',
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: 'Timeline type: for-you (algorithmic) or following (chronological)',
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(BEARER_TOKEN)}`,
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
- return allTweets.slice(0, limit);
200
+ const trimmed = allTweets.slice(0, limit);
201
+ return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
200
202
  },
201
203
  });
202
204
  export const __test__ = {
@@ -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(BEARER_TOKEN)}`,
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
- return all.slice(0, limit);
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
- await page.goto(kwargs.url);
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
- // Check if not bookmarked
27
- const bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
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 = document.querySelector('[data-testid="removeBookmark"]');
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 verify = document.querySelector('[data-testid="bookmark"]');
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
+ });