@jackwener/opencli 1.7.17 → 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 (118) hide show
  1. package/README.md +10 -8
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +585 -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/doubao/utils.js +17 -0
  9. package/clis/doubao/utils.test.js +61 -0
  10. package/clis/google/search.js +16 -6
  11. package/clis/google-scholar/search.js +20 -5
  12. package/clis/google-scholar/search.test.js +35 -2
  13. package/clis/reddit/home.js +117 -0
  14. package/clis/reddit/home.test.js +127 -0
  15. package/clis/reddit/read.js +400 -54
  16. package/clis/reddit/read.test.js +315 -12
  17. package/clis/reddit/reply.js +182 -0
  18. package/clis/reddit/reply.test.js +89 -0
  19. package/clis/reddit/subreddit-info.js +117 -0
  20. package/clis/reddit/subreddit-info.test.js +163 -0
  21. package/clis/reddit/whoami.js +84 -0
  22. package/clis/reddit/whoami.test.js +105 -0
  23. package/clis/rednote/comments.js +76 -0
  24. package/clis/rednote/download.js +59 -0
  25. package/clis/rednote/feed.js +95 -0
  26. package/clis/rednote/navigation.test.js +26 -0
  27. package/clis/rednote/note.js +68 -0
  28. package/clis/rednote/notifications.js +139 -0
  29. package/clis/rednote/rednote.test.js +157 -0
  30. package/clis/rednote/search.js +101 -0
  31. package/clis/rednote/user.js +55 -0
  32. package/clis/twitter/bookmark-folder.js +3 -1
  33. package/clis/twitter/bookmarks.js +3 -1
  34. package/clis/twitter/followers.js +20 -5
  35. package/clis/twitter/followers.test.js +44 -0
  36. package/clis/twitter/following.js +36 -20
  37. package/clis/twitter/following.test.js +60 -8
  38. package/clis/twitter/likes.js +28 -13
  39. package/clis/twitter/likes.test.js +111 -1
  40. package/clis/twitter/list-add.js +128 -204
  41. package/clis/twitter/list-add.test.js +97 -1
  42. package/clis/twitter/list-tweets.js +13 -4
  43. package/clis/twitter/list-tweets.test.js +48 -0
  44. package/clis/twitter/lists.js +5 -2
  45. package/clis/twitter/post.js +23 -4
  46. package/clis/twitter/post.test.js +30 -0
  47. package/clis/twitter/profile.js +16 -8
  48. package/clis/twitter/profile.test.js +39 -0
  49. package/clis/twitter/reply.js +133 -10
  50. package/clis/twitter/reply.test.js +55 -0
  51. package/clis/twitter/search.js +188 -170
  52. package/clis/twitter/search.test.js +96 -258
  53. package/clis/twitter/shared.js +167 -16
  54. package/clis/twitter/shared.test.js +102 -1
  55. package/clis/twitter/timeline.js +3 -1
  56. package/clis/twitter/tweets.js +147 -51
  57. package/clis/twitter/tweets.test.js +238 -1
  58. package/clis/xiaohongshu/comments.js +57 -26
  59. package/clis/xiaohongshu/comments.test.js +63 -1
  60. package/clis/xiaohongshu/download.js +32 -23
  61. package/clis/xiaohongshu/feed.js +23 -15
  62. package/clis/xiaohongshu/note-helpers.js +16 -6
  63. package/clis/xiaohongshu/note.js +26 -20
  64. package/clis/xiaohongshu/notifications.js +26 -19
  65. package/clis/xiaohongshu/search.js +201 -37
  66. package/clis/xiaohongshu/search.test.js +82 -8
  67. package/clis/xiaohongshu/user-helpers.js +13 -4
  68. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  69. package/clis/xiaohongshu/user.js +9 -4
  70. package/clis/xueqiu/earnings-date.js +2 -2
  71. package/clis/xueqiu/kline.js +2 -2
  72. package/clis/xueqiu/utils.js +19 -0
  73. package/clis/xueqiu/utils.test.js +26 -0
  74. package/clis/youtube/transcript.js +28 -3
  75. package/clis/youtube/transcript.test.js +90 -1
  76. package/clis/zhihu/answer-detail.js +233 -0
  77. package/clis/zhihu/answer-detail.test.js +330 -0
  78. package/clis/zhihu/question.js +44 -10
  79. package/clis/zhihu/question.test.js +78 -1
  80. package/clis/zhihu/recommend.js +103 -0
  81. package/clis/zhihu/recommend.test.js +143 -0
  82. package/dist/src/browser/base-page.d.ts +3 -2
  83. package/dist/src/browser/base-page.test.js +2 -2
  84. package/dist/src/browser/cdp.js +3 -3
  85. package/dist/src/browser/page.d.ts +3 -2
  86. package/dist/src/browser/page.js +4 -4
  87. package/dist/src/browser/page.test.js +31 -0
  88. package/dist/src/browser/utils.d.ts +10 -0
  89. package/dist/src/browser/utils.js +37 -0
  90. package/dist/src/browser/utils.test.d.ts +1 -0
  91. package/dist/src/browser/utils.test.js +29 -0
  92. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  93. package/dist/src/cli-argv-preprocess.js +131 -0
  94. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  95. package/dist/src/cli-argv-preprocess.test.js +130 -0
  96. package/dist/src/cli.js +123 -86
  97. package/dist/src/cli.test.js +32 -22
  98. package/dist/src/commands/daemon.js +6 -7
  99. package/dist/src/doctor.js +21 -17
  100. package/dist/src/doctor.test.js +2 -0
  101. package/dist/src/download/progress.js +15 -11
  102. package/dist/src/download/progress.test.d.ts +1 -0
  103. package/dist/src/download/progress.test.js +25 -0
  104. package/dist/src/execution.js +1 -3
  105. package/dist/src/execution.test.js +4 -16
  106. package/dist/src/help.d.ts +11 -0
  107. package/dist/src/help.js +46 -5
  108. package/dist/src/logger.js +8 -9
  109. package/dist/src/main.js +16 -0
  110. package/dist/src/output.js +4 -5
  111. package/dist/src/runtime-detect.d.ts +1 -1
  112. package/dist/src/runtime-detect.js +1 -1
  113. package/dist/src/runtime-detect.test.js +3 -2
  114. package/dist/src/tui.d.ts +0 -1
  115. package/dist/src/tui.js +9 -22
  116. package/dist/src/types.d.ts +3 -1
  117. package/dist/src/update-check.js +4 -5
  118. package/package.json +5 -4
