@lucasygu/redbook 0.3.3 → 0.5.0

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
@@ -24,9 +24,12 @@ npm install -g @lucasygu/redbook
24
24
  clawhub install redbook
25
25
  ```
26
26
 
27
- 需要 Node.js >= 22。使用 Chrome 浏览器的 Cookie —— 请先在 Chrome 中登录 xiaohongshu.com。
27
+ 需要 Node.js >= 22。支持 **macOS、Windows、Linux**。使用 Chrome 浏览器的 Cookie —— 请先在 Chrome 中登录 xiaohongshu.com。
28
28
 
29
- 安装后运行 `redbook whoami` 验证连接。如果遇到 macOS 钥匙串弹窗,请点击"始终允许"。CLI 会自动检测所有 Chrome 配置文件,找到你的小红书登录状态。
29
+ 安装后运行 `redbook whoami` 验证连接。CLI 会自动检测所有 Chrome 配置文件,找到你的小红书登录状态。
30
+
31
+ - **macOS** —— 如果遇到钥匙串弹窗,请点击"始终允许"
32
+ - **Windows** —— Chrome 127+ 使用了 App-Bound Encryption,CLI 会自动启动 Chrome headless 模式读取 Cookie(需要先关闭 Chrome)。如果自动提取失败,可以用 `--cookie-string` 手动传入
30
33
 
31
34
  ## 能做什么
32
35
 
@@ -34,6 +37,7 @@ clawhub install redbook
34
37
  - **竞品分析** —— 找到头部博主,对比粉丝量、互动数据、内容风格
35
38
  - **爆款拆解** —— 分析爆款笔记的标题钩子、互动比例、评论主题
36
39
  - **爆款模板** —— 从多篇爆款笔记提取内容模板(标题结构、正文结构、钩子模式)
40
+ - **收藏管理** —— 查看收藏列表、收藏/取消收藏笔记(支持自己和其他用户的公开收藏)
37
41
  - **评论管理** —— 发评论、回复评论、按策略批量回复(问题优先 / 高赞优先 / 未回复优先)
38
42
  - **图文卡片** —— Markdown 渲染为小红书风格的 PNG 图文卡片(7 种配色主题)
39
43
  - **内容策划** —— 基于数据发现内容机会,生成有数据支撑的选题建议
@@ -66,6 +70,14 @@ redbook user-posts <userId>
66
70
  # 搜索话题标签
67
71
  redbook topics "Claude Code"
68
72
 
73
+ # 查看收藏(默认当前用户)
74
+ redbook favorites --json
75
+ redbook favorites <userId> --json --all
76
+
77
+ # 收藏/取消收藏
78
+ redbook collect "<noteUrl>"
79
+ redbook uncollect "<noteUrl>"
80
+
69
81
  # 分析爆款笔记
70
82
  redbook analyze-viral https://www.xiaohongshu.com/explore/abc123
71
83
 
@@ -104,6 +116,9 @@ redbook post --title "测试" --body "..." --images img.png --private
104
116
  | `feed` | 获取推荐页内容 |
105
117
  | `post` | 发布图文笔记(易触发验证码,详见下方说明) |
106
118
  | `topics <关键词>` | 搜索话题/标签 |
119
+ | `favorites [userId]` | 查看收藏笔记列表(默认当前用户) |
120
+ | `collect <url>` | 收藏(书签)笔记 |
121
+ | `uncollect <url>` | 取消收藏笔记 |
107
122
  | `analyze-viral <url>` | 分析爆款笔记(钩子、互动、结构) |
108
123
  | `viral-template <url...>` | 从 1-3 篇爆款笔记提取内容模板 |
109
124
  | `comment <url>` | 发表评论 |
@@ -117,6 +132,7 @@ redbook post --title "测试" --body "..." --images img.png --private
117
132
  |------|------|--------|
118
133
  | `--cookie-source <浏览器>` | Cookie 来源浏览器(chrome, safari, firefox) | `chrome` |
119
134
  | `--chrome-profile <名称>` | Chrome 配置文件目录名(如 "Profile 1"),默认自动检测 | 自动 |
135
+ | `--cookie-string <cookies>` | 手动传入 Cookie 字符串:`"a1=值; web_session=值"`(从 Chrome DevTools 复制) | 无 |
120
136
  | `--json` | JSON 格式输出 | `false` |
121
137
 
122
138
  ### 搜索选项
@@ -180,13 +196,20 @@ npm install -g puppeteer-core marked
180
196
  | 问题 | 解决方案 |
181
197
  |------|----------|
182
198
  | `No 'a1' cookie found` | 在 Chrome 中登录 xiaohongshu.com,然后重试 |
199
+ | Windows 上 `-101` 错误 | Chrome 127+ 的 App-Bound Encryption 导致。先**关闭 Chrome**,再运行命令(CLI 会自动启动 Chrome headless 读取 Cookie)。如仍失败,用 `--cookie-string` 手动传入 |
200
+ | Windows `--cookie-string` 用法 | Chrome 按 F12 → Application → Cookies → xiaohongshu.com,复制 `a1` 和 `web_session` 的值:`redbook whoami --cookie-string "a1=值; web_session=值"` |
183
201
  | macOS 钥匙串弹窗 | 输入密码后点击"始终允许",CLI 需要读取 Chrome 的加密 Cookie |
184
- | 多个 Chrome 配置文件 | CLI 自动扫描所有配置文件。如需指定:`--chrome-profile "Profile 1"` |
202
+ | 多个 Chrome 配置文件 | CLI 自动扫描所有配置文件(macOS / Windows / Linux)。如需指定:`--chrome-profile "Profile 1"` |
185
203
  | 使用 Brave/Arc 等浏览器 | 尝试 `--cookie-source safari`,或在 Chrome 中登录 |
186
204
 
187
205
  ## 工作原理
188
206
 
189
- `redbook` 从 Chrome 读取小红书的登录 Cookie(通过 [@steipete/sweet-cookie](https://github.com/nicklockwood/sweet-cookie)),然后用 TypeScript 实现的签名算法对 API 请求签名。无需浏览器自动化,无需 headless Chrome —— 纯 HTTP 请求。
207
+ `redbook` 从 Chrome 读取小红书的登录 Cookie,然后用 TypeScript 实现的签名算法对 API 请求签名。
208
+
209
+ **三层 Cookie 提取策略:**
210
+ 1. **sweet-cookie**(快速路径)—— 直接读取 Chrome 的 SQLite 数据库,macOS 上即开即用
211
+ 2. **CDP 回退**(Windows 自动触发)—— 启动 Chrome headless,通过 DevTools Protocol 读取 Cookie,绕过 Chrome 127+ 的 App-Bound Encryption
212
+ 3. **`--cookie-string`**(手动兜底)—— 从 Chrome DevTools 复制 Cookie 字符串,任何平台通用
190
213
 
191
214
  **两套签名系统:**
192
215
  - **主 API**(`edith.xiaohongshu.com`)—— 读取:搜索、推荐页、笔记、评论、用户资料。使用 144 字节 x-s 签名(v4.3.1)
@@ -300,9 +323,12 @@ npm install -g @lucasygu/redbook
300
323
  clawhub install redbook
301
324
  ```
