@jackwener/opencli 1.7.17 → 1.7.18

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.
package/README.md CHANGED
@@ -250,6 +250,7 @@ To load the source Browser Bridge extension:
250
250
  | Site | Commands |
251
251
  |------|----------|
252
252
  | **xiaohongshu** | `search` `note` `comments` `feed` `user` `download` `publish` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` |
253
+ | **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` |
253
254
  | **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `me` `subtitle` `video` `user-videos` |
254
255
  | **tieba** | `hot` `posts` `search` `read` |
255
256
  | **hupu** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` |
@@ -328,6 +329,7 @@ OpenCLI supports downloading images, videos, and articles from supported platfor
328
329
  | Platform | Content Types | Notes |
329
330
  |----------|---------------|-------|
330
331
  | **xiaohongshu** | Images, Videos | Downloads all media from a note |
332
+ | **rednote** | Images, Videos | Downloads all media from a signed rednote note URL |
331
333
  | **bilibili** | Videos | Requires `yt-dlp` installed |
332
334
  | **twitter** | Images, Videos | From user media tab or single tweet |
333
335
  | **douban** | Images | Poster / still image lists |
@@ -342,6 +344,7 @@ For video downloads, install `yt-dlp` first: `brew install yt-dlp`
342
344
  ```bash
343
345
  opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
344
346
  opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
347
+ opencli rednote download "https://www.rednote.com/search_result/<id>?xsec_token=..." --output ./rednote
345
348
  opencli bilibili download BV1xxx --output ./bilibili
346
349
  opencli twitter download elonmusk --limit 20 --output ./twitter
347
350
  opencli 1688 download 841141931191 --output ./1688-downloads
package/README.zh-CN.md CHANGED
@@ -249,6 +249,7 @@ npm link
249
249
  | **antigravity** | `status` `send` `read` `new` `dump` `extract-code` `model` `watch` `serve` | 桌面端 |
250
250
  | **chatgpt-app** | `status` `new` `send` `read` `ask` `model` | 桌面端 |
251
251
  | **xiaohongshu** | `search` `note` `comments` `notifications` `feed` `user` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 浏览器 |
252
+ | **rednote** | `search` `note` `comments` `user` `download` `feed` `notifications` | 浏览器 |
252
253
  | **xiaoe** | `courses` `detail` `catalog` `play-url` `content` | 浏览器 |
253
254
  | **quark** | `ls` `mkdir` `mv` `rename` `rm` `save` `share-tree` | 浏览器 |
254
255
  | **uiverse** | `code` `preview` | 浏览器 |
@@ -401,6 +402,7 @@ brew install yt-dlp
401
402
  # 下载小红书笔记中的图片/视频
402
403
  opencli xiaohongshu download "https://www.xiaohongshu.com/search_result/<id>?xsec_token=..." --output ./xhs
403
404
  opencli xiaohongshu download "https://xhslink.com/..." --output ./xhs
405
+ opencli rednote download "https://www.rednote.com/search_result/<id>?xsec_token=..." --output ./rednote
404
406
 
405
407
  # 下载B站视频(需要 yt-dlp)
406
408
  opencli bilibili download BV1xxx --output ./bilibili
package/cli-manifest.json CHANGED
@@ -19217,6 +19217,39 @@
19217
19217
  "navigateBefore": "https://reddit.com",
19218
19218
  "siteSession": "persistent"
19219
19219
  },
19220
+ {
19221
+ "site": "reddit",
19222
+ "name": "reply",
19223
+ "description": "Reply to a Reddit comment",
19224
+ "access": "write",
19225
+ "domain": "reddit.com",
19226
+ "strategy": "cookie",
19227
+ "browser": true,
19228
+ "args": [
19229
+ {
19230
+ "name": "comment-id",
19231
+ "type": "string",
19232
+ "required": true,
19233
+ "positional": true,
19234
+ "help": "Comment ID (e.g. okf3s7u) or fullname (t1_xxx)"
19235
+ },
19236
+ {
19237
+ "name": "text",
19238
+ "type": "string",
19239
+ "required": true,
19240
+ "positional": true,
19241
+ "help": "Reply text"
19242
+ }
19243
+ ],
19244
+ "columns": [
19245
+ "status",
19246
+ "message"
19247
+ ],
19248
+ "type": "js",
19249
+ "modulePath": "reddit/reply.js",
19250
+ "sourceFile": "reddit/reply.js",
19251
+ "navigateBefore": "https://reddit.com"
19252
+ },
19220
19253
  {
19221
19254
  "site": "reddit",
19222
19255
  "name": "save",
@@ -19589,6 +19622,253 @@
19589
19622
  "navigateBefore": "https://reddit.com",
19590
19623
  "siteSession": "persistent"
19591
19624
  },
