@jackwener/opencli 1.7.17 → 1.7.19
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 +10 -8
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +585 -9
- package/clis/ctrip/ctrip.test.js +486 -1
- package/clis/ctrip/flight.js +136 -0
- package/clis/ctrip/hotel-search.js +132 -0
- package/clis/ctrip/utils.js +298 -0
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/google/search.js +16 -6
- package/clis/google-scholar/search.js +20 -5
- package/clis/google-scholar/search.test.js +35 -2
- package/clis/reddit/home.js +117 -0
- package/clis/reddit/home.test.js +127 -0
- package/clis/reddit/read.js +400 -54
- package/clis/reddit/read.test.js +315 -12
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/reddit/subreddit-info.js +117 -0
- package/clis/reddit/subreddit-info.test.js +163 -0
- package/clis/reddit/whoami.js +84 -0
- package/clis/reddit/whoami.test.js +105 -0
- 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 +101 -0
- package/clis/rednote/user.js +55 -0
- package/clis/twitter/bookmark-folder.js +3 -1
- package/clis/twitter/bookmarks.js +3 -1
- package/clis/twitter/followers.js +20 -5
- package/clis/twitter/followers.test.js +44 -0
- package/clis/twitter/following.js +36 -20
- package/clis/twitter/following.test.js +60 -8
- package/clis/twitter/likes.js +28 -13
- package/clis/twitter/likes.test.js +111 -1
- package/clis/twitter/list-add.js +128 -204
- package/clis/twitter/list-add.test.js +97 -1
- package/clis/twitter/list-tweets.js +13 -4
- package/clis/twitter/list-tweets.test.js +48 -0
- package/clis/twitter/lists.js +5 -2
- package/clis/twitter/post.js +23 -4
- package/clis/twitter/post.test.js +30 -0
- package/clis/twitter/profile.js +16 -8
- package/clis/twitter/profile.test.js +39 -0
- package/clis/twitter/reply.js +133 -10
- package/clis/twitter/reply.test.js +55 -0
- package/clis/twitter/search.js +188 -170
- package/clis/twitter/search.test.js +96 -258
- package/clis/twitter/shared.js +167 -16
- package/clis/twitter/shared.test.js +102 -1
- package/clis/twitter/timeline.js +3 -1
- package/clis/twitter/tweets.js +147 -51
- package/clis/twitter/tweets.test.js +238 -1
- package/clis/xiaohongshu/comments.js +57 -26
- package/clis/xiaohongshu/comments.test.js +63 -1
- 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 +201 -37
- package/clis/xiaohongshu/search.test.js +82 -8
- 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/xueqiu/earnings-date.js +2 -2
- package/clis/xueqiu/kline.js +2 -2
- package/clis/xueqiu/utils.js +19 -0
- package/clis/xueqiu/utils.test.js +26 -0
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/clis/zhihu/answer-detail.js +233 -0
- package/clis/zhihu/answer-detail.test.js +330 -0
- package/clis/zhihu/question.js +44 -10
- package/clis/zhihu/question.test.js +78 -1
- package/clis/zhihu/recommend.js +103 -0
- package/clis/zhihu/recommend.test.js +143 -0
- package/dist/src/browser/base-page.d.ts +3 -2
- package/dist/src/browser/base-page.test.js +2 -2
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/page.d.ts +3 -2
- package/dist/src/browser/page.js +4 -4
- package/dist/src/browser/page.test.js +31 -0
- package/dist/src/browser/utils.d.ts +10 -0
- package/dist/src/browser/utils.js +37 -0
- package/dist/src/browser/utils.test.d.ts +1 -0
- package/dist/src/browser/utils.test.js +29 -0
- package/dist/src/cli-argv-preprocess.d.ts +37 -0
- package/dist/src/cli-argv-preprocess.js +131 -0
- package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
- package/dist/src/cli-argv-preprocess.test.js +130 -0
- package/dist/src/cli.js +123 -86
- package/dist/src/cli.test.js +32 -22
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +21 -17
- package/dist/src/doctor.test.js +2 -0
- package/dist/src/download/progress.js +15 -11
- package/dist/src/download/progress.test.d.ts +1 -0
- package/dist/src/download/progress.test.js +25 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/help.d.ts +11 -0
- package/dist/src/help.js +46 -5
- package/dist/src/logger.js +8 -9
- package/dist/src/main.js +16 -0
- package/dist/src/output.js +4 -5
- package/dist/src/runtime-detect.d.ts +1 -1
- package/dist/src/runtime-detect.js +1 -1
- package/dist/src/runtime-detect.test.js +3 -2
- package/dist/src/tui.d.ts +0 -1
- package/dist/src/tui.js +9 -22
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.js +4 -5
- package/package.json +5 -4
package/clis/reddit/read.test.js
CHANGED
|
@@ -1,20 +1,163 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { normalizeRedditPostId, parseExpandRounds } from './read.js';
|
|
3
5
|
import './read.js';
|
|
6
|
+
|
|
7
|
+
function makePage(result) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(result),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function redditPostEnvelope(children) {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
data: {
|
|
18
|
+
children: [{
|
|
19
|
+
data: {
|
|
20
|
+
title: 'Post title',
|
|
21
|
+
selftext: '',
|
|
22
|
+
author: 'op',
|
|
23
|
+
score: 10,
|
|
24
|
+
is_self: true,
|
|
25
|
+
},
|
|
26
|
+
}],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{ data: { children } },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function commentThing(id, body, parent = 't3_abc123', score = 1) {
|
|
34
|
+
return {
|
|
35
|
+
kind: 't1',
|
|
36
|
+
data: {
|
|
37
|
+
id,
|
|
38
|
+
name: `t1_${id}`,
|
|
39
|
+
parent_id: parent,
|
|
40
|
+
author: id,
|
|
41
|
+
score,
|
|
42
|
+
body,
|
|
43
|
+
replies: '',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function moreThing(id, children, parent = 't3_abc123', count = children.length) {
|
|
49
|
+
return {
|
|
50
|
+
kind: 'more',
|
|
51
|
+
data: { id, parent_id: parent, children, count },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function jsonResponse(payload, status = 200) {
|
|
56
|
+
return {
|
|
57
|
+
ok: status >= 200 && status < 300,
|
|
58
|
+
status,
|
|
59
|
+
json: vi.fn().mockResolvedValue(payload),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeRuntimePage(fetchImpl) {
|
|
64
|
+
return {
|
|
65
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
66
|
+
evaluate: vi.fn(async (script) => {
|
|
67
|
+
const previousFetch = globalThis.fetch;
|
|
68
|
+
globalThis.fetch = fetchImpl;
|
|
69
|
+
try {
|
|
70
|
+
return await eval(script);
|
|
71
|
+
} finally {
|
|
72
|
+
globalThis.fetch = previousFetch;
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
4
78
|
describe('reddit read adapter', () => {
|
|
5
79
|
const command = getRegistry().get('reddit/read');
|
|
80
|
+
|
|
6
81
|
it('opts into the Reddit persistent site session', () => {
|
|
7
82
|
expect(command?.browser).toBe(true);
|
|
8
83
|
expect(command?.siteSession).toBe('persistent');
|
|
84
|
+
expect(command?.columns).toEqual(['type', 'author', 'score', 'text']);
|
|
9
85
|
});
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
86
|
+
|
|
87
|
+
it('exposes the new --expand-more / --expand-rounds args', () => {
|
|
88
|
+
const argNames = command.args.map((a) => a.name);
|
|
89
|
+
expect(argNames).toContain('expand-more');
|
|
90
|
+
expect(argNames).toContain('expand-rounds');
|
|
91
|
+
const expandMore = command.args.find((a) => a.name === 'expand-more');
|
|
92
|
+
expect(expandMore.type).toBe('bool');
|
|
93
|
+
expect(expandMore.default).toBe(false);
|
|
94
|
+
const rounds = command.args.find((a) => a.name === 'expand-rounds');
|
|
95
|
+
expect(rounds.type).toBe('int');
|
|
96
|
+
expect(rounds.default).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('normalizeRedditPostId', () => {
|
|
100
|
+
it('accepts bare ids, t3 fullnames, and exact reddit post URLs', () => {
|
|
101
|
+
expect(normalizeRedditPostId('1AbC23')).toBe('1abc23');
|
|
102
|
+
expect(normalizeRedditPostId('t3_1AbC23')).toBe('1abc23');
|
|
103
|
+
expect(normalizeRedditPostId('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/?sort=top')).toBe('1abc23');
|
|
104
|
+
expect(normalizeRedditPostId('https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/?context=3')).toBe('1abc23');
|
|
105
|
+
expect(normalizeRedditPostId('https://old.reddit.com/comments/1abc23/title_slug/')).toBe('1abc23');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects invalid or structurally loose post identities before navigation', () => {
|
|
109
|
+
for (const bad of [
|
|
110
|
+
'',
|
|
111
|
+
't1_okf3s7u',
|
|
112
|
+
'https://reddit.com.evil.com/r/opencli/comments/1abc23/title_slug/',
|
|
113
|
+
'http://www.reddit.com/r/opencli/comments/1abc23/title_slug/',
|
|
114
|
+
'https://www.reddit.com/r/opencli/comments/',
|
|
115
|
+
'https://www.reddit.com/r/opencli/comments/1abc23/title_slug/okf3s7u/evil',
|
|
116
|
+
'not/a/post',
|
|
117
|
+
]) {
|
|
118
|
+
expect(() => normalizeRedditPostId(bad)).toThrow(ArgumentError);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('parseExpandRounds', () => {
|
|
124
|
+
it('returns the default for absent input but throws on out-of-range / non-integer', () => {
|
|
125
|
+
expect(parseExpandRounds(undefined)).toBe(2);
|
|
126
|
+
expect(parseExpandRounds(null)).toBe(2);
|
|
127
|
+
expect(parseExpandRounds('')).toBe(2);
|
|
128
|
+
expect(parseExpandRounds(1)).toBe(1);
|
|
129
|
+
expect(parseExpandRounds(5)).toBe(5);
|
|
130
|
+
for (const bad of [0, -1, 6, 1.5, NaN, 'abc']) {
|
|
131
|
+
expect(() => parseExpandRounds(bad)).toThrow(ArgumentError);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects a bad --expand-rounds BEFORE navigating', async () => {
|
|
137
|
+
const page = makePage({ kind: 'ok', rows: [] });
|
|
138
|
+
await expect(command.func(page, { 'post-id': 'abc123', 'expand-rounds': 99 }))
|
|
139
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
140
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
141
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects a bad post identity BEFORE navigating', async () => {
|
|
145
|
+
const page = makePage({ kind: 'ok', rows: [] });
|
|
146
|
+
await expect(command.func(page, { 'post-id': 'https://evil.test/r/x/comments/abc/title/' }))
|
|
147
|
+
.rejects.toBeInstanceOf(ArgumentError);
|
|
148
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
149
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns rows when the evaluate script reports kind=ok', async () => {
|
|
153
|
+
const page = makePage({
|
|
154
|
+
kind: 'ok',
|
|
155
|
+
rows: [
|
|
14
156
|
{ type: 'POST', author: 'alice', score: 10, text: 'Title' },
|
|
15
157
|
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
|
|
16
|
-
]
|
|
17
|
-
|
|
158
|
+
],
|
|
159
|
+
expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] },
|
|
160
|
+
});
|
|
18
161
|
const result = await command.func(page, { 'post-id': 'abc123', limit: 5 });
|
|
19
162
|
expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
|
|
20
163
|
expect(result).toEqual([
|
|
@@ -22,11 +165,171 @@ describe('reddit read adapter', () => {
|
|
|
22
165
|
{ type: 'L0', author: 'bob', score: 5, text: 'Comment' },
|
|
23
166
|
]);
|
|
24
167
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
await expect(command.func(
|
|
168
|
+
|
|
169
|
+
it('maps the five failure kinds to the right typed errors', async () => {
|
|
170
|
+
await expect(command.func(makePage({ kind: 'inaccessible', detail: 'post 403' }), { 'post-id': 'abc123' }))
|
|
171
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
172
|
+
|
|
173
|
+
await expect(command.func(makePage({ kind: 'auth', detail: 'morechildren 401' }), { 'post-id': 'abc123' }))
|
|
174
|
+
.rejects.toBeInstanceOf(AuthRequiredError);
|
|
175
|
+
|
|
176
|
+
await expect(command.func(makePage({ kind: 'http', httpStatus: 503, where: '/comments/abc.json' }), { 'post-id': 'abc123' }))
|
|
177
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
178
|
+
|
|
179
|
+
await expect(command.func(makePage({ kind: 'malformed', detail: 'no comment listing' }), { 'post-id': 'abc123' }))
|
|
180
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
181
|
+
|
|
182
|
+
await expect(command.func(makePage({ kind: 'parser-drift', detail: 'walker drift' }), { 'post-id': 'abc123' }))
|
|
183
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
184
|
+
|
|
185
|
+
await expect(command.func(makePage({ kind: 'expand-failed', detail: 'morechildren errors' }), { 'post-id': 'abc123' }))
|
|
186
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('throws CommandExecutionError on an unknown envelope shape (no kind)', async () => {
|
|
190
|
+
await expect(command.func(makePage({ random: 'stuff' }), { 'post-id': 'abc123' }))
|
|
191
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
192
|
+
await expect(command.func(makePage(null), { 'post-id': 'abc123' }))
|
|
193
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('embeds expandMore=false by default and inlines flags into the evaluate script', async () => {
|
|
197
|
+
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
|
|
198
|
+
await command.func(page, { 'post-id': 'xyz', sort: 'top', limit: 3 });
|
|
199
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
200
|
+
expect(script).toContain('var expandMore = false');
|
|
201
|
+
expect(script).toContain('var expandRounds = 2');
|
|
202
|
+
expect(script).toContain('var sort = "top"');
|
|
203
|
+
expect(script).toContain('var limit = 3');
|
|
204
|
+
expect(script).toContain('var postId = "xyz"');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('embeds expandMore=true and the requested expandRounds when --expand-more is on', async () => {
|
|
208
|
+
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 3, fetched: 12, capped: true, errors: [] } });
|
|
209
|
+
await command.func(page, { 'post-id': 'xyz', 'expand-more': true, 'expand-rounds': 3 });
|
|
210
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
211
|
+
expect(script).toContain('var expandMore = true');
|
|
212
|
+
expect(script).toContain('var expandRounds = 3');
|
|
213
|
+
// The /api/morechildren request body construction must be present in
|
|
214
|
+
// the evaluate script (round-trips the link_id + children CSV).
|
|
215
|
+
expect(script).toContain("'/api/morechildren'");
|
|
216
|
+
expect(script).toContain("'api_type=json'");
|
|
217
|
+
expect(script).toContain("encodeURIComponent(linkFullname)");
|
|
218
|
+
expect(script).toContain("encodeURIComponent(batch.join(','))");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('normalizes a full reddit URL before building the browser script', async () => {
|
|
222
|
+
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
|
|
223
|
+
await command.func(page, { 'post-id': 'https://www.reddit.com/r/python/comments/1abc23/title_slug/' });
|
|
224
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
225
|
+
expect(script).toContain('var postId = "1abc23"');
|
|
226
|
+
expect(script).not.toContain('postIdRaw.match');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('expands morechildren in the original tree position instead of appending to the parent', async () => {
|
|
230
|
+
const fetchMock = vi.fn(async (url) => {
|
|
231
|
+
if (String(url).startsWith('/comments/')) {
|
|
232
|
+
return jsonResponse(redditPostEnvelope([
|
|
233
|
+
commentThing('a', 'A'),
|
|
234
|
+
moreThing('more_top', ['b', 'c']),
|
|
235
|
+
commentThing('d', 'D'),
|
|
236
|
+
]));
|
|
237
|
+
}
|
|
238
|
+
if (String(url) === '/api/morechildren') {
|
|
239
|
+
return jsonResponse({
|
|
240
|
+
json: {
|
|
241
|
+
errors: [],
|
|
242
|
+
data: { things: [commentThing('b', 'B'), commentThing('c', 'C')] },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
throw new Error(`unexpected URL ${url}`);
|
|
247
|
+
});
|
|
248
|
+
const page = makeRuntimePage(fetchMock);
|
|
249
|
+
|
|
250
|
+
const result = await command.func(page, {
|
|
251
|
+
'post-id': 'abc123',
|
|
252
|
+
'expand-more': true,
|
|
253
|
+
limit: 10,
|
|
254
|
+
replies: 10,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.map((row) => row.author)).toEqual(['op', 'a', 'b', 'c', 'd']);
|
|
258
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
259
|
+
'/api/morechildren',
|
|
260
|
+
expect.objectContaining({
|
|
261
|
+
method: 'POST',
|
|
262
|
+
body: expect.stringContaining('link_id=t3_abc123'),
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('fails expand-more when Reddit returns a child that cannot be placed in the requested tree', async () => {
|
|
268
|
+
const fetchMock = vi.fn(async (url) => {
|
|
269
|
+
if (String(url).startsWith('/comments/')) {
|
|
270
|
+
return jsonResponse(redditPostEnvelope([moreThing('more_top', ['b'])]));
|
|
271
|
+
}
|
|
272
|
+
if (String(url) === '/api/morechildren') {
|
|
273
|
+
return jsonResponse({
|
|
274
|
+
json: {
|
|
275
|
+
errors: [],
|
|
276
|
+
data: { things: [commentThing('b', 'B', 't3_other')] },
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`unexpected URL ${url}`);
|
|
281
|
+
});
|
|
282
|
+
const page = makeRuntimePage(fetchMock);
|
|
283
|
+
|
|
284
|
+
await expect(command.func(page, {
|
|
285
|
+
'post-id': 'abc123',
|
|
286
|
+
'expand-more': true,
|
|
287
|
+
})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('fails expand-more when Reddit omits a requested child instead of silently dropping the stub', async () => {
|
|
291
|
+
const fetchMock = vi.fn(async (url) => {
|
|
292
|
+
if (String(url).startsWith('/comments/')) {
|
|
293
|
+
return jsonResponse(redditPostEnvelope([moreThing('more_top', ['b', 'c'])]));
|
|
294
|
+
}
|
|
295
|
+
if (String(url) === '/api/morechildren') {
|
|
296
|
+
return jsonResponse({
|
|
297
|
+
json: {
|
|
298
|
+
errors: [],
|
|
299
|
+
data: { things: [commentThing('b', 'B')] },
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
throw new Error(`unexpected URL ${url}`);
|
|
304
|
+
});
|
|
305
|
+
const page = makeRuntimePage(fetchMock);
|
|
306
|
+
|
|
307
|
+
await expect(command.func(page, {
|
|
308
|
+
'post-id': 'abc123',
|
|
309
|
+
'expand-more': true,
|
|
310
|
+
})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('uses 5-kind discriminated union keys that DO NOT collide with declared columns', () => {
|
|
314
|
+
// Read the evaluate template once to assert the intermediate keys we
|
|
315
|
+
// return on the browser side never name any of `type` / `author` /
|
|
316
|
+
// `score` / `text` (the declared columns) — that pattern would
|
|
317
|
+
// trigger the silent-column-drop audit.
|
|
318
|
+
const page = makePage({ kind: 'ok', rows: [], expandMeta: { rounds: 0, fetched: 0, capped: false, errors: [] } });
|
|
319
|
+
return command.func(page, { 'post-id': 'xyz' }).then(() => {
|
|
320
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
321
|
+
// Each return shape uses kind / detail / httpStatus / where /
|
|
322
|
+
// rows / expandMeta. None overlap with the four declared
|
|
323
|
+
// columns. The walker IS allowed to push column-shaped row
|
|
324
|
+
// objects into `rows` — that's the final shape, not an
|
|
325
|
+
// intermediate one.
|
|
326
|
+
expect(script).toContain("kind: 'inaccessible'");
|
|
327
|
+
expect(script).toContain("kind: 'auth'");
|
|
328
|
+
expect(script).toContain("kind: 'http'");
|
|
329
|
+
expect(script).toContain("kind: 'malformed'");
|
|
330
|
+
expect(script).toContain("kind: 'parser-drift'");
|
|
331
|
+
expect(script).toContain("kind: 'expand-failed'");
|
|
332
|
+
expect(script).toContain("kind: 'ok'");
|
|
333
|
+
});
|
|
31
334
|
});
|
|
32
335
|
});
|
|
@@ -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
|
+
});
|