@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.
Files changed (95) hide show
  1. package/README.md +7 -8
  2. package/README.zh-CN.md +7 -8
  3. package/cli-manifest.json +305 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/google/search.js +16 -6
  9. package/clis/google-scholar/search.js +20 -5
  10. package/clis/google-scholar/search.test.js +35 -2
  11. package/clis/reddit/home.js +117 -0
  12. package/clis/reddit/home.test.js +127 -0
  13. package/clis/reddit/read.js +400 -54
  14. package/clis/reddit/read.test.js +315 -12
  15. package/clis/reddit/subreddit-info.js +117 -0
  16. package/clis/reddit/subreddit-info.test.js +163 -0
  17. package/clis/reddit/whoami.js +84 -0
  18. package/clis/reddit/whoami.test.js +105 -0
  19. package/clis/rednote/search.js +6 -2
  20. package/clis/twitter/bookmark-folder.js +3 -1
  21. package/clis/twitter/bookmarks.js +3 -1
  22. package/clis/twitter/followers.js +20 -5
  23. package/clis/twitter/followers.test.js +44 -0
  24. package/clis/twitter/following.js +36 -20
  25. package/clis/twitter/following.test.js +60 -8
  26. package/clis/twitter/likes.js +28 -13
  27. package/clis/twitter/likes.test.js +111 -1
  28. package/clis/twitter/list-add.js +128 -204
  29. package/clis/twitter/list-add.test.js +97 -1
  30. package/clis/twitter/list-tweets.js +13 -4
  31. package/clis/twitter/list-tweets.test.js +48 -0
  32. package/clis/twitter/lists.js +5 -2
  33. package/clis/twitter/post.js +23 -4
  34. package/clis/twitter/post.test.js +30 -0
  35. package/clis/twitter/profile.js +16 -8
  36. package/clis/twitter/profile.test.js +39 -0
  37. package/clis/twitter/reply.js +133 -10
  38. package/clis/twitter/reply.test.js +55 -0
  39. package/clis/twitter/search.js +188 -170
  40. package/clis/twitter/search.test.js +96 -258
  41. package/clis/twitter/shared.js +167 -16
  42. package/clis/twitter/shared.test.js +102 -1
  43. package/clis/twitter/timeline.js +3 -1
  44. package/clis/twitter/tweets.js +147 -51
  45. package/clis/twitter/tweets.test.js +238 -1
  46. package/clis/xiaohongshu/comments.js +23 -2
  47. package/clis/xiaohongshu/comments.test.js +63 -1
  48. package/clis/xiaohongshu/search.js +168 -13
  49. package/clis/xiaohongshu/search.test.js +82 -8
  50. package/clis/xueqiu/earnings-date.js +2 -2
  51. package/clis/xueqiu/kline.js +2 -2
  52. package/clis/xueqiu/utils.js +19 -0
  53. package/clis/xueqiu/utils.test.js +26 -0
  54. package/clis/zhihu/answer-detail.js +233 -0
  55. package/clis/zhihu/answer-detail.test.js +330 -0
  56. package/clis/zhihu/question.js +44 -10
  57. package/clis/zhihu/question.test.js +78 -1
  58. package/clis/zhihu/recommend.js +103 -0
  59. package/clis/zhihu/recommend.test.js +143 -0
  60. package/dist/src/browser/base-page.d.ts +3 -2
  61. package/dist/src/browser/base-page.test.js +2 -2
  62. package/dist/src/browser/cdp.js +3 -3
  63. package/dist/src/browser/page.d.ts +3 -2
  64. package/dist/src/browser/page.js +4 -4
  65. package/dist/src/browser/page.test.js +31 -0
  66. package/dist/src/browser/utils.d.ts +10 -0
  67. package/dist/src/browser/utils.js +37 -0
  68. package/dist/src/browser/utils.test.d.ts +1 -0
  69. package/dist/src/browser/utils.test.js +29 -0
  70. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  71. package/dist/src/cli-argv-preprocess.js +131 -0
  72. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  73. package/dist/src/cli-argv-preprocess.test.js +130 -0
  74. package/dist/src/cli.js +123 -86
  75. package/dist/src/cli.test.js +33 -28
  76. package/dist/src/commands/daemon.js +6 -7
  77. package/dist/src/doctor.js +15 -16
  78. package/dist/src/download/progress.js +15 -11
  79. package/dist/src/download/progress.test.d.ts +1 -0
  80. package/dist/src/download/progress.test.js +25 -0
  81. package/dist/src/execution.js +1 -3
  82. package/dist/src/execution.test.js +4 -16
  83. package/dist/src/help.d.ts +11 -0
  84. package/dist/src/help.js +46 -5
  85. package/dist/src/logger.js +8 -9
  86. package/dist/src/main.js +16 -0
  87. package/dist/src/output.js +4 -5
  88. package/dist/src/runtime-detect.d.ts +1 -1
  89. package/dist/src/runtime-detect.js +1 -1
  90. package/dist/src/runtime-detect.test.js +3 -2
  91. package/dist/src/tui.d.ts +0 -1
  92. package/dist/src/tui.js +9 -22
  93. package/dist/src/types.d.ts +3 -1
  94. package/dist/src/update-check.js +4 -5
  95. package/package.json +5 -4
