Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

589 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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']);
// 不再显式导航首页:框架 navigateBeforedomain=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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#x27;/gi, "'")
.replace(/&#39;/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;
},
});