@jackwener/opencli 1.7.15 → 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 +161 -31
- 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/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 +1 -0
- package/clis/reddit/subreddit.js +1 -0
- 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 +1 -0
- package/clis/reddit/user-posts.js +1 -0
- package/clis/reddit/user.js +1 -0
- package/clis/twitter/article.js +7 -4
- package/clis/twitter/bookmark-folder.js +3 -5
- package/clis/twitter/bookmark-folder.test.js +5 -2
- package/clis/twitter/bookmark-folders.js +3 -5
- package/clis/twitter/bookmark-folders.test.js +3 -1
- package/clis/twitter/bookmarks.js +3 -5
- package/clis/twitter/download.js +1 -0
- package/clis/twitter/followers.js +1 -0
- package/clis/twitter/following.js +3 -6
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/likes.js +3 -5
- package/clis/twitter/list-add.js +4 -3
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +4 -3
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +3 -5
- package/clis/twitter/lists.js +3 -5
- package/clis/twitter/notifications.js +1 -0
- package/clis/twitter/profile.js +7 -4
- package/clis/twitter/search.js +1 -0
- package/clis/twitter/thread.js +5 -7
- package/clis/twitter/timeline.js +5 -7
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +3 -6
- 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/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +2 -2
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +5 -1
- package/dist/src/cli.js +70 -2
- package/dist/src/cli.test.js +139 -7
- 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 +1 -0
- package/dist/src/help.js +29 -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 +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenReview submissions by author profile id (newest first).
|
|
3
|
+
*
|
|
4
|
+
* Pairs with `openreview paper <id>` and `openreview reviews <id>` for the
|
|
5
|
+
* full read-side workflow: list every submission an author put on
|
|
6
|
+
* OpenReview, then drill into a specific paper or its review thread.
|
|
7
|
+
*
|
|
8
|
+
* Uses the public v2 endpoint `/notes?content.authorids=~<profile-id>`,
|
|
9
|
+
* which returns the same note shape as `paper`, sorted by `cdate:desc`.
|
|
10
|
+
*/
|
|
11
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
12
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
13
|
+
import {
|
|
14
|
+
noteToRow,
|
|
15
|
+
openreviewFetch,
|
|
16
|
+
requireBoundedInt,
|
|
17
|
+
requireProfileId,
|
|
18
|
+
} from './utils.js';
|
|
19
|
+
|
|
20
|
+
cli({
|
|
21
|
+
site: 'openreview',
|
|
22
|
+
name: 'author',
|
|
23
|
+
access: 'read',
|
|
24
|
+
description: 'List OpenReview submissions by an author profile id (newest first)',
|
|
25
|
+
domain: 'openreview.net',
|
|
26
|
+
strategy: Strategy.PUBLIC,
|
|
27
|
+
browser: false,
|
|
28
|
+
args: [
|
|
29
|
+
{ name: 'profile', positional: true, required: true, help: 'OpenReview profile id (e.g. "~Yoshua_Bengio1"). Find it on the author profile URL on openreview.net.' },
|
|
30
|
+
{ name: 'limit', type: 'int', default: 50, help: 'Max submissions (1-1000)' },
|
|
31
|
+
],
|
|
32
|
+
columns: ['rank', 'id', 'title', 'authors', 'venue', 'pdate', 'url'],
|
|
33
|
+
func: async (args) => {
|
|
34
|
+
const profile = requireProfileId(args.profile);
|
|
35
|
+
const limit = requireBoundedInt(args.limit, 50, 1000);
|
|
36
|
+
const path = `/notes?content.authorids=${encodeURIComponent(profile)}&limit=${limit}&sort=cdate:desc`;
|
|
37
|
+
const json = await openreviewFetch(path, `openreview author ${profile}`);
|
|
38
|
+
const notes = Array.isArray(json?.notes) ? json.notes : [];
|
|
39
|
+
if (!notes.length) {
|
|
40
|
+
throw new EmptyResultError(
|
|
41
|
+
'openreview author',
|
|
42
|
+
`No OpenReview submissions found for profile "${profile}". Confirm the id format (~First_LastN) and that the profile has public submissions.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return notes.slice(0, limit).map((note, i) => {
|
|
46
|
+
const row = noteToRow(note);
|
|
47
|
+
return {
|
|
48
|
+
rank: i + 1,
|
|
49
|
+
id: row.id,
|
|
50
|
+
title: row.title,
|
|
51
|
+
authors: row.authors,
|
|
52
|
+
venue: row.venue,
|
|
53
|
+
pdate: row.pdate,
|
|
54
|
+
url: row.url,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -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
package/clis/reddit/subreddit.js
CHANGED
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,6 +7,7 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
12
|
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
13
|
{ name: 'limit', type: 'int', default: 15 },
|
|
@@ -7,6 +7,7 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
+
browserSession: { reuse: 'site' },
|
|
10
11
|
args: [
|
|
11
12
|
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
12
13
|
{ name: 'limit', type: 'int', default: 15 },
|
package/clis/reddit/user.js
CHANGED
package/clis/twitter/article.js
CHANGED
|
@@ -11,6 +11,7 @@ cli({
|
|
|
11
11
|
domain: 'x.com',
|
|
12
12
|
strategy: Strategy.COOKIE,
|
|
13
13
|
browser: true,
|
|
14
|
+
browserSession: { reuse: 'site' },
|
|
14
15
|
args: [
|
|
15
16
|
{ name: 'tweet-id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
|
|
16
17
|
],
|
|
@@ -51,12 +52,16 @@ cli({
|
|
|
51
52
|
// Navigate to the tweet page for cookie context
|
|
52
53
|
await page.goto(`https://x.com/i/status/${tweetId}`);
|
|
53
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)');
|
|
54
60
|
const queryId = await resolveTwitterQueryId(page, 'TweetResultByRestId', TWEET_RESULT_BY_REST_ID_QUERY_ID);
|
|
55
61
|
const result = await page.evaluate(`
|
|
56
62
|
async () => {
|
|
57
63
|
const tweetId = "${tweetId}";
|
|
58
|
-
const ct0 =
|
|
59
|
-
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
64
|
+
const ct0 = ${JSON.stringify(ct0)};
|
|
60
65
|
|
|
61
66
|
const bearer = ${JSON.stringify(TWITTER_BEARER_TOKEN)};
|
|
62
67
|
const headers = {
|
|
@@ -156,8 +161,6 @@ cli({
|
|
|
156
161
|
}
|
|
157
162
|
`);
|
|
158
163
|
if (result?.error) {
|
|
159
|
-
if (String(result.error).includes('No ct0 cookie'))
|
|
160
|
-
throw new AuthRequiredError('x.com', result.error);
|
|
161
164
|
throw new CommandExecutionError(result.error + (result.hint ? ` (${result.hint})` : ''));
|
|
162
165
|
}
|
|
163
166
|
return result || [];
|
|
@@ -122,6 +122,7 @@ cli({
|
|
|
122
122
|
domain: 'x.com',
|
|
123
123
|
strategy: Strategy.COOKIE,
|
|
124
124
|
browser: true,
|
|
125
|
+
browserSession: { reuse: 'site' },
|
|
125
126
|
args: [
|
|
126
127
|
{ name: 'folder-id', positional: true, type: 'string', required: true, help: 'Folder id from `opencli twitter bookmark-folders`.' },
|
|
127
128
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
|
|
@@ -140,11 +141,8 @@ cli({
|
|
|
140
141
|
throw new ArgumentError(`Invalid --limit: ${JSON.stringify(kwargs.limit)}. Expected a positive integer.`);
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
await page.
|
|
144
|
-
|
|
145
|
-
const ct0 = await page.evaluate(`() => {
|
|
146
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
147
|
-
}`);
|
|
144
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
145
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
148
146
|
if (!ct0)
|
|
149
147
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
150
148
|
|
|
@@ -309,11 +309,13 @@ describe('twitter bookmark-folder command (registry)', () => {
|
|
|
309
309
|
const page = {
|
|
310
310
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
311
311
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
312
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
312
313
|
evaluate: vi.fn().mockResolvedValue(null),
|
|
313
314
|
};
|
|
314
315
|
await expect(command.func(page, { 'folder-id': '12345', limit: 5 }))
|
|
315
316
|
.rejects
|
|
316
317
|
.toThrow(/Not logged into x.com/);
|
|
318
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
317
319
|
});
|
|
318
320
|
|
|
319
321
|
it('accepts an opaque safe folder-id and sends it in the GraphQL variables', async () => {
|
|
@@ -321,14 +323,15 @@ describe('twitter bookmark-folder command (registry)', () => {
|
|
|
321
323
|
const page = {
|
|
322
324
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
323
325
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
326
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'ct0-token' }]),
|
|
324
327
|
evaluate: vi.fn()
|
|
325
|
-
.mockResolvedValueOnce('ct0-token')
|
|
326
328
|
.mockResolvedValueOnce('queryX')
|
|
327
329
|
.mockResolvedValueOnce({ data: { bookmark_timeline_v2: { timeline: { instructions: [] } } } }),
|
|
328
330
|
};
|
|
329
331
|
const result = await command.func(page, { 'folder-id': 'folder_AbC-123', limit: 5 });
|
|
330
332
|
expect(result).toEqual([]);
|
|
331
|
-
|
|
333
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
334
|
+
const fetchScript = page.evaluate.mock.calls[1][0];
|
|
332
335
|
expect(decodeURIComponent(fetchScript)).toContain('"bookmark_collection_id":"folder_AbC-123"');
|
|
333
336
|
});
|
|
334
337
|
});
|
|
@@ -77,14 +77,12 @@ cli({
|
|
|
77
77
|
domain: 'x.com',
|
|
78
78
|
strategy: Strategy.COOKIE,
|
|
79
79
|
browser: true,
|
|
80
|
+
browserSession: { reuse: 'site' },
|
|
80
81
|
args: [],
|
|
81
82
|
columns: ['id', 'name', 'items', 'created_at'],
|
|
82
83
|
func: async (page) => {
|
|
83
|
-
await page.
|
|
84
|
-
|
|
85
|
-
const ct0 = await page.evaluate(`() => {
|
|
86
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
87
|
-
}`);
|
|
84
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
85
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
88
86
|
if (!ct0)
|
|
89
87
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
90
88
|
|
|
@@ -143,8 +143,10 @@ describe('twitter bookmark-folders command (registry)', () => {
|
|
|
143
143
|
const page = {
|
|
144
144
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
145
145
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
146
|
-
|
|
146
|
+
getCookies: vi.fn().mockResolvedValue([]), // no ct0 cookie → AuthRequired
|
|
147
|
+
evaluate: vi.fn().mockResolvedValue(null),
|
|
147
148
|
};
|
|
148
149
|
await expect(command.func(page, {})).rejects.toThrow(/Not logged into x.com/);
|
|
150
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
149
151
|
});
|
|
150
152
|
});
|
|
@@ -105,6 +105,7 @@ cli({
|
|
|
105
105
|
domain: 'x.com',
|
|
106
106
|
strategy: Strategy.COOKIE,
|
|
107
107
|
browser: true,
|
|
108
|
+
browserSession: { reuse: 'site' },
|
|
108
109
|
args: [
|
|
109
110
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of bookmarks to return (default 20).' },
|
|
110
111
|
{ name: 'top-by-engagement', type: 'int', default: 0, help: 'When set to N>0, re-rank the bookmarks 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.' },
|
|
@@ -112,11 +113,8 @@ cli({
|
|
|
112
113
|
columns: ['id', 'author', 'text', 'likes', 'retweets', 'bookmarks', 'created_at', 'url'],
|
|
113
114
|
func: async (page, kwargs) => {
|
|
114
115
|
const limit = kwargs.limit || 20;
|
|
115
|
-
await page.
|
|
116
|
-
|
|
117
|
-
const ct0 = await page.evaluate(`() => {
|
|
118
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
119
|
-
}`);
|
|
116
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
117
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
120
118
|
if (!ct0)
|
|
121
119
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
122
120
|
const queryId = await page.evaluate(`async () => {
|
package/clis/twitter/download.js
CHANGED
|
@@ -15,6 +15,7 @@ cli({
|
|
|
15
15
|
description: 'Download Twitter/X media (images and videos). Provide either <username> to scan a profile\'s media tab, or --tweet-url to download a single tweet.',
|
|
16
16
|
domain: 'x.com',
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
|
+
browserSession: { reuse: 'site' },
|
|
18
19
|
args: [
|
|
19
20
|
{ name: 'username', positional: true, help: 'Twitter username (with or without @) to scan their /media tab. Either <username> or --tweet-url is required.' },
|
|
20
21
|
{ name: 'tweet-url', help: 'Single tweet URL to download. Use this OR <username>, not both required at once.' },
|
|
@@ -139,6 +139,7 @@ cli({
|
|
|
139
139
|
domain: 'x.com',
|
|
140
140
|
strategy: Strategy.COOKIE,
|
|
141
141
|
browser: true,
|
|
142
|
+
browserSession: { reuse: 'site' },
|
|
142
143
|
args: [
|
|
143
144
|
{
|
|
144
145
|
name: 'user',
|
|
@@ -157,12 +158,8 @@ cli({
|
|
|
157
158
|
}
|
|
158
159
|
let targetUser = normalizeScreenName(kwargs.user);
|
|
159
160
|
|
|
160
|
-
await page.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const ct0 = await page.evaluate(`() => {
|
|
164
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
165
|
-
}`);
|
|
161
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
162
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
166
163
|
if (!ct0)
|
|
167
164
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
168
165
|
|
|
@@ -205,8 +205,8 @@ function createFollowingPage(followingResponses, { ct0 = 'token', userLookup = {
|
|
|
205
205
|
const page = {
|
|
206
206
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
207
207
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
208
|
+
getCookies: vi.fn(async () => (ct0 ? [{ name: 'ct0', value: ct0 }] : [])),
|
|
208
209
|
evaluate: vi.fn(async (script) => {
|
|
209
|
-
if (script.includes('document.cookie')) return ct0;
|
|
210
210
|
if (script.includes('operationName')) return null;
|
|
211
211
|
if (script.includes('/UserByScreenName')) return userLookup;
|
|
212
212
|
if (script.includes('/Following')) return followingResponses.shift() || followingPayload([], null);
|
|
@@ -228,6 +228,7 @@ describe('twitter following command', () => {
|
|
|
228
228
|
const rows = await command.func(page, { user: '@elonmusk', limit: 3 });
|
|
229
229
|
|
|
230
230
|
expect(rows.map((row) => row.screen_name)).toEqual(['alice', 'bob', 'carol']);
|
|
231
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
231
232
|
const userLookupScript = page.evaluate.mock.calls.find(([script]) => script.includes('/UserByScreenName'))?.[0] || '';
|
|
232
233
|
expect(decodeURIComponent(userLookupScript)).toContain('"screen_name":"elonmusk"');
|
|
233
234
|
expect(decodeURIComponent(userLookupScript)).not.toContain('"screen_name":"@elonmusk"');
|
package/clis/twitter/likes.js
CHANGED
|
@@ -142,6 +142,7 @@ cli({
|
|
|
142
142
|
domain: 'x.com',
|
|
143
143
|
strategy: Strategy.COOKIE,
|
|
144
144
|
browser: true,
|
|
145
|
+
browserSession: { reuse: 'site' },
|
|
145
146
|
args: [
|
|
146
147
|
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
147
148
|
{ name: 'limit', type: 'int', default: 20, help: 'Maximum number of liked tweets to return (default 20).' },
|
|
@@ -151,11 +152,8 @@ cli({
|
|
|
151
152
|
func: async (page, kwargs) => {
|
|
152
153
|
const limit = kwargs.limit || 20;
|
|
153
154
|
let username = (kwargs.username || '').replace(/^@/, '');
|
|
154
|
-
await page.
|
|
155
|
-
|
|
156
|
-
const ct0 = await page.evaluate(`() => {
|
|
157
|
-
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
158
|
-
}`);
|
|
155
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
156
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
159
157
|
if (!ct0)
|
|
160
158
|
throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
161
159
|
// If no username provided, detect the logged-in user
|
package/clis/twitter/list-add.js
CHANGED
|
@@ -84,11 +84,12 @@ cli({
|
|
|
84
84
|
if (!username) {
|
|
85
85
|
throw new CommandExecutionError('Username is required');
|
|
86
86
|
}
|
|
87
|
+
// Strategy.UI does not get a domain URL pre-nav from the framework.
|
|
88
|
+
// This page context is load-bearing for pre-target GraphQL calls below.
|
|
87
89
|
await page.goto('https://x.com');
|
|
88
90
|
await page.wait(3);
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
}`);
|
|
91
|
+
const cookies = await page.getCookies({ url: 'https://x.com' });
|
|
92
|
+
const ct0 = cookies.find((c) => c.name === 'ct0')?.value || null;
|
|
92
93
|
if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)');
|
|
93
94
|
|
|
94
95
|
const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import './list-add.js';
|
|
4
4
|
|
|
@@ -12,4 +12,26 @@ describe('twitter list-add registration', () => {
|
|
|
12
12
|
expect(listIdArg?.required).toBe(true);
|
|
13
13
|
expect(listIdArg?.positional).toBe(true);
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
it('keeps the x.com root navigation before pre-target GraphQL calls', async () => {
|
|
17
|
+
const cmd = getRegistry().get('twitter/list-add');
|
|
18
|
+
const page = {
|
|
19
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'ct0', value: 'token' }]),
|
|
22
|
+
evaluate: vi.fn()
|
|
23
|
+
.mockResolvedValueOnce(null) // UserByScreenName queryId fallback
|
|
24
|
+
.mockResolvedValueOnce('user-1')
|
|
25
|
+
.mockResolvedValueOnce(null) // ListsManagement queryId fallback
|
|
26
|
+
.mockResolvedValueOnce({}),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
await expect(cmd.func(page, { listId: '123', username: 'alice' }))
|
|
30
|
+
.rejects
|
|
31
|
+
.toThrow(/List 123 not found/);
|
|
32
|
+
expect(page.goto).toHaveBeenCalledWith('https://x.com');
|
|
33
|
+
expect(page.goto).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(page.wait).toHaveBeenCalledWith(3);
|
|
35
|
+
expect(page.getCookies).toHaveBeenCalledWith({ url: 'https://x.com' });
|
|
36
|
+
});
|
|
15
37
|
});
|