@@ -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
- const data = await page.evaluate(`
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(kwargs['post-id'])};
39
- var urlMatch = postId.match(/comments\\/([a-z0-9]+)/);
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
- // Request more from API than top-level limit to get inline replies
49
- // depth param tells Reddit how deep to inline replies vs "more" stubs
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 (!res.ok) return { error: 'Reddit API returned HTTP ' + res.status };
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) { return { error: 'Failed to parse response' }; }
59
- if (!Array.isArray(data) || data.length < 2) return { error: 'Unexpected response format' };
60
-
61
- var results = [];
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
- var body = post.selftext || '';
67
- if (body.length > maxLength) body = body.slice(0, maxLength) + '\\n... [truncated]';
68
- results.push({
69
- type: 'POST',
70
- author: post.author || '[deleted]',
71
- score: post.score || 0,
72
- text: post.title + (body ? '\\n\\n' + body : '') + (post.url && !post.is_self ? '\\n' + post.url : ''),
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
- // Recursive comment walker
77
- // depth 0 = top-level comments; maxDepth is exclusive,
78
- // so --depth 1 means top-level only, --depth 2 means one reply level, etc.
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 body = d.body || '';
83
- if (body.length > maxLength) body = body.slice(0, maxLength) + '...';
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
- ? body
91
- : body.split('\\n').map(function(line) { return prefix + line; }).join('\\n');
414
+ ? cBody
415
+ : cBody.split('\\n').map(function(line) { return prefix + line; }).join('\\n');
92
416
 
93
- results.push({
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
- results.push({
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
- results.push({
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 < topLevel.length; i++) {
155
- if (topLevel[i].kind === 't1') t1TopLevel.push(topLevel[i]);
472
+ for (var i = 0; i < topListing.length; i++) {
473
+ if (topListing[i].kind === 't1') t1TopLevel.push(topListing[i]);
156
474
  }
157
475
 
158
- // Top-level are already sorted by Reddit (sort param), take top N
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
- // Count remaining
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
- results.push({
488
+ rows.push({
169
489
  type: '',
170
490
  author: '',
171
491
  score: '',
@@ -173,15 +493,41 @@ cli({
173
493
  });
174
494
  }
175
495
 
176
- return results;
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
- if (!data || typeof data !== 'object')
180
- throw new CommandExecutionError('Failed to fetch post data');
181
- if (!Array.isArray(data) && data.error)
182
- throw new CommandExecutionError(data.error);
183
- if (!Array.isArray(data))
184
- throw new CommandExecutionError('Unexpected response');
185
- return data;
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
  });