19625
+ {
19626
+ "site": "rednote",
19627
+ "name": "comments",
19628
+ "description": "Read comments from a rednote note (supports nested replies)",
19629
+ "access": "read",
19630
+ "domain": "www.rednote.com",
19631
+ "strategy": "cookie",
19632
+ "browser": true,
19633
+ "args": [
19634
+ {
19635
+ "name": "note-id",
19636
+ "type": "str",
19637
+ "required": true,
19638
+ "positional": true,
19639
+ "help": "Full rednote note URL with xsec_token"
19640
+ },
19641
+ {
19642
+ "name": "limit",
19643
+ "type": "int",
19644
+ "default": 20,
19645
+ "required": false,
19646
+ "help": "Number of top-level comments (max 50)"
19647
+ },
19648
+ {
19649
+ "name": "with-replies",
19650
+ "type": "boolean",
19651
+ "default": false,
19652
+ "required": false,
19653
+ "help": "Include nested replies (楼中楼)"
19654
+ }
19655
+ ],
19656
+ "columns": [
19657
+ "rank",
19658
+ "author",
19659
+ "text",
19660
+ "likes",
19661
+ "time",
19662
+ "is_reply",
19663
+ "reply_to"
19664
+ ],
19665
+ "type": "js",
19666
+ "modulePath": "rednote/comments.js",
19667
+ "sourceFile": "rednote/comments.js",
19668
+ "navigateBefore": false
19669
+ },
19670
+ {
19671
+ "site": "rednote",
19672
+ "name": "download",
19673
+ "description": "Download images and videos from a rednote note",
19674
+ "access": "read",
19675
+ "domain": "www.rednote.com",
19676
+ "strategy": "cookie",
19677
+ "browser": true,
19678
+ "args": [
19679
+ {
19680
+ "name": "note-id",
19681
+ "type": "str",
19682
+ "required": true,
19683
+ "positional": true,
19684
+ "help": "Full rednote note URL with xsec_token"
19685
+ },
19686
+ {
19687
+ "name": "output",
19688
+ "type": "str",
19689
+ "default": "./rednote-downloads",
19690
+ "required": false,
19691
+ "help": "Output directory"
19692
+ }
19693
+ ],
19694
+ "columns": [
19695
+ "index",
19696
+ "type",
19697
+ "status",
19698
+ "size"
19699
+ ],
19700
+ "type": "js",
19701
+ "modulePath": "rednote/download.js",
19702
+ "sourceFile": "rednote/download.js",
19703
+ "navigateBefore": false
19704
+ },
19705
+ {
19706
+ "site": "rednote",
19707
+ "name": "feed",
19708
+ "description": "Rednote home feed (reads hydrated Pinia store)",
19709
+ "access": "read",
19710
+ "domain": "www.rednote.com",
19711
+ "strategy": "cookie",
19712
+ "browser": true,
19713
+ "args": [
19714
+ {
19715
+ "name": "limit",
19716
+ "type": "int",
19717
+ "default": 20,
19718
+ "required": false,
19719
+ "help": "Number of items to return"
19720
+ }
19721
+ ],
19722
+ "columns": [
19723
+ "id",
19724
+ "title",
19725
+ "author",
19726
+ "likes",
19727
+ "type",
19728
+ "url"
19729
+ ],
19730
+ "type": "js",
19731
+ "modulePath": "rednote/feed.js",
19732
+ "sourceFile": "rednote/feed.js",
19733
+ "navigateBefore": false
19734
+ },
19735
+ {
19736
+ "site": "rednote",
19737
+ "name": "note",
19738
+ "description": "Read note body and engagement counts from a rednote note",
19739
+ "access": "read",
19740
+ "domain": "www.rednote.com",
19741
+ "strategy": "cookie",
19742
+ "browser": true,
19743
+ "args": [
19744
+ {
19745
+ "name": "note-id",
19746
+ "type": "str",
19747
+ "required": true,
19748
+ "positional": true,
19749
+ "help": "Full rednote note URL with xsec_token"
19750
+ }
19751
+ ],
19752
+ "columns": [
19753
+ "field",
19754
+ "value"
19755
+ ],
19756
+ "type": "js",
19757
+ "modulePath": "rednote/note.js",
19758
+ "sourceFile": "rednote/note.js",
19759
+ "navigateBefore": false
19760
+ },
19761
+ {
19762
+ "site": "rednote",
19763
+ "name": "notifications",
19764
+ "description": "Rednote notifications (mentions/likes/connections)",
19765
+ "access": "read",
19766
+ "domain": "www.rednote.com",
19767
+ "strategy": "cookie",
19768
+ "browser": true,
19769
+ "args": [
19770
+ {
19771
+ "name": "type",
19772
+ "type": "str",
19773
+ "default": "mentions",
19774
+ "required": false,
19775
+ "help": "Notification type: mentions, likes, or connections"
19776
+ },
19777
+ {
19778
+ "name": "limit",
19779
+ "type": "int",
19780
+ "default": 20,
19781
+ "required": false,
19782
+ "help": "Number of notifications to return"
19783
+ }
19784
+ ],
19785
+ "columns": [
19786
+ "rank",
19787
+ "user",
19788
+ "action",
19789
+ "content",
19790
+ "note",
19791
+ "time"
19792
+ ],
19793
+ "type": "js",
19794
+ "modulePath": "rednote/notifications.js",
19795
+ "sourceFile": "rednote/notifications.js",
19796
+ "navigateBefore": false
19797
+ },
19798
+ {
19799
+ "site": "rednote",
19800
+ "name": "search",
19801
+ "description": "Search rednote notes",
19802
+ "access": "read",
19803
+ "domain": "www.rednote.com",
19804
+ "strategy": "cookie",
19805
+ "browser": true,
19806
+ "args": [
19807
+ {
19808
+ "name": "query",
19809
+ "type": "str",
19810
+ "required": true,
19811
+ "positional": true,
19812
+ "help": "Search keyword"
19813
+ },
19814
+ {
19815
+ "name": "limit",
19816
+ "type": "int",
19817
+ "default": 20,
19818
+ "required": false,
19819
+ "help": "Number of results"
19820
+ }
19821
+ ],
19822
+ "columns": [
19823
+ "rank",
19824
+ "title",
19825
+ "author",
19826
+ "likes",
19827
+ "published_at",
19828
+ "url",
19829
+ "author_url"
19830
+ ],
19831
+ "type": "js",
19832
+ "modulePath": "rednote/search.js",
19833
+ "sourceFile": "rednote/search.js",
19834
+ "navigateBefore": false
19835
+ },
19836
+ {
19837
+ "site": "rednote",
19838
+ "name": "user",
19839
+ "description": "Get public notes from a rednote user profile",
19840
+ "access": "read",
19841
+ "domain": "www.rednote.com",
19842
+ "strategy": "cookie",
19843
+ "browser": true,
19844
+ "args": [
19845
+ {
19846
+ "name": "id",
19847
+ "type": "str",
19848
+ "required": true,
19849
+ "positional": true,
19850
+ "help": "User id or profile URL"
19851
+ },
19852
+ {
19853
+ "name": "limit",
19854
+ "type": "int",
19855
+ "default": 15,
19856
+ "required": false,
19857
+ "help": "Number of notes to return"
19858
+ }
19859
+ ],
19860
+ "columns": [
19861
+ "id",
19862
+ "title",
19863
+ "type",
19864
+ "likes",
19865
+ "url"
19866
+ ],
19867
+ "type": "js",
19868
+ "modulePath": "rednote/user.js",
19869
+ "sourceFile": "rednote/user.js",
19870
+ "navigateBefore": false
19871
+ },
19592
19872
  {
19593
19873
  "site": "rest-countries",
19594
19874
  "name": "country",
@@ -163,6 +163,19 @@ function getTurnsScript() {
163
163
  ) {
164
164
  return 'Assistant';
165
165
  }
166
+ // 2026-05 Doubao DOM refactor: no more receive-message / bg-g-receive-msg-bubble
167
+ // markers on assistant turns. Wrappers are now [class*="inner-item-"] /
168
+ // [class*="top-item-"] and the only reliable assistant signal is the
169
+ // .flow-markdown-body content container WITHOUT any send-bubble marker.
170
+ if (
171
+ (root.matches('[class*="inner-item-"], [class*="top-item-"]')
172
+ || root.closest('[class*="inner-item-"], [class*="top-item-"]'))
173
+ && (root.matches('.flow-markdown-body') || root.querySelector('.flow-markdown-body'))
174
+ && !root.matches('[class*="bg-g-send-msg-bubble"]')
175
+ && !root.querySelector('[class*="bg-g-send-msg-bubble"]')
176
+ ) {
177
+ return 'Assistant';
178
+ }
166
179
  return '';
167
180
  };