302
325
 
303
- Requires Node.js >= 22. Uses cookies from your Chrome browser session — you must be logged into xiaohongshu.com in Chrome.
326
+ Requires Node.js >= 22. Supports **macOS, Windows, and Linux**. Uses cookies from your Chrome browser session — you must be logged into xiaohongshu.com in Chrome.
304
327
 
305
- After installing, run `redbook whoami` to verify the connection. If macOS shows a Keychain prompt, click "Always Allow". The CLI auto-detects all Chrome profiles to find your XHS session.
328
+ After installing, run `redbook whoami` to verify the connection. The CLI auto-detects all Chrome profiles to find your XHS session.
329
+
330
+ - **macOS** — If Keychain prompt appears, click "Always Allow"
331
+ - **Windows** — Chrome 127+ uses App-Bound Encryption. The CLI auto-launches Chrome headless to read cookies (close Chrome first). If auto-extraction fails, use `--cookie-string` as fallback
306
332
 
307
333
  ## What You Can Do
308
334
 
@@ -310,6 +336,7 @@ After installing, run `redbook whoami` to verify the connection. If macOS shows
310
336
  - **Competitive analysis** — Find top creators, compare followers, engagement, content style
311
337
  - **Viral note breakdown** — Analyze title hooks, engagement ratios, comment themes