@@ -0,0 +1,117 @@
1
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ // Reddit subreddit names: 3–21 chars, letters/digits/underscore, must start
5
+ // with a letter. Accept an optional `r/` prefix and normalise it off.
6
+ const SUBREDDIT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{2,20}$/;
7
+
8
+ export function parseSubredditName(raw) {
9
+ let name = String(raw || '').trim();
10
+ if (!name) {
11
+ throw new ArgumentError(
12
+ 'Subreddit name is required.',
13
+ 'Pass a subreddit name like `python` (or `r/python`).',
14
+ );
15
+ }
16
+ if (name.startsWith('/r/')) name = name.slice(3);
17
+ else if (name.startsWith('r/')) name = name.slice(2);
18
+ if (!SUBREDDIT_NAME_RE.test(name)) {
19
+ throw new ArgumentError(
20
+ 'Invalid subreddit name.',
21
+ 'Subreddit names are 3–21 characters, start with a letter, and contain only letters, digits, and underscores.',
22
+ );
23
+ }
24
+ return name;
25
+ }
26
+
27
+ cli({
28
+ site: 'reddit',
29
+ name: 'subreddit-info',
30
+ access: 'read',
31
+ description: 'Show metadata for a Reddit subreddit (subscribers, description, created date, NSFW)',
32
+ domain: 'reddit.com',
33
+ strategy: Strategy.COOKIE,
34
+ browser: true,
35
+ siteSession: 'persistent',
36
+ args: [
37
+ { name: 'name', type: 'string', required: true, positional: true, help: 'Subreddit name (no `r/` prefix needed)' },
38
+ ],
39
+ columns: ['field', 'value'],
40
+ func: async (page, kwargs) => {
41
+ const sub = parseSubredditName(kwargs.name);
42
+ await page.goto('https://www.reddit.com');
43
+ // Banned / private / non-existent subreddits return a 404 envelope
44
+ // ({"error":404,"reason":"banned"|"private"|null,"message":"Not Found"}).
45
+ // We surface those as EmptyResultError so the table never contains a
46
+ // silent sentinel row. Intermediate keys avoid `field`/`value`.
47
+ const result = await page.evaluate(`(async () => {
48
+ try {
49
+ const sub = ${JSON.stringify(sub)};
50
+ const res = await fetch('/r/' + encodeURIComponent(sub) + '/about.json?raw_json=1', { credentials: 'include' });
51
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
52
+ return { kind: 'missing', detail: 'Subreddit r/' + sub + ' was not found or is not accessible (HTTP ' + res.status + ').' };
53
+ }
54
+ if (!res.ok) {
55
+ return { kind: 'http', httpStatus: res.status, where: '/r/' + sub + '/about.json' };
56
+ }
57
+ const j = await res.json();
58
+ // Reddit may return an envelope-style 200 with {"kind":"Listing"} or
59
+ // an error body for quarantined / private subs. Identify "subreddit
60
+ // not found / not accessible" by the absence of data.display_name.
61
+ if (j?.error) {
62
+ if (j.error === 404 || j.reason === 'banned' || j.reason === 'private' || j.reason === 'quarantined') {
63
+ return { kind: 'missing', detail: 'Subreddit r/' + sub + ' is ' + (j.reason || 'unavailable') + '.' };
64
+ }
65
+ return { kind: 'http', httpStatus: j.error, where: '/r/' + sub + '/about.json (' + (j.reason || 'error') + ')' };
66
+ }
67
+ const info = j?.data;
68
+ if (!info || !info.display_name) {
69
+ return { kind: 'malformed', detail: 'Reddit returned malformed subreddit info for r/' + sub + ' (missing data.display_name).' };
70
+ }
71
+ return { kind: 'ok', info };
72
+ } catch (e) {
73
+ return { kind: 'exception', detail: String(e && e.message || e) };
74
+ }
75
+ })()`);
76
+
77
+ if (result?.kind === 'missing') {
78
+ throw new EmptyResultError(result.detail);
79
+ }
80
+ if (result?.kind === 'http') {
81
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
82
+ }
83
+ if (result?.kind === 'malformed') {
84
+ throw new CommandExecutionError(result.detail);
85
+ }
86
+ if (result?.kind === 'exception') {
87
+ throw new CommandExecutionError(`subreddit-info failed: ${result.detail}`);
88
+ }
89
+ if (result?.kind !== 'ok') {
90
+ throw new CommandExecutionError(`Unexpected result from reddit subreddit-info: ${JSON.stringify(result)}`);
91
+ }
92
+
93
+ const s = result.info;
94
+ const created = s.created_utc
95
+ ? new Date(s.created_utc * 1000).toISOString().split('T')[0]
96
+ : null;
97
+ const subscribers = typeof s.subscribers === 'number' ? s.subscribers : null;
98
+ const activeNow = typeof s.active_user_count === 'number'
99
+ ? s.active_user_count
100
+ : (typeof s.accounts_active === 'number' ? s.accounts_active : null);
101
+ const description = typeof s.public_description === 'string'
102
+ ? s.public_description.trim()
103
+ : '';
104
+
105
+ return [
106
+ { field: 'Name', value: s.display_name_prefixed || ('r/' + s.display_name) },
107
+ { field: 'Title', value: typeof s.title === 'string' ? s.title : null },
108
+ { field: 'Subscribers', value: subscribers != null ? String(subscribers) : null },
109
+ { field: 'Active Now', value: activeNow != null ? String(activeNow) : null },
110
+ { field: 'NSFW', value: s.over18 ? 'Yes' : 'No' },
111
+ { field: 'Type', value: typeof s.subreddit_type === 'string' ? s.subreddit_type : null },
112
+ { field: 'Description', value: description || null },
113
+ { field: 'Created', value: created },
114
+ { field: 'URL', value: s.url ? 'https://www.reddit.com' + s.url : null },
115
+ ];
116
+ },
117
+ });
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import { parseSubredditName } from './subreddit-info.js';
5
+ import './subreddit-info.js';
6
+
7
+ function makePage(result) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(result),
11
+ };
12
+ }
13
+
14
+ describe('reddit subreddit-info command', () => {
15
+ const command = getRegistry().get('reddit/subreddit-info');
16
+
17
+ it('registers with the expected shape', () => {
18
+ expect(command).toBeDefined();
19
+ expect(command.access).toBe('read');
20
+ expect(command.browser).toBe(true);
21
+ expect(command.columns).toEqual(['field', 'value']);
22
+ });
23
+
24
+ it('parseSubredditName strips prefixes and validates the name shape', () => {
25
+ expect(parseSubredditName('python')).toBe('python');
26
+ expect(parseSubredditName('r/python')).toBe('python');
27
+ expect(parseSubredditName('/r/python')).toBe('python');
28
+ expect(parseSubredditName(' AskReddit ')).toBe('AskReddit');
29
+ expect(parseSubredditName('aw3some_sub')).toBe('aw3some_sub');
30
+ });
31
+
32
+ it('parseSubredditName rejects invalid names without silent fallback', () => {
33
+ for (const bad of [
34
+ '',
35
+ ' ',
36
+ 'py', // too short
37
+ '1abc', // must start with letter
38
+ '_sub', // must start with letter
39
+ 'has space', // no spaces
40
+ 'has-dash', // no dashes
41
+ 'way_too_long_subreddit_name_here', // too long (>21)
42
+ ]) {
43
+ expect(() => parseSubredditName(bad)).toThrow(ArgumentError);
44
+ }
45
+ });
46
+
47
+ it('rejects bad subreddit names BEFORE navigating', async () => {
48
+ const page = makePage({ kind: 'ok', info: {} });
49
+ await expect(command.func(page, { name: 'has space' })).rejects.toBeInstanceOf(ArgumentError);
50
+ expect(page.goto).not.toHaveBeenCalled();
51
+ expect(page.evaluate).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it('throws EmptyResultError for missing / banned / private / quarantined subreddits', async () => {
55
+ for (const detail of ['not found', 'banned', 'private', 'quarantined']) {
56
+ await expect(command.func(makePage({ kind: 'missing', detail }), { name: 'python' }))
57
+ .rejects.toBeInstanceOf(EmptyResultError);
58
+ }
59
+ });
60
+
61
+ it('treats HTTP 401/403/404 about.json responses as inaccessible subreddit, not auth-required', async () => {
62
+ for (const status of [401, 403, 404]) {
63
+ const scriptResults = [];
64
+ const page = {
65
+ goto: vi.fn().mockResolvedValue(undefined),
66
+ evaluate: vi.fn(async (script) => {
67
+ const result = await (new Function('fetch', `return (${script})`))(vi.fn(async () => ({
68
+ status,
69
+ ok: false,
70
+ })));
71
+ scriptResults.push(result);
72
+ return result;
73
+ }),
74
+ };
75
+ await expect(command.func(page, { name: 'python' })).rejects.toBeInstanceOf(EmptyResultError);
76
+ expect(scriptResults[0]).toMatchObject({ kind: 'missing' });
77
+ }
78
+ });
79
+
80
+ it('throws CommandExecutionError on HTTP and exception failure modes', async () => {
81
+ await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: 'about.json' }), { name: 'python' }))
82
+ .rejects.toBeInstanceOf(CommandExecutionError);
83
+ await expect(command.func(makePage({ kind: 'exception', detail: 'bad' }), { name: 'python' }))
84
+ .rejects.toBeInstanceOf(CommandExecutionError);
85
+ });
86
+
87
+ it('throws CommandExecutionError for malformed 200 subreddit payloads', async () => {
88
+ await expect(command.func(makePage({
89
+ kind: 'malformed',
90
+ detail: 'Reddit returned malformed subreddit info for r/python (missing data.display_name).',
91
+ }), { name: 'python' }))
92
+ .rejects.toMatchObject({
93
+ code: 'COMMAND_EXEC',
94
+ message: expect.stringContaining('malformed subreddit info'),
95
+ });
96
+ });
97
+
98
+ it('maps a normal subreddit payload into typed field/value rows', async () => {
99
+ const info = {
100
+ display_name: 'python',
101
+ display_name_prefixed: 'r/python',
102
+ title: 'Python',
103
+ public_description: ' News about the programming language Python. ',
104
+ subscribers: 1234567,
105
+ active_user_count: 4321,
106
+ over18: false,
107
+ subreddit_type: 'public',
108
+ created_utc: 1201881600, // 2008-02-01
109
+ url: '/r/python/',
110
+ };
111
+ const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'python' });
112
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
113
+
114
+ expect(byField.Name).toBe('r/python');
115
+ expect(byField.Title).toBe('Python');
116
+ expect(byField.Subscribers).toBe('1234567');
117
+ expect(byField['Active Now']).toBe('4321');
118
+ expect(byField.NSFW).toBe('No');
119
+ expect(byField.Type).toBe('public');
120
+ expect(byField.Description).toBe('News about the programming language Python.');
121
+ expect(byField.Created).toBe('2008-02-01');
122
+ expect(byField.URL).toBe('https://www.reddit.com/r/python/');
123
+
124
+ for (const row of rows) {
125
+ expect(Object.keys(row).sort()).toEqual(['field', 'value']);
126
+ }
127
+ });
128
+
129
+ it('falls back to accounts_active when active_user_count is missing', async () => {
130
+ const info = {
131
+ display_name: 'sub',
132
+ display_name_prefixed: 'r/sub',
133
+ accounts_active: 99,
134
+ url: '/r/sub/',
135
+ };
136
+ const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'sub' });
137
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
138
+ expect(byField['Active Now']).toBe('99');
139
+ });
140
+
141
+ it('emits typed null (not "-" sentinel) when subscribers / activity / created are missing', async () => {
142
+ const info = {
143
+ display_name: 'sparse',
144
+ display_name_prefixed: 'r/sparse',
145
+ url: '/r/sparse/',
146
+ };
147
+ const rows = await command.func(makePage({ kind: 'ok', info }), { name: 'sparse' });
148
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
149
+ expect(byField.Subscribers).toBeNull();
150
+ expect(byField['Active Now']).toBeNull();
151
+ expect(byField.Created).toBeNull();
152
+ expect(byField.Description).toBeNull();
153
+ expect(byField.Title).toBeNull();
154
+ expect(byField.Type).toBeNull();
155
+ });
156
+
157
+ it('encodes the subreddit name into the fetch URL embedded in evaluate', async () => {
158
+ const page = makePage({ kind: 'ok', info: { display_name: 'python', display_name_prefixed: 'r/python', url: '/r/python/' } });
159
+ await command.func(page, { name: 'r/python' });
160
+ const script = page.evaluate.mock.calls[0][0];
161
+ expect(script).toContain('const sub = "python"');
162
+ });
163
+ });
@@ -0,0 +1,84 @@
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ cli({
5
+ site: 'reddit',
6
+ name: 'whoami',
7
+ access: 'read',
8
+ description: 'Show the currently logged-in Reddit user',
9
+ domain: 'reddit.com',
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ siteSession: 'persistent',
13
+ args: [],
14
+ columns: ['field', 'value'],
15
+ func: async (page) => {
16
+ await page.goto('https://www.reddit.com');
17
+ // Probe identity via /api/me.json. Reddit returns 200 with an empty
18
+ // body for stale anonymous sessions, so 401/403 alone is not a
19
+ // sufficient logged-out signal — we also verify `data.name` exists
20
+ // (two-pronged auth detection from PR #1428).
21
+ //
22
+ // Intermediate object keys deliberately avoid `field` / `value` to
23
+ // sidestep the silent-column-drop audit (columns are ['field',
24
+ // 'value']) — see PR #1329 sediment "中间解析对象 key 不能跟 columns
25
+ // 任一项重叠".
26
+ const result = await page.evaluate(`(async () => {
27
+ try {
28
+ const res = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
29
+ if (res.status === 401 || res.status === 403) {
30
+ return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + res.status };
31
+ }
32
+ if (!res.ok) {
33
+ return { kind: 'http', httpStatus: res.status, where: '/api/me.json' };
34
+ }
35
+ const d = await res.json();
36
+ const me = d?.data;
37
+ if (!me?.name) {
38
+ return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
39
+ }
40
+ return { kind: 'ok', identity: me };
41
+ } catch (e) {
42
+ return { kind: 'exception', detail: String(e && e.message || e) };
43
+ }
44
+ })()`);
45
+
46
+ if (result?.kind === 'auth') {
47
+ throw new AuthRequiredError('reddit.com', result.detail);
48
+ }
49
+ if (result?.kind === 'http') {
50
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
51
+ }
52
+ if (result?.kind === 'exception') {
53
+ throw new CommandExecutionError(`whoami failed: ${result.detail}`);
54
+ }
55
+ if (result?.kind !== 'ok') {
56
+ throw new CommandExecutionError(`Unexpected result from reddit whoami: ${JSON.stringify(result)}`);
57
+ }
58
+
59
+ const u = result.identity;
60
+ const created = u.created_utc
61
+ ? new Date(u.created_utc * 1000).toISOString().split('T')[0]
62
+ : null;
63
+ const linkKarma = typeof u.link_karma === 'number' ? u.link_karma : null;
64
+ const commentKarma = typeof u.comment_karma === 'number' ? u.comment_karma : null;
65
+ const totalKarma = typeof u.total_karma === 'number'
66
+ ? u.total_karma
67
+ : (linkKarma != null && commentKarma != null ? linkKarma + commentKarma : null);
68
+ const inboxCount = typeof u.inbox_count === 'number' ? u.inbox_count : null;
69
+
70
+ return [
71
+ { field: 'Username', value: 'u/' + u.name },
72
+ { field: 'ID', value: u.id ? 't2_' + u.id : null },
73
+ { field: 'Post Karma', value: linkKarma != null ? String(linkKarma) : null },
74
+ { field: 'Comment Karma', value: commentKarma != null ? String(commentKarma) : null },
75
+ { field: 'Total Karma', value: totalKarma != null ? String(totalKarma) : null },
76
+ { field: 'Account Created', value: created },
77
+ { field: 'Gold', value: u.is_gold ? 'Yes' : 'No' },
78
+ { field: 'Mod', value: u.is_mod ? 'Yes' : 'No' },
79
+ { field: 'Verified Email', value: u.has_verified_email ? 'Yes' : 'No' },
80
+ { field: 'Has Mail', value: u.has_mail ? 'Yes' : 'No' },
81
+ { field: 'Inbox Count', value: inboxCount != null ? String(inboxCount) : null },
82
+ ];
83
+ },
84
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './whoami.js';
5
+
6
+ function makePage(result) {
7
+ return {
8
+ goto: vi.fn().mockResolvedValue(undefined),
9
+ evaluate: vi.fn().mockResolvedValue(result),
10
+ };
11
+ }
12
+
13
+ describe('reddit whoami command', () => {
14
+ const command = getRegistry().get('reddit/whoami');
15
+
16
+ it('registers with the expected shape', () => {
17
+ expect(command).toBeDefined();
18
+ expect(command.access).toBe('read');
19
+ expect(command.browser).toBe(true);
20
+ expect(command.columns).toEqual(['field', 'value']);
21
+ expect(command.args).toEqual([]);
22
+ });
23
+
24
+ it('throws AuthRequiredError on 401/403 from /api/me.json', async () => {
25
+ const page = makePage({ kind: 'auth', detail: 'Reddit /api/me.json returned HTTP 401' });
26
+ await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
27
+ expect(page.goto).toHaveBeenCalledWith('https://www.reddit.com');
28
+ });
29
+
30
+ it('throws AuthRequiredError on 200 with missing data.name (stale anon session)', async () => {
31
+ const page = makePage({ kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' });
32
+ await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
33
+ });
34
+
35
+ it('throws CommandExecutionError on HTTP / exception failure modes', async () => {
36
+ await expect(command.func(makePage({ kind: 'http', httpStatus: 500, where: '/api/me.json' }), {}))
37
+ .rejects.toBeInstanceOf(CommandExecutionError);
38
+ await expect(command.func(makePage({ kind: 'exception', detail: 'bad json' }), {}))
39
+ .rejects.toBeInstanceOf(CommandExecutionError);
40
+ });
41
+
42
+ it('maps a full identity payload into the field/value rows', async () => {
43
+ const identity = {
44
+ name: 'alice',
45
+ id: 'abcdef',
46
+ link_karma: 1234,
47
+ comment_karma: 5678,
48
+ total_karma: 6912,
49
+ created_utc: 1577836800, // 2020-01-01
50
+ is_gold: true,
51
+ is_mod: false,
52
+ has_verified_email: true,
53
+ has_mail: false,
54
+ inbox_count: 0,
55
+ };
56
+ const page = makePage({ kind: 'ok', identity });
57
+ const rows = await command.func(page, {});
58
+
59
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
60
+ expect(byField.Username).toBe('u/alice');
61
+ expect(byField.ID).toBe('t2_abcdef');
62
+ expect(byField['Post Karma']).toBe('1234');
63
+ expect(byField['Comment Karma']).toBe('5678');
64
+ expect(byField['Total Karma']).toBe('6912');
65
+ expect(byField['Account Created']).toBe('2020-01-01');
66
+ expect(byField.Gold).toBe('Yes');
67
+ expect(byField.Mod).toBe('No');
68
+ expect(byField['Verified Email']).toBe('Yes');
69
+ expect(byField['Has Mail']).toBe('No');
70
+ expect(byField['Inbox Count']).toBe('0');
71
+
72
+ // Row shape must match the declared columns exactly so the
73
+ // silent-column-drop audit can't be triggered.
74
+ for (const row of rows) {
75
+ expect(Object.keys(row).sort()).toEqual(['field', 'value']);
76
+ }
77
+ });
78
+
79
+ it('falls back to null for missing numeric karma fields rather than 0 sentinels', async () => {
80
+ const identity = {
81
+ name: 'bob',
82
+ id: 'xyz',
83
+ created_utc: null,
84
+ is_gold: false,
85
+ is_mod: false,
86
+ has_verified_email: false,
87
+ has_mail: false,
88
+ };
89
+ const page = makePage({ kind: 'ok', identity });
90
+ const rows = await command.func(page, {});
91
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
92
+ expect(byField['Post Karma']).toBeNull();
93
+ expect(byField['Comment Karma']).toBeNull();
94
+ expect(byField['Total Karma']).toBeNull();
95
+ expect(byField['Account Created']).toBeNull();
96
+ expect(byField['Inbox Count']).toBeNull();
97
+ });
98
+
99
+ it('does not throw on `data.name` present even if optional booleans are missing', async () => {
100
+ const identity = { name: 'carol', id: 'i1' };
101
+ const page = makePage({ kind: 'ok', identity });
102
+ const rows = await command.func(page, {});
103
+ expect(rows[0]).toEqual({ field: 'Username', value: 'u/carol' });
104
+ });
105
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Rednote comments — international mirror of xiaohongshu/comments.
3
+ * Reuses the DOM-extraction IIFE from `../xiaohongshu/comments.js`.
4
+ */
5
+ import { cli, Strategy } from '@jackwener/opencli/registry';
6
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
+ import { buildCommentsExtractJs } from '../xiaohongshu/comments.js';
8
+ import { buildNoteUrl, parseNoteId } from '../xiaohongshu/note-helpers.js';
9
+
10
+ const REDNOTE_SIGNED_URL_HINT = 'Pass a full rednote.com note URL with xsec_token from search results or user/profile context.';
11
+
12
+ function parseCommentLimit(raw) {
13
+ const parsed = Number(raw ?? 20);
14
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
15
+ throw new ArgumentError(`--limit must be an integer between 1 and 50, got ${JSON.stringify(raw)}`);
16
+ }
17
+ if (parsed < 1 || parsed > 50) {
18
+ throw new ArgumentError(`--limit must be between 1 and 50, got ${parsed}`);
19
+ }
20
+ return parsed;
21
+ }
22
+
23
+ cli({
24
+ site: 'rednote',
25
+ name: 'comments',
26
+ access: 'read',
27
+ description: 'Read comments from a rednote note (supports nested replies)',
28
+ domain: 'www.rednote.com',
29
+ strategy: Strategy.COOKIE,
30
+ navigateBefore: false,
31
+ args: [
32
+ { name: 'note-id', required: true, positional: true, help: 'Full rednote note URL with xsec_token' },
33
+ { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
34
+ { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
35
+ ],
36
+ columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
37
+ func: async (page, kwargs) => {
38
+ const limit = parseCommentLimit(kwargs.limit);
39
+ const withReplies = Boolean(kwargs['with-replies']);
40
+ const raw = String(kwargs['note-id']);
41
+ const noteId = parseNoteId(raw);
42
+ await page.goto(buildNoteUrl(raw, {
43
+ commandName: 'rednote comments',
44
+ cookieRoot: 'rednote.com',
45
+ signedUrlHint: REDNOTE_SIGNED_URL_HINT,
46
+ }));
47
+ await page.wait({ time: 2 + Math.random() * 3 });
48
+ const data = await page.evaluate(buildCommentsExtractJs(withReplies));
49
+ if (!data || typeof data !== 'object') {
50
+ throw new EmptyResultError('rednote/comments', 'Unexpected evaluate response');
51
+ }
52
+ if (data.securityBlock) {
53
+ throw new CommandExecutionError('Rednote security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
54
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
55
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
56
+ }
57
+ if (data.loginWall) {
58
+ throw new AuthRequiredError('www.rednote.com', 'Note comments require login');
59
+ }
60
+ void noteId;
61
+ const all = data.results ?? [];
62
+ if (withReplies) {
63
+ const limited = [];
64
+ let topCount = 0;
65
+ for (const c of all) {
66
+ if (!c.is_reply)
67
+ topCount++;
68
+ if (topCount > limit)
69
+ break;
70
+ limited.push(c);
71
+ }
72
+ return limited.map((c, i) => ({ rank: i + 1, ...c }));
73
+ }
74
+ return all.slice(0, limit).map((c, i) => ({ rank: i + 1, ...c }));
75
+ },
76
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Rednote download — international mirror of xiaohongshu/download.
3
+ * Reuses the DOM-extraction IIFE from `../xiaohongshu/download.js`; that
4
+ * IIFE's CDN allowlist already accepts rednote-hosted media URLs.
5
+ */
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { formatCookieHeader } from '@jackwener/opencli/download';
8
+ import { downloadMedia } from '@jackwener/opencli/download/media-download';
9
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
10
+ import { buildDownloadExtractJs } from '../xiaohongshu/download.js';
11
+ import { buildNoteUrl, parseNoteId } from '../xiaohongshu/note-helpers.js';
12
+
13
+ const REDNOTE_SIGNED_URL_HINT = 'Pass a full rednote.com note URL with xsec_token from search results or user/profile context.';
14
+
15
+ cli({
16
+ site: 'rednote',
17
+ name: 'download',
18
+ access: 'read',
19
+ description: 'Download images and videos from a rednote note',
20
+ domain: 'www.rednote.com',
21
+ strategy: Strategy.COOKIE,
22
+ navigateBefore: false,
23
+ args: [
24
+ { name: 'note-id', positional: true, required: true, help: 'Full rednote note URL with xsec_token' },
25
+ { name: 'output', default: './rednote-downloads', help: 'Output directory' },
26
+ ],
27
+ columns: ['index', 'type', 'status', 'size'],
28
+ func: async (page, kwargs) => {
29
+ const rawInput = String(kwargs['note-id']);
30
+ const output = kwargs.output;
31
+ const noteId = parseNoteId(rawInput);
32
+ await page.goto(buildNoteUrl(rawInput, {
33
+ commandName: 'rednote download',
34
+ cookieRoot: 'rednote.com',
35
+ signedUrlHint: REDNOTE_SIGNED_URL_HINT,
36
+ }));
37
+ await page.wait({ time: 1 + Math.random() * 2 });
38
+ const data = await page.evaluate(buildDownloadExtractJs(noteId));
39
+ if (data?.securityBlock) {
40
+ throw new CommandExecutionError('Rednote security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
41
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
42
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
43
+ }
44
+ if (!data || !data.media || data.media.length === 0) {
45
+ throw new EmptyResultError('rednote/download', 'No downloadable media found on this rednote note.');
46
+ }
47
+ const cookies = formatCookieHeader(await page.getCookies({ url: 'https://www.rednote.com' }));
48
+ const resolvedNoteId = typeof data.noteId === 'string' && data.noteId.trim()
49
+ ? data.noteId.trim()
50
+ : noteId;
51
+ return downloadMedia(data.media, {
52
+ output,
53
+ subdir: resolvedNoteId,
54
+ cookies,
55
+ filenamePrefix: resolvedNoteId,
56
+ timeout: 60000,
57
+ });
58
+ },
59
+ });