@jackwener/opencli 1.7.18 → 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 +7 -8
- package/README.zh-CN.md +7 -8
- package/cli-manifest.json +305 -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/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/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/search.js +6 -2
- 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 +23 -2
- package/clis/xiaohongshu/comments.test.js +63 -1
- package/clis/xiaohongshu/search.js +168 -13
- package/clis/xiaohongshu/search.test.js +82 -8
- 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/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 +33 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/doctor.js +15 -16
- 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,117 @@
|
|
|
1
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
// Reddit subreddit names: 3–21 chars, letters/digits/underscore, must start
|
|
5
|
+
// with a letter. Accept an optional `r/` prefix and normalise it off.
|
|
6
|
+
const SUBREDDIT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{2,20}$/;
|
|
7
|
+
|
|
8
|
+
export function parseSubredditName(raw) {
|
|
9
|
+
let name = String(raw || '').trim();
|
|
10
|
+
if (!name) {
|
|
11
|
+
throw new ArgumentError(
|
|
12
|
+
'Subreddit name is required.',
|
|
13
|
+
'Pass a subreddit name like `python` (or `r/python`).',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
if (name.startsWith('/r/')) name = name.slice(3);
|
|
17
|
+
else if (name.startsWith('r/')) name = name.slice(2);
|
|
18
|
+
if (!SUBREDDIT_NAME_RE.test(name)) {
|
|
19
|
+
throw new ArgumentError(
|
|
20
|
+
'Invalid subreddit name.',
|
|
21
|
+
'Subreddit names are 3–21 characters, start with a letter, and contain only letters, digits, and underscores.',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cli({
|
|
28
|
+
site: 'reddit',
|
|
29
|
+
name: 'subreddit-info',
|
|
30
|
+
access: 'read',
|
|
31
|
+
description: 'Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)',
|
|
32
|
+
domain: 'reddit.com',
|
|
33
|
+
strategy: Strategy.COOKIE,
|
|
34
|
+
browser: true,
|
|
35
|
+
siteSession: 'persistent',
|
|
36
|
+
args: [
|
|
37
|
+
{ name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix needed)' },
|
|
38
|
+
],
|
|
39
|
+
columns: ['field', 'value'],
|
|
40
|
+
func: async (page, kwargs) => {
|
|
41
|
+
const sub = parseSubredditName(kwargs.name);
|
|
42
|
+
await page.goto('https://www.reddit.com');
|
|
43
|
+
// Banned / private / non-existent subreddits return a 404 envelope
|
|
44
|
+
// ({"error":404,"reason":"banned"|"private"|null,"message":"Not Found"}).
|
|
45
|
+
// We surface those as EmptyResultError so the table never contains a
|
|
46
|
+
// silent sentinel row. Intermediate keys avoid `field`/`value`.
|
|
47
|
+
const result = await page.evaluate(`(async () => {
|
|
48
|
+
try {
|
|
49
|
+
const sub = ${JSON.stringify(sub)};
|
|
50
|
+
const res = await fetch('/r/' + encodeURIComponent(sub) + '/about.json?raw_json=1', { credentials: 'include' });
|
|
51
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
52
|
+
return { kind: 'missing', detail: 'Subreddit r/' + sub + ' was not found or is not accessible (HTTP ' + res.status + ').' };
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
return { kind: 'http', httpStatus: res.status, where: '/r/' + sub + '/about.json' };
|
|
56
|
+
}
|
|
57
|
+
const j = await res.json();
|
|
58
|
+
// Reddit may return an envelope-style 200 with {"kind":"Listing"} or
|
|
59
|
+
// an error body for quarantined / private subs. Identify "subreddit
|
|
60
|
+
// not found / not accessible" by the absence of data.display_name.
|
|
61
|
+
if (j?.error) {
|
|
62
|
+
if (j.error === 404 || j.reason === 'banned' || j.reason === 'private' || j.reason === 'quarantined') {
|
|
63
|
+
return { kind: 'missing', detail: 'Subreddit r/' + sub + ' is ' + (j.reason || 'unavailable') + '.' };
|
|
64
|
+
}
|
|
65
|
+
return { kind: 'http', httpStatus: j.error, where: '/r/' + sub + '/about.json (' + (j.reason || 'error') + ')' };
|
|
66
|
+
}
|
|
67
|
+
const info = j?.data;
|
|
68
|
+
if (!info || !info.display_name) {
|
|
69
|
+
return { kind: 'malformed', detail: 'Reddit returned malformed subreddit info for r/' + sub + ' (missing data.display_name).' };
|
|
70
|
+
}
|
|
71
|
+
return { kind: 'ok', info };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { kind: 'exception', detail: String(e && e.message || e) };
|
|
74
|
+
}
|
|
75
|
+
})()`);
|
|
76
|
+
|
|
77
|
+
if (result?.kind === 'missing') {
|
|
78
|
+
throw new EmptyResultError(result.detail);
|
|
79
|
+
}
|
|
80
|
+
if (result?.kind === 'http') {
|
|
81
|
+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
|
|
82
|
+
}
|
|
83
|
+
if (result?.kind === 'malformed') {
|
|
84
|
+
throw new CommandExecutionError(result.detail);
|
|
85
|
+
}
|
|
86
|
+
if (result?.kind === 'exception') {
|
|
87
|
+
throw new CommandExecutionError(`subreddit-info failed: ${result.detail}`);
|
|
88
|
+
}
|
|
89
|
+
if (result?.kind !== 'ok') {
|
|
90
|
+
throw new CommandExecutionError(`Unexpected result from reddit subreddit-info: ${JSON.stringify(result)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const s = result.info;
|
|
94
|
+
const created = s.created_utc
|
|
95
|
+
? new Date(s.created_utc * 1000).toISOString().split('T')[0]
|
|
96
|
+
: null;
|
|
97
|
+
const subscribers = typeof s.subscribers === 'number' ? s.subscribers : null;
|
|
98
|
+
const activeNow = typeof s.active_user_count === 'number'
|
|
99
|
+
? s.active_user_count
|
|
100
|
+
: (typeof s.accounts_active === 'number' ? s.accounts_active : null);
|
|
101
|
+
const description = typeof s.public_description === 'string'
|
|
102
|
+
? s.public_description.trim()
|
|
103
|
+
: '';
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
{ field: 'Name', value: s.display_name_prefixed || ('r/' + s.display_name) },
|
|
107
|
+
{ field: 'Title', value: typeof s.title === 'string' ? s.title : null },
|
|
108
|
+
{ field: 'Subscribers', value: subscribers != null ? String(subscribers) : null },
|
|
109
|
+
{ field: 'Active Now', value: activeNow != null ? String(activeNow) : null },
|
|
110
|
+
{ field: 'NSFW', value: s.over18 ? 'Yes' : 'No' },
|
|
111
|
+
{ field: 'Type', value: typeof s.subreddit_type === 'string' ? s.subreddit_type : null },
|
|
112
|
+
{ field: 'Description', value: description || null },
|
|
113
|
+
{ field: 'Created', value: created },
|
|
114
|
+
{ field: 'URL', value: s.url ? 'https://www.reddit.com' + s.url : null },
|
|
115
|
+
];
|
|
116
|
+
},
|
|
117
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { parseSubredditName } from './subreddit-info.js';
|
|
5
|
+
import './subreddit-info.js';
|
|
6
|
+
|
|
7
|
+
function makePage(result) {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(result),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('reddit subreddit-info command', () => {
|
|
15
|
+
const command = getRegistry().get('reddit/subreddit-info');
|
|
16
|
+
|
|
17
|
+
it('registers with the expected shape', () => {
|
|
18
|
+
expect(command).toBeDefined();
|
|
19
|
+
expect(command.access).toBe('read');
|
|
20
|
+
expect(command.browser).toBe(true);
|
|
21
|
+
expect(command.columns).toEqual(['field', 'value']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('parseSubredditName strips prefixes and validates the name shape', () => {
|
|
25
|
+
expect(parseSubredditName('python')).toBe('python');
|
|
26
|
+
expect(parseSubredditName('r/python')).toBe('python');
|
|
27
|
+
expect(parseSubredditName('/r/python')).toBe('python');
|
|
28
|
+
expect(parseSubredditName(' AskReddit ')).toBe('AskReddit');
|
|
29
|
+
expect(parseSubredditName('aw3some_sub')).toBe('aw3some_sub');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('parseSubredditName rejects invalid names without silent fallback', () => {
|
|
33
|
+
for (const bad of [
|
|
34
|
+
'',
|
|
35
|
+
' ',
|
|
36
|
+
'py', // too short
|
|
37
|
+
'1abc', // must start with letter
|
|
38
|
+
'_sub', // must start with letter
|
|
39
|
+
'has space', // no spaces
|
|
40
|
+
'has-dash', // no dashes
|
|
41
|
+
'way_too_long_subreddit_name_here', // too long (>21)
|
|
42
|
+
]) {
|
|
43
|
+
expect(() => parseSubredditName(bad)).toThrow(ArgumentError);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects bad subreddit names BEFORE navigating', async () => {
|
|
48
|
+
const page = makePage({ kind: 'ok', info: {} });
|
|
49
|
+
await expect(command.func(page, { name: 'has space' })).rejects.toBeInstanceOf(ArgumentError);
|
|
50
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
51
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws EmptyResultError for missing / banned / private / quarantined subreddits', async () => {
|
|
55
|
+
for (const detail of ['not found', 'banned', 'private', 'quarantined']) {
|
|
56
|
+
await expect(command.func(makePage({ kind: 'missing', detail }), { name: 'python' }))
|
|
57
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('treats HTTP 401/403/404 about.json responses as inaccessible subreddit, not auth-required', async () => {
|
|
62
|
+
for (const status of [401, 403, 404]) {
|
|
63
|
+
const scriptResults = [];
|
|
64
|
+
const page = {
|
|
65
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
66
|
+
evaluate: vi.fn(async (script) => {
|
|
67
|
+
const result = await (new Function('fetch', `return (${script})`))(vi.fn(async () => ({
|
|
68
|
+
status,
|
|
69
|
+
ok: false,
|
|
70
|
+
})));
|
|
71
|
+
scriptResults.push(result);
|
|
72
|
+
return result;
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
await expect(command.func(page, { name: 'python' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
76
|
+
expect(scriptResults[0]).toMatchObject({ kind: 'missing' });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws CommandExecutionError on HTTP and exception failure modes', async () => {
|
|
81
|
+
await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: 'about.json' }), { name: 'python' }))
|
|
82
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
83
|
+
await expect(command.func(makePage({ kind: 'exception', detail: 'bad' }), { name: 'python' }))
|
|
84
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws CommandExecutionError for malformed 200 subreddit payloads', async () => {
|
|
88
|
+
await expect(command.func(makePage({
|
|
89
|
+
kind: 'malformed',
|
|
90
|
+
detail: 'Reddit returned malformed subreddit info for r/python (missing data.display_name).',
|
|
91
|
+
}), { name: 'python' }))
|
|
92
|
+
.rejects.toMatchObject({
|
|
93
|
+
code: 'COMMAND_EXEC',
|
|
94
|
+
message: expect.stringContaining('malformed subreddit info'),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('maps a normal subreddit payload into typed field/value rows', async () => {
|
|
99
|
+
const info = {
|
|
100
|
+
display_name: 'python',
|
|
101
|
+
display_name_prefixed: 'r/python',
|
|
102
|
+
title: 'Python',
|
|
103
|
+
public_description: ' News about the programming language Python. ',
|
|
104
|
+
subscribers: 1234567,
|
|
105
|
+
active_user_count: 4321,
|
|
106
|
+
over18: false,
|
|
107
|
+
subreddit_type: 'public',
|
|
108
|
+
created_utc: 1201881600, // 2008-02-01
|
|
109
|
+
url: '/r/python/',
|
|
110
|
+
};
|
|
111
|
+
const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'python' });
|
|
112
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
113
|
+
|
|
114
|
+
expect(byField.Name).toBe('r/python');
|
|
115
|
+
expect(byField.Title).toBe('Python');
|
|
116
|
+
expect(byField.Subscribers).toBe('1234567');
|
|
117
|
+
expect(byField['Active Now']).toBe('4321');
|
|
118
|
+
expect(byField.NSFW).toBe('No');
|
|
119
|
+
expect(byField.Type).toBe('public');
|
|
120
|
+
expect(byField.Description).toBe('News about the programming language Python.');
|
|
121
|
+
expect(byField.Created).toBe('2008-02-01');
|
|
122
|
+
expect(byField.URL).toBe('https://www.reddit.com/r/python/');
|
|
123
|
+
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
expect(Object.keys(row).sort()).toEqual(['field', 'value']);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('falls back to accounts_active when active_user_count is missing', async () => {
|
|
130
|
+
const info = {
|
|
131
|
+
display_name: 'sub',
|
|
132
|
+
display_name_prefixed: 'r/sub',
|
|
133
|
+
accounts_active: 99,
|
|
134
|
+
url: '/r/sub/',
|
|
135
|
+
};
|
|
136
|
+
const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'sub' });
|
|
137
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
138
|
+
expect(byField['Active Now']).toBe('99');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('emits typed null (not "-" sentinel) when subscribers / activity / created are missing', async () => {
|
|
142
|
+
const info = {
|
|
143
|
+
display_name: 'sparse',
|
|
144
|
+
display_name_prefixed: 'r/sparse',
|
|
145
|
+
url: '/r/sparse/',
|
|
146
|
+
};
|
|
147
|
+
const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'sparse' });
|
|
148
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
149
|
+
expect(byField.Subscribers).toBeNull();
|
|
150
|
+
expect(byField['Active Now']).toBeNull();
|
|
151
|
+
expect(byField.Created).toBeNull();
|
|
152
|
+
expect(byField.Description).toBeNull();
|
|
153
|
+
expect(byField.Title).toBeNull();
|
|
154
|
+
expect(byField.Type).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('encodes the subreddit name into the fetch URL embedded in evaluate', async () => {
|
|
158
|
+
const page = makePage({ kind: 'ok', info: { display_name: 'python', display_name_prefixed: 'r/python', url: '/r/python/' } });
|
|
159
|
+
await command.func(page, { name: 'r/python' });
|
|
160
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
161
|
+
expect(script).toContain('const sub = "python"');
|
|
162
|
+
});
|
|
163
|
+
});
|