312
338
  - **Viral templates** — Extract content templates from multiple viral notes (hook patterns, body structure, engagement profile)
339
+ - **Favorites management** — List collected notes, collect/uncollect notes (own and other users' public collections)
313
340
  - **Comment management** — Post comments, reply to comments, batch-reply with strategies (questions / top-engaged / unanswered)
314
341
  - **Image cards** — Render markdown to styled PNG cards for XHS posts (7 color themes)
315
342
  - **Content planning** — Discover content opportunities with data-backed topic suggestions
@@ -342,6 +369,14 @@ redbook user-posts <userId>
342
369
  # Search hashtags
343
370
  redbook topics "Claude Code"
344
371
 
372
+ # List favorites (defaults to current user)
373
+ redbook favorites --json
374
+ redbook favorites <userId> --json --all
375
+
376
+ # Collect/uncollect notes
377
+ redbook collect "<noteUrl>"
378
+ redbook uncollect "<noteUrl>"
379
+
345
380
  # Analyze a viral note
346
381
  redbook analyze-viral https://www.xiaohongshu.com/explore/abc123
347
382
 
@@ -380,6 +415,9 @@ redbook post --title "测试" --body "..." --images img.png --private
380
415
  | `feed` | Get homepage feed |
381
416
  | `post` | Publish an image note (captcha-prone, see below) |
382
417
  | `topics <keyword>` | Search for topics/hashtags |
418
+ | `favorites [userId]` | List collected/favorited notes (defaults to current user) |
419
+ | `collect <url>` | Collect (bookmark) a note |
420
+ | `uncollect <url>` | Remove a note from your collection |
383
421
  | `analyze-viral <url>` | Analyze why a viral note works (hooks, engagement, structure) |
384
422
  | `viral-template <url...>` | Extract a content template from 1-3 viral notes |
385
423
  | `comment <url>` | Post a top-level comment |
@@ -393,6 +431,7 @@ redbook post --title "测试" --body "..." --images img.png --private
393
431
  |--------|-------------|---------|
394
432
  | `--cookie-source <browser>` | Browser to read cookies from (chrome, safari, firefox) | `chrome` |
395
433
  | `--chrome-profile <name>` | Chrome profile directory name (e.g., "Profile 1"). Auto-detected if omitted. | auto |
434
+ | `--cookie-string <cookies>` | Manual cookie string: `"a1=VALUE; web_session=VALUE"` (from Chrome DevTools) | none |
396
435
  | `--json` | Output as JSON | `false` |
397
436
 
398
437
  ### Search Options
@@ -456,13 +495,20 @@ Publishing **frequently triggers captcha** (type=124). Image upload works, but t
456
495
  | Problem | Solution |
457
496
  |---------|----------|
458
497
  | `No 'a1' cookie found` | Log into xiaohongshu.com in Chrome, then retry |
498
+ | Windows `-101` error | Chrome 127+ App-Bound Encryption. **Close Chrome first**, then re-run (CLI auto-launches Chrome headless to read cookies). If it still fails, use `--cookie-string` |
499
+ | Windows `--cookie-string` | Press F12 in Chrome → Application → Cookies → xiaohongshu.com. Copy `a1` and `web_session` values: `redbook whoami --cookie-string "a1=VALUE; web_session=VALUE"` |
459
500
  | macOS Keychain prompt | Enter your password and click "Always Allow" — the CLI needs to decrypt Chrome's cookies |
460
- | Multiple Chrome profiles | The CLI auto-scans all profiles. To pick one: `--chrome-profile "Profile 1"` |
501
+ | Multiple Chrome profiles | The CLI auto-scans all profiles (macOS / Windows / Linux). To pick one: `--chrome-profile "Profile 1"` |
461
502
  | Using Brave/Arc/other | Try `--cookie-source safari`, or log into xiaohongshu.com in Chrome |
462
503
 
463
504
  ## How It Works
464
505
 
465
- `redbook` reads your XHS session cookies from Chrome (via [@steipete/sweet-cookie](https://github.com/nicklockwood/sweet-cookie)) and signs API requests using a TypeScript port of the XHS signing algorithm. No browser automation, no headless Chrome — just HTTP requests.
506
+ `redbook` reads your XHS session cookies from Chrome and signs API requests using a TypeScript port of the XHS signing algorithm.
507
+
508
+ **Three-tier cookie extraction:**
509
+ 1. **sweet-cookie** (fast path) — reads Chrome's SQLite cookie database directly. Works instantly on macOS
510
+ 2. **CDP fallback** (auto on Windows) — launches Chrome headless and reads cookies via DevTools Protocol, bypassing Chrome 127+ App-Bound Encryption
511
+ 3. **`--cookie-string`** (manual fallback) — paste cookie values from Chrome DevTools. Works on any platform
466
512
 
467
513
  **Two signing systems:**
468
514
  - **Main API** (`edith.xiaohongshu.com`) — for reading: search, feed, notes, comments, user profiles. Uses x-s signature with 144-byte payload (v4.3.1).
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ description: Search, read, analyze, and automate Xiaohongshu (小红书) content
3
3
  allowed-tools: Bash, Read, Write, Glob, Grep
4
4
  # OpenClaw / ClawHub metadata (clawhub install redbook)
5
5
  name: redbook
6
- version: 0.3.3
6
+ version: 0.5.0
7
7
  metadata:
8
8
  openclaw:
9
9
  requires:
@@ -53,6 +53,9 @@ Use the `redbook` CLI to search notes, read content, analyze creators, automate
53
53
  | Post a comment | `redbook comment <url> --content "text"` |
54
54
  | Reply to comment | `redbook reply <url> --comment-id <id> --content "text"` |
55
55
  | Batch reply (preview) | `redbook batch-reply <url> --strategy questions --dry-run` |
56
+ | List favorites | `redbook favorites --json` or `redbook favorites <userId> --json` |
57
+ | Collect a note | `redbook collect <url>` |
58
+ | Remove from collection | `redbook uncollect <url>` |
56
59
  | Render markdown to cards | `redbook render content.md --style xiaohongshu` |
57
60
  | Check connection | `redbook whoami` |
58
61
 
@@ -598,8 +601,8 @@ XHS enforces aggressive anti-spam (风控) that detects automated behavior throu
598
601
  ## API vs Browser Limitations
599
602
 
600
603
  The following operations work reliably via API:
601
- - **Reading**: search, notes, comments, user profiles, feed
602
- - **Writing**: top-level comments, comment replies
604
+ - **Reading**: search, notes, comments, user profiles, feed, favorites
605
+ - **Writing**: top-level comments, comment replies, collect/uncollect notes
603
606
  - **Analysis**: viral scoring, template extraction, batch reply planning
604
607
 
605
608
  The following operations are unreliable via API (frequently trigger captcha):
@@ -608,7 +611,7 @@ The following operations are unreliable via API (frequently trigger captcha):
608
611
 
609
612
  The following operations require browser automation (not supported by this CLI):
610
613
  - Captcha solving, real-time notifications
611
- - Like/collect/follow (heavy anti-automation enforcement)
614
+ - Like/follow (heavy anti-automation enforcement)
612
615
  - DM/private messaging
613
616
  - Cover image generation (use external tools like Gemini/DALL-E)
614
617
 
@@ -685,6 +688,37 @@ Search for topic hashtags. Useful for finding trending topics to attach to posts
685
688
  redbook topics "Claude Code" --json
686
689
  ```
687
690
 
691
+ ### `redbook favorites [userId]`
692
+
693
+ List a user's collected (bookmarked) notes. Defaults to the current logged-in user when no userId is provided.
694
+
695
+ ```bash
696
+ redbook favorites --json # Your own favorites
697
+ redbook favorites "5a1234567890abcdef" --json # Another user's favorites
698
+ redbook favorites --all --json # Fetch all pages
699
+ ```
700
+
701
+ **Options:**
702
+ - `--all`: Fetch all pages of favorites (default: first page only)
703
+
704
+ **Note:** Other users' favorites are only visible if they haven't set their collection to private.
705
+
706
+ ### `redbook collect <url>`
707
+
708
+ Collect (bookmark) a note to your favorites.
709
+
710
+ ```bash
711
+ redbook collect "https://www.xiaohongshu.com/explore/abc123"
712
+ ```
713
+
714
+ ### `redbook uncollect <url>`
715
+
716
+ Remove a note from your collection.
717
+
718
+ ```bash
719
+ redbook uncollect "https://www.xiaohongshu.com/explore/abc123"
720
+ ```
721
+
688
722
  ### `redbook analyze-viral <url>`
689
723
 
690
724
  Analyze why a viral note works. Returns a deterministic viral score (0–100).
package/dist/cli.d.ts CHANGED
@@ -12,6 +12,9 @@
12
12
  * redbook feed --cookie-source chrome --json
13
13
  * redbook post --title "..." --body "..." --images img1.jpg --cookie-source chrome
14
14
  * redbook topics "keyword" --cookie-source chrome
15
+ * redbook favorites --cookie-source chrome --json
16
+ * redbook collect <url> --cookie-source chrome
17
+ * redbook uncollect <url> --cookie-source chrome
15
18
  */
16
19
  export {};
17
20
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;GAaG"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;GAgBG"}
package/dist/cli.js CHANGED
@@ -12,13 +12,16 @@
12
12
  * redbook feed --cookie-source chrome --json
13
13
  * redbook post --title "..." --body "..." --images img1.jpg --cookie-source chrome
14
14
  * redbook topics "keyword" --cookie-source chrome
15
+ * redbook favorites --cookie-source chrome --json
16
+ * redbook collect <url> --cookie-source chrome
17
+ * redbook uncollect <url> --cookie-source chrome
15
18
  */
16
19
  import { Command } from "commander";
17
20
  import kleur from "kleur";
18
21
  import { readFileSync } from "node:fs";
19
22
  import { fileURLToPath } from "node:url";
20
23
  import { dirname, join } from "node:path";
21
- import { extractCookies } from "./lib/cookies.js";
24
+ import { extractCookies, parseCookieString } from "./lib/cookies.js";
22
25
  import { XhsClient, XhsApiError } from "./lib/client.js";
23
26
  import { analyzeViral, formatViralAnalysis } from "./lib/analyze.js";
24
27
  import { selectCandidates, executeReplies, DEFAULT_DELAY_MS, MIN_DELAY_MS as MIN_REPLY_DELAY, MAX_REPLIES_HARD_CAP, } from "./lib/reply-strategy.js";
@@ -34,12 +37,23 @@ program
34
37
  function addCookieOption(cmd) {
35
38
  return cmd
36
39
  .option("--cookie-source <browser>", "Browser to read cookies from (chrome, safari, firefox)", "chrome")
37
- .option("--chrome-profile <name>", 'Chrome profile directory name (e.g., "Profile 1")');
40
+ .option("--chrome-profile <name>", 'Chrome profile directory name (e.g., "Profile 1")')
41
+ .option("--cookie-string <cookies>", 'Manual cookie string: "a1=VALUE; web_session=VALUE" (from Chrome DevTools)');
38
42
  }
39
43
  function addJsonOption(cmd) {
40
44
  return cmd.option("--json", "Output as JSON");
41
45
  }
42
- async function getClient(cookieSource, chromeProfile) {
46
+ async function getClient(cookieSource, chromeProfile, cookieString) {
47
+ if (cookieString) {
48
+ const cookies = parseCookieString(cookieString);
49
+ if (!cookies.a1 || !cookies.web_session) {
50
+ console.error(kleur.red("Cookie string must contain at least 'a1' and 'web_session'. " +
51
+ "Copy them from Chrome DevTools > Application > Cookies > xiaohongshu.com"));
52
+ process.exit(1);
53
+ }
54
+ console.error(kleur.dim("Using manual cookie string."));
55
+ return new XhsClient(cookies);
56
+ }
43
57
  const cookies = await extractCookies(cookieSource, chromeProfile);
44
58
  return new XhsClient(cookies);
45
59
  }
@@ -71,7 +85,7 @@ addCookieOption(whoamiCmd);
71
85
  addJsonOption(whoamiCmd);
72
86
  whoamiCmd.action(async (opts) => {
73
87
  try {
74
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
88
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
75
89
  const info = await client.getSelfInfo();
76
90
  if (opts.json) {
77
91
  output(info, true);
@@ -100,7 +114,7 @@ addCookieOption(searchCmd);
100
114
  addJsonOption(searchCmd);
101
115
  searchCmd.action(async (keyword, opts) => {
102
116
  try {
103
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
117
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
104
118
  const sortMap = {
105
119
  general: "general",
106
120
  popular: "popularity_descending",
@@ -141,7 +155,7 @@ addCookieOption(readCmd);
141
155
  addJsonOption(readCmd);
142
156
  readCmd.action(async (url, opts) => {
143
157
  try {
144
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
158
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
145
159
  const { noteId, xsecToken } = parseNoteUrl(url);
146
160
  let result;
147
161
  if (opts.api || xsecToken) {
@@ -186,7 +200,7 @@ addCookieOption(commentsCmd);
186
200
  addJsonOption(commentsCmd);
187
201
  commentsCmd.action(async (url, opts) => {
188
202
  try {
189
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
203
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
190
204
  const { noteId, xsecToken } = parseNoteUrl(url);
191
205
  const allComments = [];
192
206
  let cursor = "";
@@ -224,7 +238,7 @@ addCookieOption(userCmd);
224
238
  addJsonOption(userCmd);
225
239
  userCmd.action(async (userId, opts) => {
226
240
  try {
227
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
241
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
228
242
  const info = await client.getUserInfo(userId);
229
243
  output(info, opts.json ?? false);
230
244
  }
@@ -240,7 +254,7 @@ addCookieOption(userPostsCmd);
240
254
  addJsonOption(userPostsCmd);
241
255
  userPostsCmd.action(async (userId, opts) => {
242
256
  try {
243
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
257
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
244
258
  const result = await client.getUserNotes(userId);
245
259
  if (opts.json) {
246
260
  output(result, true);
@@ -268,7 +282,7 @@ addCookieOption(feedCmd);
268
282
  addJsonOption(feedCmd);
269
283
  feedCmd.action(async (opts) => {
270
284
  try {
271
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
285
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
272
286
  const result = await client.getHomeFeed(opts.category);
273
287
  output(result, opts.json ?? false);
274
288
  }
@@ -289,7 +303,7 @@ addCookieOption(postCmd);
289
303
  addJsonOption(postCmd);
290
304
  postCmd.action(async (opts) => {
291
305
  try {
292
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
306
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
293
307
  const imageFiles = opts.images ?? [];
294
308
  if (imageFiles.length === 0) {
295
309
  console.error(kleur.red("At least one image is required. Use --images <path>"));
@@ -345,7 +359,7 @@ addCookieOption(topicsCmd);
345
359
  addJsonOption(topicsCmd);
346
360
  topicsCmd.action(async (keyword, opts) => {
347
361
  try {
348
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
362
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
349
363
  const result = await client.searchTopics(keyword);
350
364
  if (opts.json) {
351
365
  output(result, true);
@@ -362,6 +376,94 @@ topicsCmd.action(async (keyword, opts) => {
362
376
  handleError(err);
363
377
  }
364
378
  });
379
+ // ─── favorites ──────────────────────────────────────────────────────────────
380
+ const favoritesCmd = program
381
+ .command("favorites [userId]")
382
+ .description("List a user's collected/favorited notes (defaults to current user)")
383
+ .option("--all", "Fetch all pages");
384
+ addCookieOption(favoritesCmd);
385
+ addJsonOption(favoritesCmd);
386
+ favoritesCmd.action(async (userId, opts) => {
387
+ try {
388
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
389
+ if (!userId) {
390
+ const me = (await client.getSelfInfo());
391
+ userId = String(me.user_id ?? "");
392
+ if (!userId) {
393
+ console.error(kleur.red("Could not determine current user ID"));
394
+ process.exit(1);
395
+ }
396
+ }
397
+ const allNotes = [];
398
+ let cursor = "";
399
+ let hasMore = true;
400
+ while (hasMore) {
401
+ const res = (await client.getUserCollectedNotes(userId, 30, cursor));
402
+ if (res.notes)
403
+ allNotes.push(...res.notes);
404
+ hasMore = opts.all ? (res.has_more ?? false) : false;
405
+ cursor = res.cursor ?? "";
406
+ }
407
+ if (opts.json) {
408
+ output(allNotes, true);
409
+ }
410
+ else {
411
+ for (const note of allNotes) {
412
+ const n = note;
413
+ const user = n.user;
414
+ console.log(`${kleur.bold(String(n.display_title ?? n.title ?? "(no title)"))} ${kleur.dim(String(n.note_id ?? ""))} ${kleur.dim(`@${user?.nickname ?? "?"}`)}`);
415
+ }
416
+ console.log(kleur.dim(`\n${allNotes.length} collected notes`));
417
+ }
418
+ }
419
+ catch (err) {
420
+ handleError(err);
421
+ }
422
+ });
423
+ // ─── collect ────────────────────────────────────────────────────────────────
424
+ const collectCmd = program
425
+ .command("collect <url>")
426
+ .description("Collect (bookmark) a note");
427
+ addCookieOption(collectCmd);
428
+ addJsonOption(collectCmd);
429
+ collectCmd.action(async (url, opts) => {
430
+ try {
431
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
432
+ const { noteId } = parseNoteUrl(url);
433
+ const result = await client.collectNote(noteId);
434
+ if (opts.json) {
435
+ output(result, true);
436
+ }
437
+ else {
438
+ console.log(kleur.green("Note collected!"));
439
+ }
440
+ }
441
+ catch (err) {
442
+ handleError(err);
443
+ }
444
+ });
445
+ // ─── uncollect ──────────────────────────────────────────────────────────────
446
+ const uncollectCmd = program
447
+ .command("uncollect <url>")
448
+ .description("Remove a note from your collection");
449
+ addCookieOption(uncollectCmd);
450
+ addJsonOption(uncollectCmd);
451
+ uncollectCmd.action(async (url, opts) => {
452
+ try {
453
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
454
+ const { noteId } = parseNoteUrl(url);
455
+ const result = await client.uncollectNote(noteId);
456
+ if (opts.json) {
457
+ output(result, true);
458
+ }
459
+ else {
460
+ console.log(kleur.green("Note removed from collection!"));
461
+ }
462
+ }
463
+ catch (err) {
464
+ handleError(err);
465
+ }
466
+ });
365
467
  // ─── analyze-viral ──────────────────────────────────────────────────────────
366
468
  const analyzeViralCmd = program
367
469
  .command("analyze-viral <url>")
@@ -371,7 +473,7 @@ addCookieOption(analyzeViralCmd);
371
473
  addJsonOption(analyzeViralCmd);
372
474
  analyzeViralCmd.action(async (url, opts) => {
373
475
  try {
374
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
476
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
375
477
  const { noteId, xsecToken } = parseNoteUrl(url);
376
478
  // 1. Fetch the note (same pattern as `read` — prefer HTML, API when xsec_token present)
377
479
  let note;
@@ -478,7 +580,7 @@ viralTemplateCmd.action(async (urls, opts) => {
478
580
  console.error(kleur.red("Maximum 3 URLs allowed"));
479
581
  process.exit(1);
480
582
  }
481
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
583
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
482
584
  const commentPages = Math.min(parseInt(opts.commentPages) || 3, 10);
483
585
  const analyses = [];
484
586
  for (const url of urls) {
@@ -584,7 +686,7 @@ addCookieOption(commentCmd);
584
686
  addJsonOption(commentCmd);
585
687
  commentCmd.action(async (url, opts) => {
586
688
  try {
587
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
689
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
588
690
  const { noteId } = parseNoteUrl(url);
589
691
  const result = await client.postComment(noteId, opts.content);
590
692
  if (opts.json) {
@@ -609,7 +711,7 @@ addCookieOption(replyCmd);
609
711
  addJsonOption(replyCmd);
610
712
  replyCmd.action(async (url, opts) => {
611
713
  try {
612
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
714
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
613
715
  const { noteId } = parseNoteUrl(url);
614
716
  const result = await client.replyComment(noteId, opts.commentId, opts.content);
615
717
  if (opts.json) {
@@ -637,7 +739,7 @@ addCookieOption(batchReplyCmd);
637
739
  addJsonOption(batchReplyCmd);
638
740
  batchReplyCmd.action(async (url, opts) => {
639
741
  try {
640
- const client = await getClient(opts.cookieSource, opts.chromeProfile);
742
+ const client = await getClient(opts.cookieSource, opts.chromeProfile, opts.cookieString);
641
743
  const { noteId, xsecToken } = parseNoteUrl(url);
642
744
  const strategy = opts.strategy;
643
745
  const max = Math.min(parseInt(opts.max) || 10, MAX_REPLIES_HARD_CAP);