@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 +3 -0
- package/README.zh-CN.md +2 -0
- package/cli-manifest.json +280 -0
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/rednote/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +97 -0
- package/clis/rednote/user.js +55 -0
- package/clis/xiaohongshu/comments.js +34 -24
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +37 -28
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/dist/src/cli.js +3 -3
- package/dist/src/cli.test.js +8 -3
- package/dist/src/doctor.js +6 -1
- package/dist/src/doctor.test.js +2 -0
- package/package.json +1 -1
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",
|
package/clis/doubao/utils.js
CHANGED
|
@@ -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
|
+
});
|