168
181
 
@@ -223,6 +236,10 @@ function getTurnsScript() {
223
236
  if (!messageList) return [];
224
237
 
225
238
  const itemSelectors = [
239
+ // 2026-05 Doubao DOM refactor wrappers (prepended; outer ones win via
240
+ // ancestor-keep dedup below).
241
+ '[class*="inner-item-"]',
242
+ '[class*="top-item-"]',
226
243
  '[class*="item-kDun2N"]',
227
244
  '[data-testid="union_message"]',
228
245
  '[data-testid="message-block-container"]',
@@ -1,3 +1,4 @@
1
+ import { JSDOM } from 'jsdom';
1
2
  import { describe, expect, it, vi } from 'vitest';
2
3
  import { CommandExecutionError } from '@jackwener/opencli/errors';
3
4
  import {
@@ -145,6 +146,28 @@ describe('doubao send strategy', () => {
145
146
  });
146
147
  });
147
148
  describe('doubao receive strategy', () => {
149
+ function runTurnsScript(html) {
150
+ const dom = new JSDOM(html, { url: 'https://www.doubao.com/chat', runScripts: 'outside-only' });
151
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'innerText', {
152
+ configurable: true,
153
+ get() {
154
+ return this.textContent || '';
155
+ },
156
+ });
157
+ dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({
158
+ width: 100,
159
+ height: 24,
160
+ top: 0,
161
+ left: 0,
162
+ right: 100,
163
+ bottom: 24,
164
+ x: 0,
165
+ y: 0,
166
+ toJSON: () => ({}),
167
+ });
168
+ return dom.window.eval(__test__.getTurnsScript());
169
+ }
170
+
148
171
  it('keeps both the new skin selectors and the older structural fallbacks in the turns script', () => {
149
172
  const turnsScript = __test__.getTurnsScript();
150
173
  expect(turnsScript).toContain('[class*="message-list-S2Fv2S"]');
@@ -157,6 +180,44 @@ describe('doubao receive strategy', () => {
157
180
  expect(turnsScript).toContain('[data-testid="message-block-container"]');
158
181
  });
159
182
 
183
+ it('includes the 2026-05 doubao DOM-refactor inner-item / top-item wrappers and the flow-markdown-body assistant fallback', () => {
184
+ const turnsScript = __test__.getTurnsScript();
185
+ // New wrappers added to itemSelectors so message roots resolve under the
186
+ // refactored DOM where the legacy item-kDun2N / union_message / message-block-container
187
+ // / data-message-id selectors no longer match.
188
+ expect(turnsScript).toContain('[class*="inner-item-"]');
189
+ expect(turnsScript).toContain('[class*="top-item-"]');
190
+ // Assistant fallback: post-refactor doubao no longer emits receive-message /
191
+ // bg-g-receive-msg-bubble markup. Only signal is .flow-markdown-body content
192
+ // container without send-bubble.
193
+ expect(turnsScript).toContain('.flow-markdown-body');
194
+ });
195
+
196
+ it('extracts clean assistant turns from the 2026-05 wrapper DOM without using whole-page chrome', () => {
197
+ const turns = runTurnsScript(`
198
+ <main>
199
+ <aside>历史对话</aside>
200
+ <section class="message-list-S2Fv2S">
201
+ <div class="top-item-user">
202
+ <div class="inner-item-user">
203
+ <div class="bg-g-send-msg-bubble">测试一下,只回复OK</div>
204
+ </div>
205
+ </div>
206
+ <div class="top-item-assistant">
207
+ <div class="inner-item-assistant">
208
+ <div class="flow-markdown-body"><p>OK</p></div>
209
+ </div>
210
+ </div>
211
+ </section>
212
+ </main>
213
+ `);
214
+
215
+ expect(turns).toEqual([
216
+ { Role: 'User', Text: '测试一下,只回复OK' },
217
+ { Role: 'Assistant', Text: 'OK' },
218
+ ]);
219
+ });
220
+
160
221
  it('extends transcript-noise cleanup for the current zh-CN chrome copy', () => {
161
222
  const transcriptScript = __test__.getTranscriptLinesScript();
162
223
  expect(transcriptScript).toContain('请仔细甄别');
@@ -0,0 +1,182 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ const REDDIT_COMMENT_ID_RE = /^[a-z0-9]+$/i;
5
+
6
+ function normalizeBareCommentId(value) {
7
+ const commentId = String(value || '').trim();
8
+ if (!REDDIT_COMMENT_ID_RE.test(commentId)) {
9
+ throw new ArgumentError(
10
+ 'Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.',
11
+ 'Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.',
12
+ );
13
+ }
14
+ return commentId.toLowerCase();
15
+ }
16
+
17
+ export function normalizeRedditCommentFullname(value) {
18
+ const raw = String(value || '').trim();
19
+ if (!raw) {
20
+ throw new ArgumentError(
21
+ 'Comment ID is required.',
22
+ 'Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.',
23
+ );
24
+ }
25
+
26
+ const fullname = raw.match(/^t1_([a-z0-9]+)$/i);
27
+ if (fullname) return `t1_${normalizeBareCommentId(fullname[1])}`;
28
+
29
+ if (/^https?:\/\//i.test(raw)) {
30
+ let parsed;
31
+ try {
32
+ parsed = new URL(raw);
33
+ } catch {
34
+ throw new ArgumentError(`Invalid Reddit comment URL: ${raw}`);
35
+ }
36
+ const host = parsed.hostname.toLowerCase();
37
+ if (parsed.protocol !== 'https:' || (host !== 'reddit.com' && !host.endsWith('.reddit.com'))) {
38
+ throw new ArgumentError(
39
+ 'Comment URL must be an https reddit.com URL.',
40
+ 'Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/',
41
+ );
42
+ }
43
+ const parts = parsed.pathname.split('/').filter(Boolean);
44
+ const commentsIndex = parts.indexOf('comments');
45
+ const commentIndex = commentsIndex + 3;
46
+ if (commentsIndex < 0 || parts.length <= commentIndex) {
47
+ throw new ArgumentError(
48
+ 'Comment URL must include the target comment id.',
49
+ 'Use a URL like https://www.reddit.com/r/sub/comments/post/title/okf3s7u/',
50
+ );
51
+ }
52
+ if (parts.length !== commentIndex + 1) {
53
+ throw new ArgumentError(
54
+ 'Comment URL must end at the target comment id.',
55
+ 'Remove extra path segments after the comment id.',
56
+ );
57
+ }
58
+ return `t1_${normalizeBareCommentId(parts[commentIndex])}`;
59
+ }
60
+
61
+ if (raw.includes('/') || raw.startsWith('t3_')) {
62
+ throw new ArgumentError(
63
+ 'Comment ID must be a Reddit comment id, t1_ fullname, or reddit.com comment URL.',
64
+ 'Use a bare comment id like okf3s7u, a fullname like t1_okf3s7u, or a full Reddit comment URL.',
65
+ );
66
+ }
67
+
68
+ return `t1_${normalizeBareCommentId(raw)}`;
69
+ }
70
+
71
+ export function requireReplyText(value) {
72
+ const text = String(value || '');
73
+ if (!text.trim()) {
74
+ throw new ArgumentError('Reply text is required.', 'Pass non-empty text to post as the Reddit reply.');
75
+ }
76
+ return text;
77
+ }
78
+
79
+ cli({
80
+ site: 'reddit',
81
+ name: 'reply',
82
+ access: 'write',
83
+ description: 'Reply to a Reddit comment',
84
+ domain: 'reddit.com',
85
+ strategy: Strategy.COOKIE,
86
+ browser: true,
87
+ args: [
88
+ { name: 'comment-id', type: 'string', required: true, positional: true, help: 'Comment ID (e.g. okf3s7u) or fullname (t1_xxx)' },
89
+ { name: 'text', type: 'string', required: true, positional: true, help: 'Reply text' },
90
+ ],
91
+ columns: ['status', 'message'],
92
+ func: async (page, kwargs) => {
93
+ const fullname = normalizeRedditCommentFullname(kwargs['comment-id']);
94
+ const text = requireReplyText(kwargs.text);
95
+ await page.goto('https://www.reddit.com');
96
+ // Inside page.evaluate we can't throw typed errors (they don't survive
97
+ // the worker boundary), so we surface a structured `kind` discriminator
98
+ // and re-throw the matching typed error on the Node side. Each kind
99
+ // maps 1:1 to a typed-error class — no silent-sentinel rows on failure.
100
+ //
101
+ // Intermediate object keys deliberately avoid `status` / `message` to
102
+ // sidestep the silent-column-drop audit (columns are ['status',
103
+ // 'message']) — see PR #1329 sediment "中间解析对象 key 不能跟 columns
104
+ // 任一项重叠".
105
+ const result = await page.evaluate(`(async () => {
106
+ try {
107
+ const fullname = ${JSON.stringify(fullname)};
108
+ const text = ${JSON.stringify(text)};
109
+
110
+ // Probe identity + modhash. /api/me.json returns data.name only when
111
+ // logged in — empty modhash alone is not a strong enough auth signal
112
+ // because Reddit sometimes returns 200 with empty modhash for stale
113
+ // anonymous sessions.
114
+ const meRes = await fetch('/api/me.json', { credentials: 'include' });
115
+ if (meRes.status === 401 || meRes.status === 403) {
116
+ return { kind: 'auth', detail: 'Reddit /api/me.json returned HTTP ' + meRes.status };
117
+ }
118
+ if (!meRes.ok) {
119
+ return { kind: 'http', httpStatus: meRes.status, where: '/api/me.json' };
120
+ }
121
+ const me = await meRes.json();
122
+ if (!me?.data?.name) {
123
+ return { kind: 'auth', detail: 'Not logged in to reddit.com (no identity in /api/me.json)' };
124
+ }
125
+ const modhash = me.data.modhash || '';
126
+
127
+ const res = await fetch('/api/comment', {
128
+ method: 'POST',
129
+ credentials: 'include',
130
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
131
+ body: 'parent=' + encodeURIComponent(fullname)
132
+ + '&text=' + encodeURIComponent(text)
133
+ + '&api_type=json'
134
+ + (modhash ? '&uh=' + encodeURIComponent(modhash) : ''),
135
+ });
136
+
137
+ if (res.status === 401 || res.status === 403) {
138
+ return { kind: 'auth', detail: 'Reddit /api/comment returned HTTP ' + res.status };
139
+ }
140
+ if (!res.ok) {
141
+ return { kind: 'http', httpStatus: res.status, where: '/api/comment' };
142
+ }
143
+ const data = await res.json();
144
+ const errors = data?.json?.errors;
145
+ if (errors && errors.length > 0) {
146
+ return { kind: 'reddit-error', detail: errors.map(e => e.join(': ')).join('; ') };
147
+ }
148
+ const things = data?.json?.data?.things;
149
+ const created = Array.isArray(things)
150
+ ? things.find((thing) => thing?.kind === 't1' || String(thing?.data?.name || '').startsWith('t1_'))
151
+ : null;
152
+ const createdName = created?.data?.name || (created?.data?.id ? 't1_' + created.data.id : '');
153
+ if (!createdName) {
154
+ return { kind: 'postcondition', detail: 'Reddit comment response did not include a created reply id' };
155
+ }
156
+ return { kind: 'ok', detail: 'Reply posted on ' + fullname + ' as ' + createdName };
157
+ } catch (e) {
158
+ return { kind: 'exception', detail: String(e && e.message || e) };
159
+ }
160
+ })()`);
161
+
162
+ if (result?.kind === 'auth') {
163
+ throw new AuthRequiredError('reddit.com', result.detail);
164
+ }
165
+ if (result?.kind === 'http') {
166
+ throw new CommandExecutionError(`HTTP ${result.httpStatus} from ${result.where}`);
167
+ }
168
+ if (result?.kind === 'reddit-error') {
169
+ throw new CommandExecutionError(`Reddit rejected reply: ${result.detail}`);
170
+ }
171
+ if (result?.kind === 'postcondition') {
172
+ throw new CommandExecutionError(result.detail);
173
+ }
174
+ if (result?.kind === 'exception') {
175
+ throw new CommandExecutionError(`Reply failed: ${result.detail}`);
176
+ }
177
+ if (result?.kind !== 'ok') {
178
+ throw new CommandExecutionError(`Unexpected result from reddit reply: ${JSON.stringify(result)}`);
179
+ }
180
+ return [{ status: 'success', message: result.detail }];
181
+ },
182
+ });