@jackwener/opencli 1.7.16 → 1.7.18
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 +11 -9
- package/README.zh-CN.md +10 -8
- package/cli-manifest.json +377 -271
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/commands.test.js +2 -2
- package/clis/chatgpt/detail.js +1 -1
- package/clis/chatgpt/history.js +1 -1
- package/clis/chatgpt/image.js +38 -4
- package/clis/chatgpt/image.test.js +68 -1
- package/clis/chatgpt/new.js +1 -1
- package/clis/chatgpt/read.js +1 -1
- package/clis/chatgpt/send.js +1 -1
- package/clis/chatgpt/status.js +1 -1
- package/clis/chatgpt/utils.js +208 -16
- package/clis/chatgpt/utils.test.js +131 -2
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -1
- package/clis/claude/history.js +1 -1
- package/clis/claude/new.js +1 -1
- package/clis/claude/read.js +1 -1
- package/clis/claude/send.js +1 -1
- package/clis/claude/status.js +1 -1
- package/clis/deepseek/ask.js +1 -1
- package/clis/deepseek/detail.js +1 -1
- package/clis/deepseek/history.js +1 -1
- package/clis/deepseek/new.js +1 -1
- package/clis/deepseek/read.js +1 -1
- package/clis/deepseek/send.js +1 -1
- package/clis/deepseek/status.js +1 -1
- package/clis/doubao/ask.js +1 -1
- package/clis/doubao/detail.js +1 -1
- package/clis/doubao/history.js +1 -1
- package/clis/doubao/meeting-summary.js +1 -1
- package/clis/doubao/meeting-transcript.js +1 -1
- package/clis/doubao/new.js +1 -1
- package/clis/doubao/read.js +1 -1
- package/clis/doubao/send.js +1 -1
- package/clis/doubao/status.js +1 -1
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/gemini/ask.js +1 -1
- package/clis/gemini/deep-research-result.js +1 -1
- package/clis/gemini/deep-research.js +1 -1
- package/clis/gemini/image.js +1 -1
- package/clis/gemini/new.js +1 -1
- package/clis/grok/ask.js +1 -1
- package/clis/grok/detail.js +1 -1
- package/clis/grok/history.js +1 -1
- package/clis/grok/image.js +1 -1
- package/clis/grok/new.js +1 -1
- package/clis/grok/read.js +1 -1
- package/clis/grok/send.js +1 -1
- package/clis/grok/status.js +1 -1
- package/clis/notebooklm/current.js +1 -1
- package/clis/notebooklm/get.js +1 -1
- package/clis/notebooklm/history.js +1 -1
- package/clis/notebooklm/note-list.js +1 -1
- package/clis/notebooklm/notes-get.js +1 -1
- package/clis/notebooklm/open.js +2 -2
- package/clis/notebooklm/open.test.js +1 -1
- package/clis/notebooklm/source-fulltext.js +1 -1
- package/clis/notebooklm/source-get.js +1 -1
- package/clis/notebooklm/source-guide.js +1 -1
- package/clis/notebooklm/source-list.js +1 -1
- package/clis/notebooklm/summary.js +1 -1
- package/clis/qwen/ask.js +1 -1
- package/clis/qwen/detail.js +1 -1
- package/clis/qwen/history.js +1 -1
- package/clis/qwen/image.js +1 -1
- package/clis/qwen/new.js +1 -1
- package/clis/qwen/read.js +1 -1
- package/clis/qwen/send.js +1 -1
- package/clis/qwen/status.js +1 -1
- package/clis/reddit/comment.js +1 -1
- package/clis/reddit/frontpage.js +1 -1
- package/clis/reddit/popular.js +1 -1
- package/clis/reddit/read.js +1 -1
- package/clis/reddit/read.test.js +2 -2
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/reddit/save.js +1 -1
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/subscribe.js +1 -1
- package/clis/reddit/upvote.js +1 -1
- package/clis/reddit/upvoted.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/rednote/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +97 -0
- package/clis/rednote/user.js +55 -0
- package/clis/twitter/article.js +1 -1
- package/clis/twitter/bookmark-folder.js +1 -1
- package/clis/twitter/bookmark-folders.js +1 -1
- package/clis/twitter/bookmarks.js +1 -1
- package/clis/twitter/download.js +1 -1
- package/clis/twitter/followers.js +1 -1
- package/clis/twitter/following.js +1 -1
- package/clis/twitter/likes.js +1 -1
- package/clis/twitter/list-tweets.js +1 -1
- package/clis/twitter/lists.js +1 -1
- package/clis/twitter/notifications.js +1 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/thread.js +1 -1
- package/clis/twitter/timeline.js +1 -1
- package/clis/twitter/trending.js +1 -1
- package/clis/twitter/tweets.js +1 -1
- package/clis/xiaohongshu/comments.js +34 -24
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +37 -28
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/clis/yuanbao/ask.js +1 -1
- package/clis/yuanbao/detail.js +1 -1
- package/clis/yuanbao/history.js +1 -1
- package/clis/yuanbao/new.js +1 -1
- package/clis/yuanbao/read.js +1 -1
- package/clis/yuanbao/send.js +1 -1
- package/clis/yuanbao/status.js +1 -1
- package/dist/src/browser/bridge.d.ts +3 -1
- package/dist/src/browser/bridge.js +3 -1
- package/dist/src/browser/cdp.d.ts +3 -1
- package/dist/src/browser/daemon-client.d.ts +7 -14
- package/dist/src/browser/daemon-client.js +2 -6
- package/dist/src/browser/network-cache.d.ts +5 -5
- package/dist/src/browser/network-cache.js +8 -8
- package/dist/src/browser/network-cache.test.js +4 -4
- package/dist/src/browser/page.d.ts +8 -7
- package/dist/src/browser/page.js +23 -16
- package/dist/src/browser/page.test.js +60 -30
- package/dist/src/build-manifest.js +1 -1
- package/dist/src/cli.js +60 -162
- package/dist/src/cli.test.js +184 -198
- package/dist/src/commanderAdapter.js +2 -0
- package/dist/src/discovery.js +1 -1
- package/dist/src/doctor.d.ts +0 -4
- package/dist/src/doctor.js +14 -73
- package/dist/src/doctor.test.js +28 -97
- package/dist/src/execution.d.ts +1 -0
- package/dist/src/execution.js +20 -21
- package/dist/src/execution.test.js +27 -31
- package/dist/src/help.js +7 -1
- package/dist/src/main.js +0 -19
- package/dist/src/manifest-types.d.ts +2 -4
- package/dist/src/observation/artifact.js +1 -1
- package/dist/src/observation/artifact.test.js +3 -3
- package/dist/src/observation/events.d.ts +1 -1
- package/dist/src/observation/manager.js +1 -1
- package/dist/src/observation/manager.test.js +3 -3
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +3 -12
- package/dist/src/registry.js +6 -10
- package/dist/src/runtime.d.ts +7 -2
- package/dist/src/runtime.js +3 -1
- package/dist/src/serialization.d.ts +1 -1
- package/dist/src/serialization.js +1 -1
- package/dist/src/types.d.ts +0 -15
- package/package.json +1 -1
package/clis/notebooklm/get.js
CHANGED
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
await requireNotebooklmSession(page);
|
|
19
19
|
const state = await getNotebooklmPageState(page);
|
|
20
20
|
if (state.kind !== 'notebook') {
|
|
21
|
-
throw new EmptyResultError('opencli notebooklm get', 'No NotebookLM notebook is open in the
|
|
21
|
+
throw new EmptyResultError('opencli notebooklm get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
22
22
|
}
|
|
23
23
|
const rpcRow = await getNotebooklmDetailViaRpc(page).catch(() => null);
|
|
24
24
|
if (rpcRow)
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
await requireNotebooklmSession(page);
|
|
18
18
|
const state = await getNotebooklmPageState(page);
|
|
19
19
|
if (state.kind !== 'notebook') {
|
|
20
|
-
throw new EmptyResultError('opencli notebooklm history', 'No NotebookLM notebook is open in the
|
|
20
|
+
throw new EmptyResultError('opencli notebooklm history', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
21
21
|
}
|
|
22
22
|
const rows = await listNotebooklmHistoryViaRpc(page);
|
|
23
23
|
return rows;
|
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
await requireNotebooklmSession(page);
|
|
19
19
|
const state = await getNotebooklmPageState(page);
|
|
20
20
|
if (state.kind !== 'notebook') {
|
|
21
|
-
throw new EmptyResultError('opencli notebooklm note-list', 'No NotebookLM notebook is open in the
|
|
21
|
+
throw new EmptyResultError('opencli notebooklm note-list', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
22
22
|
}
|
|
23
23
|
const rows = await listNotebooklmNotesFromPage(page);
|
|
24
24
|
if (rows.length > 0)
|
|
@@ -31,7 +31,7 @@ cli({
|
|
|
31
31
|
await requireNotebooklmSession(page);
|
|
32
32
|
const state = await getNotebooklmPageState(page);
|
|
33
33
|
if (state.kind !== 'notebook') {
|
|
34
|
-
throw new EmptyResultError('opencli notebooklm notes-get', 'No NotebookLM notebook is open in the
|
|
34
|
+
throw new EmptyResultError('opencli notebooklm notes-get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
35
35
|
}
|
|
36
36
|
const query = typeof kwargs.note === 'string' ? kwargs.note : String(kwargs.note ?? '');
|
|
37
37
|
const visible = await readNotebooklmVisibleNoteFromPage(page);
|
package/clis/notebooklm/open.js
CHANGED
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
name: 'open',
|
|
8
8
|
access: 'read',
|
|
9
9
|
aliases: ['select'],
|
|
10
|
-
description: 'Open one NotebookLM notebook in the
|
|
10
|
+
description: 'Open one NotebookLM notebook in the adapter session by id or URL',
|
|
11
11
|
domain: NOTEBOOKLM_DOMAIN,
|
|
12
12
|
strategy: Strategy.COOKIE,
|
|
13
13
|
browser: true,
|
|
@@ -28,7 +28,7 @@ cli({
|
|
|
28
28
|
await requireNotebooklmSession(page);
|
|
29
29
|
const state = await getNotebooklmPageState(page);
|
|
30
30
|
if (state.kind !== 'notebook') {
|
|
31
|
-
throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the
|
|
31
|
+
throw new CliError('NOTEBOOKLM_OPEN_FAILED', `NotebookLM notebook "${notebookId}" did not open in the adapter session`, 'Run `opencli notebooklm list -f json` first and pass a valid notebook id.');
|
|
32
32
|
}
|
|
33
33
|
if (state.notebookId !== notebookId) {
|
|
34
34
|
console.warn(`[notebooklm open] expected notebook "${notebookId}" but page reports "${state.notebookId}"; continuing`);
|
|
@@ -38,7 +38,7 @@ describe('notebooklm open', () => {
|
|
|
38
38
|
source: 'current-page',
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
|
-
it('opens a notebook by id in the
|
|
41
|
+
it('opens a notebook by id in the adapter session', async () => {
|
|
42
42
|
const page = {
|
|
43
43
|
goto: vi.fn(async () => { }),
|
|
44
44
|
wait: vi.fn(async () => { }),
|
|
@@ -24,7 +24,7 @@ cli({
|
|
|
24
24
|
await requireNotebooklmSession(page);
|
|
25
25
|
const state = await getNotebooklmPageState(page);
|
|
26
26
|
if (state.kind !== 'notebook') {
|
|
27
|
-
throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the
|
|
27
|
+
throw new EmptyResultError('opencli notebooklm source-fulltext', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
28
28
|
}
|
|
29
29
|
const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
|
|
30
30
|
const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
|
|
@@ -24,7 +24,7 @@ cli({
|
|
|
24
24
|
await requireNotebooklmSession(page);
|
|
25
25
|
const state = await getNotebooklmPageState(page);
|
|
26
26
|
if (state.kind !== 'notebook') {
|
|
27
|
-
throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the
|
|
27
|
+
throw new EmptyResultError('opencli notebooklm source-get', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
28
28
|
}
|
|
29
29
|
const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
|
|
30
30
|
const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
|
|
@@ -24,7 +24,7 @@ cli({
|
|
|
24
24
|
await requireNotebooklmSession(page);
|
|
25
25
|
const state = await getNotebooklmPageState(page);
|
|
26
26
|
if (state.kind !== 'notebook') {
|
|
27
|
-
throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the
|
|
27
|
+
throw new EmptyResultError('opencli notebooklm source-guide', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
28
28
|
}
|
|
29
29
|
const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
|
|
30
30
|
const rows = rpcRows.length > 0 ? rpcRows : await listNotebooklmSourcesFromPage(page);
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
await requireNotebooklmSession(page);
|
|
18
18
|
const state = await getNotebooklmPageState(page);
|
|
19
19
|
if (state.kind !== 'notebook') {
|
|
20
|
-
throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the
|
|
20
|
+
throw new EmptyResultError('opencli notebooklm source-list', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
21
21
|
}
|
|
22
22
|
const rpcRows = await listNotebooklmSourcesViaRpc(page).catch(() => []);
|
|
23
23
|
if (rpcRows.length > 0)
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
await requireNotebooklmSession(page);
|
|
18
18
|
const state = await getNotebooklmPageState(page);
|
|
19
19
|
if (state.kind !== 'notebook') {
|
|
20
|
-
throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the
|
|
20
|
+
throw new EmptyResultError('opencli notebooklm summary', 'No NotebookLM notebook is open in the adapter session. Run `opencli notebooklm open <notebook>` first.');
|
|
21
21
|
}
|
|
22
22
|
const domSummary = await readNotebooklmSummaryFromPage(page);
|
|
23
23
|
if (domSummary)
|
package/clis/qwen/ask.js
CHANGED
package/clis/qwen/detail.js
CHANGED
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
strategy: Strategy.COOKIE,
|
|
19
19
|
browser: true,
|
|
20
20
|
navigateBefore: false,
|
|
21
|
-
|
|
21
|
+
siteSession: 'persistent',
|
|
22
22
|
args: [
|
|
23
23
|
{ name: 'id', positional: true, required: true, help: 'Session ID (32-char hex) or full https://www.qianwen.com/chat/<id> URL' },
|
|
24
24
|
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
|
package/clis/qwen/history.js
CHANGED
|
@@ -24,7 +24,7 @@ cli({
|
|
|
24
24
|
domain: QIANWEN_DOMAIN,
|
|
25
25
|
strategy: Strategy.COOKIE,
|
|
26
26
|
browser: true,
|
|
27
|
-
|
|
27
|
+
siteSession: 'persistent',
|
|
28
28
|
navigateBefore: false,
|
|
29
29
|
args: [
|
|
30
30
|
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show (default 20, max 100)' },
|
package/clis/qwen/image.js
CHANGED
package/clis/qwen/new.js
CHANGED
package/clis/qwen/read.js
CHANGED
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
domain: QIANWEN_DOMAIN,
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
18
|
browser: true,
|
|
19
|
-
|
|
19
|
+
siteSession: 'persistent',
|
|
20
20
|
navigateBefore: false,
|
|
21
21
|
args: [
|
|
22
22
|
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
|
package/clis/qwen/send.js
CHANGED
|
@@ -20,7 +20,7 @@ cli({
|
|
|
20
20
|
domain: QIANWEN_DOMAIN,
|
|
21
21
|
strategy: Strategy.COOKIE,
|
|
22
22
|
browser: true,
|
|
23
|
-
|
|
23
|
+
siteSession: 'persistent',
|
|
24
24
|
navigateBefore: false,
|
|
25
25
|
args: [
|
|
26
26
|
{ name: 'prompt', required: true, positional: true, help: 'Prompt to send to Qianwen' },
|
package/clis/qwen/status.js
CHANGED
package/clis/reddit/comment.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
-
|
|
11
|
+
siteSession: 'persistent',
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
14
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
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
domain: 'reddit.com',
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
18
|
browser: true,
|
|
19
|
-
|
|
19
|
+
siteSession: 'persistent',
|
|
20
20
|
args: [
|
|
21
21
|
{ name: 'post-id', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or full URL' },
|
|
22
22
|
{ name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
|
package/clis/reddit/read.test.js
CHANGED
|
@@ -3,9 +3,9 @@ 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
|
|
6
|
+
it('opts into the Reddit persistent site session', () => {
|
|
7
7
|
expect(command?.browser).toBe(true);
|
|
8
|
-
expect(command?.
|
|
8
|
+
expect(command?.siteSession).toBe('persistent');
|
|
9
9
|
});
|
|
10
10
|
it('returns threaded rows from the browser-evaluated payload', async () => {
|
|
11
11
|
const page = {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
const REDDIT_COMMENT_ID_RE = /^[a-z0-9]+$/i;
|
|
5
|
+
|
|
6
|
+
function normalizeBareCommentId(value) {
|
|
7
|
+
const commentId = String(value || '').trim();
|
|
8
|
+
if (!REDDIT_COMMENT_ID_RE.test(commentId)) {
|
|
9
|
+
throw new ArgumentError(
|
|
10
|
+
'Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.',
|
|
11
|
+
'Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.',
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return commentId.toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeRedditCommentFullname(value) {
|
|
18
|
+
const raw = String(value || '').trim();
|
|
19
|
+
if (!raw) {
|
|
20
|
+
throw new ArgumentError(
|
|
21
|
+
'Comment ID is required.',
|
|
22
|
+
'Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fullname = raw.match(/^t1_([a-z0-9]+)$/i);
|
|
27
|
+
if (fullname) return `t1_${normalizeBareCommentId(fullname[1])}`;
|
|
28
|
+
|
|
29
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = new URL(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ArgumentError(`Invalid Reddit comment URL: ${raw}`);
|
|
35
|
+
}
|
|
36
|
+
const host = parsed.hostname.toLowerCase();
|
|
37
|
+
if (parsed.protocol !== 'https:' || (host !== 'reddit.com' && !host.endsWith('.reddit.com'))) {
|
|
38
|
+
throw new ArgumentError(
|
|
39
|
+
'Comment URL must be an https reddit.com URL.',
|
|
40
|
+
'Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
44
|
+
const commentsIndex = parts.indexOf('comments');
|
|
45
|
+
const commentIndex = commentsIndex + 3;
|
|
46
|
+
if (commentsIndex < 0 || parts.length <= commentIndex) {
|
|
47
|
+
throw new ArgumentError(
|
|
48
|
+
'Comment URL must include the target comment id.',
|
|
49
|
+
'Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (parts.length !== commentIndex + 1) {
|
|
53
|
+
throw new ArgumentError(
|
|
54
|
+
'Comment URL must end at the target comment id.',
|
|
55
|
+
'Remove extra path segments after the comment id.',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return `t1_${normalizeBareCommentId(parts[commentIndex])}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (raw.includes('/') || raw.startsWith('t3_')) {
|
|
62
|
+
throw new ArgumentError(
|
|
63
|
+
'Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.',
|
|
64
|
+
'Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return `t1_${normalizeBareCommentId(raw)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function requireReplyText(value) {
|
|
72
|
+
const text = String(value || '');
|
|
73
|
+
if (!text.trim()) {
|
|
74
|
+
throw new ArgumentError('Reply text is required.', 'Pass non-empty text to post as the Reddit reply.');
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cli({
|
|
80
|
+
site: 'reddit',
|
|
81
|
+
name: 'reply',
|
|
82
|
+
access: 'write',
|
|
83
|
+
description: 'Reply to a Reddit comment',
|
|
84
|
+
domain: 'reddit.com',
|
|
85
|
+
strategy: Strategy.COOKIE,
|
|
86
|
+
browser: true,
|
|
87
|
+
args: [
|
|
88
|
+
{ name: 'comment-id', type: 'string', required: true, positional: true, help: 'Comment ID (e.g. okf3s7u) or fullname (t1_xxx)' },
|
|
89
|
+
{ name: 'text', type: 'string', required: true, positional: true, help: 'Reply text' },
|
|
90
|
+
],
|
|
91
|
+
columns: ['status', 'message'],
|
|
92
|
+
func: async (page, kwargs) => {
|
|
93
|
+
const fullname = normalizeRedditCommentFullname(kwargs['comment-id']);
|
|
94
|
+
const text = requireReplyText(kwargs.text);
|
|
95
|
+
await page.goto('https://www.reddit.com');
|
|
96
|
+
// Inside page.evaluate we can't throw typed errors (they don't survive
|
|
97
|
+
// the worker boundary), so we surface a structured `kind` discriminator
|
|
98
|
+
// and re-throw the matching typed error on the Node side. Each kind
|
|
99
|
+
// maps 1:1 to a typed-error class — no silent-sentinel rows on failure.
|
|
100
|
+
//
|
|
101
|
+
// Intermediate object keys deliberately avoid `status` / `message` to
|
|
102
|
+
// sidestep the silent-column-drop audit (columns are ['status',
|
|
103
|
+
// 'message']) — see PR #1329 sediment "中间解析对象 key 不能跟 columns
|
|
104
|
+
// 任一项重叠".
|
|
105
|
+
const result = await page.evaluate(`(async () => {
|
|
106
|
+
try {
|
|
107
|
+
const fullname = ${JSON.stringify(fullname)};
|
|
108
|
+
const text = ${JSON.stringify(text)};
|
|
109
|
+
|
|
110
|
+
// Probe identity + modhash. /api/me.json returns data.name only when
|
|
111
|
+
// logged in — empty modhash alone is not a strong enough auth signal
|
|
112
|
+
// because Reddit sometimes returns 200 with empty modhash for stale
|
|
113
|
+
// anonymous sessions.
|
|
114
|
+
const meRes = await fetch('/api/me.json', { credentials: 'include' });
|
|
115
|
+
if (meRes.status === 401 || meRes.status === 403) {
|
|
116
|
+
return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + meRes.status };
|
|
117
|
+
}
|
|
118
|
+
if (!meRes.ok) {
|
|
119
|
+
return { kind: 'http', httpStatus: meRes.status, where: '/api/me.json' };
|
|
120
|
+
}
|
|
121
|
+
const me = await meRes.json();
|
|
122
|
+
if (!me?.data?.name) {
|
|
123
|
+
return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
|
|
124
|
+
}
|
|
125
|
+
const modhash = me.data.modhash || '';
|
|
126
|
+
|
|
127
|
+
const res = await fetch('/api/comment', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
credentials: 'include',
|
|
130
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
131
|
+
body: 'parent=' + encodeURIComponent(fullname)
|
|
132
|
+
+ '&text=' + encodeURIComponent(text)
|
|
133
|
+
+ '&api_type=json'
|
|
134
|
+
+ (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (res.status === 401 || res.status === 403) {
|
|
138
|
+
return { kind: 'auth', detail: 'Reddit /api/comment returned HTTP ' + res.status };
|
|
139
|
+
}
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
return { kind: 'http', httpStatus: res.status, where: '/api/comment' };
|
|
142
|
+
}
|
|
143
|
+
const data = await res.json();
|
|
144
|
+
const errors = data?.json?.errors;
|
|
145
|
+
if (errors && errors.length > 0) {
|
|
146
|
+
return { kind: 'reddit-error', detail: errors.map(e => e.join(': ')).join('; ') };
|
|
147
|
+
}
|
|
148
|
+
const things = data?.json?.data?.things;
|
|
149
|
+
const created = Array.isArray(things)
|
|
150
|
+
? things.find((thing) => thing?.kind === 't1' || String(thing?.data?.name || '').startsWith('t1_'))
|
|
151
|
+
: null;
|
|
152
|
+
const createdName = created?.data?.name || (created?.data?.id ? 't1_' + created.data.id : '');
|
|
153
|
+
if (!createdName) {
|
|
154
|
+
return { kind: 'postcondition', detail: 'Reddit comment response did not include a created reply id' };
|
|
155
|
+
}
|
|
156
|
+
return { kind: 'ok', detail: 'Reply posted on ' + fullname + ' as ' + createdName };
|
|
157
|
+
} catch (e) {
|
|
158
|
+
return { kind: 'exception', detail: String(e && e.message || e) };
|
|
159
|
+
}
|
|
160
|
+
})()`);
|
|
161
|
+
|
|
162
|
+
if (result?.kind === 'auth') {
|
|
163
|
+
throw new AuthRequiredError('reddit.com', result.detail);
|
|
164
|
+
}
|
|
165
|
+
if (result?.kind === 'http') {
|
|
166
|
+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
|
|
167
|
+
}
|
|
168
|
+
if (result?.kind === 'reddit-error') {
|
|
169
|
+
throw new CommandExecutionError(`Reddit rejected reply: ${result.detail}`);
|
|
170
|
+
}
|
|
171
|
+
if (result?.kind === 'postcondition') {
|
|
172
|
+
throw new CommandExecutionError(result.detail);
|
|
173
|
+
}
|
|
174
|
+
if (result?.kind === 'exception') {
|
|
175
|
+
throw new CommandExecutionError(`Reply failed: ${result.detail}`);
|
|
176
|
+
}
|
|
177
|
+
if (result?.kind !== 'ok') {
|
|
178
|
+
throw new CommandExecutionError(`Unexpected result from reddit reply: ${JSON.stringify(result)}`);
|
|
179
|
+
}
|
|
180
|
+
return [{ status: 'success', message: result.detail }];
|
|
181
|
+
},
|
|
182
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { normalizeRedditCommentFullname, requireReplyText } from './reply.js';
|
|
5
|
+
import './reply.js';
|
|
6
|
+
|
|
7
|
+
function makePage(result = { kind: 'ok', detail: 'Reply posted on t1_okf3s7u as t1_reply123' }) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(result),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('reddit reply command', () => {
|
|
15
|
+
const command = getRegistry().get('reddit/reply');
|
|
16
|
+
|
|
17
|
+
it('normalizes bare ids, fullnames, and exact reddit comment URLs', () => {
|
|
18
|
+
expect(normalizeRedditCommentFullname('okf3s7u')).toBe('t1_okf3s7u');
|
|
19
|
+
expect(normalizeRedditCommentFullname('T1_OKF3S7U')).toBe('t1_okf3s7u');
|
|
20
|
+
expect(normalizeRedditCommentFullname('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/?context=3')).toBe('t1_okf3s7u');
|
|
21
|
+
expect(normalizeRedditCommentFullname('https://old.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/')).toBe('t1_okf3s7u');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects invalid or ambiguous comment identities before navigation', async () => {
|
|
25
|
+
const page = makePage();
|
|
26
|
+
|
|
27
|
+
for (const value of [
|
|
28
|
+
'',
|
|
29
|
+
't3_1abc23',
|
|
30
|
+
'abc/def',
|
|
31
|
+
'https://reddit.com.evil.com/r/opencli/comments/1abc23/title_slug/okf3s7u/',
|
|
32
|
+
'http://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/',
|
|
33
|
+
'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/',
|
|
34
|
+
'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/evil',
|
|
35
|
+
]) {
|
|
36
|
+
await expect(command.func(page, { 'comment-id': value, text: 'hello' })).rejects.toBeInstanceOf(ArgumentError);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
40
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects blank reply text before navigation', async () => {
|
|
44
|
+
const page = makePage();
|
|
45
|
+
|
|
46
|
+
await expect(command.func(page, { 'comment-id': 'okf3s7u', text: ' ' })).rejects.toBeInstanceOf(ArgumentError);
|
|
47
|
+
|
|
48
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
49
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
50
|
+
expect(() => requireReplyText('hello')).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('posts to the normalized t1 fullname and returns success only on ok result', async () => {
|
|
54
|
+
const page = makePage();
|
|
55
|
+
|
|
56
|
+
const rows = await command.func(page, {
|
|
57
|
+
'comment-id': 'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/',
|
|
58
|
+
text: 'hello',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
|
|
62
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
63
|
+
expect(script).toContain('const fullname = "t1_okf3s7u"');
|
|
64
|
+
expect(script).toContain('const text = "hello"');
|
|
65
|
+
expect(rows).toEqual([{ status: 'success', message: 'Reply posted on t1_okf3s7u as t1_reply123' }]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('maps auth, http, reddit, exception, and postcondition failures to typed errors', async () => {
|
|
69
|
+
await expect(command.func(makePage({ kind: 'auth', detail: 'login required' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
70
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
71
|
+
await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: '/api/comment' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
72
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
73
|
+
await expect(command.func(makePage({ kind: 'reddit-error', detail: 'RATELIMIT: try later' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
74
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
75
|
+
await expect(command.func(makePage({ kind: 'exception', detail: 'bad json' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
76
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
77
|
+
await expect(command.func(makePage({ kind: 'postcondition', detail: 'Reddit comment response did not include a created reply id' }), { 'comment-id': 'okf3s7u', text: 'hello' }))
|
|
78
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('requires the Reddit response to include a created reply id', async () => {
|
|
82
|
+
const page = makePage();
|
|
83
|
+
|
|
84
|
+
await command.func(page, { 'comment-id': 'okf3s7u', text: 'hello' });
|
|
85
|
+
|
|
86
|
+
expect(page.evaluate.mock.calls[0][0]).toContain('Reddit comment response did not include a created reply id');
|
|
87
|
+
expect(page.evaluate.mock.calls[0][0]).toContain("String(thing?.data?.name || '').startsWith('t1_')");
|
|
88
|
+
});
|
|
89
|
+
});
|
package/clis/reddit/save.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
-
|
|
11
|
+
siteSession: 'persistent',
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
14
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
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
-
|
|
10
|
+
siteSession: 'persistent',
|
|
11
11
|
args: [
|
|
12
12
|
{ name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix; e.g. `python`)' },
|
|
13
13
|
{
|
package/clis/reddit/subscribe.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
-
|
|
11
|
+
siteSession: 'persistent',
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'subreddit', type: 'string', required: true, positional: true, help: 'Subreddit name (e.g. python)' },
|
|
14
14
|
{ name: 'undo', type: 'boolean', default: false, help: 'Unsubscribe instead of subscribe' },
|
package/clis/reddit/upvote.js
CHANGED
|
@@ -8,7 +8,7 @@ cli({
|
|
|
8
8
|
domain: 'reddit.com',
|
|
9
9
|
strategy: Strategy.COOKIE,
|
|
10
10
|
browser: true,
|
|
11
|
-
|
|
11
|
+
siteSession: 'persistent',
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'post-id', type: 'string', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or fullname (t3_xxx)' },
|
|
14
14
|
{ name: 'direction', type: 'string', default: 'up', help: 'Vote direction: up, down, none' },
|
package/clis/reddit/upvoted.js
CHANGED
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
-
|
|
10
|
+
siteSession: 'persistent',
|
|
11
11
|
args: [
|
|
12
12
|
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
13
13
|
{ name: 'limit', type: 'int', default: 15 },
|
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
-
|
|
10
|
+
siteSession: 'persistent',
|
|
11
11
|
args: [
|
|
12
12
|
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
13
13
|
{ name: 'limit', type: 'int', default: 15 },
|
package/clis/reddit/user.js
CHANGED
|
@@ -7,7 +7,7 @@ cli({
|
|
|
7
7
|
domain: 'reddit.com',
|
|
8
8
|
strategy: Strategy.COOKIE,
|
|
9
9
|
browser: true,
|
|
10
|
-
|
|
10
|
+
siteSession: 'persistent',
|
|
11
11
|
args: [
|
|
12
12
|
{ name: 'username', type: 'string', required: true, positional: true, help: 'Reddit username (no `u/` prefix needed)' },
|
|
13
13
|
],
|