@jackwener/opencli 1.7.18 → 1.7.20
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 +18 -17
- package/README.zh-CN.md +16 -18
- package/cli-manifest.json +311 -186
- 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 +8 -4
- package/clis/twitter/bookmark-folder.test.js +59 -1
- package/clis/twitter/bookmarks.js +12 -4
- package/clis/twitter/bookmarks.test.js +205 -0
- 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/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +3 -0
- package/dist/src/browser/daemon-client.test.js +20 -0
- 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 +131 -89
- package/dist/src/cli.test.js +34 -28
- package/dist/src/commands/daemon.js +6 -7
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +44 -13
- package/dist/src/daemon.test.js +42 -1
- 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/electron-apps.js +0 -1
- package/dist/src/electron-apps.test.js +1 -0
- package/dist/src/execution.js +1 -3
- package/dist/src/execution.test.js +4 -16
- package/dist/src/external-clis.yaml +12 -3
- package/dist/src/external.d.ts +4 -0
- package/dist/src/external.js +3 -0
- package/dist/src/external.test.js +24 -1
- package/dist/src/help.d.ts +16 -1
- package/dist/src/help.js +50 -8
- package/dist/src/help.test.js +5 -1
- 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/notion/export.js +0 -32
- package/clis/notion/favorites.js +0 -85
- package/clis/notion/new.js +0 -35
- package/clis/notion/read.js +0 -31
- package/clis/notion/search.js +0 -47
- package/clis/notion/sidebar.js +0 -42
- package/clis/notion/status.js +0 -17
- package/clis/notion/write.js +0 -41
package/clis/reddit/read.js
CHANGED
|
@@ -5,9 +5,98 @@
|
|
|
5
5
|
* - Top-K comments by score at each level
|
|
6
6
|
* - Configurable depth and replies-per-level
|
|
7
7
|
* - Indented output showing conversation threads
|
|
8
|
+
* - Optional --expand-more to follow Reddit's "more comments" stubs via
|
|
9
|
+
* /api/morechildren.json (rdt-cli parity, PR B of #1481 follow-up)
|
|
8
10
|
*/
|
|
9
11
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
10
|
-
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
12
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
13
|
+
|
|
14
|
+
const REDDIT_EXPAND_ROUNDS_MIN = 1;
|
|
15
|
+
const REDDIT_EXPAND_ROUNDS_MAX = 5;
|
|
16
|
+
const DEFAULT_EXPAND_ROUNDS = 2;
|
|
17
|
+
const REDDIT_POST_ID_RE = /^[a-z0-9]+$/i;
|
|
18
|
+
|
|
19
|
+
function normalizeBareRedditPostId(value) {
|
|
20
|
+
const postId = String(value || '').trim();
|
|
21
|
+
if (!REDDIT_POST_ID_RE.test(postId)) {
|
|
22
|
+
throw new ArgumentError(
|
|
23
|
+
'Post ID must be a Reddit post id, t3_ fullname, or reddit.com post URL.',
|
|
24
|
+
'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return postId.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeRedditPostId(value) {
|
|
31
|
+
const raw = String(value || '').trim();
|
|
32
|
+
if (!raw) {
|
|
33
|
+
throw new ArgumentError(
|
|
34
|
+
'Post ID is required.',
|
|
35
|
+
'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const fullname = raw.match(/^t3_([a-z0-9]+)$/i);
|
|
40
|
+
if (fullname) return normalizeBareRedditPostId(fullname[1]);
|
|
41
|
+
|
|
42
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = new URL(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new ArgumentError(`Invalid Reddit post URL: ${raw}`);
|
|
48
|
+
}
|
|
49
|
+
const host = parsed.hostname.toLowerCase();
|
|
50
|
+
if (parsed.protocol !== 'https:' || (host !== 'reddit.com' && !host.endsWith('.reddit.com'))) {
|
|
51
|
+
throw new ArgumentError(
|
|
52
|
+
'Post URL must be an https reddit.com URL.',
|
|
53
|
+
'Use a URL like https://www.reddit.com/r/sub/comments/1abc123/title_slug/',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const parts = parsed.pathname.split('/').filter(Boolean);
|
|
57
|
+
const commentsIndex = parts.indexOf('comments');
|
|
58
|
+
const postIndex = commentsIndex + 1;
|
|
59
|
+
if (commentsIndex < 0 || parts.length <= postIndex) {
|
|
60
|
+
throw new ArgumentError(
|
|
61
|
+
'Post URL must include the target post id.',
|
|
62
|
+
'Use a URL like https://www.reddit.com/r/sub/comments/1abc123/title_slug/',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (parts.length > postIndex + 3) {
|
|
66
|
+
throw new ArgumentError(
|
|
67
|
+
'Post URL must end at the post slug or comment permalink id.',
|
|
68
|
+
'Remove extra path segments after the post slug or comment id.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (parts.length === postIndex + 3) normalizeBareRedditPostId(parts[postIndex + 2]);
|
|
72
|
+
return normalizeBareRedditPostId(parts[postIndex]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (raw.includes('/') || raw.startsWith('t1_')) {
|
|
76
|
+
throw new ArgumentError(
|
|
77
|
+
'Post ID must be a Reddit post id, t3_ fullname, or reddit.com post URL.',
|
|
78
|
+
'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return normalizeBareRedditPostId(raw);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseExpandRounds(raw) {
|
|
86
|
+
if (raw === undefined || raw === null || raw === '') return DEFAULT_EXPAND_ROUNDS;
|
|
87
|
+
const n = Number(raw);
|
|
88
|
+
if (
|
|
89
|
+
!Number.isFinite(n) || !Number.isInteger(n)
|
|
90
|
+
|| n < REDDIT_EXPAND_ROUNDS_MIN || n > REDDIT_EXPAND_ROUNDS_MAX
|
|
91
|
+
) {
|
|
92
|
+
throw new ArgumentError(
|
|
93
|
+
`expand-rounds must be an integer in [${REDDIT_EXPAND_ROUNDS_MIN}, ${REDDIT_EXPAND_ROUNDS_MAX}].`,
|
|
94
|
+
`Got: ${raw}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return n;
|
|
98
|
+
}
|
|
99
|
+
|
|
11
100
|
cli({
|
|
12
101
|
site: 'reddit',
|
|
13
102
|
name: 'read',
|
|
@@ -24,80 +113,314 @@ cli({
|
|
|
24
113
|
{ name: 'depth', type: 'int', default: 2, help: 'Max reply depth (1=no replies, 2=one level of replies, etc.)' },
|
|
25
114
|
{ name: 'replies', type: 'int', default: 5, help: 'Max replies shown per comment at each level (sorted by score)' },
|
|
26
115
|
{ name: 'max-length', type: 'int', default: 2000, help: 'Max characters per comment body (min 100)' },
|
|
116
|
+
{
|
|
117
|
+
name: 'expand-more',
|
|
118
|
+
type: 'bool',
|
|
119
|
+
default: false,
|
|
120
|
+
help: 'Follow Reddit "more comments" stubs by calling /api/morechildren.json',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'expand-rounds',
|
|
124
|
+
type: 'int',
|
|
125
|
+
default: DEFAULT_EXPAND_ROUNDS,
|
|
126
|
+
help: `Max expansion passes when --expand-more is on (${REDDIT_EXPAND_ROUNDS_MIN}–${REDDIT_EXPAND_ROUNDS_MAX}; each round can fan out new "more" stubs)`,
|
|
127
|
+
},
|
|
27
128
|
],
|
|
28
129
|
columns: ['type', 'author', 'score', 'text'],
|
|
29
130
|
func: async (page, kwargs) => {
|
|
131
|
+
// Note: --limit / --depth / --replies / --max-length keep their original
|
|
132
|
+
// Math.max-style behaviour for backward compatibility (grandfathered in
|
|
133
|
+
// the typed-error-lint baseline). The new --expand-rounds argument is
|
|
134
|
+
// strictly validated via parseExpandRounds — no silent clamp.
|
|
30
135
|
const sort = kwargs.sort ?? 'best';
|
|
31
136
|
const limit = Math.max(1, kwargs.limit ?? 25);
|
|
32
137
|
const maxDepth = Math.max(1, kwargs.depth ?? 2);
|
|
33
138
|
const maxReplies = Math.max(1, kwargs.replies ?? 5);
|
|
34
139
|
const maxLength = Math.max(100, kwargs['max-length'] ?? 2000);
|
|
140
|
+
const expandMore = Boolean(kwargs['expand-more']);
|
|
141
|
+
const expandRounds = parseExpandRounds(kwargs['expand-rounds']);
|
|
142
|
+
const postId = normalizeRedditPostId(kwargs['post-id']);
|
|
143
|
+
|
|
35
144
|
await page.goto('https://www.reddit.com');
|
|
36
|
-
|
|
145
|
+
|
|
146
|
+
// The in-browser script returns a discriminated union so we can map
|
|
147
|
+
// each failure mode to its proper typed error on the Node side
|
|
148
|
+
// (page.evaluate boundary can't carry typed error instances). Kinds:
|
|
149
|
+
// - inaccessible: 401/403/404 on /comments/<id>.json (post-specific,
|
|
150
|
+
// not session auth — same session works for other posts)
|
|
151
|
+
// - auth: /api/morechildren.json 401/403 (session-level on
|
|
152
|
+
// the write-like expand endpoint — see two-pronged auth detection
|
|
153
|
+
// sediment from PR #1428)
|
|
154
|
+
// - http: 5xx or other non-ok
|
|
155
|
+
// - malformed: 200 but Reddit shape is unexpected (schema drift)
|
|
156
|
+
// - parser-drift: tree non-empty but walk produced 0 rows
|
|
157
|
+
// - expand-failed: morechildren returned errors
|
|
158
|
+
// - ok: rows array
|
|
159
|
+
//
|
|
160
|
+
// Intermediate keys (`rows` / `detail` / `httpStatus` / `where`)
|
|
161
|
+
// deliberately avoid the declared columns (`type`/`author`/`score`/
|
|
162
|
+
// `text`) to sidestep the silent-column-drop audit (PR #1329).
|
|
163
|
+
const result = await page.evaluate(`
|
|
37
164
|
(async function() {
|
|
38
|
-
var postId = ${JSON.stringify(
|
|
39
|
-
var
|
|
40
|
-
if (urlMatch) postId = urlMatch[1];
|
|
165
|
+
var postId = ${JSON.stringify(postId)};
|
|
166
|
+
var linkFullname = 't3_' + postId;
|
|
41
167
|
|
|
42
168
|
var sort = ${JSON.stringify(sort)};
|
|
43
169
|
var limit = ${limit};
|
|
44
170
|
var maxDepth = ${maxDepth};
|
|
45
171
|
var maxReplies = ${maxReplies};
|
|
46
172
|
var maxLength = ${maxLength};
|
|
173
|
+
var expandMore = ${JSON.stringify(expandMore)};
|
|
174
|
+
var expandRounds = ${expandRounds};
|
|
47
175
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
176
|
+
// ---------------------------------------------------------------
|
|
177
|
+
// Step 1: fetch the post + initial comment tree
|
|
178
|
+
// ---------------------------------------------------------------
|
|
179
|
+
// Request more from API than top-level limit to get inline replies.
|
|
180
|
+
// depth param tells Reddit how deep to inline replies vs "more" stubs.
|
|
50
181
|
var apiLimit = Math.max(limit * 3, 100);
|
|
51
182
|
var res = await fetch(
|
|
52
183
|
'/comments/' + postId + '.json?sort=' + sort + '&limit=' + apiLimit + '&depth=' + (maxDepth + 1) + '&raw_json=1',
|
|
53
184
|
{ credentials: 'include' }
|
|
54
185
|
);
|
|
55
|
-
if (
|
|
56
|
-
|
|
186
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
187
|
+
return { kind: 'inaccessible', detail: 'Reddit post ' + postId + ' is not accessible (HTTP ' + res.status + ').' };
|
|
188
|
+
}
|
|
189
|
+
if (!res.ok) {
|
|
190
|
+
return { kind: 'http', httpStatus: res.status, where: '/comments/' + postId + '.json' };
|
|
191
|
+
}
|
|
57
192
|
var data;
|
|
58
|
-
try { data = await res.json(); } catch(e) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
193
|
+
try { data = await res.json(); } catch (e) {
|
|
194
|
+
return { kind: 'malformed', detail: 'Failed to parse Reddit /comments/' + postId + '.json response: ' + (e && e.message || e) };
|
|
195
|
+
}
|
|
196
|
+
if (!Array.isArray(data) || data.length < 2) {
|
|
197
|
+
return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had unexpected envelope shape (length ' + (Array.isArray(data) ? data.length : typeof data) + ').' };
|
|
198
|
+
}
|
|
62
199
|
|
|
63
|
-
// Post
|
|
64
200
|
var post = data[0] && data[0].data && data[0].data.children && data[0].data.children[0] && data[0].data.children[0].data;
|
|
65
|
-
if (post) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
201
|
+
if (!post) {
|
|
202
|
+
return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had no post body.' };
|
|
203
|
+
}
|
|
204
|
+
var topListing = data[1] && data[1].data && Array.isArray(data[1].data.children) ? data[1].data.children : null;
|
|
205
|
+
if (!topListing) {
|
|
206
|
+
return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had no comment listing.' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------
|
|
210
|
+
// Step 2: optionally follow "more" stubs via /api/morechildren.json
|
|
211
|
+
// ---------------------------------------------------------------
|
|
212
|
+
// Each "more" thing has a .data.children array (t1 ids to fetch).
|
|
213
|
+
// The morechildren API returns a FLAT list of things; we re-thread
|
|
214
|
+
// them by parent_id (either t3_<postId> for top-level or t1_<id>
|
|
215
|
+
// for nested). Each round may surface new "more" stubs (because
|
|
216
|
+
// expansion is bounded by Reddit's depth param), so we iterate up
|
|
217
|
+
// to expandRounds times.
|
|
218
|
+
var expandMeta = { rounds: 0, fetched: 0, capped: false, errors: [] };
|
|
219
|
+
|
|
220
|
+
if (expandMore) {
|
|
221
|
+
// Index every existing t1 node so we can splice replies onto it.
|
|
222
|
+
var t1Index = {};
|
|
223
|
+
function indexT1(arr) {
|
|
224
|
+
if (!Array.isArray(arr)) return;
|
|
225
|
+
for (var i = 0; i < arr.length; i++) {
|
|
226
|
+
var node = arr[i];
|
|
227
|
+
if (node && node.kind === 't1' && node.data && node.data.id) {
|
|
228
|
+
t1Index[node.data.name || ('t1_' + node.data.id)] = node;
|
|
229
|
+
if (node.data.replies && node.data.replies.data && node.data.replies.data.children) {
|
|
230
|
+
indexT1(node.data.replies.data.children);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
indexT1(topListing);
|
|
236
|
+
|
|
237
|
+
// Collect "more" stubs (with non-empty children) from anywhere in
|
|
238
|
+
// the tree. Each stub knows its host array via a closure-bound
|
|
239
|
+
// reference we attach.
|
|
240
|
+
function collectMoreStubs(parentArr, parentT1) {
|
|
241
|
+
var out = [];
|
|
242
|
+
if (!Array.isArray(parentArr)) return out;
|
|
243
|
+
for (var i = 0; i < parentArr.length; i++) {
|
|
244
|
+
var n = parentArr[i];
|
|
245
|
+
if (!n || !n.data) continue;
|
|
246
|
+
if (n.kind === 'more' && Array.isArray(n.data.children) && n.data.children.length > 0) {
|
|
247
|
+
out.push({ stub: n, hostArr: parentArr, hostT1: parentT1 });
|
|
248
|
+
} else if (n.kind === 't1' && n.data.replies && n.data.replies.data && n.data.replies.data.children) {
|
|
249
|
+
var nested = collectMoreStubs(n.data.replies.data.children, n);
|
|
250
|
+
for (var k = 0; k < nested.length; k++) out.push(nested[k]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (var r = 0; r < expandRounds; r++) {
|
|
257
|
+
var stubs = collectMoreStubs(topListing, null);
|
|
258
|
+
if (stubs.length === 0) break;
|
|
259
|
+
|
|
260
|
+
// Build the union of t1 ids to request this round. Reddit's
|
|
261
|
+
// morechildren API caps at ~100 ids per call; batch accordingly.
|
|
262
|
+
var allIds = [];
|
|
263
|
+
for (var s = 0; s < stubs.length; s++) {
|
|
264
|
+
var st = stubs[s].stub;
|
|
265
|
+
for (var c = 0; c < st.data.children.length; c++) allIds.push(st.data.children[c]);
|
|
266
|
+
}
|
|
267
|
+
if (allIds.length === 0) break;
|
|
268
|
+
|
|
269
|
+
// dedupe preserving order
|
|
270
|
+
var seen = {};
|
|
271
|
+
var uniqIds = [];
|
|
272
|
+
for (var j = 0; j < allIds.length; j++) {
|
|
273
|
+
if (!seen[allIds[j]]) { seen[allIds[j]] = 1; uniqIds.push(allIds[j]); }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
var fetchedThings = [];
|
|
277
|
+
var batchSize = 100;
|
|
278
|
+
var batchFailed = false;
|
|
279
|
+
for (var b = 0; b < uniqIds.length; b += batchSize) {
|
|
280
|
+
var batch = uniqIds.slice(b, b + batchSize);
|
|
281
|
+
var body = 'api_type=json'
|
|
282
|
+
+ '&link_id=' + encodeURIComponent(linkFullname)
|
|
283
|
+
+ '&children=' + encodeURIComponent(batch.join(','))
|
|
284
|
+
+ '&sort=' + encodeURIComponent(sort)
|
|
285
|
+
+ '&raw_json=1';
|
|
286
|
+
var mcRes;
|
|
287
|
+
try {
|
|
288
|
+
mcRes = await fetch('/api/morechildren', {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
credentials: 'include',
|
|
291
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
292
|
+
body: body,
|
|
293
|
+
});
|
|
294
|
+
} catch (e) {
|
|
295
|
+
return { kind: 'expand-failed', detail: 'morechildren request threw: ' + (e && e.message || e), expandMeta: expandMeta };
|
|
296
|
+
}
|
|
297
|
+
if (mcRes.status === 401 || mcRes.status === 403) {
|
|
298
|
+
return { kind: 'auth', detail: '/api/morechildren returned HTTP ' + mcRes.status + ' (write/expand likely requires login)' };
|
|
299
|
+
}
|
|
300
|
+
if (!mcRes.ok) {
|
|
301
|
+
return { kind: 'http', httpStatus: mcRes.status, where: '/api/morechildren (round ' + (r + 1) + ', batch ' + ((b / batchSize) + 1) + ')' };
|
|
302
|
+
}
|
|
303
|
+
var mcData;
|
|
304
|
+
try { mcData = await mcRes.json(); } catch (e) {
|
|
305
|
+
return { kind: 'malformed', detail: 'Failed to parse /api/morechildren response: ' + (e && e.message || e) };
|
|
306
|
+
}
|
|
307
|
+
var errs = mcData && mcData.json && mcData.json.errors;
|
|
308
|
+
if (Array.isArray(errs) && errs.length > 0) {
|
|
309
|
+
return { kind: 'expand-failed', detail: 'Reddit /api/morechildren rejected: ' + errs.map(function(e) { return e.join(': '); }).join('; '), expandMeta: expandMeta };
|
|
310
|
+
}
|
|
311
|
+
var things = mcData && mcData.json && mcData.json.data && mcData.json.data.things;
|
|
312
|
+
if (!Array.isArray(things)) {
|
|
313
|
+
return { kind: 'malformed', detail: '/api/morechildren returned no things array.' };
|
|
314
|
+
}
|
|
315
|
+
for (var t = 0; t < things.length; t++) fetchedThings.push(things[t]);
|
|
316
|
+
}
|
|
317
|
+
expandMeta.rounds = r + 1;
|
|
318
|
+
expandMeta.fetched += fetchedThings.length;
|
|
319
|
+
|
|
320
|
+
var fetchedById = {};
|
|
321
|
+
for (var t = 0; t < fetchedThings.length; t++) {
|
|
322
|
+
var thing = fetchedThings[t];
|
|
323
|
+
if (!thing || !thing.data) continue;
|
|
324
|
+
if (thing.data.id) fetchedById[thing.data.id] = thing;
|
|
325
|
+
if (thing.data.name) fetchedById[thing.data.name] = thing;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
var inserted = {};
|
|
329
|
+
function thingKey(thing) {
|
|
330
|
+
return thing && thing.data && (thing.data.name || (thing.kind + '_' + thing.data.id));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Replace each collected stub in-place so expansion preserves the
|
|
334
|
+
// surrounding tree order instead of appending fetched comments at
|
|
335
|
+
// the end of the parent array.
|
|
336
|
+
for (var s = 0; s < stubs.length; s++) {
|
|
337
|
+
var rec = stubs[s];
|
|
338
|
+
var idx = rec.hostArr.indexOf(rec.stub);
|
|
339
|
+
if (idx < 0) continue;
|
|
340
|
+
var expectedParent = rec.hostT1
|
|
341
|
+
? (rec.hostT1.data.name || ('t1_' + rec.hostT1.data.id))
|
|
342
|
+
: linkFullname;
|
|
343
|
+
var replacements = [];
|
|
344
|
+
for (var c = 0; c < rec.stub.data.children.length; c++) {
|
|
345
|
+
var childId = rec.stub.data.children[c];
|
|
346
|
+
var replacement = fetchedById[childId] || fetchedById['t1_' + childId];
|
|
347
|
+
if (!replacement || !replacement.data) {
|
|
348
|
+
expandMeta.errors.push('missing: ' + childId + ' parent=' + expectedParent);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
var key = thingKey(replacement);
|
|
352
|
+
if (key && inserted[key]) continue;
|
|
353
|
+
if (replacement.data.parent_id !== expectedParent) {
|
|
354
|
+
expandMeta.errors.push('orphan: ' + (replacement.data.id || '?') + ' parent=' + (replacement.data.parent_id || '?'));
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
replacements.push(replacement);
|
|
358
|
+
if (key) inserted[key] = 1;
|
|
359
|
+
if (replacement.kind === 't1' && replacement.data && replacement.data.id) {
|
|
360
|
+
t1Index[replacement.data.name || ('t1_' + replacement.data.id)] = replacement;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
rec.hostArr.splice(idx, 1, ...replacements);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (var t = 0; t < fetchedThings.length; t++) {
|
|
367
|
+
var unplaced = fetchedThings[t];
|
|
368
|
+
var unplacedKey = thingKey(unplaced);
|
|
369
|
+
if (unplacedKey && inserted[unplacedKey]) continue;
|
|
370
|
+
if (unplaced && unplaced.data) {
|
|
371
|
+
expandMeta.errors.push('unplaced: ' + (unplaced.data.id || '?') + ' parent=' + (unplaced.data.parent_id || '?'));
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (r + 1 >= expandRounds) {
|
|
377
|
+
// If after the last round there are still "more" stubs, mark capped.
|
|
378
|
+
var remaining = collectMoreStubs(topListing, null);
|
|
379
|
+
if (remaining.length > 0) expandMeta.capped = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (expandMeta.errors.length > 0) {
|
|
384
|
+
return { kind: 'expand-failed', detail: 'Reddit /api/morechildren returned unplaceable comments: ' + expandMeta.errors.slice(0, 5).join('; '), expandMeta: expandMeta };
|
|
385
|
+
}
|
|
74
386
|
}
|
|
75
387
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
388
|
+
// ---------------------------------------------------------------
|
|
389
|
+
// Step 3: walk the (possibly augmented) tree into indented rows
|
|
390
|
+
// ---------------------------------------------------------------
|
|
391
|
+
var rows = [];
|
|
392
|
+
|
|
393
|
+
// Post header row.
|
|
394
|
+
var body = post.selftext || '';
|
|
395
|
+
if (body.length > maxLength) body = body.slice(0, maxLength) + '\\n... [truncated]';
|
|
396
|
+
rows.push({
|
|
397
|
+
type: 'POST',
|
|
398
|
+
author: post.author || '[deleted]',
|
|
399
|
+
score: post.score || 0,
|
|
400
|
+
text: post.title + (body ? '\\n\\n' + body : '') + (post.url && !post.is_self ? '\\n' + post.url : ''),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Recursive comment walker.
|
|
79
404
|
function walkComment(node, depth) {
|
|
80
405
|
if (!node || node.kind !== 't1') return;
|
|
81
406
|
var d = node.data;
|
|
82
|
-
var
|
|
83
|
-
if (
|
|
407
|
+
var cBody = d.body || '';
|
|
408
|
+
if (cBody.length > maxLength) cBody = cBody.slice(0, maxLength) + '...';
|
|
84
409
|
|
|
85
|
-
// Indent prefix: apply to every line so multiline bodies stay aligned
|
|
86
410
|
var indent = '';
|
|
87
411
|
for (var i = 0; i < depth; i++) indent += ' ';
|
|
88
412
|
var prefix = depth === 0 ? '' : indent + '> ';
|
|
89
413
|
var indentedBody = depth === 0
|
|
90
|
-
?
|
|
91
|
-
:
|
|
414
|
+
? cBody
|
|
415
|
+
: cBody.split('\\n').map(function(line) { return prefix + line; }).join('\\n');
|
|
92
416
|
|
|
93
|
-
|
|
417
|
+
rows.push({
|
|
94
418
|
type: depth === 0 ? 'L0' : 'L' + depth,
|
|
95
419
|
author: d.author || '[deleted]',
|
|
96
420
|
score: d.score || 0,
|
|
97
421
|
text: indentedBody,
|
|
98
422
|
});
|
|
99
423
|
|
|
100
|
-
// Count all available replies (for accurate "more" count)
|
|
101
424
|
var t1Children = [];
|
|
102
425
|
var moreCount = 0;
|
|
103
426
|
if (d.replies && d.replies.data && d.replies.data.children) {
|
|
@@ -111,13 +434,12 @@ cli({
|
|
|
111
434
|
}
|
|
112
435
|
}
|
|
113
436
|
|
|
114
|
-
// At depth cutoff: don't recurse, but show all replies as hidden
|
|
115
437
|
if (depth + 1 >= maxDepth) {
|
|
116
438
|
var totalHidden = t1Children.length + moreCount;
|
|
117
439
|
if (totalHidden > 0) {
|
|
118
440
|
var cutoffIndent = '';
|
|
119
441
|
for (var j = 0; j <= depth; j++) cutoffIndent += ' ';
|
|
120
|
-
|
|
442
|
+
rows.push({
|
|
121
443
|
type: 'L' + (depth + 1),
|
|
122
444
|
author: '',
|
|
123
445
|
score: '',
|
|
@@ -127,19 +449,17 @@ cli({
|
|
|
127
449
|
return;
|
|
128
450
|
}
|
|
129
451
|
|
|
130
|
-
// Sort by score descending, take top N
|
|
131
452
|
t1Children.sort(function(a, b) { return (b.data.score || 0) - (a.data.score || 0); });
|
|
132
453
|
var toProcess = Math.min(t1Children.length, maxReplies);
|
|
133
454
|
for (var i = 0; i < toProcess; i++) {
|
|
134
455
|
walkComment(t1Children[i], depth + 1);
|
|
135
456
|
}
|
|
136
457
|
|
|
137
|
-
// Show hidden count (skipped replies + "more" stubs)
|
|
138
458
|
var hidden = t1Children.length - toProcess + moreCount;
|
|
139
459
|
if (hidden > 0) {
|
|
140
460
|
var moreIndent = '';
|
|
141
461
|
for (var j = 0; j <= depth; j++) moreIndent += ' ';
|
|
142
|
-
|
|
462
|
+
rows.push({
|
|
143
463
|
type: 'L' + (depth + 1),
|
|
144
464
|
author: '',
|
|
145
465
|
score: '',
|
|
@@ -148,24 +468,24 @@ cli({
|
|
|
148
468
|
}
|
|
149
469
|
}
|
|
150
470
|
|
|
151
|
-
// Walk top-level comments
|
|
152
|
-
var topLevel = data[1].data.children || [];
|
|
153
471
|
var t1TopLevel = [];
|
|
154
|
-
for (var i = 0; i <
|
|
155
|
-
if (
|
|
472
|
+
for (var i = 0; i < topListing.length; i++) {
|
|
473
|
+
if (topListing[i].kind === 't1') t1TopLevel.push(topListing[i]);
|
|
156
474
|
}
|
|
157
475
|
|
|
158
|
-
//
|
|
476
|
+
// Detect parser drift: tree had content but the walker produced nothing.
|
|
477
|
+
// We must check this AFTER the walk because top-level may be only "more"
|
|
478
|
+
// stubs (legitimate empty case for a brand-new post).
|
|
479
|
+
var preWalkSize = topListing.length;
|
|
159
480
|
for (var i = 0; i < Math.min(t1TopLevel.length, limit); i++) {
|
|
160
481
|
walkComment(t1TopLevel[i], 0);
|
|
161
482
|
}
|
|
162
483
|
|
|
163
|
-
|
|
164
|
-
var moreTopLevel = topLevel.filter(function(c) { return c.kind === 'more'; })
|
|
484
|
+
var moreTopLevel = topListing.filter(function(c) { return c.kind === 'more'; })
|
|
165
485
|
.reduce(function(sum, c) { return sum + (c.data.count || 0); }, 0);
|
|
166
486
|
var hiddenTopLevel = Math.max(0, t1TopLevel.length - limit) + moreTopLevel;
|
|
167
487
|
if (hiddenTopLevel > 0) {
|
|
168
|
-
|
|
488
|
+
rows.push({
|
|
169
489
|
type: '',
|
|
170
490
|
author: '',
|
|
171
491
|
score: '',
|
|
@@ -173,15 +493,41 @@ cli({
|
|
|
173
493
|
});
|
|
174
494
|
}
|
|
175
495
|
|
|
176
|
-
|
|
496
|
+
// If we produced nothing beyond the POST row but the comment listing
|
|
497
|
+
// wasn't empty, that's parser drift (e.g. Reddit changed t1/more
|
|
498
|
+
// schema). Surface as CommandExecutionError on the Node side.
|
|
499
|
+
if (rows.length <= 1 && preWalkSize > 0 && t1TopLevel.length > 0) {
|
|
500
|
+
return { kind: 'parser-drift', detail: 'Reddit comment listing for post ' + postId + ' had ' + t1TopLevel.length + ' t1 entries but walker produced no rows.' };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { kind: 'ok', rows: rows, expandMeta: expandMeta };
|
|
177
504
|
})()
|
|
178
505
|
`);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
throw new
|
|
185
|
-
|
|
506
|
+
|
|
507
|
+
if (!result || typeof result !== 'object') {
|
|
508
|
+
throw new CommandExecutionError('Reddit /comments fetch returned no result envelope.');
|
|
509
|
+
}
|
|
510
|
+
if (result.kind === 'inaccessible') {
|
|
511
|
+
throw new EmptyResultError(result.detail);
|
|
512
|
+
}
|
|
513
|
+
if (result.kind === 'auth') {
|
|
514
|
+
throw new AuthRequiredError('reddit.com', result.detail);
|
|
515
|
+
}
|
|
516
|
+
if (result.kind === 'http') {
|
|
517
|
+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
|
|
518
|
+
}
|
|
519
|
+
if (result.kind === 'malformed') {
|
|
520
|
+
throw new CommandExecutionError(result.detail);
|
|
521
|
+
}
|
|
522
|
+
if (result.kind === 'parser-drift') {
|
|
523
|
+
throw new CommandExecutionError(result.detail);
|
|
524
|
+
}
|
|
525
|
+
if (result.kind === 'expand-failed') {
|
|
526
|
+
throw new CommandExecutionError(result.detail);
|
|
527
|
+
}
|
|
528
|
+
if (result.kind !== 'ok' || !Array.isArray(result.rows)) {
|
|
529
|
+
throw new CommandExecutionError(`Unexpected result from reddit read: ${JSON.stringify(result)}`);
|
|
530
|
+
}
|
|
531
|
+
return result.rows;
|
|
186
532
|
},
|
|
187
533
|
});
|