9b395f5cc3
Build Chrome Extension / build (push) Waiting to run
Trigger Website Rebuild (Docs Updated) / dispatch (push) Waiting to run
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
589 lines
26 KiB
JavaScript
589 lines
26 KiB
JavaScript
/**
|
||
* Reddit post reader with threaded comment tree.
|
||
*
|
||
* Replaces the original flat read.yaml with recursive comment traversal:
|
||
* - Top-K comments by score at each level
|
||
* - Configurable depth and replies-per-level
|
||
* - Indented output showing conversation threads
|
||
* - Optional --expand-more to follow Reddit's "more comments" stubs via
|
||
* /api/morechildren.json (rdt-cli parity, PR B of #1481 follow-up)
|
||
*/
|
||
import { cli, Strategy } from '@jackwener/opencli/registry';
|
||
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
||
|
||
const REDDIT_EXPAND_ROUNDS_MIN = 1;
|
||
const REDDIT_EXPAND_ROUNDS_MAX = 5;
|
||
const DEFAULT_EXPAND_ROUNDS = 2;
|
||
const REDDIT_POST_ID_RE = /^[a-z0-9]+$/i;
|
||
|
||
function normalizeBareRedditPostId(value) {
|
||
const postId = String(value || '').trim();
|
||
if (!REDDIT_POST_ID_RE.test(postId)) {
|
||
throw new ArgumentError(
|
||
'Post ID must be a Reddit post id, t3_ fullname, or reddit.com post URL.',
|
||
'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
|
||
);
|
||
}
|
||
return postId.toLowerCase();
|
||
}
|
||
|
||
export function normalizeRedditPostId(value) {
|
||
const raw = String(value || '').trim();
|
||
if (!raw) {
|
||
throw new ArgumentError(
|
||
'Post ID is required.',
|
||
'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
|
||
);
|
||
}
|
||
|
||
const fullname = raw.match(/^t3_([a-z0-9]+)$/i);
|
||
if (fullname) return normalizeBareRedditPostId(fullname[1]);
|
||
|
||
if (/^https?:\/\//i.test(raw)) {
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(raw);
|
||
} catch {
|
||
throw new ArgumentError(`Invalid Reddit post URL: ${raw}`);
|
||
}
|
||
const host = parsed.hostname.toLowerCase();
|
||
if (parsed.protocol !== 'https:' || (host !== 'reddit.com' && !host.endsWith('.reddit.com'))) {
|
||
throw new ArgumentError(
|
||
'Post URL must be an https reddit.com URL.',
|
||
'Use a URL like https://www.reddit.com/r/sub/comments/1abc123/title_slug/',
|
||
);
|
||
}
|
||
const parts = parsed.pathname.split('/').filter(Boolean);
|
||
const commentsIndex = parts.indexOf('comments');
|
||
const postIndex = commentsIndex + 1;
|
||
if (commentsIndex < 0 || parts.length <= postIndex) {
|
||
throw new ArgumentError(
|
||
'Post URL must include the target post id.',
|
||
'Use a URL like https://www.reddit.com/r/sub/comments/1abc123/title_slug/',
|
||
);
|
||
}
|
||
if (parts.length > postIndex + 3) {
|
||
throw new ArgumentError(
|
||
'Post URL must end at the post slug or comment permalink id.',
|
||
'Remove extra path segments after the post slug or comment id.',
|
||
);
|
||
}
|
||
if (parts.length === postIndex + 3) normalizeBareRedditPostId(parts[postIndex + 2]);
|
||
return normalizeBareRedditPostId(parts[postIndex]);
|
||
}
|
||
|
||
if (raw.includes('/') || raw.startsWith('t1_')) {
|
||
throw new ArgumentError(
|
||
'Post ID must be a Reddit post id, t3_ fullname, or reddit.com post URL.',
|
||
'Use a bare post id like 1abc123, a fullname like t3_1abc123, or a full Reddit post URL.',
|
||
);
|
||
}
|
||
|
||
return normalizeBareRedditPostId(raw);
|
||
}
|
||
|
||
export function parseExpandRounds(raw) {
|
||
if (raw === undefined || raw === null || raw === '') return DEFAULT_EXPAND_ROUNDS;
|
||
const n = Number(raw);
|
||
if (
|
||
!Number.isFinite(n) || !Number.isInteger(n)
|
||
|| n < REDDIT_EXPAND_ROUNDS_MIN || n > REDDIT_EXPAND_ROUNDS_MAX
|
||
) {
|
||
throw new ArgumentError(
|
||
`expand-rounds must be an integer in [${REDDIT_EXPAND_ROUNDS_MIN}, ${REDDIT_EXPAND_ROUNDS_MAX}].`,
|
||
`Got: ${raw}`,
|
||
);
|
||
}
|
||
return n;
|
||
}
|
||
|
||
cli({
|
||
site: 'reddit',
|
||
name: 'read',
|
||
access: 'read',
|
||
description: 'Read a Reddit post and its comments',
|
||
domain: 'reddit.com',
|
||
strategy: Strategy.COOKIE,
|
||
browser: true,
|
||
args: [
|
||
{ name: 'post-id', required: true, positional: true, help: 'Post ID (e.g. 1abc123) or full URL' },
|
||
{ name: 'sort', default: 'best', help: 'Comment sort: best, top, new, controversial, old, qa' },
|
||
{ name: 'limit', type: 'int', default: 25, help: 'Number of top-level comments' },
|
||
{ name: 'depth', type: 'int', default: 2, help: 'Max reply depth (1=no replies, 2=one level of replies, etc.)' },
|
||
{ name: 'replies', type: 'int', default: 5, help: 'Max replies shown per comment at each level (sorted by score)' },
|
||
{ name: 'max-length', type: 'int', default: 2000, help: 'Max characters per comment body (min 100)' },
|
||
{
|
||
name: 'expand-more',
|
||
type: 'bool',
|
||
default: false,
|
||
help: 'Follow Reddit "more comments" stubs by calling /api/morechildren.json',
|
||
},
|
||
{
|
||
name: 'expand-rounds',
|
||
type: 'int',
|
||
default: DEFAULT_EXPAND_ROUNDS,
|
||
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)`,
|
||
},
|
||
],
|
||
columns: ['type', 'author', 'score', 'text', 'post_hint', 'url_overridden_by_dest', 'preview_image_url', 'gallery_urls'],
|
||
func: async (page, kwargs) => {
|
||
// Note: --limit / --depth / --replies / --max-length keep their original
|
||
// Math.max-style behaviour for backward compatibility (grandfathered in
|
||
// the typed-error-lint baseline). The new --expand-rounds argument is
|
||
// strictly validated via parseExpandRounds — no silent clamp.
|
||
const sort = kwargs.sort ?? 'best';
|
||
const limit = Math.max(1, kwargs.limit ?? 25);
|
||
const maxDepth = Math.max(1, kwargs.depth ?? 2);
|
||
const maxReplies = Math.max(1, kwargs.replies ?? 5);
|
||
const maxLength = Math.max(100, kwargs['max-length'] ?? 2000);
|
||
const expandMore = Boolean(kwargs['expand-more']);
|
||
const expandRounds = parseExpandRounds(kwargs['expand-rounds']);
|
||
const postId = normalizeRedditPostId(kwargs['post-id']);
|
||
|
||
// 不再显式导航首页:框架 navigateBefore(domain=reddit.com)已把页面带到 reddit origin,
|
||
// 下面的相对 fetch(/comments/<id>.json) 照常可用。省掉每条评论命令一次冗余首页导航。
|
||
|
||
// The in-browser script returns a discriminated union so we can map
|
||
// each failure mode to its proper typed error on the Node side
|
||
// (page.evaluate boundary can't carry typed error instances). Kinds:
|
||
// - inaccessible: 401/403/404 on /comments/<id>.json (post-specific,
|
||
// not session auth — same session works for other posts)
|
||
// - auth: /api/morechildren.json 401/403 (session-level on
|
||
// the write-like expand endpoint — see two-pronged auth detection
|
||
// sediment from PR #1428)
|
||
// - http: 5xx or other non-ok
|
||
// - malformed: 200 but Reddit shape is unexpected (schema drift)
|
||
// - parser-drift: tree non-empty but walk produced 0 rows
|
||
// - expand-failed: morechildren returned errors
|
||
// - ok: rows array
|
||
//
|
||
// Intermediate keys (`rows` / `detail` / `httpStatus` / `where`)
|
||
// deliberately avoid the declared columns (`type`/`author`/`score`/
|
||
// `text`) to sidestep the silent-column-drop audit (PR #1329).
|
||
const result = await page.evaluate(`
|
||
(async function() {
|
||
function decodeHtml(s) {
|
||
if (typeof s !== 'string' || !s) return '';
|
||
return s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/gi, "'")
|
||
.replace(/'/g, "'");
|
||
}
|
||
function extractRedditMedia(d) {
|
||
var post_hint = (d && d.post_hint) || '';
|
||
var url_overridden_by_dest = decodeHtml((d && d.url_overridden_by_dest) || '');
|
||
var preview_image_url = decodeHtml(
|
||
(d && d.preview && d.preview.images && d.preview.images[0] && d.preview.images[0].source && d.preview.images[0].source.url) || ''
|
||
);
|
||
var gallery_urls = [];
|
||
var items = d && d.gallery_data && d.gallery_data.items;
|
||
var meta = d && d.media_metadata;
|
||
if (Array.isArray(items) && meta) {
|
||
for (var gi = 0; gi < items.length; gi++) {
|
||
var it = items[gi];
|
||
var m = it && meta[it.media_id];
|
||
var u = m && m.s && (m.s.u || m.s.gif || m.s.mp4);
|
||
if (u) gallery_urls.push(decodeHtml(u));
|
||
}
|
||
}
|
||
return {
|
||
post_hint: post_hint,
|
||
url_overridden_by_dest: url_overridden_by_dest,
|
||
preview_image_url: preview_image_url,
|
||
gallery_urls: gallery_urls,
|
||
};
|
||
}
|
||
var postId = ${JSON.stringify(postId)};
|
||
var linkFullname = 't3_' + postId;
|
||
|
||
var sort = ${JSON.stringify(sort)};
|
||
var limit = ${limit};
|
||
var maxDepth = ${maxDepth};
|
||
var maxReplies = ${maxReplies};
|
||
var maxLength = ${maxLength};
|
||
var expandMore = ${JSON.stringify(expandMore)};
|
||
var expandRounds = ${expandRounds};
|
||
|
||
// ---------------------------------------------------------------
|
||
// Step 1: fetch the post + initial comment tree
|
||
// ---------------------------------------------------------------
|
||
// Request more from API than top-level limit to get inline replies.
|
||
// depth param tells Reddit how deep to inline replies vs "more" stubs.
|
||
var apiLimit = Math.max(limit * 3, 100);
|
||
var res = await fetch(
|
||
'/comments/' + postId + '.json?sort=' + sort + '&limit=' + apiLimit + '&depth=' + (maxDepth + 1) + '&raw_json=1',
|
||
{ credentials: 'include' }
|
||
);
|
||
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
||
return { kind: 'inaccessible', detail: 'Reddit post ' + postId + ' is not accessible (HTTP ' + res.status + ').' };
|
||
}
|
||
if (!res.ok) {
|
||
return { kind: 'http', httpStatus: res.status, where: '/comments/' + postId + '.json' };
|
||
}
|
||
var data;
|
||
try { data = await res.json(); } catch (e) {
|
||
return { kind: 'malformed', detail: 'Failed to parse Reddit /comments/' + postId + '.json response: ' + (e && e.message || e) };
|
||
}
|
||
if (!Array.isArray(data) || data.length < 2) {
|
||
return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had unexpected envelope shape (length ' + (Array.isArray(data) ? data.length : typeof data) + ').' };
|
||
}
|
||
|
||
var post = data[0] && data[0].data && data[0].data.children && data[0].data.children[0] && data[0].data.children[0].data;
|
||
if (!post) {
|
||
return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had no post body.' };
|
||
}
|
||
var topListing = data[1] && data[1].data && Array.isArray(data[1].data.children) ? data[1].data.children : null;
|
||
if (!topListing) {
|
||
return { kind: 'malformed', detail: 'Reddit /comments/' + postId + '.json had no comment listing.' };
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// Step 2: optionally follow "more" stubs via /api/morechildren.json
|
||
// ---------------------------------------------------------------
|
||
// Each "more" thing has a .data.children array (t1 ids to fetch).
|
||
// The morechildren API returns a FLAT list of things; we re-thread
|
||
// them by parent_id (either t3_<postId> for top-level or t1_<id>
|
||
// for nested). Each round may surface new "more" stubs (because
|
||
// expansion is bounded by Reddit's depth param), so we iterate up
|
||
// to expandRounds times.
|
||
var expandMeta = { rounds: 0, fetched: 0, capped: false, errors: [] };
|
||
|
||
if (expandMore) {
|
||
// Index every existing t1 node so we can splice replies onto it.
|
||
var t1Index = {};
|
||
function indexT1(arr) {
|
||
if (!Array.isArray(arr)) return;
|
||
for (var i = 0; i < arr.length; i++) {
|
||
var node = arr[i];
|
||
if (node && node.kind === 't1' && node.data && node.data.id) {
|
||
t1Index[node.data.name || ('t1_' + node.data.id)] = node;
|
||
if (node.data.replies && node.data.replies.data && node.data.replies.data.children) {
|
||
indexT1(node.data.replies.data.children);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
indexT1(topListing);
|
||
|
||
// Collect "more" stubs (with non-empty children) from anywhere in
|
||
// the tree. Each stub knows its host array via a closure-bound
|
||
// reference we attach.
|
||
function collectMoreStubs(parentArr, parentT1) {
|
||
var out = [];
|
||
if (!Array.isArray(parentArr)) return out;
|
||
for (var i = 0; i < parentArr.length; i++) {
|
||
var n = parentArr[i];
|
||
if (!n || !n.data) continue;
|
||
if (n.kind === 'more' && Array.isArray(n.data.children) && n.data.children.length > 0) {
|
||
out.push({ stub: n, hostArr: parentArr, hostT1: parentT1 });
|
||
} else if (n.kind === 't1' && n.data.replies && n.data.replies.data && n.data.replies.data.children) {
|
||
var nested = collectMoreStubs(n.data.replies.data.children, n);
|
||
for (var k = 0; k < nested.length; k++) out.push(nested[k]);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
for (var r = 0; r < expandRounds; r++) {
|
||
var stubs = collectMoreStubs(topListing, null);
|
||
if (stubs.length === 0) break;
|
||
|
||
// Build the union of t1 ids to request this round. Reddit's
|
||
// morechildren API caps at ~100 ids per call; batch accordingly.
|
||
var allIds = [];
|
||
for (var s = 0; s < stubs.length; s++) {
|
||
var st = stubs[s].stub;
|
||
for (var c = 0; c < st.data.children.length; c++) allIds.push(st.data.children[c]);
|
||
}
|
||
if (allIds.length === 0) break;
|
||
|
||
// dedupe preserving order
|
||
var seen = {};
|
||
var uniqIds = [];
|
||
for (var j = 0; j < allIds.length; j++) {
|
||
if (!seen[allIds[j]]) { seen[allIds[j]] = 1; uniqIds.push(allIds[j]); }
|
||
}
|
||
|
||
var fetchedThings = [];
|
||
var batchSize = 100;
|
||
var batchFailed = false;
|
||
for (var b = 0; b < uniqIds.length; b += batchSize) {
|
||
var batch = uniqIds.slice(b, b + batchSize);
|
||
var body = 'api_type=json'
|
||
+ '&link_id=' + encodeURIComponent(linkFullname)
|
||
+ '&children=' + encodeURIComponent(batch.join(','))
|
||
+ '&sort=' + encodeURIComponent(sort)
|
||
+ '&raw_json=1';
|
||
var mcRes;
|
||
try {
|
||
mcRes = await fetch('/api/morechildren', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body: body,
|
||
});
|
||
} catch (e) {
|
||
return { kind: 'expand-failed', detail: 'morechildren request threw: ' + (e && e.message || e), expandMeta: expandMeta };
|
||
}
|
||
if (mcRes.status === 401 || mcRes.status === 403) {
|
||
return { kind: 'auth', detail: '/api/morechildren returned HTTP ' + mcRes.status + ' (write/expand likely requires login)' };
|
||
}
|
||
if (!mcRes.ok) {
|
||
return { kind: 'http', httpStatus: mcRes.status, where: '/api/morechildren (round ' + (r + 1) + ', batch ' + ((b / batchSize) + 1) + ')' };
|
||
}
|
||
var mcData;
|
||
try { mcData = await mcRes.json(); } catch (e) {
|
||
return { kind: 'malformed', detail: 'Failed to parse /api/morechildren response: ' + (e && e.message || e) };
|
||
}
|
||
var errs = mcData && mcData.json && mcData.json.errors;
|
||
if (Array.isArray(errs) && errs.length > 0) {
|
||
return { kind: 'expand-failed', detail: 'Reddit /api/morechildren rejected: ' + errs.map(function(e) { return e.join(': '); }).join('; '), expandMeta: expandMeta };
|
||
}
|
||
var things = mcData && mcData.json && mcData.json.data && mcData.json.data.things;
|
||
if (!Array.isArray(things)) {
|
||
return { kind: 'malformed', detail: '/api/morechildren returned no things array.' };
|
||
}
|
||
for (var t = 0; t < things.length; t++) fetchedThings.push(things[t]);
|
||
}
|
||
expandMeta.rounds = r + 1;
|
||
expandMeta.fetched += fetchedThings.length;
|
||
|
||
var fetchedById = {};
|
||
for (var t = 0; t < fetchedThings.length; t++) {
|
||
var thing = fetchedThings[t];
|
||
if (!thing || !thing.data) continue;
|
||
if (thing.data.id) fetchedById[thing.data.id] = thing;
|
||
if (thing.data.name) fetchedById[thing.data.name] = thing;
|
||
}
|
||
|
||
var inserted = {};
|
||
function thingKey(thing) {
|
||
return thing && thing.data && (thing.data.name || (thing.kind + '_' + thing.data.id));
|
||
}
|
||
|
||
// Replace each collected stub in-place so expansion preserves the
|
||
// surrounding tree order instead of appending fetched comments at
|
||
// the end of the parent array.
|
||
for (var s = 0; s < stubs.length; s++) {
|
||
var rec = stubs[s];
|
||
var idx = rec.hostArr.indexOf(rec.stub);
|
||
if (idx < 0) continue;
|
||
var expectedParent = rec.hostT1
|
||
? (rec.hostT1.data.name || ('t1_' + rec.hostT1.data.id))
|
||
: linkFullname;
|
||
var replacements = [];
|
||
for (var c = 0; c < rec.stub.data.children.length; c++) {
|
||
var childId = rec.stub.data.children[c];
|
||
var replacement = fetchedById[childId] || fetchedById['t1_' + childId];
|
||
if (!replacement || !replacement.data) {
|
||
expandMeta.errors.push('missing: ' + childId + ' parent=' + expectedParent);
|
||
continue;
|
||
}
|
||
var key = thingKey(replacement);
|
||
if (key && inserted[key]) continue;
|
||
if (replacement.data.parent_id !== expectedParent) {
|
||
expandMeta.errors.push('orphan: ' + (replacement.data.id || '?') + ' parent=' + (replacement.data.parent_id || '?'));
|
||
continue;
|
||
}
|
||
replacements.push(replacement);
|
||
if (key) inserted[key] = 1;
|
||
if (replacement.kind === 't1' && replacement.data && replacement.data.id) {
|
||
t1Index[replacement.data.name || ('t1_' + replacement.data.id)] = replacement;
|
||
}
|
||
}
|
||
rec.hostArr.splice(idx, 1, ...replacements);
|
||
}
|
||
|
||
for (var t = 0; t < fetchedThings.length; t++) {
|
||
var unplaced = fetchedThings[t];
|
||
var unplacedKey = thingKey(unplaced);
|
||
if (unplacedKey && inserted[unplacedKey]) continue;
|
||
if (unplaced && unplaced.data) {
|
||
expandMeta.errors.push('unplaced: ' + (unplaced.data.id || '?') + ' parent=' + (unplaced.data.parent_id || '?'));
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (r + 1 >= expandRounds) {
|
||
// If after the last round there are still "more" stubs, mark capped.
|
||
var remaining = collectMoreStubs(topListing, null);
|
||
if (remaining.length > 0) expandMeta.capped = true;
|
||
}
|
||
}
|
||
|
||
if (expandMeta.errors.length > 0) {
|
||
return { kind: 'expand-failed', detail: 'Reddit /api/morechildren returned unplaceable comments: ' + expandMeta.errors.slice(0, 5).join('; '), expandMeta: expandMeta };
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// Step 3: walk the (possibly augmented) tree into indented rows
|
||
// ---------------------------------------------------------------
|
||
var rows = [];
|
||
|
||
// Post header row.
|
||
var body = post.selftext || '';
|
||
if (body.length > maxLength) body = body.slice(0, maxLength) + '\\n... [truncated]';
|
||
var postMedia = extractRedditMedia(post);
|
||
rows.push({
|
||
type: 'POST',
|
||
author: post.author || '[deleted]',
|
||
score: post.score || 0,
|
||
text: post.title + (body ? '\\n\\n' + body : '') + (post.url && !post.is_self ? '\\n' + post.url : ''),
|
||
post_hint: postMedia.post_hint,
|
||
url_overridden_by_dest: postMedia.url_overridden_by_dest,
|
||
preview_image_url: postMedia.preview_image_url,
|
||
gallery_urls: postMedia.gallery_urls,
|
||
});
|
||
|
||
// Recursive comment walker.
|
||
function walkComment(node, depth) {
|
||
if (!node || node.kind !== 't1') return;
|
||
var d = node.data;
|
||
var cBody = d.body || '';
|
||
if (cBody.length > maxLength) cBody = cBody.slice(0, maxLength) + '...';
|
||
|
||
var indent = '';
|
||
for (var i = 0; i < depth; i++) indent += ' ';
|
||
var prefix = depth === 0 ? '' : indent + '> ';
|
||
var indentedBody = depth === 0
|
||
? cBody
|
||
: cBody.split('\\n').map(function(line) { return prefix + line; }).join('\\n');
|
||
|
||
rows.push({
|
||
type: depth === 0 ? 'L0' : 'L' + depth,
|
||
author: d.author || '[deleted]',
|
||
score: d.score || 0,
|
||
text: indentedBody,
|
||
post_hint: '',
|
||
url_overridden_by_dest: '',
|
||
preview_image_url: '',
|
||
gallery_urls: [],
|
||
});
|
||
|
||
var t1Children = [];
|
||
var moreCount = 0;
|
||
if (d.replies && d.replies.data && d.replies.data.children) {
|
||
var children = d.replies.data.children;
|
||
for (var i = 0; i < children.length; i++) {
|
||
if (children[i].kind === 't1') {
|
||
t1Children.push(children[i]);
|
||
} else if (children[i].kind === 'more') {
|
||
moreCount += children[i].data.count || 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (depth + 1 >= maxDepth) {
|
||
var totalHidden = t1Children.length + moreCount;
|
||
if (totalHidden > 0) {
|
||
var cutoffIndent = '';
|
||
for (var j = 0; j <= depth; j++) cutoffIndent += ' ';
|
||
rows.push({
|
||
type: 'L' + (depth + 1),
|
||
author: '',
|
||
score: '',
|
||
text: cutoffIndent + '[+' + totalHidden + ' more replies]',
|
||
post_hint: '',
|
||
url_overridden_by_dest: '',
|
||
preview_image_url: '',
|
||
gallery_urls: [],
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
t1Children.sort(function(a, b) { return (b.data.score || 0) - (a.data.score || 0); });
|
||
var toProcess = Math.min(t1Children.length, maxReplies);
|
||
for (var i = 0; i < toProcess; i++) {
|
||
walkComment(t1Children[i], depth + 1);
|
||
}
|
||
|
||
var hidden = t1Children.length - toProcess + moreCount;
|
||
if (hidden > 0) {
|
||
var moreIndent = '';
|
||
for (var j = 0; j <= depth; j++) moreIndent += ' ';
|
||
rows.push({
|
||
type: 'L' + (depth + 1),
|
||
author: '',
|
||
score: '',
|
||
text: moreIndent + '[+' + hidden + ' more replies]',
|
||
post_hint: '',
|
||
url_overridden_by_dest: '',
|
||
preview_image_url: '',
|
||
gallery_urls: [],
|
||
});
|
||
}
|
||
}
|
||
|
||
var t1TopLevel = [];
|
||
for (var i = 0; i < topListing.length; i++) {
|
||
if (topListing[i].kind === 't1') t1TopLevel.push(topListing[i]);
|
||
}
|
||
|
||
// Detect parser drift: tree had content but the walker produced nothing.
|
||
// We must check this AFTER the walk because top-level may be only "more"
|
||
// stubs (legitimate empty case for a brand-new post).
|
||
var preWalkSize = topListing.length;
|
||
for (var i = 0; i < Math.min(t1TopLevel.length, limit); i++) {
|
||
walkComment(t1TopLevel[i], 0);
|
||
}
|
||
|
||
var moreTopLevel = topListing.filter(function(c) { return c.kind === 'more'; })
|
||
.reduce(function(sum, c) { return sum + (c.data.count || 0); }, 0);
|
||
var hiddenTopLevel = Math.max(0, t1TopLevel.length - limit) + moreTopLevel;
|
||
if (hiddenTopLevel > 0) {
|
||
rows.push({
|
||
type: '',
|
||
author: '',
|
||
score: '',
|
||
text: '[+' + hiddenTopLevel + ' more top-level comments]',
|
||
post_hint: '',
|
||
url_overridden_by_dest: '',
|
||
preview_image_url: '',
|
||
gallery_urls: [],
|
||
});
|
||
}
|
||
|
||
// If we produced nothing beyond the POST row but the comment listing
|
||
// wasn't empty, that's parser drift (e.g. Reddit changed t1/more
|
||
// schema). Surface as CommandExecutionError on the Node side.
|
||
if (rows.length <= 1 && preWalkSize > 0 && t1TopLevel.length > 0) {
|
||
return { kind: 'parser-drift', detail: 'Reddit comment listing for post ' + postId + ' had ' + t1TopLevel.length + ' t1 entries but walker produced no rows.' };
|
||
}
|
||
|
||
return { kind: 'ok', rows: rows, expandMeta: expandMeta };
|
||
})()
|
||
`);
|
||
|
||
if (!result || typeof result !== 'object') {
|
||
throw new CommandExecutionError('Reddit /comments fetch returned no result envelope.');
|
||
}
|
||
if (result.kind === 'inaccessible') {
|
||
throw new EmptyResultError(result.detail);
|
||
}
|
||
if (result.kind === 'auth') {
|
||
throw new AuthRequiredError('reddit.com', result.detail);
|
||
}
|
||
if (result.kind === 'http') {
|
||
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
|
||
}
|
||
if (result.kind === 'malformed') {
|
||
throw new CommandExecutionError(result.detail);
|
||
}
|
||
if (result.kind === 'parser-drift') {
|
||
throw new CommandExecutionError(result.detail);
|
||
}
|
||
if (result.kind === 'expand-failed') {
|
||
throw new CommandExecutionError(result.detail);
|
||
}
|
||
if (result.kind !== 'ok' || !Array.isArray(result.rows)) {
|
||
throw new CommandExecutionError(`Unexpected result from reddit read: ${JSON.stringify(result)}`);
|
||
}
|
||
return result.rows;
|
||
},
|
||
});
|