Skip to content

Commit fa9b38c

Browse files
authored
feat(reddit): add whoami, home, subreddit-info read commands (#1491)
* feat(reddit): add whoami, home, subreddit-info read commands Closes gap against jackwener/rdt-cli — three commands the existing 17 reddit adapters were missing: - `reddit whoami` — show the currently logged-in identity (fields: Username, ID, Post / Comment / Total Karma, Account Created, Gold, Mod, Verified Email, Has Mail, Inbox Count). Probes `/api/me.json` with two-pronged auth detection (401/403 OR `data.name` missing on 200 — Reddit returns 200 with an empty body for stale anon sessions, see PR #1428). - `reddit home` — personalized Best feed (`/best.json`). Distinct from the public `frontpage`/`r/all` command: enforces login via the same two-pronged auth check rather than silently degrading to the unauthenticated default feed. `--limit` accepts [1, 100] — out-of-range raises `ArgumentError` before navigation, no silent clamp. - `reddit subreddit-info` — subreddit metadata (Name, Title, Subscribers, Active Now, NSFW, Type, Description, Created, URL) from `/r/<X>/about.json`. Banned / private / quarantined / 404 subreddits raise `EmptyResultError` so the output table never holds a silent sentinel row. All three use Strategy.COOKIE + siteSession:'persistent' matching the existing reddit adapters, validate args upfront before `page.goto`, and use the 5-kind discriminated-union pattern (kind: auth/http/missing/ exception/ok) from PR #1428 to map page.evaluate results to typed errors on the Node side. Intermediate object keys deliberately avoid the declared columns (`field`/`value`/`rank`/etc.) per the silent-column-drop audit sediment from PR #1329. Tests: 28 new (whoami 6, home 9, subreddit-info 13); full reddit suite 38/38. Audits: typed-error-lint 189/189 (0 new), silent-column-drop 103/103 (0 new). Manifest 812 → 815. Refs: https://github.com/jackwener/rdt-cli * fix(reddit): tighten new read command failure contracts * fix(reddit): treat inaccessible subreddit info as empty
1 parent 93bc374 commit fa9b38c

8 files changed

Lines changed: 831 additions & 16 deletions

File tree

cli-manifest.json

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19199,6 +19199,39 @@
1919919199
"navigateBefore": "https://reddit.com",
1920019200
"siteSession": "persistent"
1920119201
},
19202+
{
19203+
"site": "reddit",
19204+
"name": "home",
19205+
"description": "Reddit personalized home feed (Best, requires login)",
19206+
"access": "read",
19207+
"domain": "reddit.com",
19208+
"strategy": "cookie",
19209+
"browser": true,
19210+
"args": [
19211+
{
19212+
"name": "limit",
19213+
"type": "int",
19214+
"default": 25,
19215+
"required": false,
19216+
"help": "Number of posts (1–100)"
19217+
}
19218+
],
19219+
"columns": [
19220+
"rank",
19221+
"title",
19222+
"subreddit",
19223+
"score",
19224+
"comments",
19225+
"postId",
19226+
"author",
19227+
"url"
19228+
],
19229+
"type": "js",
19230+
"modulePath": "reddit/home.js",
19231+
"sourceFile": "reddit/home.js",
19232+
"navigateBefore": "https://reddit.com",
19233+
"siteSession": "persistent"
19234+
},
1920219235
{
1920319236
"site": "reddit",
1920419237
"name": "hot",
@@ -19540,6 +19573,33 @@
1954019573
"navigateBefore": "https://reddit.com",
1954119574
"siteSession": "persistent"
1954219575
},
19576+
{
19577+
"site": "reddit",
19578+
"name": "subreddit-info",
19579+
"description": "Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)",
19580+
"access": "read",
19581+
"domain": "reddit.com",
19582+
"strategy": "cookie",
19583+
"browser": true,
19584+
"args": [
19585+
{
19586+
"name": "name",
19587+
"type": "string",
19588+
"required": true,
19589+
"positional": true,
19590+
"help": "Subreddit name (no `r/` prefix needed)"
19591+
}
19592+
],
19593+
"columns": [
19594+
"field",
19595+
"value"
19596+
],
19597+
"type": "js",
19598+
"modulePath": "reddit/subreddit-info.js",
19599+
"sourceFile": "reddit/subreddit-info.js",
19600+
"navigateBefore": "https://reddit.com",
19601+
"siteSession": "persistent"
19602+
},
1954319603
{
1954419604
"site": "reddit",
1954519605
"name": "subscribe",
@@ -19738,6 +19798,25 @@
1973819798
"navigateBefore": "https://reddit.com",
1973919799
"siteSession": "persistent"
1974019800
},
19801+
{
19802+
"site": "reddit",
19803+
"name": "whoami",
19804+
"description": "Show the currently logged-in Reddit user",
19805+
"access": "read",
19806+
"domain": "reddit.com",
19807+
"strategy": "cookie",
19808+
"browser": true,
19809+
"args": [],
19810+
"columns": [
19811+
"field",
19812+
"value"
19813+
],
19814+
"type": "js",
19815+
"modulePath": "reddit/whoami.js",
19816+
"sourceFile": "reddit/whoami.js",
19817+
"navigateBefore": "https://reddit.com",
19818+
"siteSession": "persistent"
19819+
},
1974119820
{
1974219821
"site": "rednote",
1974319822
"name": "comments",

clis/reddit/home.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2+
import { cli, Strategy } from '@jackwener/opencli/registry';
3+
4+
const REDDIT_HOME_MAX_LIMIT = 100;
5+
6+
export function parseRedditHomeLimit(raw) {
7+
if (raw === undefined || raw === null || raw === '') return 25;
8+
const n = Number(raw);
9+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > REDDIT_HOME_MAX_LIMIT) {
10+
throw new ArgumentError(
11+
`limit must be an integer in [1, ${REDDIT_HOME_MAX_LIMIT}].`,
12+
`Got: ${raw}`,
13+
);
14+
}
15+
return n;
16+
}
17+
18+
cli({
19+
site: 'reddit',
20+
name: 'home',
21+
access: 'read',
22+
description: 'Reddit personalized home feed (Best, requires login)',
23+
domain: 'reddit.com',
24+
strategy: Strategy.COOKIE,
25+
browser: true,
26+
siteSession: 'persistent',
27+
args: [
28+
{ name: 'limit', type: 'int', default: 25, help: `Number of posts (1–${REDDIT_HOME_MAX_LIMIT})` },
29+
],
30+
columns: ['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url'],
31+
func: async (page, kwargs) => {
32+
const limit = parseRedditHomeLimit(kwargs.limit);
33+
await page.goto('https://www.reddit.com');
34+
// The Best feed is personalized only when logged in — for anonymous
35+
// sessions Reddit returns a generic listing that overlaps with /r/all.
36+
// To make `home` semantically meaningful (vs the public `frontpage`
37+
// command) we require auth and surface the logged-out case via
38+
// AuthRequiredError instead of silently returning the public feed.
39+
//
40+
// Two-pronged auth detection: HTTP 401/403 OR `me.data.name` missing
41+
// on 200 (stale anonymous cookie session). See PR #1428 sediment.
42+
//
43+
// Intermediate object keys avoid `rank`/`title`/`subreddit`/etc. to
44+
// sidestep the silent-column-drop audit; we use `entries` for the
45+
// raw payload. See PR #1329 sediment "中间解析对象 key 不能跟
46+
// columns 任一项重叠".
47+
const result = await page.evaluate(`(async () => {
48+
try {
49+
const meRes = await fetch('/api/me.json', { credentials: 'include' });
50+
if (meRes.status === 401 || meRes.status === 403) {
51+
return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + meRes.status };
52+
}
53+
if (!meRes.ok) {
54+
return { kind: 'http', httpStatus: meRes.status, where: '/api/me.json' };
55+
}
56+
const me = await meRes.json();
57+
if (!me?.data?.name) {
58+
return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
59+
}
60+
61+
const limit = ${JSON.stringify(limit)};
62+
const res = await fetch('/best.json?limit=' + limit + '&raw_json=1', { credentials: 'include' });
63+
if (res.status === 401 || res.status === 403) {
64+
return { kind: 'auth', detail: 'Reddit /best.json returned HTTP ' + res.status };
65+
}
66+
if (!res.ok) {
67+
return { kind: 'http', httpStatus: res.status, where: '/best.json' };
68+
}
69+
const j = await res.json();
70+
const entries = j?.data?.children;
71+
if (!Array.isArray(entries)) {
72+
return { kind: 'http', httpStatus: 200, where: '/best.json (no data.children array)' };
73+
}
74+
return { kind: 'ok', entries };
75+
} catch (e) {
76+
return { kind: 'exception', detail: String(e && e.message || e) };
77+
}
78+
})()`);
79+
80+
if (result?.kind === 'auth') {
81+
throw new AuthRequiredError('reddit.com', result.detail);
82+
}
83+
if (result?.kind === 'http') {
84+
throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
85+
}
86+
if (result?.kind === 'exception') {
87+
throw new CommandExecutionError(`home failed: ${result.detail}`);
88+
}
89+
if (result?.kind !== 'ok') {
90+
throw new CommandExecutionError(`Unexpected result from reddit home: ${JSON.stringify(result)}`);
91+
}
92+
93+
const rows = [];
94+
const entries = result.entries.slice(0, limit);
95+
for (let i = 0; i < entries.length; i++) {
96+
const d = entries[i]?.data;
97+
if (!d || !d.id) continue;
98+
rows.push({
99+
rank: i + 1,
100+
title: typeof d.title === 'string' ? d.title : null,
101+
subreddit: typeof d.subreddit_name_prefixed === 'string' ? d.subreddit_name_prefixed : null,
102+
score: typeof d.score === 'number' ? d.score : null,
103+
comments: typeof d.num_comments === 'number' ? d.num_comments : null,
104+
postId: d.id,
105+
author: typeof d.author === 'string' ? d.author : null,
106+
url: d.permalink ? 'https://www.reddit.com' + d.permalink : null,
107+
});
108+
}
109+
if (rows.length === 0) {
110+
if (entries.length > 0) {
111+
throw new CommandExecutionError('Reddit home feed entries were missing required post id anchors');
112+
}
113+
throw new EmptyResultError('Reddit returned no posts in the personalized home feed.');
114+
}
115+
return rows;
116+
},
117+
});

clis/reddit/home.test.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4+
import { parseRedditHomeLimit } from './home.js';
5+
import './home.js';
6+
7+
function makePage(result) {
8+
return {
9+
goto: vi.fn().mockResolvedValue(undefined),
10+
evaluate: vi.fn().mockResolvedValue(result),
11+
};
12+
}
13+
14+
function makeEntry(id, overrides = {}) {
15+
return {
16+
data: {
17+
id,
18+
title: `Title for ${id}`,
19+
subreddit_name_prefixed: 'r/dummy',
20+
score: 100,
21+
num_comments: 10,
22+
author: 'someone',
23+
permalink: `/r/dummy/comments/${id}/title/`,
24+
...overrides,
25+
},
26+
};
27+
}
28+
29+
describe('reddit home command', () => {
30+
const command = getRegistry().get('reddit/home');
31+
32+
it('registers with the expected shape', () => {
33+
expect(command).toBeDefined();
34+
expect(command.access).toBe('read');
35+
expect(command.browser).toBe(true);
36+
expect(command.columns).toEqual(['rank', 'title', 'subreddit', 'score', 'comments', 'postId', 'author', 'url']);
37+
});
38+
39+
it('parseRedditHomeLimit accepts [1,100] and rejects out-of-range / non-integer without silent clamp', () => {
40+
expect(parseRedditHomeLimit(undefined)).toBe(25);
41+
expect(parseRedditHomeLimit(null)).toBe(25);
42+
expect(parseRedditHomeLimit('')).toBe(25);
43+
expect(parseRedditHomeLimit(1)).toBe(1);
44+
expect(parseRedditHomeLimit(25)).toBe(25);
45+
expect(parseRedditHomeLimit(100)).toBe(100);
46+
47+
for (const bad of [0, -1, 101, 1.5, NaN, 'abc']) {
48+
expect(() => parseRedditHomeLimit(bad)).toThrow(ArgumentError);
49+
}
50+
});
51+
52+
it('rejects a bad limit BEFORE navigating', async () => {
53+
const page = makePage({ kind: 'ok', entries: [] });
54+
await expect(command.func(page, { limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
55+
expect(page.goto).not.toHaveBeenCalled();
56+
expect(page.evaluate).not.toHaveBeenCalled();
57+
});
58+
59+
it('throws AuthRequiredError when logged out (401/403 or missing identity)', async () => {
60+
await expect(command.func(makePage({ kind: 'auth', detail: 'login required' }), { limit: 25 }))
61+
.rejects.toBeInstanceOf(AuthRequiredError);
62+
});
63+
64+
it('throws CommandExecutionError on HTTP / exception failure modes', async () => {
65+
await expect(command.func(makePage({ kind: 'http', httpStatus: 503, where: '/best.json' }), { limit: 25 }))
66+
.rejects.toBeInstanceOf(CommandExecutionError);
67+
await expect(command.func(makePage({ kind: 'exception', detail: 'network' }), { limit: 25 }))
68+
.rejects.toBeInstanceOf(CommandExecutionError);
69+
});
70+
71+
it('throws EmptyResultError when Reddit returns no posts', async () => {
72+
await expect(command.func(makePage({ kind: 'ok', entries: [] }), { limit: 25 }))
73+
.rejects.toBeInstanceOf(EmptyResultError);
74+
});
75+
76+
it('maps entries to row shape with 1-based rank, full URLs, and typed numbers', async () => {
77+
const entries = [makeEntry('a1'), makeEntry('b2', { score: 250, num_comments: 42 })];
78+
const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 });
79+
80+
expect(rows).toEqual([
81+
{
82+
rank: 1, title: 'Title for a1', subreddit: 'r/dummy', score: 100, comments: 10,
83+
postId: 'a1', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/a1/title/',
84+
},
85+
{
86+
rank: 2, title: 'Title for b2', subreddit: 'r/dummy', score: 250, comments: 42,
87+
postId: 'b2', author: 'someone', url: 'https://www.reddit.com/r/dummy/comments/b2/title/',
88+
},
89+
]);
90+
// Row shape must match declared columns exactly.
91+
for (const row of rows) {
92+
expect(Object.keys(row).sort()).toEqual(
93+
['author', 'comments', 'postId', 'rank', 'score', 'subreddit', 'title', 'url'],
94+
);
95+
}
96+
});
97+
98+
it('applies the post-fetch limit slice (defence in depth vs Reddit overshoot)', async () => {
99+
const entries = Array.from({ length: 30 }, (_, i) => makeEntry(`p${i}`));
100+
const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 5 });
101+
expect(rows).toHaveLength(5);
102+
expect(rows[0].postId).toBe('p0');
103+
expect(rows[4].postId).toBe('p4');
104+
});
105+
106+
it('drops entries with no data.id rather than silently emitting sentinels', async () => {
107+
const entries = [makeEntry('keep1'), { data: { title: 'no id' } }, makeEntry('keep2')];
108+
const rows = await command.func(makePage({ kind: 'ok', entries }), { limit: 25 });
109+
expect(rows.map((r) => r.postId)).toEqual(['keep1', 'keep2']);
110+
});
111+
112+
it('throws CommandExecutionError when all home entries are missing post ids', async () => {
113+
const entries = [{ data: { title: 'no id' } }, { data: { title: 'also no id' } }];
114+
await expect(command.func(makePage({ kind: 'ok', entries }), { limit: 25 }))
115+
.rejects.toMatchObject({
116+
code: 'COMMAND_EXEC',
117+
message: expect.stringContaining('required post id anchors'),
118+
});
119+
});
120+
121+
it('embeds the requested limit literally inside the evaluate script', async () => {
122+
const page = makePage({ kind: 'ok', entries: [makeEntry('x')] });
123+
await command.func(page, { limit: 7 });
124+
const script = page.evaluate.mock.calls[0][0];
125+
expect(script).toContain('const limit = 7');
126+
});
127+
});

0 commit comments

Comments
 (0)