Skip to content

Commit 0c4bcdb

Browse files
huanghejackwener
andauthored
feat(reddit): 新增 subscribed 命令 + 在 listing 命令上暴露 id / created_utc / selftext (#1651)
* feat(reddit): subscribed command + expose id/created_utc/selftext on listing commands Adds `opencli reddit subscribed` to list the user's subscribed subreddits, mirroring `saved.js`'s cookie auth + AuthRequiredError pattern. Auto-paginates via `/subreddits/mine/subscriptions.json` (max 1000 subs, default 100). Also extends the JSON output of `popular` / `search` / `subreddit` with `id`, `created_utc`, `selftext` (and `author` on popular) — the table view stays clean (columns: unchanged), but `--format json` now surfaces fields needed for downstream content-recommendation tooling that filters by post age, dedupes by post id, or uses self-post bodies for embeddings. Tests: 4 new vitest cases for subscribed.js (happy / auth fail / HTTP / --limit truncation). All existing reddit tests still pass. Note on cli-manifest.json diff: the rebuild on fork/main drops 13 entries whose source files import lowercase `selectorError` from `@jackwener/opencli/errors` (the actual export is `SelectorError` — casing bug pre-existing in fork/main). Not introduced by this PR. * fix(reddit): harden subscribed listing contract * fix(reddit): require subreddit identity for subscriptions --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent ec3b7da commit 0c4bcdb

8 files changed

Lines changed: 370 additions & 15 deletions

File tree

cli-manifest.json

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20065,11 +20065,15 @@
2006520065
],
2006620066
"columns": [
2006720067
"rank",
20068+
"id",
2006820069
"title",
2006920070
"subreddit",
2007020071
"score",
2007120072
"comments",
20072-
"url"
20073+
"author",
20074+
"url",
20075+
"created_utc",
20076+
"selftext"
2007320077
],
2007420078
"type": "js",
2007520079
"modulePath": "reddit/popular.js",
@@ -20294,12 +20298,15 @@
2029420298
}
2029520299
],
2029620300
"columns": [
20301+
"id",
2029720302
"title",
2029820303
"subreddit",
2029920304
"author",
2030020305
"score",
2030120306
"comments",
20302-
"url"
20307+
"url",
20308+
"created_utc",
20309+
"selftext"
2030320310
],
2030420311
"type": "js",
2030520312
"modulePath": "reddit/search.js",
@@ -20345,11 +20352,15 @@
2034520352
}
2034620353
],
2034720354
"columns": [
20355+
"id",
2034820356
"title",
20357+
"subreddit",
2034920358
"author",
2035020359
"upvotes",
2035120360
"comments",
20352-
"url"
20361+
"url",
20362+
"created_utc",
20363+
"selftext"
2035320364
],
2035420365
"type": "js",
2035520366
"modulePath": "reddit/subreddit.js",
@@ -20415,6 +20426,36 @@
2041520426
"sourceFile": "reddit/subscribe.js",
2041620427
"navigateBefore": "https://reddit.com"
2041720428
},
20429+
{
20430+
"site": "reddit",
20431+
"name": "subscribed",
20432+
"description": "List subreddits you are subscribed to",
20433+
"access": "read",
20434+
"domain": "reddit.com",
20435+
"strategy": "cookie",
20436+
"browser": true,
20437+
"args": [
20438+
{
20439+
"name": "limit",
20440+
"type": "int",
20441+
"default": 100,
20442+
"required": false,
20443+
"help": "Max subreddits to return (1-1000, auto-paginates)"
20444+
}
20445+
],
20446+
"columns": [
20447+
"id",
20448+
"subreddit",
20449+
"title",
20450+
"subscribers",
20451+
"description",
20452+
"url"
20453+
],
20454+
"type": "js",
20455+
"modulePath": "reddit/subscribed.js",
20456+
"sourceFile": "reddit/subscribed.js",
20457+
"navigateBefore": "https://reddit.com"
20458+
},
2041820459
{
2041920460
"site": "reddit",
2042020461
"name": "upvote",

clis/reddit/popular.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ cli({
1010
args: [
1111
{ name: 'limit', type: 'int', default: 20 },
1212
],
13-
columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'url'],
13+
columns: ['rank', 'id', 'title', 'subreddit', 'score', 'comments', 'author', 'url', 'created_utc', 'selftext'],
1414
pipeline: [
1515
{ navigate: 'https://www.reddit.com' },
1616
{ evaluate: `(async () => {
@@ -20,22 +20,29 @@ cli({
2020
});
2121
const d = await res.json();
2222
return (d?.data?.children || []).map(c => ({
23+
id: c.data.id,
2324
title: c.data.title,
2425
subreddit: c.data.subreddit_name_prefixed,
2526
score: c.data.score,
2627
comments: c.data.num_comments,
2728
author: c.data.author,
2829
url: 'https://www.reddit.com' + c.data.permalink,
30+
created_utc: c.data.created_utc,
31+
selftext: c.data.selftext || '',
2932
}));
3033
})()
3134
` },
3235
{ map: {
3336
rank: '${{ index + 1 }}',
37+
id: '${{ item.id }}',
3438
title: '${{ item.title }}',
3539
subreddit: '${{ item.subreddit }}',
3640
score: '${{ item.score }}',
3741
comments: '${{ item.comments }}',
42+
author: '${{ item.author }}',
3843
url: '${{ item.url }}',
44+
created_utc: '${{ item.created_utc }}',
45+
selftext: '${{ item.selftext }}',
3946
} },
4047
{ limit: '${{ args.limit }}' },
4148
],

clis/reddit/search.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ cli({
2929
},
3030
{ name: 'limit', type: 'int', default: 15 },
3131
],
32-
columns: ['title', 'subreddit', 'author', 'score', 'comments', 'url'],
32+
columns: ['id', 'title', 'subreddit', 'author', 'score', 'comments', 'url', 'created_utc', 'selftext'],
3333
pipeline: [
3434
{ navigate: 'https://www.reddit.com' },
3535
{ evaluate: `(async () => {
@@ -44,22 +44,28 @@ cli({
4444
const res = await fetch(basePath + '?' + params, { credentials: 'include' });
4545
const d = await res.json();
4646
return (d?.data?.children || []).map(c => ({
47+
id: c.data.id,
4748
title: c.data.title,
4849
subreddit: c.data.subreddit_name_prefixed,
4950
author: c.data.author,
5051
score: c.data.score,
5152
comments: c.data.num_comments,
5253
url: 'https://www.reddit.com' + c.data.permalink,
54+
created_utc: c.data.created_utc,
55+
selftext: c.data.selftext || '',
5356
}));
5457
})()
5558
` },
5659
{ map: {
60+
id: '${{ item.id }}',
5761
title: '${{ item.title }}',
5862
subreddit: '${{ item.subreddit }}',
5963
author: '${{ item.author }}',
6064
score: '${{ item.score }}',
6165
comments: '${{ item.comments }}',
6266
url: '${{ item.url }}',
67+
created_utc: '${{ item.created_utc }}',
68+
selftext: '${{ item.selftext }}',
6369
} },
6470
{ limit: '${{ args.limit }}' },
6571
],

clis/reddit/subreddit.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ cli({
2323
},
2424
{ name: 'limit', type: 'int', default: 15 },
2525
],
26-
columns: ['title', 'author', 'upvotes', 'comments', 'url'],
26+
columns: ['id', 'title', 'subreddit', 'author', 'upvotes', 'comments', 'url', 'created_utc', 'selftext'],
2727
pipeline: [
2828
{ navigate: 'https://www.reddit.com' },
2929
{ evaluate: `(async () => {
@@ -42,11 +42,15 @@ cli({
4242
})()
4343
` },
4444
{ map: {
45+
id: '${{ item.data.id }}',
4546
title: '${{ item.data.title }}',
47+
subreddit: '${{ item.data.subreddit_name_prefixed }}',
4648
author: '${{ item.data.author }}',
4749
upvotes: '${{ item.data.score }}',
4850
comments: '${{ item.data.num_comments }}',
4951
url: 'https://www.reddit.com${{ item.data.permalink }}',
52+
created_utc: '${{ item.data.created_utc }}',
53+
selftext: '${{ item.data.selftext }}',
5054
} },
5155
{ limit: '${{ args.limit }}' },
5256
],

clis/reddit/subscribed.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
4+
export const REDDIT_SUBSCRIBED_MAX_LIMIT = 1000;
5+
6+
export function parseRedditSubscribedLimit(raw) {
7+
if (raw === undefined || raw === null || raw === '') return 100;
8+
const n = Number(raw);
9+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > REDDIT_SUBSCRIBED_MAX_LIMIT) {
10+
throw new ArgumentError(
11+
`limit must be an integer in [1, ${REDDIT_SUBSCRIBED_MAX_LIMIT}].`,
12+
`Got: ${raw}`,
13+
);
14+
}
15+
return n;
16+
}
17+
18+
export function unwrapEvaluateResult(payload) {
19+
if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
20+
return payload.data;
21+
}
22+
return payload;
23+
}
24+
25+
function mapSubredditRow(entry, index) {
26+
const data = entry?.data;
27+
if (!data || typeof data !== 'object') {
28+
throw new CommandExecutionError(`Reddit subscriptions row ${index + 1} was missing data.`);
29+
}
30+
const fullname = typeof data.name === 'string' ? data.name : '';
31+
const id = fullname.startsWith('t5_')
32+
? fullname
33+
: (entry?.kind === 't5' && typeof data.id === 'string' && data.id ? `t5_${data.id}` : '');
34+
const displayName = typeof data.display_name === 'string' && data.display_name
35+
? data.display_name
36+
: '';
37+
const subreddit = typeof data.display_name_prefixed === 'string' && data.display_name_prefixed
38+
? data.display_name_prefixed
39+
: (displayName ? `r/${displayName}` : '');
40+
const path = typeof data.url === 'string' && data.url.startsWith('/r/') ? data.url : '';
41+
if (!id || !displayName || !subreddit || !path) {
42+
throw new CommandExecutionError(`Reddit subscriptions row ${index + 1} was missing subreddit identity.`);
43+
}
44+
return {
45+
id,
46+
subreddit,
47+
title: typeof data.title === 'string' ? data.title : '',
48+
subscribers: typeof data.subscribers === 'number' ? data.subscribers : null,
49+
description: typeof data.public_description === 'string' ? data.public_description.slice(0, 200) : '',
50+
url: 'https://www.reddit.com' + path,
51+
};
52+
}
53+
54+
cli({
55+
site: 'reddit',
56+
name: 'subscribed',
57+
description: 'List subreddits you are subscribed to',
58+
access: 'read',
59+
domain: 'reddit.com',
60+
strategy: Strategy.COOKIE,
61+
browser: true,
62+
args: [
63+
{ name: 'limit', type: 'int', default: 100, help: `Max subreddits to return (1-${REDDIT_SUBSCRIBED_MAX_LIMIT}, auto-paginates)` },
64+
],
65+
columns: ['id', 'subreddit', 'title', 'subscribers', 'description', 'url'],
66+
func: async (page, kwargs) => {
67+
const limit = parseRedditSubscribedLimit(kwargs.limit);
68+
if (!page)
69+
throw new CommandExecutionError('Browser session required');
70+
await page.goto('https://www.reddit.com');
71+
const result = unwrapEvaluateResult(await page.evaluate(`(async () => {
72+
try {
73+
const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
74+
if (meRes.status === 401 || meRes.status === 403) {
75+
return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + meRes.status };
76+
}
77+
if (!meRes.ok) {
78+
return { kind: 'http', httpStatus: meRes.status, where: '/api/me.json' };
79+
}
80+
const me = await meRes.json();
81+
const username = me?.data?.name || me?.name;
82+
if (!username) return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
83+
84+
const target = ${JSON.stringify(limit)};
85+
const PAGE_SIZE = 100;
86+
const out = [];
87+
let after = null;
88+
const seenCursors = new Set();
89+
for (let pageIndex = 0; pageIndex < 20 && out.length < target; pageIndex++) {
90+
const remaining = target - out.length;
91+
const pageLimit = Math.min(PAGE_SIZE, remaining);
92+
const url = '/subreddits/mine/subscriptions.json?limit=' + pageLimit
93+
+ '&raw_json=1'
94+
+ (after ? '&after=' + encodeURIComponent(after) : '');
95+
const res = await fetch(url, { credentials: 'include' });
96+
if (res.status === 401 || res.status === 403) {
97+
return { kind: 'auth', detail: 'Reddit subscriptions endpoint returned HTTP ' + res.status };
98+
}
99+
if (!res.ok) return { kind: 'http', httpStatus: res.status, where: url };
100+
const d = await res.json();
101+
const children = d?.data?.children;
102+
if (!Array.isArray(children)) {
103+
return { kind: 'malformed', detail: 'Reddit subscriptions payload was missing data.children.' };
104+
}
105+
for (const child of children) {
106+
if (out.length >= target) break;
107+
out.push(child);
108+
}
109+
const next = d?.data?.after ?? null;
110+
if (next !== null && typeof next !== 'string') {
111+
return { kind: 'malformed', detail: 'Reddit subscriptions payload had a malformed after cursor.' };
112+
}
113+
if (out.length >= target || !next) break;
114+
if (children.length === 0) {
115+
return { kind: 'malformed', detail: 'Reddit subscriptions page was empty but returned an after cursor.' };
116+
}
117+
if (seenCursors.has(next)) {
118+
return { kind: 'malformed', detail: 'Reddit subscriptions repeated pagination cursor ' + next + '.' };
119+
}
120+
seenCursors.add(next);
121+
after = next;
122+
}
123+
if (out.length < target && after) {
124+
return { kind: 'malformed', detail: 'Reddit subscriptions pagination exceeded the safety cap before satisfying the requested limit.' };
125+
}
126+
return { kind: 'ok', entries: out };
127+
} catch (e) {
128+
return { kind: 'exception', detail: String(e && e.message || e) };
129+
}
130+
})()`));
131+
if (result?.kind === 'auth') {
132+
throw new AuthRequiredError('reddit.com', result.detail);
133+
}
134+
if (result?.kind === 'http') {
135+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
136+
}
137+
if (result?.kind === 'malformed') {
138+
throw new CommandExecutionError(result.detail);
139+
}
140+
if (result?.kind === 'exception') {
141+
throw new CommandExecutionError(`subscribed failed: ${result.detail}`);
142+
}
143+
if (result?.kind !== 'ok' || !Array.isArray(result.entries)) {
144+
throw new CommandExecutionError(`Unexpected result from reddit subscribed: ${JSON.stringify(result)}`);
145+
}
146+
const rows = result.entries.slice(0, limit).map((entry, index) => mapSubredditRow(entry, index));
147+
if (rows.length === 0) {
148+
throw new EmptyResultError('Reddit returned no subscribed subreddits for the logged-in account.');
149+
}
150+
return rows;
151+
}
152+
});

0 commit comments

Comments
 (0)