@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
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
requireBoundedInt,
|
|
9
9
|
requireForumId,
|
|
10
10
|
requireNonNegativeInt,
|
|
11
|
+
requireProfileId,
|
|
11
12
|
} from './utils.js';
|
|
12
13
|
import './search.js';
|
|
13
14
|
import './venue.js';
|
|
14
15
|
import './paper.js';
|
|
15
16
|
import './reviews.js';
|
|
17
|
+
import './author.js';
|
|
16
18
|
|
|
17
19
|
const SAMPLE_NOTE = {
|
|
18
20
|
id: 'abc123XYZ_',
|
|
@@ -37,21 +39,24 @@ afterEach(() => {
|
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
describe('openreview adapter', () => {
|
|
40
|
-
it('registers all
|
|
42
|
+
it('registers all five commands with the expected columns', () => {
|
|
41
43
|
const search = getRegistry().get('openreview/search');
|
|
42
44
|
const venue = getRegistry().get('openreview/venue');
|
|
43
45
|
const paper = getRegistry().get('openreview/paper');
|
|
44
46
|
const reviews = getRegistry().get('openreview/reviews');
|
|
47
|
+
const author = getRegistry().get('openreview/author');
|
|
45
48
|
|
|
46
49
|
expect(search).toBeDefined();
|
|
47
50
|
expect(venue).toBeDefined();
|
|
48
51
|
expect(paper).toBeDefined();
|
|
49
52
|
expect(reviews).toBeDefined();
|
|
53
|
+
expect(author).toBeDefined();
|
|
50
54
|
|
|
51
55
|
expect(search.columns).toEqual(['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url']);
|
|
52
56
|
expect(venue.columns).toEqual(['rank', 'id', 'title', 'authors', 'keywords', 'primary_area', 'pdate', 'pdf', 'url']);
|
|
53
57
|
expect(paper.columns).toEqual(['id', 'title', 'authors', 'keywords', 'venue', 'venueid', 'primary_area', 'abstract', 'pdate', 'pdf', 'url']);
|
|
54
58
|
expect(reviews.columns).toEqual(['type', 'author', 'rating', 'confidence', 'text']);
|
|
59
|
+
expect(author.columns).toEqual(['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url']);
|
|
55
60
|
});
|
|
56
61
|
|
|
57
62
|
it('noteToRow extracts every wrapped v2 field, joins lists, and builds absolute URLs', () => {
|
|
@@ -109,6 +114,30 @@ describe('openreview adapter', () => {
|
|
|
109
114
|
expect(() => requireForumId('short')).toThrow('not a valid forum id');
|
|
110
115
|
});
|
|
111
116
|
|
|
117
|
+
it('requireProfileId accepts canonical profile ids and rejects malformed input', () => {
|
|
118
|
+
expect(requireProfileId('~Yoshua_Bengio1')).toBe('~Yoshua_Bengio1');
|
|
119
|
+
expect(requireProfileId('~Bo_Liu17')).toBe('~Bo_Liu17');
|
|
120
|
+
expect(requireProfileId('~Geoffrey_Everest_Hinton1')).toBe('~Geoffrey_Everest_Hinton1');
|
|
121
|
+
expect(requireProfileId('~Anne-Christin_Hauschild1')).toBe('~Anne-Christin_Hauschild1');
|
|
122
|
+
expect(requireProfileId('~S.Aruna_Deepthi1')).toBe('~S.Aruna_Deepthi1');
|
|
123
|
+
expect(requireProfileId('~Andrzej_Czyżewski1')).toBe('~Andrzej_Czyżewski1');
|
|
124
|
+
expect(requireProfileId('~August_Bøgh_Rønberg1')).toBe('~August_Bøgh_Rønberg1');
|
|
125
|
+
expect(requireProfileId('~Wagner_Meira_Jr.1')).toBe('~Wagner_Meira_Jr.1');
|
|
126
|
+
expect(() => requireProfileId('')).toThrow('required');
|
|
127
|
+
expect(() => requireProfileId(' ')).toThrow('required');
|
|
128
|
+
// Missing leading tilde.
|
|
129
|
+
expect(() => requireProfileId('Bo_Liu17')).toThrow('not a valid profile id');
|
|
130
|
+
// Missing trailing disambiguator number.
|
|
131
|
+
expect(() => requireProfileId('~Bo_Liu')).toThrow('not a valid profile id');
|
|
132
|
+
// Spaces / non-letter characters break the underscore-joined name.
|
|
133
|
+
expect(() => requireProfileId('~Bo Liu1')).toThrow('not a valid profile id');
|
|
134
|
+
// dblp-style PID must not silently fall through.
|
|
135
|
+
expect(() => requireProfileId('56/953')).toThrow('not a valid profile id');
|
|
136
|
+
expect(() => requireProfileId('~Bo_Liu1?evil=1')).toThrow('not a valid profile id');
|
|
137
|
+
expect(() => requireProfileId('~Bo/Liu1')).toThrow('not a valid profile id');
|
|
138
|
+
expect(() => requireProfileId('~123')).toThrow('not a valid profile id');
|
|
139
|
+
});
|
|
140
|
+
|
|
112
141
|
it('formatDate handles ms-since-epoch and rejects invalid input', () => {
|
|
113
142
|
expect(formatDate(1727524853394)).toBe('2024-09-28');
|
|
114
143
|
expect(formatDate(0)).toBe('');
|
|
@@ -342,4 +371,57 @@ describe('openreview adapter', () => {
|
|
|
342
371
|
expect(rows[1].text.length).toBe(500);
|
|
343
372
|
expect(rows[1].text.endsWith('...')).toBe(true);
|
|
344
373
|
});
|
|
374
|
+
|
|
375
|
+
it('author rejects invalid profile ids before calling the network', async () => {
|
|
376
|
+
const fetchMock = vi.fn();
|
|
377
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
378
|
+
const author = getRegistry().get('openreview/author');
|
|
379
|
+
await expect(author.func({ profile: 'Bo_Liu17', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
380
|
+
await expect(author.func({ profile: '', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
381
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('author throws EmptyResult when the profile has no submissions', async () => {
|
|
385
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ notes: [] }), { status: 200 })));
|
|
386
|
+
const author = getRegistry().get('openreview/author');
|
|
387
|
+
await expect(author.func({ profile: '~No_Submissions1', limit: 5 })).rejects.toMatchObject({ code: 'EMPTY_RESULT' });
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('author wraps non-200 responses as CommandExecutionError', async () => {
|
|
391
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
|
|
392
|
+
const author = getRegistry().get('openreview/author');
|
|
393
|
+
await expect(author.func({ profile: '~Bo_Liu17', limit: 5 })).rejects.toMatchObject({ code: 'COMMAND_EXEC' });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('author wraps fetch network errors as CommandExecutionError', async () => {
|
|
397
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNRESET')));
|
|
398
|
+
const author = getRegistry().get('openreview/author');
|
|
399
|
+
await expect(author.func({ profile: '~Bo_Liu17', limit: 5 })).rejects.toMatchObject({
|
|
400
|
+
code: 'COMMAND_EXEC',
|
|
401
|
+
message: expect.stringContaining('Network failure'),
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('author hits /notes?content.authorids and returns rank-ordered rows', async () => {
|
|
406
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ notes: [SAMPLE_NOTE, SAMPLE_NOTE] }), { status: 200 }));
|
|
407
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
408
|
+
const author = getRegistry().get('openreview/author');
|
|
409
|
+
const rows = await author.func({ profile: '~Bo_Liu17', limit: 50 });
|
|
410
|
+
expect(rows).toHaveLength(2);
|
|
411
|
+
expect(rows[0]).toEqual({
|
|
412
|
+
rank: 1,
|
|
413
|
+
id: 'abc123XYZ_',
|
|
414
|
+
title: 'Test Paper Title with spaces',
|
|
415
|
+
authors: 'Alice Smith, Bob Jones',
|
|
416
|
+
venue: 'ICLR 2024 oral',
|
|
417
|
+
pdate: '2024-09-28',
|
|
418
|
+
url: 'https://openreview.net/forum?id=abc123XYZ_',
|
|
419
|
+
});
|
|
420
|
+
expect(rows[1].rank).toBe(2);
|
|
421
|
+
// Confirm the request shape: canonical authorids filter + cdate sort.
|
|
422
|
+
const url = fetchMock.mock.calls[0][0];
|
|
423
|
+
expect(url).toContain('content.authorids=');
|
|
424
|
+
expect(url).toContain(encodeURIComponent('~Bo_Liu17'));
|
|
425
|
+
expect(url).toContain('sort=cdate:desc');
|
|
426
|
+
});
|
|
345
427
|
});
|
package/clis/openreview/utils.js
CHANGED
|
@@ -55,6 +55,20 @@ export function requireForumId(value, label = 'id') {
|
|
|
55
55
|
return id;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** OpenReview profile ids are `~...N` slugs and may include dots, hyphens, and Unicode letters. */
|
|
59
|
+
const PROFILE_ID_PATTERN = /^~(?=.*\p{L})[\p{L}\p{M}0-9._-]+\d+$/u;
|
|
60
|
+
|
|
61
|
+
export function requireProfileId(value, label = 'profile') {
|
|
62
|
+
const id = String(value ?? '').trim();
|
|
63
|
+
if (!id) {
|
|
64
|
+
throw new ArgumentError(`openreview ${label} is required`);
|
|
65
|
+
}
|
|
66
|
+
if (!PROFILE_ID_PATTERN.test(id)) {
|
|
67
|
+
throw new ArgumentError(`openreview ${label} "${value}" is not a valid profile id (expected "~First_Last1" or similar; find it on the author's openreview.net profile URL)`);
|
|
68
|
+
}
|
|
69
|
+
return id;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
/** Wrap fetch + json with typed errors so failures never look like empty results. */
|
|
59
73
|
export async function openreviewFetch(path, label) {
|
|
60
74
|
const url = `${OPENREVIEW_API}${path}`;
|
package/clis/reddit/comment.js
CHANGED
|
@@ -8,6 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
+
browserSession: { reuse: 'site' },
|
|
11
12
|
args: [
|
|
12
13
|
{ name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
13
14
|
{ name: 'text', type: 'string', required: true, positional: true, help: 'Comment text' },
|
package/clis/reddit/frontpage.js
CHANGED
package/clis/reddit/popular.js
CHANGED
package/clis/reddit/read.js
CHANGED
|
@@ -15,6 +15,8 @@ cli({
|
|
|
15
15
|
description: 'Read a Reddit post and its comments',
|
|
16
16
|
domain: 'reddit.com',
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
|
+
browser: true,
|
|
19
|
+
browserSession: { reuse: 'site' },
|
|
18
20
|
args: [
|
|
19
21
|
{ name: 'post-id', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or full URL' },
|
|
20
22
|
{ name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
|
package/clis/reddit/read.test.js
CHANGED
|
@@ -3,6 +3,10 @@ import { getRegistry } from '@jackwener/opencli/registry';
|
|
|
3
3
|
import './read.js';
|
|
4
4
|
describe('reddit read adapter', () => {
|
|
5
5
|
const command = getRegistry().get('reddit/read');
|
|
6
|
+
it('opts into the Reddit site browser session lease', () => {
|
|
7
|
+
expect(command?.browser).toBe(true);
|
|
8
|
+
expect(command?.browserSession).toEqual({ reuse: 'site' });
|
|
9
|
+
});
|
|
6
10
|
it('returns threaded rows from the browser-evaluated payload', async () => {
|
|
7
11
|
const page = {
|
|
8
12
|
goto: vi.fn().mockResolvedValue(undefined),
|
package/clis/reddit/save.js
CHANGED
|
@@ -8,6 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
+
browserSession: { reuse: 'site' },
|
|
11
12
|
args: [
|
|
12
13
|
{ name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
13
14
|
{ name: 'undo', type: 'boolean', default: false, help: 'Unsave instead of save' },
|
package/clis/reddit/saved.js
CHANGED
package/clis/reddit/search.js
CHANGED
|
@@ -7,8 +7,9 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
|
-
{ name: 'query', type: 'string', required: true, positional: true },
|
|
12
|
+
{ name: 'query', type: 'string', required: true, positional: true, help: 'Reddit search query' },
|
|
12
13
|
{
|
|
13
14
|
name: 'subreddit',
|
|
14
15
|
type: 'string',
|
package/clis/reddit/subreddit.js
CHANGED
|
@@ -7,8 +7,9 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
|
-
{ name: 'name', type: 'string', required: true, positional: true },
|
|
12
|
+
{ name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix; e.g. `python`)' },
|
|
12
13
|
{
|
|
13
14
|
name: 'sort',
|
|
14
15
|
type: 'string',
|
package/clis/reddit/subscribe.js
CHANGED
|
@@ -8,6 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
+
browserSession: { reuse: 'site' },
|
|
11
12
|
args: [
|
|
12
13
|
{ name: 'subreddit', type: 'string', required: true, positional: true, help: 'Subreddit name (e.g. python)' },
|
|
13
14
|
{ name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' },
|
package/clis/reddit/upvote.js
CHANGED
|
@@ -8,6 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
+
browserSession: { reuse: 'site' },
|
|
11
12
|
args: [
|
|
12
13
|
{ name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
13
14
|
{ name: 'direction', type: 'string', default: 'up', help: 'Vote direction: up, down, none' },
|
package/clis/reddit/upvoted.js
CHANGED
|
@@ -7,8 +7,9 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
|
-
{ name: 'username', type: 'string', required: true, positional: true },
|
|
12
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
13
|
{ name: 'limit', type: 'int', default: 15 },
|
|
13
14
|
],
|
|
14
15
|
columns: ['subreddit', 'score', 'body', 'url'],
|
|
@@ -7,8 +7,9 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
|
-
{ name: 'username', type: 'string', required: true, positional: true },
|
|
12
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
13
|
{ name: 'limit', type: 'int', default: 15 },
|
|
13
14
|
],
|
|
14
15
|
columns: ['title', 'subreddit', 'score', 'comments', 'url'],
|
package/clis/reddit/user.js
CHANGED
|
@@ -7,8 +7,9 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
|
-
{ name: 'username', type: 'string', required: true, positional: true },
|
|
12
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
13
|
],
|
|
13
14
|
columns: ['field', 'value'],
|
|
14
15
|
pipeline: [
|
package/clis/twitter/article.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 TWEET_RESULT_BY_REST_ID_QUERY_ID = '7xflPyRiUxGVbJd4uWmbfg';
|
|
5
6
|
cli({
|
|
6
7
|
site: 'twitter',
|
|
@@ -10,6 +11,7 @@ cli({
|
|
|
10
11
|
domain: 'x.com',
|
|
11
12
|
strategy: Strategy.COOKIE,
|
|
12
13
|
browser: true,
|
|
14
|
+
browserSession: { reuse: 'site' },
|
|
13
15
|
args: [
|
|
14
16
|
{ name: 'tweet-id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
|
|
15
17
|
],
|
|
@@ -50,14 +52,18 @@ cli({
|
|
|
50
52
|
// Navigate to the tweet page for cookie context
|
|
51
53
|
await page.goto(`https://x.com/i/status/${tweetId}`);
|
|
52
54
|
await page.wait(3);
|
|
55
|
+
// Read CSRF token directly from the cookie store via CDP — zero page.evaluate round-trip
|
|
56
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
57
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
58
|
+
if (!ct0)
|
|
59
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
53
60
|
const queryId = await resolveTwitterQueryId(page, 'TweetResultByRestId', TWEET_RESULT_BY_REST_ID_QUERY_ID);
|
|
54
61
|
const result = await page.evaluate(`
|
|
55
62
|
async () => {
|
|
56
63
|
const tweetId = "${tweetId}";
|
|
57
|
-
const ct0 =
|
|
58
|
-
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
64
|
+
const ct0 = ${JSON.stringify(ct0)};
|
|
59
65
|
|
|
60
|
-
const bearer =
|
|
66
|
+
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
|
|
61
67
|
const headers = {
|
|
62
68
|
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
|
63
69
|
'X-Csrf-Token': ct0,
|
|
@@ -155,8 +161,6 @@ cli({
|
|
|
155
161
|
}
|
|
156
162
|
`);
|
|
157
163
|
if (result?.error) {
|
|
158
|
-
if (String(result.error).includes('No ct0 cookie'))
|
|
159
|
-
throw new AuthRequiredError('x.com', result.error);
|
|
160
164
|
throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
|
|
161
165
|
}
|
|
162
166
|
return result || [];
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { TWITTER_BEARER_TOKEN, applyTopByEngagement } from './utils.js';
|
|
4
|
+
import { resolveTwitterQueryId } from './shared.js';
|
|
5
|
+
|
|
6
|
+
// Companion to bookmark-folders.js: reads tweets inside a single folder.
|
|
7
|
+
// X exposes folder contents through a separate timeline operation
|
|
8
|
+
// (BookmarkFolderTimeline). The shape is essentially the same as the
|
|
9
|
+
// global bookmarks timeline (bookmark_timeline_v2.timeline.instructions),
|
|
10
|
+
// just scoped to one folder via the bookmark_collection_id variable.
|
|
11
|
+
const OPERATION_NAME = 'BookmarkFolderTimeline';
|
|
12
|
+
const FALLBACK_QUERY_ID = '13H7EUATwethsj_jZ6QQAQ';
|
|
13
|
+
const FOLDER_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
|
|
15
|
+
const FEATURES = {
|
|
16
|
+
rweb_video_screen_enabled: false,
|
|
17
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
18
|
+
responsive_web_profile_redirect_enabled: false,
|
|
19
|
+
rweb_tipjar_consumption_enabled: false,
|
|
20
|
+
verified_phone_label_enabled: false,
|
|
21
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
22
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
23
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
24
|
+
premium_content_api_read_enabled: false,
|
|
25
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
26
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
27
|
+
articles_preview_enabled: true,
|
|
28
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
29
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
30
|
+
view_counts_everywhere_api_enabled: true,
|
|
31
|
+
longform_notetweets_consumption_enabled: true,
|
|
32
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
33
|
+
tweet_awards_web_tipping_enabled: false,
|
|
34
|
+
content_disclosure_indicator_enabled: true,
|
|
35
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
36
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
37
|
+
standardized_nudges_misinfo: true,
|
|
38
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
39
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
40
|
+
longform_notetweets_inline_media_enabled: false,
|
|
41
|
+
responsive_web_enhance_cards_enabled: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function buildFolderTimelineUrl(queryId, folderId, count, cursor) {
|
|
45
|
+
const vars = {
|
|
46
|
+
bookmark_collection_id: String(folderId),
|
|
47
|
+
count,
|
|
48
|
+
includePromotedContent: false,
|
|
49
|
+
};
|
|
50
|
+
if (cursor) vars.cursor = cursor;
|
|
51
|
+
return `/i/api/graphql/${queryId}/${OPERATION_NAME}`
|
|
52
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
53
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractFolderTweet(result, seen) {
|
|
57
|
+
if (!result) return null;
|
|
58
|
+
const tw = result.tweet || result;
|
|
59
|
+
const legacy = tw.legacy || {};
|
|
60
|
+
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
|
61
|
+
seen.add(tw.rest_id);
|
|
62
|
+
const user = tw.core?.user_results?.result;
|
|
63
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || '';
|
|
64
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
65
|
+
return {
|
|
66
|
+
id: tw.rest_id,
|
|
67
|
+
author: screenName,
|
|
68
|
+
text: noteText || legacy.full_text || '',
|
|
69
|
+
likes: legacy.favorite_count || 0,
|
|
70
|
+
retweets: legacy.retweet_count || 0,
|
|
71
|
+
bookmarks: legacy.bookmark_count || 0,
|
|
72
|
+
created_at: legacy.created_at || '',
|
|
73
|
+
url: screenName ? `https://x.com/${screenName}/status/${tw.rest_id}` : `https://x.com/i/status/${tw.rest_id}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse the bookmark-folder timeline payload. Same instruction-walking
|
|
79
|
+
* pattern as the global bookmarks timeline; X re-uses the
|
|
80
|
+
* `bookmark_timeline_v2` envelope for folder-scoped queries.
|
|
81
|
+
*
|
|
82
|
+
* Exported via __test__.
|
|
83
|
+
*/
|
|
84
|
+
export function parseBookmarkFolderTimeline(data, seen) {
|
|
85
|
+
const tweets = [];
|
|
86
|
+
let nextCursor = null;
|
|
87
|
+
const instructions = data?.data?.bookmark_collection_timeline?.timeline?.instructions
|
|
88
|
+
|| data?.data?.bookmark_timeline_v2?.timeline?.instructions
|
|
89
|
+
|| data?.data?.bookmark_timeline?.timeline?.instructions
|
|
90
|
+
|| [];
|
|
91
|
+
for (const inst of instructions) {
|
|
92
|
+
for (const entry of inst.entries || []) {
|
|
93
|
+
const content = entry.content;
|
|
94
|
+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
|
95
|
+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore')
|
|
96
|
+
nextCursor = content.value;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
100
|
+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const direct = extractFolderTweet(content?.itemContent?.tweet_results?.result, seen);
|
|
104
|
+
if (direct) {
|
|
105
|
+
tweets.push(direct);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const item of content?.items || []) {
|
|
109
|
+
const nested = extractFolderTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
110
|
+
if (nested) tweets.push(nested);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { tweets, nextCursor };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cli({
|
|
118
|
+
site: 'twitter',
|
|
119
|
+
name: 'bookmark-folder',
|
|
120
|
+
access: 'read',
|
|
121
|
+
description: 'Read the tweets inside a single Twitter/X bookmark folder. Get the folder id from `opencli twitter bookmark-folders`.',
|
|
122
|
+
domain: 'x.com',
|
|
123
|
+
strategy: Strategy.COOKIE,
|
|
124
|
+
browser: true,
|
|
125
|
+
browserSession: { reuse: 'site' },
|
|
126
|
+
args: [
|
|
127
|
+
{ name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
|
|
128
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
|
|
129
|
+
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the folder by weighted engagement (likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5) and return the top N. Default 0 keeps the API\'s native (saved-time) ordering.' },
|
|
130
|
+
],
|
|
131
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
|
|
132
|
+
func: async (page, kwargs) => {
|
|
133
|
+
const folderId = String(kwargs['folder-id'] || '').trim();
|
|
134
|
+
if (!folderId || !FOLDER_ID_PATTERN.test(folderId)) {
|
|
135
|
+
throw new ArgumentError(
|
|
136
|
+
`Invalid folder-id: ${JSON.stringify(kwargs['folder-id'])}. Expected a safe folder ID from \`opencli twitter bookmark-folders\`.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const limit = Number(kwargs.limit ?? 20);
|
|
140
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
141
|
+
throw new ArgumentError(`Invalid --limit: ${JSON.stringify(kwargs.limit)}. Expected a positive integer.`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
145
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
146
|
+
if (!ct0)
|
|
147
|
+
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
148
|
+
|
|
149
|
+
const queryId = await resolveTwitterQueryId(page, OPERATION_NAME, FALLBACK_QUERY_ID);
|
|
150
|
+
|
|
151
|
+
const headers = JSON.stringify({
|
|
152
|
+
'Authorization': `Bearer ${decodeURIComponent(TWITTER_BEARER_TOKEN)}`,
|
|
153
|
+
'X-Csrf-Token': ct0,
|
|
154
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
155
|
+
'X-Twitter-Active-User': 'yes',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const allTweets = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
let cursor = null;
|
|
161
|
+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
|
162
|
+
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
163
|
+
const apiUrl = buildFolderTimelineUrl(queryId, folderId, fetchCount, cursor);
|
|
164
|
+
const data = await page.evaluate(`async () => {
|
|
165
|
+
const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' });
|
|
166
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
167
|
+
}`);
|
|
168
|
+
if (data?.error) {
|
|
169
|
+
if (allTweets.length === 0)
|
|
170
|
+
throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch folder ${folderId}. queryId may have expired, or the folder may not exist.`);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const { tweets, nextCursor } = parseBookmarkFolderTimeline(data, seen);
|
|
174
|
+
allTweets.push(...tweets);
|
|
175
|
+
if (!nextCursor || nextCursor === cursor) break;
|
|
176
|
+
cursor = nextCursor;
|
|
177
|
+
}
|
|
178
|
+
const trimmed = allTweets.slice(0, limit);
|
|
179
|
+
return applyTopByEngagement(trimmed, kwargs['top-by-engagement']);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const __test__ = {
|
|
184
|
+
parseBookmarkFolderTimeline,
|
|
185
|
+
buildFolderTimelineUrl,
|
|
186
|
+
FOLDER_ID_PATTERN,
|
|
187
|
+
};
|