@jackwener/opencli 1.7.13 → 1.7.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-manifest.json +326 -44
- package/clis/bilibili/subtitle.js +1 -1
- package/clis/dianping/cityResolver.js +185 -0
- package/clis/dianping/dianping.test.js +154 -0
- package/clis/dianping/search.js +6 -3
- package/clis/douyin/_shared/browser-fetch.js +14 -2
- package/clis/douyin/_shared/browser-fetch.test.js +13 -0
- package/clis/douyin/stats.js +1 -1
- package/clis/douyin/update.js +1 -1
- package/clis/jike/search.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +2 -1
- package/clis/twitter/bookmark-folder.js +189 -0
- package/clis/twitter/bookmark-folder.test.js +334 -0
- package/clis/twitter/bookmark-folders.js +117 -0
- package/clis/twitter/bookmark-folders.test.js +150 -0
- package/clis/twitter/bookmark.js +15 -6
- package/clis/twitter/bookmark.test.js +74 -0
- package/clis/twitter/bookmarks.js +7 -5
- package/clis/twitter/delete.js +11 -35
- package/clis/twitter/delete.test.js +21 -9
- package/clis/twitter/download.js +5 -5
- package/clis/twitter/followers.js +9 -3
- package/clis/twitter/following.js +11 -5
- package/clis/twitter/hide-reply.js +24 -5
- package/clis/twitter/hide-reply.test.js +76 -0
- package/clis/twitter/like.js +21 -11
- package/clis/twitter/like.test.js +73 -0
- package/clis/twitter/likes.js +8 -6
- package/clis/twitter/list-add.js +4 -4
- package/clis/twitter/list-remove.js +4 -4
- package/clis/twitter/list-tweets.js +6 -4
- package/clis/twitter/lists.js +3 -3
- package/clis/twitter/notifications.js +2 -2
- package/clis/twitter/profile.js +4 -3
- package/clis/twitter/quote.js +167 -0
- package/clis/twitter/quote.test.js +194 -0
- package/clis/twitter/reply.js +24 -178
- package/clis/twitter/reply.test.js +29 -11
- package/clis/twitter/retweet.js +94 -0
- package/clis/twitter/retweet.test.js +73 -0
- package/clis/twitter/search.js +175 -23
- package/clis/twitter/search.test.js +266 -1
- package/clis/twitter/shared.js +81 -0
- package/clis/twitter/shared.test.js +134 -1
- package/clis/twitter/thread.js +6 -4
- package/clis/twitter/timeline.js +8 -6
- package/clis/twitter/tweets.js +5 -3
- package/clis/twitter/unbookmark.js +13 -6
- package/clis/twitter/unbookmark.test.js +73 -0
- package/clis/twitter/unlike.js +80 -0
- package/clis/twitter/unlike.test.js +75 -0
- package/clis/twitter/unretweet.js +94 -0
- package/clis/twitter/unretweet.test.js +73 -0
- package/clis/twitter/utils.js +286 -0
- package/clis/twitter/utils.test.js +169 -0
- package/dist/src/browser/ax-snapshot.d.ts +37 -0
- package/dist/src/browser/ax-snapshot.js +217 -0
- package/dist/src/browser/ax-snapshot.test.d.ts +1 -0
- package/dist/src/browser/ax-snapshot.test.js +91 -0
- package/dist/src/browser/base-page.d.ts +51 -0
- package/dist/src/browser/base-page.js +545 -2
- package/dist/src/browser/base-page.test.js +520 -4
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp-click-fixture.test.d.ts +1 -0
- package/dist/src/browser/cdp-click-fixture.test.js +87 -0
- package/dist/src/browser/cdp.js +5 -0
- package/dist/src/browser/cdp.test.js +1 -0
- package/dist/src/browser/daemon-client.d.ts +3 -1
- package/dist/src/browser/find.d.ts +9 -1
- package/dist/src/browser/find.js +219 -0
- package/dist/src/browser/find.test.js +61 -1
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +13 -0
- package/dist/src/browser/page.test.js +28 -0
- package/dist/src/browser/target-errors.d.ts +3 -1
- package/dist/src/browser/target-errors.js +2 -0
- package/dist/src/browser/target-resolver.d.ts +14 -0
- package/dist/src/browser/target-resolver.js +28 -0
- package/dist/src/browser/visual-refs.d.ts +11 -0
- package/dist/src/browser/visual-refs.js +108 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.d.ts +23 -0
- package/dist/src/build-manifest.js +34 -0
- package/dist/src/build-manifest.test.js +108 -1
- package/dist/src/cli.js +560 -58
- package/dist/src/cli.test.js +689 -1
- package/dist/src/commanderAdapter.js +23 -4
- package/dist/src/help.d.ts +36 -0
- package/dist/src/help.js +301 -5
- package/dist/src/types.d.ts +82 -0
- package/package.json +1 -1
- package/scripts/typed-error-lint-baseline.json +18 -18
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import './hide-reply.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter hide-reply command', () => {
|
|
8
|
+
it('navigates to the reply URL and reports success when the hide-reply script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Reply successfully hidden.' },
|
|
13
|
+
]);
|
|
14
|
+
const result = await cmd.func(page, {
|
|
15
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
16
|
+
});
|
|
17
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com/alice/status/2040254679301718161');
|
|
18
|
+
expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="primaryColumn"]' });
|
|
19
|
+
expect(page.wait).toHaveBeenNthCalledWith(2, 2);
|
|
20
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
21
|
+
// Article-scoped More menu lookup — without scoping, the bare
|
|
22
|
+
// [aria-label="More"] selector grabs the parent tweet's More menu and
|
|
23
|
+
// silently hides the wrong reply (or fails because the parent is not a
|
|
24
|
+
// reply you authored).
|
|
25
|
+
expect(script).toContain('moreMenu.click()');
|
|
26
|
+
expect(script).toContain('[role="menuitem"]');
|
|
27
|
+
expect(script).toContain("'Hide reply'");
|
|
28
|
+
expect(script).toContain('hideItem.click()');
|
|
29
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
30
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
31
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
32
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
33
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
34
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
35
|
+
expect(result).toEqual([
|
|
36
|
+
{ status: 'success', message: 'Reply successfully hidden.' },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns a failed row without re-waiting when the hide-reply script reports a UI mismatch', async () => {
|
|
41
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
42
|
+
const page = createPageMock([
|
|
43
|
+
{
|
|
44
|
+
ok: false,
|
|
45
|
+
message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.',
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
const result = await cmd.func(page, {
|
|
49
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
50
|
+
});
|
|
51
|
+
expect(result).toEqual([
|
|
52
|
+
{
|
|
53
|
+
status: 'failed',
|
|
54
|
+
message: 'Could not find "Hide reply" option. This may not be a reply on your tweet.',
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
61
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
62
|
+
await expect(cmd.func(undefined, {
|
|
63
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
64
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects invalid tweet URLs before navigation', async () => {
|
|
68
|
+
const cmd = getRegistry().get('twitter/hide-reply');
|
|
69
|
+
const page = createPageMock([]);
|
|
70
|
+
await expect(cmd.func(page, {
|
|
71
|
+
url: 'https://x.com.evil.com/alice/status/2040254679301718161',
|
|
72
|
+
})).rejects.toThrow(ArgumentError);
|
|
73
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
74
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/clis/twitter/like.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
4
|
+
|
|
3
5
|
cli({
|
|
4
6
|
site: 'twitter',
|
|
5
7
|
name: 'like',
|
|
@@ -15,21 +17,28 @@ cli({
|
|
|
15
17
|
func: async (page, kwargs) => {
|
|
16
18
|
if (!page)
|
|
17
19
|
throw new CommandExecutionError('Browser session required for twitter like');
|
|
18
|
-
|
|
20
|
+
const target = parseTweetUrl(kwargs.url);
|
|
21
|
+
await page.goto(target.url);
|
|
19
22
|
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
20
23
|
const result = await page.evaluate(`(async () => {
|
|
21
24
|
try {
|
|
22
|
-
|
|
25
|
+
${buildTwitterArticleScopeSource(target.id)}
|
|
26
|
+
// Poll for the tweet to render. We scope state probes to the
|
|
27
|
+
// article matching the requested status id — on conversation
|
|
28
|
+
// pages multiple articles render and a bare querySelector would
|
|
29
|
+
// grab the first one (silent: like the wrong tweet).
|
|
23
30
|
let attempts = 0;
|
|
24
31
|
let likeBtn = null;
|
|
25
32
|
let unlikeBtn = null;
|
|
26
|
-
|
|
33
|
+
let targetArticle = null;
|
|
34
|
+
|
|
27
35
|
while (attempts < 20) {
|
|
28
|
-
|
|
29
|
-
likeBtn =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
targetArticle = findTargetArticle();
|
|
37
|
+
likeBtn = targetArticle?.querySelector('[data-testid="like"]') || null;
|
|
38
|
+
unlikeBtn = targetArticle?.querySelector('[data-testid="unlike"]') || null;
|
|
39
|
+
|
|
40
|
+
if (likeBtn || unlikeBtn) break;
|
|
41
|
+
|
|
33
42
|
await new Promise(r => setTimeout(r, 500));
|
|
34
43
|
attempts++;
|
|
35
44
|
}
|
|
@@ -46,9 +55,10 @@ cli({
|
|
|
46
55
|
// Click Like
|
|
47
56
|
likeBtn.click();
|
|
48
57
|
await new Promise(r => setTimeout(r, 1000));
|
|
49
|
-
|
|
50
|
-
// Verify success by checking if the 'unlike' button
|
|
51
|
-
const
|
|
58
|
+
|
|
59
|
+
// Verify success by checking if the 'unlike' button reappeared
|
|
60
|
+
const verifyArticle = findTargetArticle() || targetArticle;
|
|
61
|
+
const verifyBtn = verifyArticle?.querySelector('[data-testid="unlike"]');
|
|
52
62
|
if (verifyBtn) {
|
|
53
63
|
return { ok: true, message: 'Tweet successfully liked.' };
|
|
54
64
|
} else {
|
|
@@ -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 './like.js';
|
|
5
|
+
import { createPageMock } from '../test-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('twitter like command', () => {
|
|
8
|
+
it('navigates to the tweet URL and reports success when the like script confirms', async () => {
|
|
9
|
+
const cmd = getRegistry().get('twitter/like');
|
|
10
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
11
|
+
const page = createPageMock([
|
|
12
|
+
{ ok: true, message: 'Tweet successfully liked.' },
|
|
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: looks for the unlike button (already-liked path) before clicking.
|
|
22
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"like\"]')");
|
|
23
|
+
expect(script).toContain("targetArticle?.querySelector('[data-testid=\"unlike\"]')");
|
|
24
|
+
expect(script).toContain('likeBtn.click()');
|
|
25
|
+
// Article scoping comes from the shared helper (buildTwitterArticleScopeSource):
|
|
26
|
+
// emits __twHasLinkToTarget + __twGetStatusIdFromHref + the anchored
|
|
27
|
+
// tweet-path regex. JSDOM-level coverage lives in shared.test.js.
|
|
28
|
+
expect(script).toContain('__twHasLinkToTarget');
|
|
29
|
+
expect(script).toContain('__twGetStatusIdFromHref');
|
|
30
|
+
expect(script).toContain("document.querySelectorAll('article')");
|
|
31
|
+
expect(result).toEqual([
|
|
32
|
+
{ status: 'success', message: 'Tweet successfully liked.' },
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns a failed row without re-waiting when the like script reports a UI mismatch', async () => {
|
|
37
|
+
const cmd = getRegistry().get('twitter/like');
|
|
38
|
+
const page = createPageMock([
|
|
39
|
+
{
|
|
40
|
+
ok: false,
|
|
41
|
+
message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
const result = await cmd.func(page, {
|
|
45
|
+
url: 'https://x.com/alice/status/2040254679301718161',
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual([
|
|
48
|
+
{
|
|
49
|
+
status: 'failed',
|
|
50
|
+
message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?',
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
// Only the primaryColumn wait should run when ok is false.
|
|
54
|
+
expect(page.wait).toHaveBeenCalledTimes(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws CommandExecutionError when no page is provided', async () => {
|
|
58
|
+
const cmd = getRegistry().get('twitter/like');
|
|
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/like');
|
|
66
|
+
const page = createPageMock([]);
|
|
67
|
+
await expect(cmd.func(page, {
|
|
68
|
+
url: 'https://x.com/alice/status/2040254679301718161/photo/1',
|
|
69
|
+
})).rejects.toThrow(ArgumentError);
|
|
70
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
71
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
});
|
package/clis/twitter/likes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js';
|
|
4
|
-
|
|
4
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
5
5
|
const LIKES_QUERY_ID = 'RozQdCp4CilQzrcuU0NY5w';
|
|
6
6
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
7
7
|
const FEATURES = {
|
|
@@ -138,13 +138,14 @@ cli({
|
|
|
138
138
|
site: 'twitter',
|
|
139
139
|
name: 'likes',
|
|
140
140
|
access: 'read',
|
|
141
|
-
description: 'Fetch liked tweets of a Twitter user',
|
|
141
|
+
description: 'Fetch liked tweets of a Twitter user (defaults to the logged-in user when no username is given)',
|
|
142
142
|
domain: 'x.com',
|
|
143
143
|
strategy: Strategy.COOKIE,
|
|
144
144
|
browser: true,
|
|
145
145
|
args: [
|
|
146
|
-
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
|
147
|
-
{ name: 'limit', type: 'int', default: 20 },
|
|
146
|
+
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
147
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
|
|
148
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the liked 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 API\'s native (recency) ordering.' },
|
|
148
149
|
],
|
|
149
150
|
columns: ['id', 'author', 'name', 'text', 'likes', 'retweets', 'created_at', 'url', 'has_media', 'media_urls'],
|
|
150
151
|
func: async (page, kwargs) => {
|
|
@@ -170,7 +171,7 @@ cli({
|
|
|
170
171
|
const likesQueryId = await resolveTwitterQueryId(page, 'Likes', LIKES_QUERY_ID);
|
|
171
172
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
172
173
|
const headers = JSON.stringify({
|
|
173
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
174
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
174
175
|
'X-Csrf-Token': ct0,
|
|
175
176
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
176
177
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -208,7 +209,8 @@ cli({
|
|
|
208
209
|
break;
|
|
209
210
|
cursor = nextCursor;
|
|
210
211
|
}
|
|
211
|
-
|
|
212
|
+
const trimmed = allTweets.slice(0, limit);
|
|
213
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
212
214
|
},
|
|
213
215
|
});
|
|
214
216
|
export const __test__ = {
|
package/clis/twitter/list-add.js
CHANGED
|
@@ -2,8 +2,8 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { resolveTwitterQueryId } from './shared.js';
|
|
4
4
|
import { parseListsManagement } from './lists.js';
|
|
5
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
6
|
|
|
6
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
7
7
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
8
8
|
const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
|
|
9
9
|
|
|
@@ -71,8 +71,8 @@ cli({
|
|
|
71
71
|
strategy: Strategy.UI,
|
|
72
72
|
browser: true,
|
|
73
73
|
args: [
|
|
74
|
-
{ name: 'listId', positional: true, type: 'string', required: true },
|
|
75
|
-
{ name: 'username', positional: true, type: 'string', required: true },
|
|
74
|
+
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' },
|
|
75
|
+
{ name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to add (with or without @)' },
|
|
76
76
|
],
|
|
77
77
|
columns: ['listId', 'username', 'userId', 'status', 'message'],
|
|
78
78
|
func: async (page, kwargs) => {
|
|
@@ -94,7 +94,7 @@ cli({
|
|
|
94
94
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
95
95
|
|
|
96
96
|
const headers = JSON.stringify({
|
|
97
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
97
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
98
98
|
'X-Csrf-Token': ct0,
|
|
99
99
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
100
100
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -2,8 +2,8 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
3
|
import { resolveTwitterQueryId } from './shared.js';
|
|
4
4
|
import { parseListsManagement } from './lists.js';
|
|
5
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
5
6
|
|
|
6
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
7
7
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
8
8
|
const LISTS_MANAGEMENT_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
|
|
9
9
|
|
|
@@ -80,8 +80,8 @@ cli({
|
|
|
80
80
|
strategy: Strategy.UI,
|
|
81
81
|
browser: true,
|
|
82
82
|
args: [
|
|
83
|
-
{ name: 'listId', positional: true, type: 'string', required: true },
|
|
84
|
-
{ name: 'username', positional: true, type: 'string', required: true },
|
|
83
|
+
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of the list you own (e.g. from `opencli twitter lists`)' },
|
|
84
|
+
{ name: 'username', positional: true, type: 'string', required: true, help: 'Twitter/X handle to remove (with or without @)' },
|
|
85
85
|
],
|
|
86
86
|
columns: ['listId', 'username', 'userId', 'status', 'message'],
|
|
87
87
|
func: async (page, kwargs) => {
|
|
@@ -101,7 +101,7 @@ cli({
|
|
|
101
101
|
|
|
102
102
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
103
103
|
const headers = JSON.stringify({
|
|
104
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
104
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
105
105
|
'X-Csrf-Token': ct0,
|
|
106
106
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
107
107
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
3
4
|
|
|
4
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
5
|
const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA';
|
|
6
6
|
const OPERATION_NAME = 'ListLatestTweetsTimeline';
|
|
7
7
|
|
|
@@ -113,8 +113,9 @@ cli({
|
|
|
113
113
|
strategy: Strategy.COOKIE,
|
|
114
114
|
browser: true,
|
|
115
115
|
args: [
|
|
116
|
-
{ name: 'listId', positional: true, type: 'string', required: true },
|
|
116
|
+
{ name: 'listId', positional: true, type: 'string', required: true, help: 'Numeric ID of a Twitter/X list (e.g. from `opencli twitter lists`)' },
|
|
117
117
|
{ name: 'limit', type: 'int', default: 50 },
|
|
118
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the list 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 the list\'s native (recency) ordering.' },
|
|
118
119
|
],
|
|
119
120
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'],
|
|
120
121
|
func: async (page, kwargs) => {
|
|
@@ -155,7 +156,7 @@ cli({
|
|
|
155
156
|
return null;
|
|
156
157
|
}`) || LIST_TWEETS_QUERY_ID;
|
|
157
158
|
const headers = JSON.stringify({
|
|
158
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
159
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
159
160
|
'X-Csrf-Token': ct0,
|
|
160
161
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
161
162
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -181,6 +182,7 @@ cli({
|
|
|
181
182
|
break;
|
|
182
183
|
cursor = nextCursor;
|
|
183
184
|
}
|
|
184
|
-
|
|
185
|
+
const trimmed = allTweets.slice(0, limit);
|
|
186
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
185
187
|
},
|
|
186
188
|
});
|
package/clis/twitter/lists.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
3
4
|
|
|
4
|
-
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
5
5
|
const LISTS_QUERY_ID = '78UbkyXwXBD98IgUWXOy9g';
|
|
6
6
|
const OPERATION_NAME = 'ListsManagementPageTimeline';
|
|
7
7
|
|
|
@@ -93,7 +93,7 @@ export const command = cli({
|
|
|
93
93
|
strategy: Strategy.COOKIE,
|
|
94
94
|
browser: true,
|
|
95
95
|
args: [
|
|
96
|
-
{ name: 'limit', type: 'int', default: 50 },
|
|
96
|
+
{ name: 'limit', type: 'int', default: 50, help: 'Maximum number of lists to return (default 50).' },
|
|
97
97
|
],
|
|
98
98
|
columns: ['id', 'name', 'members', 'followers', 'mode'],
|
|
99
99
|
func: async (page, kwargs) => {
|
|
@@ -130,7 +130,7 @@ export const command = cli({
|
|
|
130
130
|
return null;
|
|
131
131
|
}`) || LISTS_QUERY_ID;
|
|
132
132
|
const headers = JSON.stringify({
|
|
133
|
-
'Authorization': `Bearer ${decodeURIComponent(
|
|
133
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
134
134
|
'X-Csrf-Token': ct0,
|
|
135
135
|
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
136
136
|
'X-Twitter-Active-User': 'yes',
|
|
@@ -4,12 +4,12 @@ cli({
|
|
|
4
4
|
site: 'twitter',
|
|
5
5
|
name: 'notifications',
|
|
6
6
|
access: 'read',
|
|
7
|
-
description: 'Get Twitter/X notifications',
|
|
7
|
+
description: 'Get your Twitter/X notifications (the logged-in user\'s likes/replies/follows feed, newest first)',
|
|
8
8
|
domain: 'x.com',
|
|
9
9
|
strategy: Strategy.INTERCEPT,
|
|
10
10
|
browser: true,
|
|
11
11
|
args: [
|
|
12
|
-
{ name: 'limit', type: 'int', default: 20 },
|
|
12
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of notifications to return (default 20).' },
|
|
13
13
|
],
|
|
14
14
|
columns: ['id', 'action', 'author', 'text', 'url'],
|
|
15
15
|
func: async (page, kwargs) => {
|
package/clis/twitter/profile.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
2
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
3
|
import { resolveTwitterQueryId } from './shared.js';
|
|
4
|
+
import { TWITTER_BEARER_TOKEN } from './utils.js';
|
|
4
5
|
const USER_BY_SCREEN_NAME_QUERY_ID = 'qRednkZG-rn1P6b48NINmQ';
|
|
5
6
|
cli({
|
|
6
7
|
site: 'twitter',
|
|
7
8
|
name: 'profile',
|
|
8
9
|
access: 'read',
|
|
9
|
-
description: 'Fetch a Twitter user profile
|
|
10
|
+
description: 'Fetch a Twitter user profile — bio, stats, etc. (defaults to the logged-in user when no username is given)',
|
|
10
11
|
domain: 'x.com',
|
|
11
12
|
strategy: Strategy.COOKIE,
|
|
12
13
|
browser: true,
|
|
13
14
|
args: [
|
|
14
|
-
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
|
15
|
+
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
15
16
|
],
|
|
16
17
|
columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
|
|
17
18
|
func: async (page, kwargs) => {
|
|
@@ -38,7 +39,7 @@ cli({
|
|
|
38
39
|
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
|
39
40
|
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
40
41
|
|
|
41
|
-
const bearer =
|
|
42
|
+
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
|
|
42
43
|
const headers = {
|
|
43
44
|
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
|
44
45
|
'X-Csrf-Token': ct0,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
4
|
+
import { parseTweetUrl, buildTwitterArticleScopeSource } from './shared.js';
|
|
5
|
+
import {
|
|
6
|
+
COMPOSER_FILE_INPUT_SELECTOR,
|
|
7
|
+
attachComposerImage,
|
|
8
|
+
downloadRemoteImage,
|
|
9
|
+
resolveImagePath,
|
|
10
|
+
} from './utils.js';
|
|
11
|
+
|
|
12
|
+
function buildQuoteComposerUrl(url) {
|
|
13
|
+
// Twitter/X quote-tweet compose URL: the `url` param attaches the source
|
|
14
|
+
// tweet as a quoted card. Validating tweet-id shape early surfaces obvious
|
|
15
|
+
// typos before any browser interaction.
|
|
16
|
+
const parsed = parseTweetUrl(url);
|
|
17
|
+
return `https://x.com/compose/post?url=${encodeURIComponent(parsed.url)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function submitQuote(page, text, tweetId) {
|
|
21
|
+
return page.evaluate(`(async () => {
|
|
22
|
+
try {
|
|
23
|
+
${buildTwitterArticleScopeSource(tweetId)}
|
|
24
|
+
const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
|
|
25
|
+
const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
|
|
26
|
+
const box = boxes.find(visible) || boxes[0];
|
|
27
|
+
if (!box) {
|
|
28
|
+
return { ok: false, message: 'Could not find the quote composer text area. Are you logged in?' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
box.focus();
|
|
32
|
+
const textToInsert = ${JSON.stringify(text)};
|
|
33
|
+
// execCommand('insertText') is more reliable with Twitter's Draft.js editor.
|
|
34
|
+
if (!document.execCommand('insertText', false, textToInsert)) {
|
|
35
|
+
// Fallback to paste event if execCommand fails.
|
|
36
|
+
const dataTransfer = new DataTransfer();
|
|
37
|
+
dataTransfer.setData('text/plain', textToInsert);
|
|
38
|
+
box.dispatchEvent(new ClipboardEvent('paste', {
|
|
39
|
+
clipboardData: dataTransfer,
|
|
40
|
+
bubbles: true,
|
|
41
|
+
cancelable: true,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
46
|
+
|
|
47
|
+
// Confirm the quoted card is rendered before submitting; otherwise
|
|
48
|
+
// we may accidentally post a plain tweet without the quote
|
|
49
|
+
// attachment. The compose page does not wrap the card in an
|
|
50
|
+
// <article>, so we probe the document for any link whose path
|
|
51
|
+
// exactly matches the requested status id (uses __twHasLinkToTarget
|
|
52
|
+
// from buildTwitterArticleScopeSource).
|
|
53
|
+
let cardAttempts = 0;
|
|
54
|
+
let hasQuoteCard = false;
|
|
55
|
+
while (cardAttempts < 20) {
|
|
56
|
+
hasQuoteCard = __twHasLinkToTarget(document);
|
|
57
|
+
if (hasQuoteCard) break;
|
|
58
|
+
await new Promise(r => setTimeout(r, 250));
|
|
59
|
+
cardAttempts++;
|
|
60
|
+
}
|
|
61
|
+
if (!hasQuoteCard) {
|
|
62
|
+
return { ok: false, message: 'Quote target did not render in the composer. The source tweet may be deleted or restricted.' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const buttons = Array.from(
|
|
66
|
+
document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
|
|
67
|
+
);
|
|
68
|
+
const btn = buttons.find((el) => visible(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true');
|
|
69
|
+
if (!btn) {
|
|
70
|
+
return { ok: false, message: 'Tweet button is disabled or not found.' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
btn.click();
|
|
74
|
+
|
|
75
|
+
const normalize = s => String(s || '').replace(/\\u00a0/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
76
|
+
const expectedText = normalize(textToInsert);
|
|
77
|
+
for (let i = 0; i < 30; i++) {
|
|
78
|
+
await new Promise(r => setTimeout(r, 500));
|
|
79
|
+
const toasts = Array.from(document.querySelectorAll('[role="alert"], [data-testid="toast"]'))
|
|
80
|
+
.filter((el) => visible(el));
|
|
81
|
+
const successToast = toasts.find((el) => /sent|posted|your post was sent|your tweet was sent/i.test(el.textContent || ''));
|
|
82
|
+
if (successToast) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
83
|
+
const alert = toasts.find((el) => /failed|error|try again|not sent|could not/i.test(el.textContent || ''));
|
|
84
|
+
if (alert) return { ok: false, message: (alert.textContent || 'Quote tweet failed to post.').trim() };
|
|
85
|
+
|
|
86
|
+
const visibleBoxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')).filter(visible);
|
|
87
|
+
const composerStillHasText = visibleBoxes.some((box) =>
|
|
88
|
+
normalize(box.innerText || box.textContent || '').includes(expectedText)
|
|
89
|
+
);
|
|
90
|
+
if (!composerStillHasText) return { ok: true, message: 'Quote tweet posted successfully.' };
|
|
91
|
+
}
|
|
92
|
+
return { ok: false, message: 'Quote tweet submission did not complete before timeout.' };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { ok: false, message: e.toString() };
|
|
95
|
+
}
|
|
96
|
+
})()`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
cli({
|
|
100
|
+
site: 'twitter',
|
|
101
|
+
name: 'quote',
|
|
102
|
+
access: 'write',
|
|
103
|
+
description: 'Quote-tweet a specific tweet with your own text, optionally with a local or remote image',
|
|
104
|
+
domain: 'x.com',
|
|
105
|
+
strategy: Strategy.UI,
|
|
106
|
+
browser: true,
|
|
107
|
+
args: [
|
|
108
|
+
{ name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to quote' },
|
|
109
|
+
{ name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your quote' },
|
|
110
|
+
{ name: 'image', help: 'Optional local image path to attach to the quote tweet' },
|
|
111
|
+
{ name: 'image-url', help: 'Optional remote image URL to download and attach to the quote tweet' },
|
|
112
|
+
],
|
|
113
|
+
columns: ['status', 'message', 'text'],
|
|
114
|
+
func: async (page, kwargs) => {
|
|
115
|
+
if (!page)
|
|
116
|
+
throw new CommandExecutionError('Browser session required for twitter quote');
|
|
117
|
+
if (kwargs.image && kwargs['image-url']) {
|
|
118
|
+
throw new CommandExecutionError('Use either --image or --image-url, not both.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Validate URL (typed ArgumentError on malformed/off-domain inputs)
|
|
122
|
+
// before any browser interaction or remote image download.
|
|
123
|
+
const target = parseTweetUrl(kwargs.url);
|
|
124
|
+
|
|
125
|
+
let localImagePath;
|
|
126
|
+
let cleanupDir;
|
|
127
|
+
try {
|
|
128
|
+
if (kwargs.image) {
|
|
129
|
+
localImagePath = resolveImagePath(kwargs.image);
|
|
130
|
+
} else if (kwargs['image-url']) {
|
|
131
|
+
const downloaded = await downloadRemoteImage(kwargs['image-url']);
|
|
132
|
+
localImagePath = downloaded.absPath;
|
|
133
|
+
cleanupDir = downloaded.cleanupDir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Dedicated composer is more reliable than the inline quote-tweet button.
|
|
137
|
+
await page.goto(`https://x.com/compose/post?url=${encodeURIComponent(target.url)}`, { waitUntil: 'load', settleMs: 2500 });
|
|
138
|
+
await page.wait({ selector: '[data-testid="tweetTextarea_0"]', timeout: 15 });
|
|
139
|
+
|
|
140
|
+
if (localImagePath) {
|
|
141
|
+
await page.wait({ selector: COMPOSER_FILE_INPUT_SELECTOR, timeout: 20 });
|
|
142
|
+
await attachComposerImage(page, localImagePath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = await submitQuote(page, kwargs.text, target.id);
|
|
146
|
+
if (result.ok) {
|
|
147
|
+
// Wait for network submission to complete
|
|
148
|
+
await page.wait(3);
|
|
149
|
+
}
|
|
150
|
+
return [{
|
|
151
|
+
status: result.ok ? 'success' : 'failed',
|
|
152
|
+
message: result.message,
|
|
153
|
+
text: kwargs.text,
|
|
154
|
+
...(kwargs.image ? { image: kwargs.image } : {}),
|
|
155
|
+
...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
|
|
156
|
+
}];
|
|
157
|
+
} finally {
|
|
158
|
+
if (cleanupDir) {
|
|
159
|
+
fs.rmSync(cleanupDir, { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
export const __test__ = {
|
|
166
|
+
buildQuoteComposerUrl,
|
|
167
|
+
};
|