diff --git a/LOCAL_BUILD.md b/LOCAL_BUILD.md new file mode 100644 index 0000000..1a06c99 --- /dev/null +++ b/LOCAL_BUILD.md @@ -0,0 +1,93 @@ +# 扩展功能版 + +发布分支:`local/pr-features`。上游基线:`4d63f3c`,包版本仍为 `0.6.4`。 + +已合入 PR:#87、#47、#64(含 #63 的扫码插件修复)、#84(含 #83)、#52、#15、#88。 +本分支以扩展功能快照发布;上游主干历史保留。上述 PR 来源于 jackwener/xiaohongshu-cli。 + +## 新增能力 + +```bash +xhs sub-comments "<带 xsec_token 的笔记 URL>" --json +xhs search "旅行" --time "一周内" --json +xhs search-user "摄影" --page 2 --page-size 20 --json +xhs following --page 1 --page-size 200 --json +xhs history --json +xhs read "<笔记 URL>" --save-images --output ./downloads --json +xhs chats --limit 20 --json +xhs messages --limit 30 --json +xhs send -c "消息正文" --json +xhs my-notes-data --page 1 --page-size 10 --json +xhs post-video --title "标题" --body "正文" --video ./video.mp4 --private --json +``` + +私信发送默认每分钟一次。不要把安装或测试视为发送消息/发布笔记的授权。 + +图文和视频发布的话题正文会转换为小红书原生 `#名称[话题]#`,同时提交解析所得的有效话题元数据。原生标记不重复叠加,URL fragment 保持原样,未解析到的普通标签保留原文。该功能来自 PR #88,并适配了视频发布及边界情况;验证使用模拟发布,不代表已执行真实发帖。 +私信与发布请求遇到超时或服务端错误不自动重发,避免产生重复内容。 +视频发布支持 MP4/MOV、可选封面、多次 `--topic` 和正文话题提取。 +视频编码/时长等媒体有效性仍由服务器判断;上传实现会将文件读入内存。 + +## 本机适配 + +- HERMES 补丁已提交到源码:`extract_browser_cookies()` 直接返回 `None`,避免 macOS 钥匙串弹窗。 +- 登录使用已有保存的 Cookie 或 `xhs login --qrcode`;普通浏览器 Cookie 自动提取已禁用。 +- 无法识别当前账号时,私信方向标记为 `unknown`。 +- 视频与封面先检查,再开始上传;进度写到 stderr,stdout 保持 JSON 信封。 +- 保留原有图文发布的请求通道,视频使用 PR #15 的 creator 通道。 +- 单元测试隔离账号配置目录;真实接口测试必须单独运行。 + +## 后续更新 + +本分支包含本地提交,不能再用主干的 `git pull --ff-only` 流程。 + +```bash +cd xiaohongshu-cli +git switch local/pr-features +git status --short +git fetch upstream +git merge upstream/main +# 如有冲突,解决并测试后再安装;保留 HERMES 补丁。 +uv tool install --force . +xhs --help +``` + +新 PR 同样先下载、审查、逐个合并和测试,再重装。 +不要直接升级成 PyPI 包,否则会覆盖本地功能及 HERMES 补丁。 + +## 验证范围 + +组合代码的 211 项单元测试与 Ruff 检查通过。单元测试排除 +`tests/test_integration.py` 和 `tests/test_smoke.py`,不发送真实私信、不发布笔记。 +私信发送和视频发布的服务器端结果尚需在用户明确指定内容后单独实测。 + +已增加 result 包装兼容与嵌套失败检查,避免把服务端拒绝显示为成功。 + +## 用户搜索与关系列表 + +`search-user` 支持 `--page`(从 1 开始)、`--page-size`、可选 `--search-id`。 +默认参数保持原行为;需要复用搜索会话时,在连续页中传入相同 search-id。 +用户搜索分页已通过只读接口验证。 + +`following` 使用 `/api/im/web/users/following/all`,仅支持当前账号。 +参数是 `page`、`size`(CLI 为 `--page-size`),分页已通过只读接口验证。 +数据保留原始关系 `status`,不筛选为互关列表。 +服务端没有显式 has_more;按网页逻辑由当页数量推断下一页,输出标注 +`pagination_inferred=true`,满页时可以继续请求下一页。 + +`history` 使用 `/api/sns/web/v1/common/dual_feed`,参数 +`scene=browse_history`、`cursor_score`、`note_index`、`image_formats`。 +这是服务器保存的当前账号笔记浏览历史,已与历史计数接口交叉验证。 +使用返回的 `cursor`、`next_note_index` 续取: + +```bash +xhs history --cursor "<上一页 cursor>" --note-index <上一页 next_note_index> --json +``` + +笔记保留 `browse_time` 和原始笔记字段。Rich 显示本机时区的浏览时间。 +`has_more` 来源于服务端 `has_no_more`;嵌套错误及缺失分页标志会报错。 +`/api/sns/web/v1/feed/browsing_history` 实际是直播历史,本次未封装。 + +以上接口定义来自 2026-10-13 当前网页公开主脚本 +`/formula-static/xhs-pc-web/public/resource/js/index.c7cc70de.js`。 +粉丝名单的有效接口仍未确认;新增关注通知属于事件记录,不能代表完整粉丝名单。 diff --git a/README.md b/README.md index 69a6168..6a75fc6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ A CLI for Xiaohongshu (小红书) — search, read, interact, and post via rever - 📰 **Feed** — recommendation feed, hot/trending by category - 👥 **Social** — follow/unfollow, favorites - 👍 **Interactions** — like, favorite, comment, reply, delete -- ✍️ **Creator** — post image notes, my-notes list, delete +- ✍️ **Creator** — post image/video notes, my-notes list, delete +- 💬 **Direct messages** — conversation list, history, and send (`xhs chats` / `xhs messages` / `xhs send`) - 🔔 **Notifications** — unread count, mentions, likes, new followers - 🛡️ **Anti-detection** — consistent macOS Chrome fingerprint, `sec-ch-ua` alignment, session-stable browser identity, Gaussian jitter, captcha cooldown, exponential backoff - 📊 **Structured output** — commands support `--yaml` and `--json`; non-TTY stdout defaults to YAML @@ -88,7 +89,7 @@ xhs comments "" --all # Fetch ALL comments (auto-paginate all p xhs comments "" --all --json # All comments as JSON xhs comments --xsec-token T # Use note_id + explicit xsec_token xhs comments # Reuse cached token if available -xhs sub-comments # View replies to a comment +xhs sub-comments "" # View replies to a comment (URL / cached token / --xsec-token) xhs user # User profile xhs user-posts # User's published notes xhs user-posts --cursor X # Paginate with cursor @@ -132,7 +133,9 @@ xhs delete-comment # Delete own comment # ─── Creator ───────────────────────────────────── xhs my-notes # List own notes (v2 creator endpoint) xhs my-notes --page 1 # Next page -xhs post --title "标题" --body "正文" --images img.jpg # Post note +xhs post --title "标题" --body "正文" --images img.jpg # Post image note +xhs post-video --title "标题" --body "正文" --video video.mp4 # Post video note +xhs post-video --title "标题" --body "正文" --video video.mov --cover cover.jpg # Post video with custom cover xhs delete # Delete note xhs delete -y # Skip confirmation @@ -141,6 +144,9 @@ xhs unread # Unread counts (likes, mentions, follows xhs notifications # 评论和@ notifications xhs notifications --type likes # 赞和收藏 notifications xhs notifications --type connections # 新增关注 notifications +xhs chats # Direct-message conversations (with unread) +xhs messages # Read one conversation's history +xhs send -c "你好" # Send a direct message ``` @@ -174,6 +180,7 @@ After any listing command such as `search`, `feed`, `hot`, `user-posts`, `favori | Variable | Default | Description | |----------|---------|-------------| | `OUTPUT` | `auto` | Output format: `json`, `yaml`, `rich`, or `auto` (→ YAML when non-TTY) | +| `XHS_WRITE_RPM` | `1` | Max direct messages per minute (cross-process throttle); `0` disables | ## Rate Limiting & Anti-Detection xiaohongshu-cli includes comprehensive anti-risk-control measures designed to minimize detection: @@ -192,6 +199,13 @@ xiaohongshu-cli includes comprehensive anti-risk-control measures designed to mi - **Progressive backoff**: On captcha trigger (HTTP 461/471), automatically sleeps 5→10→20→30 seconds with increasing delays - **Adaptive rate limiting**: Request delay is permanently doubled after a captcha event to reduce future risk +### Write Throttle (Direct Messages) +Direct messages are more sensitive than reads (roughly ten sends can trigger a captcha), so +`xhs send` is gated by a cross-process token bucket: **1 write/minute by default**, persisted at +`~/.xiaohongshu-cli/limiter.json`. Tune it with `XHS_WRITE_RPM=`, or set `XHS_WRITE_RPM=0` +to disable throttling. When the budget is exhausted the command fails with the `rate_limited` +error code instead of silently hammering the API. + ### Signed Requests - All API calls use `x-s` / `x-s-common` / `x-t` signatures (reverse-engineered from web client) - `x-b3-traceid` and `x-xray-traceid` for distributed tracing consistency @@ -322,7 +336,7 @@ The built-in Gaussian jitter delay (~1-1.5s between requests) is intentional to - 📰 **发现** — 推荐 Feed、按分类浏览热门 - 👥 **社交** — 关注/取关、收藏夹 - 👍 **互动** — 点赞、收藏、评论、回复、删除 -- ✍️ **创作者** — 发布图文笔记、我的笔记列表、删除 +- ✍️ **创作者** — 发布图文/视频笔记、我的笔记列表、删除 - 🔔 **通知** — 未读数、@、点赞、新关注 - 🛡️ **反风控** — macOS Chrome 指纹一致性、session 级浏览器身份持久化、高斯抖动延迟、验证码自动冷却、指数退避重试 - 📊 **结构化输出** — `--yaml` / `--json`,非 TTY 默认输出 YAML @@ -381,7 +395,7 @@ xhs comments "" --all # 获取全部评论(自动翻页) xhs comments "" --all --json # 全部评论,JSON 格式 xhs comments --xsec-token T # 用 note_id + 显式 xsec_token xhs comments # 如果之前访问过 URL,会复用缓存 token -xhs sub-comments # 查看评论的回复 +xhs sub-comments "" # 查看评论的回复(支持 URL / 缓存 token / --xsec-token) xhs user # 用户主页 xhs user-posts # 用户发布的笔记 @@ -415,7 +429,9 @@ xhs delete-comment # 删除自己的评论 # 创作者 xhs my-notes # 我的笔记列表 -xhs post --title "标题" --body "正文" --images img.jpg # 发布笔记 +xhs post --title "标题" --body "正文" --images img.jpg # 发布图文笔记 +xhs post-video --title "标题" --body "正文" --video video.mp4 # 发布视频笔记 +xhs post-video --title "标题" --body "正文" --video video.mov --cover cover.jpg # 发布视频笔记(自定义封面) xhs delete # 删除笔记 xhs delete -y # 跳过确认 @@ -424,6 +440,9 @@ xhs unread # 未读数 xhs notifications # 评论和 @ 通知 xhs notifications --type likes # 赞和收藏通知 xhs notifications --type connections # 新增关注通知 +xhs chats # 私信会话列表(含未读数) +xhs messages # 读某个会话的私信历史 +xhs send -c "你好" # 发送私信 ``` ## 认证策略 diff --git a/SCHEMA.md b/SCHEMA.md index e4ff620..b87ee3f 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -27,4 +27,4 @@ error: - reading and search commands return their payload under `data` - `status` returns `data.authenticated` plus `data.user` - `whoami` returns `data.user` -- common `error.code` values include `not_authenticated`, `verification_required`, `ip_blocked`, `signature_error`, `unsupported_operation`, and `api_error` +- common `error.code` values include `not_authenticated`, `verification_required`, `ip_blocked`, `signature_error`, `rate_limited`, `unsupported_operation`, and `api_error` diff --git a/SKILL.md b/SKILL.md index 5909f93..bda3025 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,252 +1,49 @@ --- name: xiaohongshu-cli -description: Use xiaohongshu-cli for ALL Xiaohongshu (Little Red Book, 小红书) operations — searching notes, reading content, browsing users, liking, collecting, commenting, following, and posting. Invoke whenever the user requests any Xiaohongshu interaction. -author: jackwener -version: "0.6.4" -tags: - - xiaohongshu - - xhs - - redbook - - 小红书 - - social-media - - cli +description: 使用 xiaohongshu-cli 功能版或已登录浏览器操作小红书,支持搜索分页、读取笔记和评论、用户与关注列表、收藏和赞过列表、浏览历史、私信、发布及 CLI 维护。用户要求小红书账号操作或内容调研时使用。 --- -# xiaohongshu-cli — Xiaohongshu CLI Tool +# 小红书 CLI 操作 -**Binary:** `xhs` -**Credentials:** browser cookies (auto-extracted) or browser-assisted QR login (`--qrcode`) +优先使用已安装的 `xhs`。本分支包含额外功能,包版本可能仍为 0.6.4;先执行 `xhs --help` 和目标子命令的 `--help`,不要仅凭版本号判断能力。命令、分页与响应字段见 [references/commands.md](references/commands.md)。 -## Setup +## 登录与账号 -```bash -# Install (requires Python 3.10+) -uv tool install xiaohongshu-cli -# Or: pipx install xiaohongshu-cli +新任务先运行 `xhs status --json`,检查 `data.authenticated` 和 `data.user`;需要详细身份时使用 `xhs whoami --json`。扫码可能登录到与上次不同的账号,操作前核对,不将历史账号或授权沿用到新账号。 -# Upgrade to latest (recommended to avoid API errors) -uv tool upgrade xiaohongshu-cli -# Or: pipx upgrade xiaohongshu-cli -``` +本分支的 HERMES 补丁禁用了自动浏览器 Cookie 解密,以避免 macOS 钥匙串弹窗。已有保存的凭证可以继续使用;会话过期时优先 `xhs login --qrcode`。浏览器参与的登录、profile 复用和故障处理见 [references/authentication.md](references/authentication.md)。 -## Authentication +不在聊天或公共日志展示 Cookie,不提交登录 profile、二维码、私信、浏览历史或账号配置。公开 skill 只包含可复用方法,本机账号和路径另外保存在私有环境记录中。 -**IMPORTANT FOR AGENTS**: Before executing ANY xhs command, check if credentials exist first. Do NOT assume cookies are configured. +## 读写与分页 -### Step 0: Check if already authenticated +统一使用 `--json`,检查 `{ok, data, error}` 信封,业务数据在 `.data`。HTTP 200 或进程退出并不总等于业务成功,创作者接口还可能有嵌套 `result.code` / `result.success`。输出结构见 [SCHEMA.md](SCHEMA.md)。 -```bash -xhs status --yaml >/dev/null && echo "AUTH_OK" || echo "AUTH_NEEDED" -``` +详情和评论优先传入带 `xsec_token` 的完整笔记链接。裸 ID 可能缺令牌,短索引依赖本机缓存。短链接若返回登录页或 HTML 解析失败,先只读解析跳转,保留原链接中的笔记 ID 和令牌,再读取规范笔记 URL;不要把解析失败直接视为笔记不存在。 -If `AUTH_OK`, skip to [Command Reference](#command-reference). -If `AUTH_NEEDED`, proceed to Step 1. Prefer `--qrcode` when browser cookie extraction is unavailable but launching a browser is acceptable. +使用上一页返回的游标或对应页码续取。服务端表示结束、游标缺失、游标重复或没有新条目时停止并说明范围。关注列表的 `has_more` 由满页数量推断,不代表服务端明确提供分页标志。 -### Step 1: Guide user to authenticate +发送私信、发布、评论、关注等写操作只执行当前用户要求的对象与内容。调用 skill 或测试安装不代表授权发布。提交超时或结果不明时先只读核查,避免重复发送。保留节流,不并发批量请求;遇到验证码、会话过期或 IP 限制,恢复条件后再继续。 -Ensure user is logged into xiaohongshu.com in any browser supported by [browser_cookie3](https://github.com/borisbabic/browser_cookie3). Supported browsers: Chrome, Arc, Edge, Firefox, Safari, Brave, Chromium, Opera, Opera GX, Vivaldi, LibreWolf, Lynx, w3m. Then: +## 浏览器补充 -```bash -xhs login # auto-detect browser with valid cookies -xhs login --cookie-source arc # specify browser explicitly -xhs login --qrcode # browser-assisted QR login with terminal QR output -``` +CLI 未覆盖的操作,使用用户指定的已登录浏览器和当前文档化工具,先检查页面,再操作可见入口。浏览器能启动不代表工具已获得控制权限;先检查权限或连接阻碍,避免重复启动同一 profile。 -Verify with: +是否支持导入登录态,以工具实际能力列表为准;CLI 凭证不自动等于内置浏览器登录态。没有导入能力时,复用原浏览器或正常登录,不猜测隐藏接口。 -```bash -xhs status -xhs whoami -``` +目前未验证可用的头像/昵称修改接口;曾尝试的 `/api/sns/web/v1/user/update` 返回 404。需要改资料时从真实编辑页面或已确认实现入手。完成写操作后核查结果,按浏览器工具要求提供可验证截图。 -### Step 2: Handle common auth issues +## 内容调研 -| Symptom | Agent action | -|---------|-------------| -| `NoCookieError: No 'a1' cookie found` | Guide user to login to xiaohongshu.com in browser | -| `NeedVerifyError: Captcha required` | Ask user to open browser, complete captcha, then retry | -| `IpBlockedError: IP blocked` | Suggest switching network (hotspot/VPN) | -| `SessionExpiredError` | Run `xhs login` to refresh cookies | +按同一关键词、排序、内容类型和分页口径取样,记录采集日期、样本数及时间范围。区分作者本人作品、品牌自售、生活日常与商品推荐。筛选对标账号时,不仅看搜索中的单篇爆款,也看主页近期作品的重复表现;置顶和刚发布作品应单独标明,样本不足时如实报告。 -## Agent Defaults +公开主页通常只有点赞等有限字段,详情能读取哪些指标就报告哪些;缺失值不是零。互动不能代表阅读、自然流量、销售、佣金或退货。不同发布时间、粉丝基数和投放条件的样本不直接推算成功概率。用户未授权时,调研只读,不进行关注、评论或私信。 -All machine-readable output uses the envelope documented in [SCHEMA.md](./SCHEMA.md). -Payloads live under `.data`. +## 能力边界与维护 -- Non-TTY stdout → auto YAML -- `--json` / `--yaml` → explicit format -- `OUTPUT=json` env → global override -- `OUTPUT=rich` env → force human output +- `likes` 是用户赞过的笔记,不是某篇笔记的完整点赞者名单。 +- 当前账号关注列表、笔记浏览历史已有命令;粉丝完整名单的有效接口尚未确认。新增关注通知不等于完整粉丝名单。 +- 支持笔记媒体下载;私信、创作者分析和视频发布已有实现,但单元检查不代表当前账号实测成功。阅读量仅限当前账号有权取得的创作者数据。 +- 不支持直播操作;接口和页面可能变化,过往成功不能代替本次验证。 -## Command Reference - -### Reading - -| Command | Description | Example | -|---------|-------------|---------| -| `xhs search ` | Search notes | `xhs search "美食" --sort popular --type video` | -| `xhs read ` | Read a note by ID, URL, or short index | `xhs read 1` / `xhs read "https://...?xsec_token=xxx"` | -| `xhs comments ` | Get comments by ID, URL, or short index | `xhs comments 1` / `xhs comments "https://...?xsec_token=..."` | -| `xhs comments --all` | Get ALL comments (auto-paginate) | `xhs comments "" --all --json` | -| `xhs sub-comments ` | Get replies to comment | `xhs sub-comments abc 123` | -| `xhs user ` | View user profile | `xhs user 5f2e123` | -| `xhs user-posts ` | List user's notes | `xhs user-posts 5f2e123 --cursor ""` | -| `xhs feed` | Browse recommendation feed | `xhs feed --yaml` | -| `xhs hot` | Browse trending notes | `xhs hot -c food` | -| `xhs topics ` | Search topics/hashtags | `xhs topics "旅行"` | -| `xhs search-user ` | Search users | `xhs search-user "摄影"` | -| `xhs my-notes` | List own published notes | `xhs my-notes --page 0` | -| `xhs notifications` | View notifications | `xhs notifications --type likes` | -| `xhs unread` | Show unread counts | `xhs unread --json` | - -### Interactions (Write) - -| Command | Description | Example | -|---------|-------------|---------| -| `xhs like ` | Like a note | `xhs like 1` / `xhs like abc123` | -| `xhs like --undo` | Unlike a note | `xhs like 1 --undo` | -| `xhs favorite ` | Bookmark a note | `xhs favorite 1` | -| `xhs unfavorite ` | Remove bookmark | `xhs unfavorite 1` | -| `xhs comment -c "text"` | Post a comment | `xhs comment 1 -c "好看!"` | -| `xhs reply --comment-id ID -c "text"` | Reply to comment | `xhs reply 1 --comment-id 456 -c "谢谢"` | -| `xhs delete-comment ` | Delete own comment | `xhs delete-comment abc 123 -y` | - -### Social - -| Command | Description | Example | -|---------|-------------|---------| -| `xhs follow ` | Follow a user | `xhs follow 5f2e123` | -| `xhs unfollow ` | Unfollow a user | `xhs unfollow 5f2e123` | -| `xhs favorites [user_id]` | List bookmarked notes (defaults to self) | `xhs favorites --json` | - -### Creator - -| Command | Description | Example | -|---------|-------------|---------| -| `xhs post --title "..." --body "..." --images img.png` | Publish a note | `xhs post --title "Test" --body "Hello"` | -| `xhs delete ` | Delete own note | `xhs delete abc123 -y` | - -### Account - -| Command | Description | -|---------|-------------| -| `xhs login` | Extract cookies from browser (auto-detect) | -| `xhs login --qrcode` | Browser-assisted QR login — terminal QR output, browser completes login | -| `xhs status` | Check authentication status | -| `xhs logout` | Clear cached cookies | -| `xhs whoami` | Show current user profile | - -## Agent Workflow Examples - -### Search → Read → Like pipeline - -```bash -NOTE_ID=$(xhs search "美食推荐" --json | jq -r '.data.items[0].id') -xhs read "$NOTE_ID" --json | jq '.data' -xhs like "$NOTE_ID" -``` - -### Browse trending food notes - -```bash -xhs hot -c food --json | jq '.data.items[:5] | .[].note_card | {title, likes: .interact_info.liked_count}' -``` - -### Get user info then follow - -```bash -xhs user 5f2e123 --json | jq '.data.basic_info | {nickname, user_id}' -xhs follow 5f2e123 -``` - -### Check notifications - -```bash -xhs unread --json | jq '.data' -xhs notifications --type mentions --json | jq '.data.message_list[:5]' -``` - -### Analyze all comments on a note - -```bash -# Fetch ALL comments and analyze themes -xhs comments "$NOTE_URL" --all --json | jq '.data.comments | length' -# Count questions -xhs comments "$NOTE_URL" --all --json | jq '[.data.comments[] | select(.content | test("[\uff1f?]"))] | length' -``` - -### Daily reading workflow - -```bash -# Browse recommendation feed -xhs feed --yaml - -# Interactive short-index workflow -xhs search "旅行" -xhs read 1 -xhs comments 1 -xhs like 1 -xhs favorite 1 -xhs comment 1 -c "收藏了" - -# Browse trending by category -xhs hot -c food --yaml -xhs hot -c travel --yaml -``` - -### QR code login - -```bash -# When browser cookie extraction is not available -xhs login --qrcode -# → Launches a browser-assisted login flow -# → Renders QR in terminal using Unicode half-blocks -# → Scan with Xiaohongshu app → confirm → export cookies -``` - -### URL to insights pipeline - -```bash -# User pastes a URL → read + all comments -xhs read "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --json -xhs comments "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --all --json -``` - -## Hot Categories - -Available for `xhs hot -c `: -`fashion`, `food`, `cosmetics`, `movie`, `career`, `love`, `home`, `gaming`, `travel`, `fitness` - -## Error Codes - -Structured error codes returned in the `error.code` field: -- `not_authenticated` — cookies expired or missing -- `verification_required` — captcha/verification needed -- `ip_blocked` — IP rate limited -- `signature_error` — request signing failed -- `api_error` — upstream API error -- `unsupported_operation` — operation not available - -## Limitations - -- **No video download** — cannot download note images/videos -- **No DMs** — cannot access private messages -- **No live streaming** — live features not supported -- **No following/followers list** — XHS web API doesn't expose these endpoints -- **Single account** — one set of cookies at a time -- **Rate limited** — built-in Gaussian jitter delay (~1-1.5s) between requests; aggressive usage may trigger captchas or IP blocks - -## Anti-Detection Notes for Agents - -- **Do NOT parallelize requests** — the built-in rate-limit delay exists for account safety -- **Captcha recovery**: if `NeedVerifyError` occurs, the client auto-cools-down with increasing delays (5s→10s→20s→30s). Ask the user to complete captcha in browser before retrying -- **Batch operations**: when doing bulk work (e.g., reading many notes), add `time.sleep()` between CLI calls -- **Session stability**: all requests in a session share a consistent browser fingerprint. Restarting the CLI creates a new fingerprint session - -## Safety Notes - -- Do not ask users to share raw cookie values in chat logs. -- Prefer local browser cookie extraction over manual secret copy/paste. -- If auth fails, ask the user to re-login via `xhs login`. -- Agent should treat cookie values as secrets (do not echo to stdout unnecessarily). -- Built-in rate-limit delay protects accounts; do not bypass it. +修改功能和升级安装时读取 [references/maintenance.md](references/maintenance.md)。不要直接改 site-packages,也不要用 PyPI 升级覆盖本分支的扩展和登录补丁。 diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..8f435d8 --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "小红书 CLI" + short_description: "小红书功能版 CLI 与浏览器操作:登录、分页、调研、互动及发布" + default_prompt: "使用 $xiaohongshu-cli 完成本次小红书操作,先确认登录账号与可用命令。" diff --git a/references/authentication.md b/references/authentication.md new file mode 100644 index 0000000..99c163f --- /dev/null +++ b/references/authentication.md @@ -0,0 +1,44 @@ +# 登录、二维码与浏览器 + +## 保存的凭证 + +先用 `xhs status --json` 判断会话有效性,不能仅以凭证文件存在判定登录成功。默认配置一般位于 `~/.xiaohongshu-cli/`,具体以安装版本为准;只引用路径,不展示或提交文件内容。 + +本分支 `cookies.py` 的 HERMES 补丁令 `extract_browser_cookies()` 直接返回 None,避免自动解密触发 macOS 钥匙串弹窗。它不影响已有凭证读取或浏览器扫码登录。不要反复执行普通 `xhs login` 来尝试自动提取;需要解密提取能力时,先说明该分支差异。 + +## 刷新登录 + +```bash +xhs login --qrcode +xhs status --json +``` + +正常流程由浏览器参与,终端显示二维码,用户在小红书 App 扫描并确认,CLI 保存凭证。扫码不等于已完成授权,必须等登录确认并复查 status。纯 HTTP 扫码此前曾遭遇 `type=124`,不要不断重试同一路径;优先检查浏览器参与的流程。 + +用户需要图片形式的二维码时,优先使用现有工具的导出能力;只有终端字符码时,可以从本次 QR 数据生成临时 PNG,保持同一有效会话。不要把二维码或其中的登录数据写入仓库。用户没有扫描成功或二维码过期时,结束旧流程后再生成,不同时运行多个会话。 + +登录成功后核对 `data.user.id` 与昵称。不同设备或 App 账号可能导致本次登录身份变化。确认本次目标账号后再继续账号相关写操作。 + +## 复用浏览器 profile + +CLI 凭证与浏览器 profile 不自动同步。使用用户指定的已登录浏览器时,先探测实际可执行文件、profile 位置和是否已有进程。Firefox/Camoufox 的独立 profile 通常需要显式启动参数,例如: + +```bash +"" -profile "" -no-remote "https://www.xiaohongshu.com/" +``` + +双击 App 可能使用默认 profile;同一 profile 不能同时被两个进程占用。保留用户现有窗口,优先复用,不直接复制整份登录目录进入项目。 + +浏览器界面操作使用当前环境支持的工具。原生窗口控制若需要系统权限,说明具体阻碍;授权或连接未完成时,不反复等待同一提示。内置浏览器是否支持导入登录态,必须以当前能力列表为准。 + +## 常见阻碍 + +| 状态 | 处理 | +|---|---| +| `not_authenticated` / 会话过期 | 浏览器扫码刷新,完成后复查身份 | +| `verification_required` | 用户在浏览器按提示完成验证,再继续 | +| `ip_blocked` | 停止请求,等待或由用户处理网络条件 | +| DNS、沙箱网络失败 | 按工具权限正常申请联网执行,不误报为登录失败 | +| 默认工作目录访问异常 | 使用一个可访问的工作目录运行只读 CLI,保留原项目文件 | + +日志默认保持必要输出,避免以 verbose 模式输出原始请求、凭证或完整私信。 diff --git a/references/commands.md b/references/commands.md new file mode 100644 index 0000000..e1baaf4 --- /dev/null +++ b/references/commands.md @@ -0,0 +1,75 @@ +# 常用命令与分页 + +以下针对本仓库功能分支。参数以已安装的 `xhs --help` 为准,不使用真实写操作验证安装。 + +## 登录与读取 + +```bash +xhs status --json +xhs whoami --json +xhs login --qrcode +xhs search "旅行" --page 2 --json +xhs search "收纳" --type image --sort popular --json +xhs search "收纳" --type image --sort latest --json +xhs search "旅行" --time "一周内" --json +xhs search-user "摄影" --page 2 --page-size 20 --json +xhs read "<带 xsec_token 的笔记 URL>" --json +xhs comments "<笔记 URL>" --all --json +xhs sub-comments "<笔记 URL>" --json +xhs user --json +xhs user-posts --cursor "<游标>" --json +xhs feed --json +xhs hot -c food --json +xhs topics "旅行" --json +xhs likes --json +xhs favorites --json +xhs following --page 1 --page-size 200 --json +xhs history --json +xhs read "<笔记 URL>" --save-images --output ./downloads --json +xhs chats --limit 20 --json +xhs messages --limit 30 --json +xhs unread --json +xhs notifications --type connections --json +xhs my-notes --page 0 --json +xhs my-notes-data --page 1 --page-size 10 --json +``` + +JSON 业务数据在 `.data`。搜索通常为 `.data.items[].note_card`;详情为 `.data.items[].note_card`;用户笔记为 `.data.notes`,其单条字段、互动字段与搜索卡片不同,先检查实际响应再解析。 + +搜索类型 `image` 的响应中,图文笔记 `type` 通常为 `normal`。点赞/收藏可能是含“万”或“+”的展示字符串,转换时保留近似属性;没有提供的指标不能补零。 + +## 分页 + +- `search-user` 从第 1 页开始,支持 `--page-size`、`--search-id`。连续页需要维持搜索会话时复用相同 search-id。 +- `comments --all` 自动翻一级评论;楼中楼使用 `sub-comments`,按帮助传递所需令牌与续页参数。 +- 用户笔记、收藏、赞过列表按各命令返回的游标续取;不同命令页码起点不同,勿统一假定。 +- `following` 仅当前账号。接口 `/api/im/web/users/following/all` 参数为 `page`、`size`(CLI 为 `--page-size`)。保留原始关系 status,不筛为互关。`pagination_inferred=true` 表示由当页数量推断 has_more。 +- 笔记历史使用 `/api/sns/web/v1/common/dual_feed`、`scene=browse_history`。续页同时使用返回的 `cursor` 和 `next_note_index`: + +```bash +xhs history --cursor "<上一页 cursor>" --note-index <上一页 next_note_index> --json +``` + +历史 `has_more` 来源于 `has_no_more`;`/api/sns/web/v1/feed/browsing_history` 是直播历史,不用于笔记历史。 + +## 用户要求写操作后使用 + +```bash +xhs follow --json +xhs unfollow --json +xhs like "<笔记 URL>" --json +xhs like "<笔记 URL>" --undo --json +xhs favorite "<笔记 URL>" --json +xhs unfavorite "<笔记 URL>" --json +xhs comment "<笔记 URL>" -c "用户确定的正文" --json +xhs reply "<笔记 URL>" --comment-id -c "用户确定的正文" --json +xhs send -c "用户确定的正文" --json +xhs post --title "标题" --body "正文" --images ./image.png --json +xhs post-video --title "标题" --body "正文" --video ./video.mp4 --private --json +``` + +删除命令 `delete`、`delete-comment` 及其参数先查帮助,确认目标与授权后使用。`logout` 会清除保存的凭证,不作为常规检查步骤。 + +私信默认每分钟一次,不解除节流来解决 `rate_limited`。提交结果不明时不盲目重发。图文与视频发布通道不同,大视频上传会读入内存;确认媒体有效性和账号权限后再发布。 + +图文和视频发布都会提取正文中的 `#话题`,与多次 `--topic` 合并并保留最多十个待解析话题。成功查询到有效 ID 和名称后,正文中对应标签转换为 `#官方名称[话题]#`,并附上话题元数据;未解析到的普通标签保持原文。已写成原生格式的标签不会重复叠加标记,URL 的 `#fragment` 不处理。`--topic` 附加元数据,不自动在正文末尾补写标签。 diff --git a/references/maintenance.md b/references/maintenance.md new file mode 100644 index 0000000..88bb0a6 --- /dev/null +++ b/references/maintenance.md @@ -0,0 +1,50 @@ +# 功能分支维护 + +本分支在上游 CLI 基础上合入搜索过滤、楼中楼令牌、媒体下载、私信、创作者分析、视频发布,并增加用户搜索分页、当前账号关注列表、笔记历史及登录适配。实际命令以 `xhs --help` 为准;包版本可能仍为 0.6.4。 + +## 源码位置与安装 + +先确定当前设备的源码目录、分支、远端与工作树状态。不要把本机用户名、账号 ID 或 profile 路径固化进公开 skill。安装目录可用对应环境的 Python 查询 `xhs_cli.__file__`;读取位置不代表应直接修改安装产物。 + +主要源码: + +- `client_mixins.py`:接口实现。 +- `cli.py` / `commands/`:命令与参数。 +- `client.py`:请求层和重试。 +- `signing.py`:签名适配。 +- `qr_login.py` / `cookies.py`:扫码及凭证管理。 + +修改源码并检查后,从源码目录安装: + +```bash +uv tool install --force --reinstall-package xiaohongshu-cli . +xhs --help +``` + +直接改 site-packages 会在重装时丢失。本功能分支不宜用 `uv tool upgrade xiaohongshu-cli` 替换为 PyPI 包,否则可能覆盖扩展能力与 HERMES 补丁。 + +源码变更但包版本未变化时,uv 可能复用旧构建;显式重装该包,并从源码目录之外导入检查,确认实际安装文件包含新功能,避免只检查源码或帮助文本而误报更新完成。 + +## 更新分支 + +```bash +git status --short +git branch --show-current +git remote -v +``` + +确认用户的目标分支和上游远端后 fetch,审查差异,再合并正确的上游 ref。有本地提交的功能分支不能照搬主干的 fast-forward-only 更新流程。保留用户改动,不自动 reset 或强推。 + +维护 HERMES 补丁时,检查 `extract_browser_cookies()` 仍返回 None;不要通过实际解密浏览器凭证来验证。更换 Cookie 提取策略属于单独行为变更,不在普通升级里悄悄恢复。 + +## 验证范围 + +文档更新检查命令帮助、相对链接及 skill 格式即可,不运行真实账号写操作。代码更新根据变更运行单元检查和项目要求的静态检查。若沿用本分支测试,明确区分 `tests/test_integration.py`、`tests/test_smoke.py` 与隔离单元测试;真实接口测试另行授权和执行。 + +曾经的测试通过结果不能替代本次验证。私信与发布请求超时或服务端结果不明时,不增加自动重发来让检查变绿。 + +## 公开提交 + +公开代码、skill 与私有环境记录分开。提交前检查工作树和即将推送的提交范围,排除凭证、账号配置、浏览器 profile、二维码、下载内容和个人调研记录。 + +本地开发分支与公开脱敏快照可能有不同历史。推送公开更新时从已核实的公开分支建立提交,不为了发布文档而把私有开发历史一起合入。提交作者使用用户已选择的公开身份;不能把第三方贡献者原有信息误当作当前用户的隐私信息。 diff --git a/tests/conftest.py b/tests/conftest.py index 4f256a8..2b5eda7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,4 +2,22 @@ import os +import pytest + os.environ.setdefault("OUTPUT", "rich") +# Keep the on-disk DM write throttle out of unit tests; the limiter itself is +# exercised in test_limiter.py against a temporary state directory. +os.environ.setdefault("XHS_WRITE_RPM", "0") + + +@pytest.fixture(autouse=True) +def isolated_local_config(tmp_path, monkeypatch): + """Unit tests must not modify the installed account's cookies or caches.""" + from xhs_cli import client_mixins, cookies, limiter + + for module in (cookies, client_mixins, limiter): + monkeypatch.setattr(module, "get_config_dir", lambda: tmp_path) + monkeypatch.setattr(cookies, "_TOKEN_CACHE_MEMORY", None) + monkeypatch.setattr(cookies, "_TOKEN_CACHE_PATH", None) + monkeypatch.setattr(client_mixins, "_SEARCH_SESSION_CACHE_PATH", None) + monkeypatch.setattr(client_mixins, "_SEARCH_SESSION_CACHE_LOADED", False) diff --git a/tests/test_anti_detection.py b/tests/test_anti_detection.py index 62f889c..432aa94 100644 --- a/tests/test_anti_detection.py +++ b/tests/test_anti_detection.py @@ -39,7 +39,9 @@ def test_returns_all_required_keys(self): cookies = {"a1": "test_a1_12345678901234567890123456789012345678901234"} headers = sign_main_api("GET", "/api/test", cookies) expected_keys = {"x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"} - assert set(headers.keys()) == expected_keys + # New signer versions may include additional anti-bot headers. + assert expected_keys <= set(headers) + assert all(headers[key] for key in expected_keys) def test_xs_has_xys_prefix(self): cookies = {"a1": "test_a1_12345678901234567890123456789012345678901234"} diff --git a/tests/test_cli.py b/tests/test_cli.py index f4bec77..c441aa1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,11 +1,14 @@ """Tests for CLI commands using Click's test runner.""" +from pathlib import Path + import pytest import yaml from click.testing import CliRunner from xhs_cli.cli import cli -from xhs_cli.exceptions import NoCookieError, SessionExpiredError, UnsupportedOperationError +from xhs_cli.downloads import SavedImage +from xhs_cli.exceptions import NoCookieError, RateLimitedError, SessionExpiredError, UnsupportedOperationError runner = CliRunner() @@ -71,6 +74,8 @@ def test_all_commands_registered(self): "search", "read", "comments", "sub-comments", "user", "user-posts", "feed", "hot", "topics", "search-user", "my-notes", "notifications", "unread", + # Messages + "chats", "messages", "send", # Interactions "like", "favorite", "unfavorite", "comment", "reply", "delete-comment", # Social @@ -172,6 +177,34 @@ def test_logout_supports_structured_output(self): assert payload["ok"] is True assert payload["data"]["logged_out"] is True + def test_send_emits_structured_payload(self, monkeypatch): + monkeypatch.setenv("OUTPUT", "auto") + monkeypatch.setattr( + "xhs_cli.commands._common.run_client_action", + lambda ctx, action: {"mid": "m-1", "receiver_id": "peer-1", "response": {"code": 0}}, + ) + + result = runner.invoke(cli, ["send", "peer-1", "-c", "hi", "--json"]) + + assert result.exit_code == 0 + payload = yaml.safe_load(result.output) + assert payload["ok"] is True + assert payload["data"]["receiver_id"] == "peer-1" + assert payload["data"]["mid"] == "m-1" + + def test_send_maps_throttle_to_rate_limited(self, monkeypatch): + def fake_run_client_action(ctx, action): + raise RateLimitedError("too fast") + + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", fake_run_client_action) + + result = runner.invoke(cli, ["send", "peer-1", "-c", "hi", "--yaml"]) + + assert result.exit_code != 0 + payload = yaml.safe_load(result.output) + assert payload["ok"] is False + assert payload["error"]["code"] == "rate_limited" + def test_delete_reports_unsupported_operation(self, monkeypatch): monkeypatch.setattr( "xhs_cli.commands.creator.run_client_action", @@ -236,6 +269,27 @@ def test_search_rich_output_shortens_visible_links(self, monkeypatch): assert "search_result/69ad061d" in result.output assert "very-long-token-value" not in result.output + def test_search_accepts_distance_filter_argument(self, monkeypatch): + captured = {} + + def fake_handle_command(ctx, action, render, as_json, as_yaml): + class FakeClient: + def search_notes(self, **kwargs): + captured.update(kwargs) + return {"items": [], "has_more": False} + + action(FakeClient()) + return None + + monkeypatch.setattr("xhs_cli.commands.reading.handle_command", fake_handle_command) + + result = runner.invoke(cli, ["search", "旅行", "--sort", "popular", "--distance", "附近"]) + + assert result.exit_code == 0 + assert captured["keyword"] == "旅行" + assert captured["sort"] == "popularity_descending" + assert captured["filter_overrides"]["filter_pos_distance"] == "附近" + def test_feed_rich_output_shortens_visible_links(self, monkeypatch): monkeypatch.setenv("OUTPUT", "rich") monkeypatch.setattr( @@ -266,6 +320,11 @@ def test_read_help_mentions_short_index(self): assert result.exit_code == 0 assert "index" in result.output.lower() + def test_read_help_mentions_save_images(self): + result = runner.invoke(cli, ["read", "--help"]) + assert result.exit_code == 0 + assert "--save-images" in result.output + def test_comments_help_mentions_short_index(self): result = runner.invoke(cli, ["comments", "--help"]) assert result.exit_code == 0 @@ -302,6 +361,43 @@ def fake_handle_command(ctx, action, render, as_json, as_yaml): assert called["kwargs"]["xsec_token"] == "token-abc" assert called["kwargs"]["xsec_source"] == "pc_search" + def test_read_save_images_outputs_saved_paths(self, monkeypatch, tmp_path): + class FakeClient: + def get_note_detail(self, note_id, **kwargs): + assert note_id == "note-abc" + return FAKE_NOTE_RESPONSE + + def fake_run_client_action(ctx, action): + return action(FakeClient()) + + output_dir = tmp_path / "downloads" + saved_path = output_dir / "note-abc" / "image-1.jpg" + + monkeypatch.setattr("xhs_cli.commands.reading.run_client_action", fake_run_client_action) + monkeypatch.setattr( + "xhs_cli.commands.reading.download_note_images", + lambda data, output: [ + SavedImage( + index=1, + url="https://img.example/image.jpg", + path=Path(output) / "note-abc" / "image-1.jpg", + ) + ], + ) + + result = runner.invoke(cli, ["read", "note-abc", "--save-images", "--output", str(output_dir), "--yaml"]) + + assert result.exit_code == 0 + payload = yaml.safe_load(result.output) + assert payload["ok"] is True + assert payload["data"]["saved_images"] == [ + { + "index": 1, + "url": "https://img.example/image.jpg", + "path": str(saved_path), + } + ] + def test_comments_index_resolves_note_context(self, monkeypatch): monkeypatch.setattr( "xhs_cli.note_refs.get_note_by_index", @@ -333,6 +429,54 @@ def fake_run_client_action(ctx, action): assert called["kwargs"]["xsec_token"] == "token-abc" assert called["kwargs"]["xsec_source"] == "pc_search" + def test_sub_comments_passes_explicit_xsec_token(self, monkeypatch): + called = {} + + class FakeClient: + def get_sub_comments(self, note_id, comment_id, cursor="", **kwargs): + called["note_id"] = note_id + called["comment_id"] = comment_id + called["cursor"] = cursor + called["kwargs"] = kwargs + return {"comments": []} + + def fake_handle_command(ctx, action, render, as_json, as_yaml): + return action(FakeClient()) + + monkeypatch.setattr("xhs_cli.commands.reading.handle_command", fake_handle_command) + + result = runner.invoke( + cli, ["sub-comments", "note-abc", "comment-1", "--xsec-token", "token-abc", "--cursor", "c1", "--yaml"] + ) + + assert result.exit_code == 0 + assert called["note_id"] == "note-abc" + assert called["comment_id"] == "comment-1" + assert called["cursor"] == "c1" + assert called["kwargs"]["xsec_token"] == "token-abc" + + def test_sub_comments_url_resolves_note_context(self, monkeypatch): + called = {} + + class FakeClient: + def get_sub_comments(self, note_id, comment_id, cursor="", **kwargs): + called["note_id"] = note_id + called["kwargs"] = kwargs + return {"comments": []} + + def fake_handle_command(ctx, action, render, as_json, as_yaml): + return action(FakeClient()) + + monkeypatch.setattr("xhs_cli.commands.reading.handle_command", fake_handle_command) + + url = "https://www.xiaohongshu.com/explore/note-abc?xsec_token=token-url&xsec_source=pc_search" + result = runner.invoke(cli, ["sub-comments", url, "comment-1", "--yaml"]) + + assert result.exit_code == 0 + assert called["note_id"] == "note-abc" + assert called["kwargs"]["xsec_token"] == "token-url" + assert called["kwargs"]["xsec_source"] == "pc_search" + def test_read_index_not_found_returns_usage_error(self, monkeypatch): monkeypatch.setattr("xhs_cli.note_refs.get_note_by_index", lambda idx: None) diff --git a/tests/test_client.py b/tests/test_client.py index eb55af7..78e7172 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -163,6 +163,44 @@ def fake_post(self, uri, data, header_overrides=None): assert notes_payload["filters"][0]["type"] == "sort_type" assert notes_payload["filters"][1]["type"] == "filter_note_type" + def test_search_notes_applies_filter_overrides(self, monkeypatch): + calls = [] + + monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict()) + monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", True) + + def fake_get(self, uri, params=None): + calls.append(("GET", uri, params)) + return {"ok": True} + + def fake_post(self, uri, data, header_overrides=None): + calls.append(("POST", uri, data)) + if uri == "/api/sns/web/v1/search/notes": + return {"items": [], "has_more": False} + return {"ok": True} + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + monkeypatch.setattr(XhsClient, "_main_api_post", fake_post) + + client = XhsClient({"a1": "cookie"}) + try: + client.search_notes( + "旅行", + filter_overrides={ + "filter_note_time": "一周内", + "filter_note_range": "图文", + "filter_pos_distance": "附近", + }, + ) + finally: + client.close() + + notes_payload = next(call[2] for call in calls if call[1] == "/api/sns/web/v1/search/notes") + filters_by_type = {item["type"]: item["tags"] for item in notes_payload["filters"]} + assert filters_by_type["filter_note_time"] == ["一周内"] + assert filters_by_type["filter_note_range"] == ["图文"] + assert filters_by_type["filter_pos_distance"] == ["附近"] + def test_search_notes_reuses_search_id_across_pages(self, monkeypatch): calls = [] @@ -276,3 +314,75 @@ def fake_get_note_from_html(self, note_id, xsec_token="", xsec_source="pc_feed") assert result["items"][0]["note_card"]["title"] == "html-ok" assert get_cached_note_context("note-123") == {} + + def test_get_sub_comments_sends_explicit_xsec_token(self, monkeypatch): + captured = {} + + def fake_get(self, uri, params=None, **kwargs): + captured["uri"] = uri + captured["params"] = params + return {"comments": []} + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + + client = XhsClient({"a1": "cookie"}) + try: + client.get_sub_comments("note-sub", "root-1", cursor="c1", xsec_token="token-sub", xsec_source="pc_search") + finally: + client.close() + + assert captured["uri"] == "/api/sns/web/v2/comment/sub/page" + assert captured["params"]["note_id"] == "note-sub" + assert captured["params"]["root_comment_id"] == "root-1" + assert captured["params"]["cursor"] == "c1" + assert captured["params"]["xsec_token"] == "token-sub" + assert get_cached_note_context("note-sub")["token"] == "token-sub" + + def test_get_sub_comments_uses_cached_note_context(self, monkeypatch): + cache_note_context("note-sub-cached", "cached-token", "pc_feed") + captured = {} + + def fake_get(self, uri, params=None, **kwargs): + captured["params"] = params + return {"comments": []} + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + + client = XhsClient({"a1": "cookie"}) + try: + client.get_sub_comments("note-sub-cached", "root-1") + finally: + client.close() + + assert captured["params"]["xsec_token"] == "cached-token" + + def test_get_sub_comments_retries_after_stale_cached_token(self, monkeypatch): + cache_note_context("note-sub-stale", "stale-token", "pc_feed") + seen_tokens = [] + + def fake_get(self, uri, params=None, **kwargs): + seen_tokens.append(params["xsec_token"]) + if params["xsec_token"] == "stale-token": + raise XhsApiError("stale token") + return {"comments": [{"id": "reply-1"}]} + + def fake_resolve(self, note_id, preferred_token="", preferred_source=""): + if preferred_token: + return preferred_token, preferred_source + cached = get_cached_note_context(note_id) + if cached.get("token"): + return cached["token"], cached.get("source", "") + return "fresh-token", "pc_feed" + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + monkeypatch.setattr(XhsClient, "resolve_xsec_context", fake_resolve) + + client = XhsClient({"a1": "cookie"}) + try: + result = client.get_sub_comments("note-sub-stale", "root-1") + finally: + client.close() + + assert seen_tokens == ["stale-token", "fresh-token"] + assert result["comments"][0]["id"] == "reply-1" + assert get_cached_note_context("note-sub-stale")["token"] == "fresh-token" diff --git a/tests/test_creator.py b/tests/test_creator.py index e9c7ab0..98b8ced 100644 --- a/tests/test_creator.py +++ b/tests/test_creator.py @@ -1,4 +1,10 @@ -from xhs_cli.commands.creator import extract_hashtags +import json + +import pytest +from click.testing import CliRunner + +from xhs_cli.cli import cli +from xhs_cli.commands.creator import extract_hashtags, format_body_hashtags def test_extract_hashtags(): @@ -28,3 +34,90 @@ def test_extract_hashtags(): # Emoji hashtag assert extract_hashtags("Let's go #🎉party") == ["🎉party"] + + +def test_format_body_hashtags(): + # Empty map returns original + assert format_body_hashtags("Hello world", {}) == "Hello world" + + # Single replacement + tag_map = {"美股": "美股"} + assert format_body_hashtags("分析 #美股 走势", tag_map) == "分析 #美股[话题]# 走势" + + # Multiple replacements with prefix collision prevention (#美股 vs #美股大跌) + tag_map_multi = {"美股": "美股", "美股大跌": "美股大跌"} + result = format_body_hashtags("今日盘点 #美股 #美股大跌 发生", tag_map_multi) + assert result == "今日盘点 #美股[话题]# #美股大跌[话题]# 发生" + + # URL fragment should NOT be formatted + sample = "Link https://site.com#section here #test" + mapping = {"section": "section", "test": "test"} + assert format_body_hashtags(sample, mapping) == "Link https://site.com#section here #test[话题]#" + + # Start of line and end of string + assert ( + format_body_hashtags("#tag at start and #end", {"tag": "tag", "end": "end"}) + == "#tag[话题]# at start and #end[话题]#" + ) + + +def test_native_hashtags_are_not_formatted_twice(): + body = "正文 #旅行[话题]# #美食" + assert extract_hashtags(body) == ["旅行", "美食"] + formatted = format_body_hashtags(body, {"旅行": "旅行", "美食": "美食"}) + assert formatted == "正文 #旅行[话题]# #美食[话题]#" + assert format_body_hashtags(formatted, {"旅行": "旅行", "美食": "美食"}) == formatted + assert format_body_hashtags("#旅行[话题]#", {"旅行": "旅行日记"}) == "#旅行日记[话题]#" + + +def test_format_only_complete_resolved_tokens(): + assert format_body_hashtags("#美股大跌 #未知 #美股", {"美股": "股票"}) == "#美股大跌 #未知 #股票[话题]#" + assert format_body_hashtags("#raw", {"raw": r"name\1"}) == "#name\\1[话题]#" + assert format_body_hashtags("#旅行#", {"旅行": "旅行"}) == "#旅行[话题]#" + + +@pytest.mark.parametrize("command", ["post", "post-video"]) +@pytest.mark.parametrize("tag", ["#旅行", "#旅行[话题]#"]) +def test_publish_formats_body_and_attaches_only_resolved_topics(command, tag, tmp_path, monkeypatch): + media = tmp_path / ("image.jpg" if command == "post" else "video.mp4") + media.write_bytes(b"mock media, never uploaded") + calls = [] + captured = {} + + class FakeClient: + def get_upload_permit(self): + return {"fileId": "media", "token": "fake"} + + get_video_upload_permit = get_upload_permit + + def upload_file(self, *args): + pass + + upload_video = upload_file + + def search_topics(self, name): + calls.append(name) + if name == "旅行": + return {"topic_info_dtos": [{"id": "topic-id", "name": "旅行日记"}]} + if name == "无ID": + return {"topic_info_dtos": [{"name": "无ID"}]} + return {"topic_info_dtos": []} + + def create_image_note(self, **kwargs): + captured.update(kwargs) + return {"note_id": "fake-note"} + + create_video_note = create_image_note + + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", lambda ctx, action: action(FakeClient())) + flag = "--images" if command == "post" else "--video" + result = CliRunner().invoke(cli, [ + command, "--title", "test", "--body", f"{tag} #未知 #无ID https://example.com#旅行", + flag, str(media), "--topic", "旅行", "--json", + ]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["ok"] is True + assert calls == ["旅行", "未知", "无ID"] + assert captured["desc"] == "#旅行日记[话题]# #未知 #无ID https://example.com#旅行" + assert captured["topics"] == [{"id": "topic-id", "name": "旅行日记", "type": "topic"}] + diff --git a/tests/test_downloads.py b/tests/test_downloads.py new file mode 100644 index 0000000..234c525 --- /dev/null +++ b/tests/test_downloads.py @@ -0,0 +1,56 @@ +"""Unit tests for media download helpers.""" + +from xhs_cli.downloads import download_note_images + + +class FakeResponse: + content = b"image-bytes" + + def raise_for_status(self): + return None + + +class FakeHttpClient: + requested_urls = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def get(self, url): + self.requested_urls.append(url) + return FakeResponse() + + +def test_download_note_images_saves_under_note_directory(monkeypatch, tmp_path): + FakeHttpClient.requested_urls = [] + monkeypatch.setattr("xhs_cli.downloads.httpx.Client", FakeHttpClient) + + data = { + "items": [ + { + "id": "note/abc", + "note_card": { + "image_list": [ + { + "url_default": "https://img.example/path/photo.webp!nd_dft_wgth_webp_3", + } + ] + }, + } + ] + } + + saved = download_note_images(data, tmp_path) + + assert FakeHttpClient.requested_urls == [ + "https://img.example/path/photo.webp!nd_dft_wgth_webp_3", + ] + assert len(saved) == 1 + assert saved[0].path == tmp_path / "note-abc" / "image-1.webp" + assert saved[0].path.read_bytes() == b"image-bytes" diff --git a/tests/test_formatter.py b/tests/test_formatter.py index c8ae6d2..d0b4b11 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,6 +1,7 @@ """Unit tests for formatter (no network required).""" from xhs_cli.formatter import coerce_int, extract_note_id, format_count, parse_note_reference +from xhs_cli.formatter_normalizers import normalize_note_detail class TestFormatCount: @@ -60,3 +61,28 @@ def test_extracts_token_and_source(self): assert note_id == "abc123" assert token == "token-1" assert source == "pc_search" + + +class TestNormalizeNoteDetail: + def test_extracts_image_urls(self): + note = normalize_note_detail({ + "items": [ + { + "note_card": { + "title": "Test", + "image_list": [ + {"url_default": "https://img.example/default.jpg"}, + {"url_list": ["https://img.example/list.jpg"]}, + {"info_list": [{"url_pre": "https://img.example/pre.jpg"}]}, + ], + } + } + ] + }) + + assert note["image_count"] == 3 + assert note["image_urls"] == [ + "https://img.example/default.jpg", + "https://img.example/list.jpg", + "https://img.example/pre.jpg", + ] diff --git a/tests/test_im_codec.py b/tests/test_im_codec.py new file mode 100644 index 0000000..cf1ac66 --- /dev/null +++ b/tests/test_im_codec.py @@ -0,0 +1,114 @@ +"""Short-link direct-message codec (no network). + +The wire layout is pinned by a byte-exact regression fixture: the server accepts +or rejects a message on its exact byte layout, so a refactor that changes the +encoding must fail loudly here rather than at send time. +""" + +from __future__ import annotations + +import pytest + +from xhs_cli.im_codec import ( + _CHUNK_BYTES, + _MODULUS, + CONTENT_TYPE_TEXT, + _varint, + encode_chat_message, + encrypt_payload, +) + +# Captured from a message the server accepted; regenerating it must be +# deterministic and byte-identical. +FIXTURE_ARGS = { + "local_mid": "11111111-2222-3333-4444-555555555555", + "timestamp_ms": 1789000000000, + "sender_id": "a" * 24, + "receiver_id": "b" * 24, + "content": "test", +} +FIXTURE_HEX = ( + "08014a730a2431313131313131312d323232322d333333332d343434342d353535" + "3535353535353535351080c4d6c588341a00221861616161616161616161616161" + "61616161616161616161612a186262626262626262626262626262626262626262" + "626262623204746573743801420048005a006001" +) + + +class TestVarint: + @pytest.mark.parametrize( + ("value", "expected"), + [ + (0, b"\x00"), + (1, b"\x01"), + (127, b"\x7f"), + (128, b"\x80\x01"), + (300, b"\xac\x02"), + (1789000000000, b"\x80\xc4\xd6\xc5\x88\x34"), + ], + ) + def test_known_encodings(self, value, expected): + assert _varint(value) == expected + + def test_rejects_negative(self): + with pytest.raises(ValueError, match="negative"): + _varint(-1) + + +class TestEncodeChatMessage: + def test_matches_accepted_byte_layout(self): + assert encode_chat_message(**FIXTURE_ARGS).hex() == FIXTURE_HEX + + def test_is_deterministic(self): + assert encode_chat_message(**FIXTURE_ARGS) == encode_chat_message(**FIXTURE_ARGS) + + def test_default_valued_fields_are_emitted_not_omitted(self): + """本传输不省略默认值;省略会改变字节布局,服务端会拒。""" + payload = encode_chat_message(**FIXTURE_ARGS) + assert b"\x1a\x00" in payload, "empty token (field 3) must occupy a tag" + assert b"\x42\x00" in payload, "empty nickname (field 8) must occupy a tag" + assert b"\x48\x00" in payload, "group_chat=0 (field 9) must be explicit" + assert b"\x5a\x00" in payload, "empty ref_id (field 11) must occupy a tag" + + def test_command_field_10_is_never_emitted(self): + payload = encode_chat_message(**FIXTURE_ARGS) + assert b"\x52" not in payload, "field 10 has no meaning for plain text" + + def test_carries_participants_and_content(self): + payload = encode_chat_message(**{**FIXTURE_ARGS, "content": "hello 世界"}) + assert b"a" * 24 in payload + assert b"b" * 24 in payload + assert "hello 世界".encode() in payload + + def test_multibyte_content_uses_utf8_length(self): + payload = encode_chat_message(**{**FIXTURE_ARGS, "content": "世界"}) + # 2 chars -> 6 UTF-8 bytes; field 6 tag is 0x32. + assert b"\x32\x06" + "世界".encode() in payload + + +class TestEncryptPayload: + def test_key_is_1024_bit(self): + assert _MODULUS.bit_length() == 1024 + assert _CHUNK_BYTES == 117 + + @pytest.mark.parametrize( + ("size", "blocks"), + [(1, 1), (_CHUNK_BYTES, 1), (_CHUNK_BYTES + 1, 2), (_CHUNK_BYTES * 2, 2)], + ) + def test_output_is_block_aligned(self, size, blocks): + out = encrypt_payload(b"x" * size) + assert len(out) == blocks * 128 + + def test_padding_is_randomized(self): + payload = encode_chat_message(**FIXTURE_ARGS) + assert encrypt_payload(payload) != encrypt_payload(payload) + + def test_real_payload_spans_two_blocks(self): + assert len(encrypt_payload(encode_chat_message(**FIXTURE_ARGS))) == 256 + + def test_empty_payload_produces_no_blocks(self): + assert encrypt_payload(b"") == b"" + + +def test_content_type_text_constant(): + assert CONTENT_TYPE_TEXT == 1 diff --git a/tests/test_limiter.py b/tests/test_limiter.py new file mode 100644 index 0000000..3dd2908 --- /dev/null +++ b/tests/test_limiter.py @@ -0,0 +1,58 @@ +"""Tests for the cross-process write throttle (no network).""" + +from __future__ import annotations + +import json +import time + +import pytest + +from xhs_cli import limiter +from xhs_cli.exceptions import RateLimitedError + + +@pytest.fixture +def state_dir(tmp_path, monkeypatch): + monkeypatch.setattr(limiter, "get_config_dir", lambda: tmp_path) + monkeypatch.setenv("XHS_WRITE_RPM", "1") + limiter.reset() + return tmp_path + + +class TestWriteBudget: + def test_first_write_passes_second_is_throttled(self, state_dir): + limiter.check_write_budget("dm") + with pytest.raises(RateLimitedError, match="写入限流"): + limiter.check_write_budget("dm") + + def test_buckets_are_independent(self, state_dir): + limiter.check_write_budget("dm") + # A different bucket has its own budget. + limiter.check_write_budget("other") + + def test_zero_rpm_disables_throttle(self, state_dir, monkeypatch): + monkeypatch.setenv("XHS_WRITE_RPM", "0") + for _ in range(5): + limiter.check_write_budget("dm") + + def test_invalid_rpm_falls_back_to_default(self, state_dir, monkeypatch): + monkeypatch.setenv("XHS_WRITE_RPM", "not-a-number") + limiter.check_write_budget("dm") + with pytest.raises(RateLimitedError): + limiter.check_write_budget("dm") + + def test_window_expiry_releases_token(self, state_dir, monkeypatch): + limiter.check_write_budget("dm") + real_time = time.time + monkeypatch.setattr(limiter.time, "time", lambda: real_time() + limiter.WINDOW_SECONDS + 1) + limiter.check_write_budget("dm") + + def test_state_file_is_valid_json(self, state_dir): + limiter.check_write_budget("dm") + payload = json.loads((state_dir / limiter.STATE_FILE).read_text()) + assert payload["dm"] + + def test_reset_clears_bucket(self, state_dir): + limiter.check_write_budget("dm") + limiter.reset("dm") + limiter.check_write_budget("dm") diff --git a/tests/test_local_features.py b/tests/test_local_features.py new file mode 100644 index 0000000..316ca8b --- /dev/null +++ b/tests/test_local_features.py @@ -0,0 +1,252 @@ +"""Regression checks for the combined local PR build; all requests are mocked.""" + +import json + +import httpx +import pytest +from click.testing import CliRunner + +from xhs_cli.cli import cli +from xhs_cli.client import XhsClient +from xhs_cli.cookies import extract_browser_cookies +from xhs_cli.exceptions import XhsApiError +from xhs_cli.formatter_normalizers import normalize_creator_note_data + + +def test_hermes_never_attempts_browser_decryption(monkeypatch): + def unexpected(*args): + pytest.fail("browser extraction must stay disabled") + + monkeypatch.setattr("xhs_cli.cookies._extract_in_process", unexpected) + monkeypatch.setattr("xhs_cli.cookies._extract_via_subprocess", unexpected) + assert extract_browser_cookies("chrome") is None + assert extract_browser_cookies("auto") is None + + +@pytest.mark.parametrize("endpoint", ["/api/im/web/short_link/send_message", "/web_api/sns/v2/note"]) +@pytest.mark.parametrize("failure", ["timeout", "503"]) +def test_sensitive_writes_are_not_replayed(endpoint, failure): + calls = [] + + def respond(request): + calls.append(request) + if failure == "timeout": + raise httpx.ReadTimeout("unknown outcome", request=request) + return httpx.Response(503, request=request) + + with XhsClient({"a1": "fake"}, request_delay=0) as client: + client._http.close() + client._http = httpx.Client(transport=httpx.MockTransport(respond)) + with pytest.raises(XhsApiError, match="not retried"): + client._request_with_retry("POST", "https://example.invalid" + endpoint, json={}) + assert len(calls) == 1 + + +def test_video_payload_and_original_image_endpoint(monkeypatch): + calls = [] + monkeypatch.setattr(XhsClient, "_creator_post", lambda self, uri, data: calls.append(("creator", uri, data)) or {}) + monkeypatch.setattr( + XhsClient, "_main_api_post", + lambda self, uri, data, headers=None: calls.append(("main", uri, data)) or {}, + ) + with XhsClient({"a1": "fake"}) as client: + client.create_video_note("中文", "正文", "vid", cover_file_id="cover", is_private=True) + client.create_image_note("image", "body", ["img"]) + video = calls[0][2] + assert calls[0][:2] == ("creator", "/web_api/sns/v2/note") + assert video["common"]["type"] == "video" + assert video["video_info"]["file_id"] == "vid" + assert video["image_info"]["images"][0]["file_id"] == "cover" + assert video["common"]["privacy_info"]["type"] == 1 + assert calls[1][0] == "main" + + +def test_video_json_and_topics(tmp_path, monkeypatch): + video = tmp_path / "movie.mp4" + video.write_bytes(b"mock upload bytes") + captured = {} + + class FakeClient: + def get_video_upload_permit(self): + return {"fileId": "vid", "token": "fake"} + + def upload_video(self, file_id, token, path): + assert file_id == "vid" and path == str(video) + + def search_topics(self, name): + return {"topic_info_dtos": [{"id": name, "name": name}]} + + def create_video_note(self, **kwargs): + captured.update(kwargs) + return {"note_id": "new-note"} + + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", lambda ctx, action: action(FakeClient())) + result = CliRunner().invoke(cli, [ + "post-video", "--title", "中文", "--body", "正文 #旅行", "--video", str(video), + "--topic", "旅行", "--topic", "美食", "--private", "--json", + ]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["data"]["note_id"] == "new-note" + assert [topic["name"] for topic in captured["topics"]] == ["旅行", "美食"] + assert captured["is_private"] is True + + +def test_missing_cover_fails_before_upload(tmp_path, monkeypatch): + video = tmp_path / "movie.mp4" + video.write_bytes(b"mock upload bytes") + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", lambda ctx, action: action(object())) + result = CliRunner().invoke(cli, [ + "post-video", "--title", "t", "--body", "b", "--video", str(video), + "--cover", str(tmp_path / "missing.jpg"), "--json", + ]) + assert result.exit_code != 0 + assert "Cover image not found" in json.loads(result.stdout)["error"]["message"] + + +def test_creator_analysis_endpoint_and_counts(monkeypatch): + captured = {} + + def fake_get(self, uri, params): + captured.update(uri=uri, params=params) + return {"notes": [{"read_count": 12, "imp_count": 30, "coverClickRate": 0.4}]} + + monkeypatch.setattr(XhsClient, "_creator_get", fake_get) + with XhsClient({"a1": "fake"}) as client: + result = client.get_creator_note_analyze_list(page_num=2, page_size=5) + assert captured["uri"] == "/api/galaxy/creator/datacenter/note/analyze/list" + assert captured["params"]["page_num"] == 2 + row = normalize_creator_note_data(result)[0] + assert row["view_count"] == "12" + assert row["click_rate"] == "40.0%" + + +def test_creator_analysis_nested_failure_is_not_success(monkeypatch): + monkeypatch.setattr( + XhsClient, "_creator_get", + lambda *args: {"result": {"success": False, "code": 123, "message": "denied"}}, + ) + with XhsClient({"a1": "fake"}) as client: + with pytest.raises(XhsApiError, match="denied"): + client.get_creator_note_analyze_list() + + +def test_creator_analysis_wrapped_nullable_rate(): + rows = normalize_creator_note_data({"result": {"data": {"notes": [{"coverClickRate": None}]}}}) + assert rows[0]["click_rate"] == "-" + + +def test_search_users_pagination_payload(monkeypatch): + captured = {} + + def fake_post(self, uri, data): + captured.update(uri=uri, data=data) + return {"user_info_dtos": []} + + monkeypatch.setattr(XhsClient, "_creator_post", fake_post) + with XhsClient({"a1": "fake"}) as client: + client.search_users("摄影", page=3, page_size=10, search_id="same-session") + assert captured["uri"] == "/web_api/sns/v1/search/user_info" + assert captured["data"]["page"] == {"page": 3, "page_size": 10} + assert captured["data"]["search_id"] == "same-session" + + +def test_search_user_cli_passes_pagination(monkeypatch): + captured = {} + + class FakeClient: + def search_users(self, keyword, **kwargs): + captured.update(keyword=keyword, **kwargs) + return {"user_info_dtos": []} + + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", lambda ctx, action: action(FakeClient())) + result = CliRunner().invoke(cli, [ + "search-user", "摄影", "--page", "2", "--page-size", "10", "--search-id", "session", "--json", + ]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["ok"] is True + assert captured == {"keyword": "摄影", "page": 2, "page_size": 10, "search_id": "session"} + + +def test_search_user_rejects_invalid_page_before_request(): + result = CliRunner().invoke(cli, ["search-user", "摄影", "--page", "0"]) + assert result.exit_code == 2 + assert "x>=1" in result.output + + +def test_following_pagination_and_status_preserved(monkeypatch): + requested = [] + + def fake_get(self, uri, params): + requested.append((uri, params)) + return {"follow_user_d_t_o_list": [{"user_id": "peer", "nick_name": "name", "status": 2}]} + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + with XhsClient({"a1": "fake"}) as client: + data = client.get_following(page=2, page_size=1) + assert requested == [("/api/im/web/users/following/all", {"page": 2, "size": 1})] + assert data["users"][0]["status"] == 2 + assert data["has_more"] is True + assert data["next_page"] == 3 + assert data["pagination_inferred"] is True + + +def test_following_reject_unknown_shape(monkeypatch): + monkeypatch.setattr(XhsClient, "_main_api_get", lambda *args, **kwargs: {}) + with XhsClient({"a1": "fake"}) as client: + with pytest.raises(XhsApiError, match="Unexpected"): + client.get_following() + + +def test_browsing_history_cursor_and_account_source(monkeypatch): + captured = {} + + def fake_get(self, uri, params): + captured.update(uri=uri, params=params) + return {"result": {"success": True, "code": 0}, "data": [{"id": "note", "browse_time": 123}], + "has_no_more": False, "next_cursor": "next"} + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + with XhsClient({"a1": "fake"}) as client: + data = client.get_browsing_history(cursor="previous", note_index=20) + assert captured["uri"] == "/api/sns/web/v1/common/dual_feed" + assert captured["params"]["scene"] == "browse_history" + assert captured["params"]["cursor_score"] == "previous" + assert captured["params"]["note_index"] == 20 + assert data["cursor"] == "next" + assert data["has_more"] is True + assert data["next_note_index"] == 21 + assert data["notes"][0]["browse_time"] == 123 + + +@pytest.mark.parametrize("response", [ + {"result": {"success": False, "code": 1, "message": "denied"}}, + {"data": []}, +]) +def test_browsing_history_reject_failure_or_missing_pagination(monkeypatch, response): + monkeypatch.setattr(XhsClient, "_main_api_get", lambda *args: response) + with XhsClient({"a1": "fake"}) as client: + with pytest.raises(XhsApiError): + client.get_browsing_history() + + +@pytest.mark.parametrize("command,expected", [ + (["following", "--page", "2", "--page-size", "1", "--json"], {"page": 2, "page_size": 1}), + (["history", "--cursor", "next", "--note-index", "20", "--json"], {"cursor": "next", "note_index": 20}), +]) +def test_following_and_history_cli_pass_pagination(monkeypatch, command, expected): + captured = {} + + class FakeClient: + def get_following(self, **kwargs): + captured.update(kwargs) + return {"users": []} + + def get_browsing_history(self, **kwargs): + captured.update(kwargs) + return {"notes": []} + + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", lambda ctx, action: action(FakeClient())) + result = CliRunner().invoke(cli, command) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["ok"] is True + assert captured == expected diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..4ca8a4f --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,267 @@ +"""Direct-message endpoints and normalizers (no network).""" + +from __future__ import annotations + +import pytest + +from xhs_cli.client import XhsClient +from xhs_cli.command_normalizers import ( + normalize_chat_messages, + normalize_chats, + unwrap_message_content, +) +from xhs_cli.exceptions import RateLimitedError, XhsApiError + + +class TestMessagingEndpoints: + """私信端点在 /api/im/web/ 命名空间,但复用同一套 x-s 签名。""" + + @pytest.mark.parametrize( + ("call", "uri"), + [ + (lambda c: c.get_im_unread(), "/api/im/web/chat/get_unread"), + (lambda c: c.get_chats(), "/api/im/web/v3/chats"), + (lambda c: c.get_chat_messages("peer-1"), "/api/im/web/messages/history"), + ], + ) + def test_endpoint_uris(self, monkeypatch, call, uri): + captured = {} + + def fake_get(self, target, params=None): + captured["uri"] = target + captured["params"] = params + return {} + + monkeypatch.setattr(XhsClient, "_main_api_get", fake_get) + client = XhsClient({"a1": "cookie"}) + try: + call(client) + finally: + client.close() + assert captured["uri"] == uri + + def test_chat_messages_params(self, monkeypatch): + captured = {} + monkeypatch.setattr( + XhsClient, "_main_api_get", + lambda self, uri, params=None: captured.update(params=params) or {}, + ) + client = XhsClient({"a1": "cookie"}) + try: + client.get_chat_messages("peer-1", limit=5) + finally: + client.close() + assert captured["params"]["chat_user_id"] == "peer-1" + assert captured["params"]["limit"] == 5 + + +class TestUnwrapMessageContent: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ('{"content":"9.26-9.28两晚"}', "9.26-9.28两晚"), + ("plain text", "plain text"), + ("", ""), + (None, ""), + ("{broken json", "{broken json"), + ], + ) + def test_shapes(self, raw, expected): + assert unwrap_message_content(raw) == expected + + def test_card_prefers_front_chain(self): + """卡片/笔记分享保留 "[笔记]" 类型标记,与网页显示一致。""" + raw = '{"content":"{\\"frontChain\\":\\"[笔记]标题\\",\\"title\\":\\"标题\\"}"}' + assert unwrap_message_content(raw) == "[笔记]标题" + + def test_deep_nesting_stops_at_max_depth(self): + """畸形深嵌套不能死循环。""" + assert unwrap_message_content('{"content":' * 40, max_depth=4) + + +class TestNormalizeChats: + def test_lifts_nickname_and_merges_unread(self): + raw = { + "total": 2, + "chats": [ + {"chat_user_id": "u1", "last_msg_content": '{"content":"hi"}', + "last_msg_time": 7, "info": {"nickname": "Alice", "is_friend": True}}, + {"chat_user_id": "u2", "last_msg_content": "yo", + "last_msg_time": 8, "info": {"user_name": "bob"}}, + ], + } + out = normalize_chats(raw, {"u1": 3}) + assert [c["nickname"] for c in out["chats"]] == ["Alice", "bob"] + assert out["chats"][0]["unread"] == 3 + assert out["chats"][1]["unread"] == 0 + assert out["unread_total"] == 3 + assert out["chats"][0]["last_msg"] == "hi" + + def test_empty_payload(self): + out = normalize_chats({}, None) + assert out == {"chats": [], "total": 0, "unread_total": 0} + + +class TestNormalizeChatMessages: + def test_direction_uses_self_id(self): + raw = {"out_message_list": [ + {"sender_id": "me", "content": '{"content":"a"}', "created_at": 2}, + {"sender_id": "peer", "content": '{"content":"b"}', "created_at": 1}, + ]} + out = normalize_chat_messages(raw, "me") + assert [m["direction"] for m in out] == ["sent", "received"] + assert [m["text"] for m in out] == ["a", "b"] + + def test_unknown_self_id_marks_direction_unknown(self): + raw = {"out_message_list": [{"sender_id": "me", "content": "a"}]} + assert normalize_chat_messages(raw, "")[0]["direction"] == "unknown" + + def test_revoked_flag(self): + raw = {"out_message_list": [{"sender_id": "p", "content": "x", "revoked": True}]} + assert normalize_chat_messages(raw, "me")[0]["revoked"] is True + + +class TestSendDirectMessage: + """发送走 HTTP short-link:protobuf + RSA,不需要 WebSocket。""" + + def _client(self, monkeypatch, captured, post_result, self_info=None): + monkeypatch.setattr( + XhsClient, "get_self_info", + lambda self: {"user_id": "me-1"} if self_info is None else self_info, + ) + + def fake_post(self, uri, data, header_overrides=None): + captured["uri"] = uri + captured["data"] = data + return post_result + + monkeypatch.setattr(XhsClient, "_main_api_post", fake_post) + return XhsClient({"a1": "cookie"}) + + def test_uses_short_link_endpoint_and_encrypted_payload(self, monkeypatch): + captured = {} + client = self._client(monkeypatch, captured, {"code": 0}) + try: + result = client.send_direct_message("peer-1", "hello") + finally: + client.close() + + assert captured["uri"] == "/api/im/web/short_link/send_message" + message = captured["data"]["message"] + assert isinstance(message, str) + raw = message.encode("latin-1") + assert raw and len(raw) % 128 == 0 + assert result["receiver_id"] == "peer-1" + assert result["local_mid"] + + def test_throttle_checks_dm_bucket(self, monkeypatch): + buckets = [] + monkeypatch.setattr( + "xhs_cli.client_mixins.check_write_budget", lambda bucket: buckets.append(bucket) + ) + captured = {} + client = self._client(monkeypatch, captured, {"code": 0}) + try: + client.send_direct_message("peer-1", "hello") + finally: + client.close() + assert buckets == ["dm"] + + def test_throttle_error_propagates(self, monkeypatch): + def deny(bucket): + raise RateLimitedError("too fast") + + monkeypatch.setattr("xhs_cli.client_mixins.check_write_budget", deny) + monkeypatch.setattr(XhsClient, "get_self_info", lambda self: {"user_id": "me-1"}) + monkeypatch.setattr(XhsClient, "_main_api_post", lambda *a, **k: {"code": 0}) + client = XhsClient({"a1": "cookie"}) + try: + with pytest.raises(RateLimitedError): + client.send_direct_message("peer-1", "hello") + finally: + client.close() + + def test_rejects_nested_failure_code(self, monkeypatch): + captured = {} + client = self._client(monkeypatch, captured, {"code": 12345, "msg": "blocked"}) + try: + with pytest.raises(XhsApiError, match="rejected"): + client.send_direct_message("peer-1", "hello") + finally: + client.close() + + @pytest.mark.parametrize("payload", [None, {}, "str", [], {"data": {"code": 0}}]) + def test_unrecognised_response_is_unconfirmed_not_success(self, monkeypatch, payload): + """空响应/未知形状必须报未确认,不能当成发送成功。""" + captured = {} + client = self._client(monkeypatch, captured, payload) + try: + with pytest.raises(XhsApiError, match="unconfirmed"): + client.send_direct_message("peer-1", "hello") + finally: + client.close() + + def test_server_message_id_is_surfaced(self, monkeypatch): + """真实成功响应形状:{"chat_ack": ..., "message_id": ""}。""" + captured = {} + client = self._client( + monkeypatch, captured, + {"chat_ack": {"ts": 1}, "message_id": "srv-123"}, + ) + try: + out = client.send_direct_message("peer-1", "hello") + finally: + client.close() + assert out["message_id"] == "srv-123" + assert out["local_mid"] and out["local_mid"] != "srv-123" + + @pytest.mark.parametrize("payload", [{"code": 0}, {"success": True}, {"code": "0"}]) + def test_positive_confirmation_accepted(self, monkeypatch, payload): + captured = {} + client = self._client(monkeypatch, captured, payload) + try: + assert client.send_direct_message("peer-1", "hello")["receiver_id"] == "peer-1" + finally: + client.close() + + def test_non_text_content_type_rejected(self, monkeypatch): + captured = {} + client = self._client(monkeypatch, captured, {"code": 0}) + try: + with pytest.raises(XhsApiError, match="content_type=1"): + client.send_direct_message("peer-1", "hello", content_type=2) + finally: + client.close() + + def test_requires_resolvable_sender(self, monkeypatch): + monkeypatch.setattr(XhsClient, "get_self_info", lambda self: {}) + monkeypatch.setattr(XhsClient, "_main_api_post", lambda *a, **k: {}) + client = XhsClient({"a1": "cookie"}) + try: + with pytest.raises(XhsApiError, match="current user id"): + client.send_direct_message("peer-1", "hello") + finally: + client.close() + + def test_empty_content_fails_before_network(self, monkeypatch): + client = XhsClient({"a1": "cookie"}) + self_info_calls = [] + monkeypatch.setattr( + XhsClient, "get_self_info", + lambda self: self_info_calls.append(1) or {"user_id": "me"}, + ) + try: + with pytest.raises(XhsApiError, match="content is required"): + client.send_direct_message("peer-1", "") + finally: + client.close() + assert self_info_calls == [] + + def test_empty_receiver_fails_before_network(self, monkeypatch): + client = XhsClient({"a1": "cookie"}) + monkeypatch.setattr(XhsClient, "get_self_info", lambda self: {"user_id": "me"}) + try: + with pytest.raises(XhsApiError, match="receiver_id is required"): + client.send_direct_message("", "hello") + finally: + client.close() diff --git a/tests/test_qr_login.py b/tests/test_qr_login.py index 4f397b9..74c54d9 100644 --- a/tests/test_qr_login.py +++ b/tests/test_qr_login.py @@ -4,7 +4,12 @@ from xhs_cli.command_normalizers import normalize_xhs_user_payload from xhs_cli.exceptions import XhsApiError -from xhs_cli.qr_login import BrowserQrLoginUnavailable, _normalize_browser_cookies, qrcode_login +from xhs_cli.qr_login import ( + BrowserQrLoginUnavailable, + _camoufox_launch_options, + _normalize_browser_cookies, + qrcode_login, +) class _FakeQrClient: @@ -219,6 +224,15 @@ def test_qrcode_login_falls_back_when_browser_backend_unavailable(monkeypatch): } +def test_browser_assisted_login_excludes_default_addons(): + from camoufox import DefaultAddons + + options = _camoufox_launch_options() + + assert options["headless"] is False + assert options["exclude_addons"] == [DefaultAddons.UBO] + + def test_normalize_browser_cookies_uses_allowlist(): cookies = _normalize_browser_cookies([ {"name": "a1", "value": "a1-value", "domain": ".xiaohongshu.com"}, diff --git a/xhs_cli/cli.py b/xhs_cli/cli.py index a33a08e..d175d69 100644 --- a/xhs_cli/cli.py +++ b/xhs_cli/cli.py @@ -3,6 +3,7 @@ Usage: xhs login / status / logout xhs search [--sort popular|latest] [--type video|image] [--page N] + [--time TAG] [--range TAG] [--distance TAG] xhs read [--xsec-token TOKEN] xhs comments xhs user @@ -21,6 +22,9 @@ xhs unread xhs post --title "..." --body "..." --images img.png xhs delete [-y] + xhs chats + xhs messages + xhs send --content "..." """ from __future__ import annotations @@ -31,7 +35,18 @@ import click from . import __version__ -from .commands import auth, creator, interactions, notifications, reading, social +from .commands import ( + auth, + creator, + history, + interactions, + notifications, + reading, + social, +) +from .commands import ( + messages as messages_cmd, +) def _fix_windows_encoding() -> None: @@ -103,17 +118,24 @@ def cli(ctx, verbose: bool, cookie_source: str): cli.add_command(social.unfollow) cli.add_command(social.favorites) cli.add_command(social.likes) +cli.add_command(social.following) +cli.add_command(history.history) # ─── Creator commands ─────────────────────────────────────────────────────── cli.add_command(creator.post) +cli.add_command(creator.post_video) cli.add_command(creator.my_notes) +cli.add_command(creator.my_notes_data) cli.add_command(creator.delete) # ─── Notification commands ────────────────────────────────────────────────── cli.add_command(notifications.notifications) cli.add_command(notifications.unread) +cli.add_command(messages_cmd.chats) +cli.add_command(messages_cmd.messages) +cli.add_command(messages_cmd.send) if __name__ == "__main__": cli() diff --git a/xhs_cli/client.py b/xhs_cli/client.py index f0e4038..752747f 100644 --- a/xhs_cli/client.py +++ b/xhs_cli/client.py @@ -18,6 +18,7 @@ AuthEndpointsMixin, CreatorEndpointsMixin, InteractionEndpointsMixin, + MessagingEndpointsMixin, NotificationEndpointsMixin, ReadingEndpointsMixin, SocialEndpointsMixin, @@ -43,6 +44,7 @@ class XhsClient( CreatorEndpointsMixin, SocialEndpointsMixin, NotificationEndpointsMixin, + MessagingEndpointsMixin, AuthEndpointsMixin, ): """Xiaohongshu API client with automatic signing, rate limiting, and retry.""" @@ -160,13 +162,23 @@ def _merge_response_cookies(self, resp: httpx.Response) -> None: def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response: self._rate_limit_delay() last_exc: Exception | None = None + # A timeout/5xx can arrive after the server accepted a message or note. + # These endpoints have no verified deduplication guarantee. + single_attempt = method.upper() == "POST" and url.split("?", 1)[0].endswith( + ("/api/im/web/short_link/send_message", "/web_api/sns/v2/note") + ) + attempts = 1 if single_attempt else self._max_retries - for attempt in range(self._max_retries): + for attempt in range(attempts): try: resp = self._http.request(method, url, **kwargs) self._merge_response_cookies(resp) self._mark_request() if resp.status_code in (429, 500, 502, 503, 504): + if single_attempt: + raise XhsApiError( + f"Write returned HTTP {resp.status_code}; outcome unconfirmed, not retried" + ) wait = (2 ** attempt) + random.uniform(0, 1) logger.warning( "HTTP %d from %s, retrying in %.1fs (attempt %d/%d)", @@ -176,6 +188,8 @@ def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response continue return resp except (httpx.TimeoutException, httpx.NetworkError) as exc: + if single_attempt: + raise XhsApiError("Write response unavailable; outcome unconfirmed, not retried") from exc last_exc = exc wait = (2 ** attempt) + random.uniform(0, 1) logger.warning( diff --git a/xhs_cli/client_mixins.py b/xhs_cli/client_mixins.py index b4dada7..0a23530 100644 --- a/xhs_cli/client_mixins.py +++ b/xhs_cli/client_mixins.py @@ -9,10 +9,13 @@ import re import threading import time +import uuid from collections import OrderedDict +from collections.abc import Callable from pathlib import Path from typing import Any +from .command_normalizers import resolve_current_user_id from .constants import CREATOR_HOST, HOME_URL, UPLOAD_HOST, USER_AGENT from .cookies import ( cache_note_context, @@ -23,6 +26,8 @@ ) from .exceptions import NeedVerifyError, UnsupportedOperationError, XhsApiError from .html_parser import extract_note_from_html +from .im_codec import CONTENT_TYPE_TEXT, encode_chat_message, encrypt_payload +from .limiter import check_write_budget logger = logging.getLogger(__name__) @@ -191,6 +196,21 @@ def get_search_session_stats() -> dict[str, Any]: } +def _build_search_filters(filter_overrides: dict[str, str] | None = None) -> list[dict[str, Any]]: + filters = [ + {"tags": list(item["tags"]), "type": item["type"]} + for item in _SEARCH_DEFAULT_FILTERS + ] + if not filter_overrides: + return filters + + for item in filters: + filter_value = filter_overrides.get(item["type"], "").strip() + if filter_value: + item["tags"] = [filter_value] + return filters + + class ReadingEndpointsMixin: """Read-only note, profile, and discovery endpoints.""" @@ -271,6 +291,33 @@ def get_user_notes(self, user_id: str, cursor: str = "") -> dict[str, Any]: "image_scenes": "FD_WM_WEBP", }) + def get_browsing_history(self, cursor: str = "", note_index: int = 0) -> dict[str, Any]: + """Current account's note browsing history, matching the web profile tab.""" + if note_index < 0: + raise ValueError("note_index must be non-negative") + data = self._main_api_get("/api/sns/web/v1/common/dual_feed", { + "scene": "browse_history", + "cursor_score": cursor, + "note_index": note_index, + "image_formats": "jpg,webp,avif", + }) + if not isinstance(data, dict): + raise XhsApiError("Unexpected browsing-history response") + result = data.get("result") or {} + if result.get("success") is False or result.get("code") not in (None, 0, "0"): + raise XhsApiError(f"Browsing history rejected: {result.get('message') or result.get('code')}") + notes = data.get("data") + no_more = data.get("has_no_more", data.get("hasNoMore")) + if not isinstance(notes, list) or not isinstance(no_more, bool): + raise XhsApiError("Unexpected browsing-history response; missing data list or pagination flag") + return { + "notes": notes, + "has_more": not no_more, + "cursor": data.get("next_cursor", data.get("nextCursor", "")) or "", + "next_note_index": note_index + len(notes), + "source": "account_browsing_history", + } + def search_notes( self, keyword: str, @@ -278,6 +325,7 @@ def search_notes( page_size: int = 20, sort: str = "general", note_type: int = 0, + filter_overrides: dict[str, str] | None = None, ) -> Any: search_id, is_new_session = _acquire_search_session(keyword, sort, note_type) if is_new_session: @@ -304,7 +352,7 @@ def search_notes( "sort": sort, "note_type": note_type, "ext_flags": [], - "filters": _SEARCH_DEFAULT_FILTERS, + "filters": _build_search_filters(filter_overrides), "geo": "", "image_formats": ["jpg", "webp", "avif"], }) @@ -385,31 +433,27 @@ def get_home_feed(self, category: str = "homefeed_recommend") -> dict[str, Any]: def get_hot_feed(self, category: str = "homefeed.fashion_v3") -> dict[str, Any]: return self.get_home_feed(category=category) - def get_comments( + def _request_with_note_context( self, note_id: str, - cursor: str = "", - xsec_token: str = "", - top_comment_id: str = "", - xsec_source: str = "", + xsec_token: str, + xsec_source: str, + request: Callable[[str], Any], + *, + purpose: str, ) -> Any: + """Run ``request(token)`` with a resolved xsec_token, retrying once if a cached token is stale.""" cached = get_cached_note_context(note_id) used_cached_context = not xsec_token and bool(cached.get("token")) token, source = self.resolve_xsec_context(note_id, xsec_token, xsec_source) if not token: raise XhsApiError( - "Could not resolve xsec_token for comments. Pass a full note URL or --xsec-token explicitly." + f"Could not resolve xsec_token for {purpose}. Pass a full note URL or --xsec-token explicitly." ) if source: cache_note_context(note_id, token, source) try: - return self._main_api_get("/api/sns/web/v2/comment/page", { - "note_id": note_id, - "cursor": cursor, - "top_comment_id": top_comment_id, - "image_formats": "jpg,webp,avif", - "xsec_token": token, - }) + return request(token) except (NeedVerifyError, XhsApiError): if not used_cached_context: raise @@ -419,14 +463,27 @@ def get_comments( raise if refreshed_source: cache_note_context(note_id, refreshed_token, refreshed_source) + return request(refreshed_token) + + def get_comments( + self, + note_id: str, + cursor: str = "", + xsec_token: str = "", + top_comment_id: str = "", + xsec_source: str = "", + ) -> Any: + def _request(token: str) -> Any: return self._main_api_get("/api/sns/web/v2/comment/page", { "note_id": note_id, "cursor": cursor, "top_comment_id": top_comment_id, "image_formats": "jpg,webp,avif", - "xsec_token": refreshed_token, + "xsec_token": token, }) + return self._request_with_note_context(note_id, xsec_token, xsec_source, _request, purpose="comments") + def get_all_comments( self, note_id: str, @@ -472,13 +529,22 @@ def get_sub_comments( root_comment_id: str, num: int = 30, cursor: str = "", + xsec_token: str = "", + xsec_source: str = "", ) -> Any: - return self._main_api_get("/api/sns/web/v2/comment/sub/page", { - "note_id": note_id, - "root_comment_id": root_comment_id, - "num": num, - "cursor": cursor, - }) + # The sub-comment endpoint requires the same xsec_token as /comment/page; + # without it the API answers with a 461 captcha challenge. + def _request(token: str) -> Any: + return self._main_api_get("/api/sns/web/v2/comment/sub/page", { + "note_id": note_id, + "root_comment_id": root_comment_id, + "num": num, + "cursor": cursor, + "image_formats": "jpg,webp,avif", + "xsec_token": token, + }) + + return self._request_with_note_context(note_id, xsec_token, xsec_source, _request, purpose="sub-comments") class InteractionEndpointsMixin: @@ -528,11 +594,19 @@ def search_topics(self, keyword: str) -> dict[str, Any]: "page": {"page_size": 20, "page": 1}, }) - def search_users(self, keyword: str) -> dict[str, Any]: + def search_users( + self, + keyword: str, + page: int = 1, + page_size: int = 20, + search_id: str = "", + ) -> dict[str, Any]: + if page < 1 or page_size < 1: + raise ValueError("page and page_size must be positive integers") return self._creator_post("/web_api/sns/v1/search/user_info", { "keyword": keyword, - "search_id": str(int(time.time() * 1000)), - "page": {"page_size": 20, "page": 1}, + "search_id": search_id or str(int(time.time() * 1000)), + "page": {"page_size": page_size, "page": page}, }) def get_upload_permit(self, file_type: str = "image", count: int = 1) -> dict[str, str]: @@ -546,6 +620,18 @@ def get_upload_permit(self, file_type: str = "image", count: int = 1) -> dict[st permit = data["uploadTempPermits"][0] return {"fileId": permit["fileIds"][0], "token": permit["token"]} + def get_video_upload_permit(self, count: int = 1) -> dict[str, str]: + """获取视频上传许可""" + data = self._creator_get("/api/media/v1/upload/web/permit", { + "biz_name": "spectrum", + "scene": "video", + "file_count": count, + "version": 1, + "source": "web", + }) + permit = data["uploadTempPermits"][0] + return {"fileId": permit["fileIds"][0], "token": permit["token"]} + def upload_file( self, file_id: str, @@ -570,6 +656,31 @@ def upload_file( if resp.status_code >= 400: raise XhsApiError(f"Upload failed: {resp.status_code} {resp.reason_phrase}") + def upload_video( + self, + file_id: str, + token: str, + file_path: str, + content_type: str | None = None, + ) -> None: + """上传视频文件""" + with open(file_path, "rb") as f: + file_data = f.read() + + url = f"{UPLOAD_HOST}/{file_id}" + content_type = content_type or mimetypes.guess_type(file_path)[0] or "video/mp4" + resp = self._request_with_retry( + "PUT", + url, + headers={ + "X-Cos-Security-Token": token, + "Content-Type": content_type, + }, + content=file_data, + ) + if resp.status_code >= 400: + raise XhsApiError(f"Video upload failed: {resp.status_code} {resp.reason_phrase}") + def create_image_note( self, title: str, @@ -593,7 +704,7 @@ def create_image_note( "note_id": "", "desc": desc, "source": '{"type":"web","ids":"","extraInfo":"{\\"subType\\":\\"official\\"}"}', - "business_binds": json.dumps(business_binds), + "business_binds": json.dumps(business_binds, ensure_ascii=False), "ats": [], "hash_tag": topics or [], "post_loc": {}, @@ -607,6 +718,54 @@ def create_image_note( "referer": f"{CREATOR_HOST}/", }) + def create_video_note( + self, + title: str, + desc: str, + video_file_id: str, + cover_file_id: str | None = None, + topics: list[dict[str, str]] | None = None, + is_private: bool = False, + ) -> Any: + """创建视频笔记""" + # 视频信息 + video_info = { + "file_id": video_file_id, + "metadata": {"source": -1} + } + + # 封面信息(如果有) + images = [] + if cover_file_id: + images = [{"file_id": cover_file_id, "metadata": {"source": -1}}] + + business_binds = { + "version": 1, + "noteId": 0, + "noteOrderBind": {}, + "notePostTiming": {"postTime": None}, + "noteCollectionBind": {"id": ""}, + } + + data = { + "common": { + "type": "video", + "title": title, + "note_id": "", + "desc": desc, + "source": '{"type":"web","ids":"","extraInfo":"{\\"subType\\":\\"official\\"}"}', + "business_binds": json.dumps(business_binds, ensure_ascii=False), + "ats": [], + "hash_tag": topics or [], + "post_loc": {}, + "privacy_info": {"op_type": 1, "type": 1 if is_private else 0}, + }, + "image_info": {"images": images} if images else None, + "video_info": video_info, + } + + return self._creator_post("/web_api/sns/v2/note", data) + def delete_note(self, note_id: str) -> dict[str, Any]: try: return self._creator_post("/api/galaxy/creator/note/delete", { @@ -627,10 +786,45 @@ def get_creator_note_list(self, tab: int = 0, page: int = 0) -> dict[str, Any]: "page": page, }) + def get_creator_note_analyze_list(self, type: int = 0, page_size: int = 10, page_num: int = 1) -> dict[str, Any]: + data = self._creator_get("/api/galaxy/creator/datacenter/note/analyze/list", { + "type": type, + "page_size": page_size, + "page_num": page_num, + }) + result = data.get("result") if isinstance(data, dict) else None + if isinstance(result, dict) and ( + result.get("success") is False or result.get("code") not in (None, 0, "0") + ): + raise XhsApiError( + f"Creator analytics rejected: {result.get('message') or result.get('code')}", + code=result.get("code"), + response=result, + ) + return data + class SocialEndpointsMixin: """Social graph and saved-content endpoints.""" + def get_following(self, page: int = 1, page_size: int = 200) -> dict[str, Any]: + """Current account's following list, using the web IM social service.""" + if page < 1 or not 1 <= page_size <= 200: + raise ValueError("page must be positive; page_size must be between 1 and 200") + data = self._main_api_get("/api/im/web/users/following/all", {"page": page, "size": page_size}) + users = data.get("follow_user_d_t_o_list") if isinstance(data, dict) else None + if not isinstance(users, list): + raise XhsApiError("Unexpected following response; expected follow_user_d_t_o_list") + return { + "users": users, + "page": page, + "page_size": page_size, + "has_more": len(users) >= page_size, + "next_page": page + 1 if len(users) >= page_size else None, + "pagination_inferred": True, + "source": "im_following", + } + def follow_user(self, user_id: str) -> dict[str, Any]: return self._main_api_post("/api/sns/web/v1/user/follow", {"target_user_id": user_id}) @@ -677,6 +871,145 @@ def get_notification_connections(self, cursor: str = "", num: int = 20) -> dict[ }) +def _message_delivery_error(result: Any) -> str: + """Return a description unless the IM response positively confirms delivery. + + Deliberately requires *positive* confirmation rather than the absence of a + failure marker. ``_handle_response`` returns ``None`` for an empty body, so + "no failure marker found" would otherwise report a silent drop as a + successful send — the exact failure mode that makes a write API untrustworthy + for outreach work. An unrecognised shape is reported as unconfirmed, which + is the safe direction: a false failure is recoverable, a false success is not. + """ + if not isinstance(result, dict) or not result: + return "empty or non-dict response — delivery unconfirmed" + if result.get("success") is False: + return str(result.get("msg") or result.get("message") or "success=false") + code = result.get("code") + if code not in (None, 0, "0"): + return str(result.get("msg") or result.get("message") or f"code={code}") + # 观测到的成功响应形状是 {"chat_ack": ..., "message_id": ""}: + # 服务端签发的 message_id 就是投递凭证,比 code=0 更强。 + if result.get("message_id") or result.get("success") is True or code in (0, "0"): + return "" + # 带上顶层字段名,让"未确认"可诊断而不必靠猜。 + return ( + "response carries no delivery marker — unconfirmed " + f"(keys: {sorted(result)})" + ) + + +class MessagingEndpointsMixin: + """Direct-message (IM) endpoints: conversation reads and short-link send. + + 小红书私信不在 ``/api/sns/web/`` 命名空间下,而是独立的 ``/api/im/web/`` + 前缀,但同在 edith 主机、共用同一套 x-s 签名,所以直接复用 + ``_main_api_get`` / ``_main_api_post`` 即可,无需额外的传输层或握手。 + + 注意 ``/api/sns/web/unread_count``(``get_unread_count``)只统计通知 + (评论和@ / 赞和收藏 / 新增关注),**不包含私信**;私信未读要单独取 + ``/api/im/web/chat/get_unread``。 + + 发送走 HTTP short-link 兜底:把 protobuf 私信体 RSA 加密后 POST, + 不需要建立 WebSocket(详见 ``im_codec``)。 + """ + + def get_im_unread(self) -> dict[str, Any]: + """Per-peer direct-message unread counts, keyed by user id.""" + return self._main_api_get("/api/im/web/chat/get_unread", {}) + + def get_chats(self, limit: int = 50, page: int = 0) -> dict[str, Any]: + """Conversation list (the message page's left column).""" + return self._main_api_get("/api/im/web/v3/chats", { + "limit": limit, + "complete": "true", + "page": page, + "source": "pc", + }) + + def get_chat_messages( + self, + chat_user_id: str, + limit: int = 30, + last_id: int = 0, + start_id: int = 0, + ) -> dict[str, Any]: + """Message history for one conversation, newest first.""" + return self._main_api_get("/api/im/web/messages/history", { + "chat_user_id": chat_user_id, + "last_id": last_id, + "start_id": start_id, + "limit": limit, + }) + + def send_direct_message( + self, + receiver_id: str, + content: str, + *, + content_type: int = 1, + ) -> dict[str, Any]: + """Send one direct message over the RSA short-link HTTP transport. + + 发送前先过本地写入限流(``XHS_WRITE_RPM``,默认 1 写/分钟),再解析 + 当前用户 id 作为 sender。返回体不做 ``ok`` 硬编码:``_handle_response`` + 已拒绝 ``success=false``,这里再检查嵌套 ``code``,并在失败时抛错。 + """ + receiver = str(receiver_id or "") + text = str(content or "") + if not receiver: + raise XhsApiError("receiver_id is required") + if not text: + raise XhsApiError("content is required") + # 只支持文本;图片/卡片需要另一种 payload 结构,传 1 以外的值会造出 + # 服务端无法解析的消息体。 + if int(content_type) != CONTENT_TYPE_TEXT: + raise XhsApiError( + f"only content_type={CONTENT_TYPE_TEXT} (text) is supported" + ) + + sender = resolve_current_user_id(self.get_self_info()) + if not sender: + raise XhsApiError( + "Cannot resolve the current user id — run 'xhs login' or 'xhs status' first" + ) + + # Consume the throttle immediately before the sensitive POST, so a + # session-expiry retry through run_client_action is not double-charged. + check_write_budget("dm") + + local_mid = str(uuid.uuid4()) + payload = encode_chat_message( + local_mid=local_mid, + timestamp_ms=int(time.time() * 1000), + sender_id=sender, + receiver_id=receiver, + content=text, + content_type=int(content_type), + ) + encrypted = encrypt_payload(payload) + result = self._main_api_post( + "/api/im/web/short_link/send_message", + {"message": encrypted.decode("latin-1")}, + ) + + rejection = _message_delivery_error(result) + if rejection: + raise XhsApiError( + f"Direct message rejected by XHS: {rejection}", + response=result if isinstance(result, dict) else None, + ) + # message_id 由服务端签发,是真正的投递凭证;local_mid 只是客户端 + # 生成的追踪 id,不能当成送达证明。 + server_id = result.get("message_id", "") if isinstance(result, dict) else "" + return { + "message_id": str(server_id), + "local_mid": local_mid, + "receiver_id": receiver, + "response": result, + } + + class AuthEndpointsMixin: """Authentication-specific endpoints.""" diff --git a/xhs_cli/command_normalizers.py b/xhs_cli/command_normalizers.py index cdb9033..88d2058 100644 --- a/xhs_cli/command_normalizers.py +++ b/xhs_cli/command_normalizers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Any @@ -62,4 +63,92 @@ def select_topic_payload(topic_data: Any, fallback_name: str) -> list[dict[str, def resolve_current_user_id(info: dict[str, Any]) -> str: - return info.get("user_id", "") if isinstance(info, dict) else "" + """Extract the current user id from activate / self-info payloads. + + 不同端点的 payload 把 id 放在不同层级(``login_info`` / ``basic_info`` / + 顶层),逐个回退,避免把已登录用户当成空 id。 + """ + if not isinstance(info, dict): + return "" + login_info = info.get("login_info") + if isinstance(login_info, dict) and login_info.get("user_id"): + return str(login_info["user_id"]) + basic = info.get("basic_info", info) + if isinstance(basic, dict) and basic.get("user_id"): + return str(basic["user_id"]) + for key in ("user_id", "userid"): + if info.get(key): + return str(info[key]) + return "" + +# frontChain 带 "[笔记]"/"[卡片]" 类型标记,比裸 title 更接近网页显示 +_CONTENT_TEXT_KEYS = ("text", "frontChain", "title", "content") + + +def unwrap_message_content(raw: Any, max_depth: int = 8) -> str: + """Peel a direct-message body down to its innermost text. + + 私信正文是多层嵌套的 JSON 字符串(卡片/笔记分享往往嵌 3 层以上), + 逐层剥到第一个纯文本为止;剥不动就原样返回,不抛错。 + """ + value: Any = raw + for _ in range(max_depth): + if isinstance(value, str): + stripped = value.strip() + if not stripped.startswith(("{", "[")): + return value + try: + value = json.loads(stripped) + except (json.JSONDecodeError, TypeError, ValueError): + return value + continue + if isinstance(value, dict): + for key in _CONTENT_TEXT_KEYS: + if key in value: + value = value[key] + break + else: + return json.dumps(value, ensure_ascii=False) + continue + return "" if value is None else str(value) + return value if isinstance(value, str) else str(value) + + +def normalize_chats( + data: dict[str, Any], unread_map: dict[str, Any] | None = None +) -> dict[str, Any]: + """Flatten the conversation list; merge per-peer unread when available.""" + counts = unread_map or {} + chats = [] + for chat in data.get("chats", []) or []: + info = chat.get("info") or {} + peer_id = str(chat.get("chat_user_id", "")) + chats.append({ + "user_id": peer_id, + "nickname": info.get("nickname", "") or info.get("user_name", ""), + "unread": int(counts.get(peer_id, 0) or 0), + "last_msg": unwrap_message_content(chat.get("last_msg_content", "")), + "last_msg_time": chat.get("last_msg_time", 0), + "is_official": bool(info.get("is_official", False)), + "is_friend": bool(info.get("is_friend", False)), + }) + return { + "chats": chats, + "total": int(data.get("total", len(chats)) or 0), + "unread_total": sum(c["unread"] for c in chats), + } + + +def normalize_chat_messages(data: dict[str, Any], me_id: str = "") -> list[dict[str, Any]]: + """Flatten one conversation's history (API returns newest first).""" + out = [] + for msg in data.get("out_message_list", []) or []: + sender = str(msg.get("sender_id", "")) + out.append({ + "direction": ("sent" if sender == me_id else "received") if me_id else "unknown", + "sender_id": sender, + "text": unwrap_message_content(msg.get("content", "")), + "created_at": msg.get("created_at", 0), + "revoked": bool(msg.get("revoked", False)), + }) + return out diff --git a/xhs_cli/commands/creator.py b/xhs_cli/commands/creator.py index 63237f5..d2cffe3 100644 --- a/xhs_cli/commands/creator.py +++ b/xhs_cli/commands/creator.py @@ -1,15 +1,18 @@ """Creator commands: post, my-notes, delete.""" import re +from pathlib import Path import click from ..command_normalizers import select_topic_payload +from ..exceptions import XhsApiError from ..formatter import ( extract_note_id, maybe_print_structured, print_info, print_success, + render_creator_note_data, render_creator_notes, ) from ..note_refs import save_index_from_notes @@ -22,7 +25,25 @@ def extract_hashtags(body: str) -> list[str]: Matches '#tag' at start-of-string or preceded by whitespace. Does not match URL fragments like 'https://example.com#section'. """ - return re.findall(r"(?:^|(?<=\s))#([^\s#]+)", body) + return [tag.removesuffix("[话题]") for tag in re.findall(r"(?:^|(?<=\s))#([^\s#]+)", body)] + + +def format_body_hashtags(body: str, tag_map: dict[str, str]) -> str: + """Format hashtag occurrences in body to Xiaohongshu's native '#topic[话题]#' syntax. + + Matches '#tag' at start-of-string or preceded by whitespace. + Replace complete tokens in one pass, including already-native markup. + """ + if not tag_map: + return body + + def replace(match: re.Match[str]) -> str: + raw_tag = match.group(1).removesuffix("[话题]") + if raw_tag not in tag_map: + return match.group(0) + return f"#{tag_map[raw_tag]}[话题]#" + + return re.sub(r"(?:^|(?<=\s))#([^\s#]+)#?", replace, body) @click.command() @@ -63,13 +84,19 @@ def _publish(client): unique_topics = unique_topics[:10] resolved_topics = [] + tag_map: dict[str, str] = {} for t in unique_topics: topic_data = client.search_topics(t) - resolved_topics.extend(select_topic_payload(topic_data, t)) + payloads = [p for p in select_topic_payload(topic_data, t) if p.get("id") and p.get("name")] + if payloads: + resolved_topics.extend(payloads) + tag_map[t] = payloads[0].get("name", t) + + formatted_desc = format_body_hashtags(body, tag_map) return client.create_image_note( title=title, - desc=body, + desc=formatted_desc, image_file_ids=file_ids, topics=resolved_topics, is_private=is_private, @@ -105,6 +132,25 @@ def _my_notes_action(client): ) +@click.command("my-notes-data") +@click.option("--page", default=1, help="Page number (1-indexed)") +@click.option("--page-size", default=10, help="Page size") +@structured_output_options +@click.pass_context +def my_notes_data(ctx, page: int, page_size: int, as_json: bool, as_yaml: bool): + """List your own published notes data.""" + def _my_notes_data_action(client): + return client.get_creator_note_analyze_list(page_num=page, page_size=page_size) + + handle_command( + ctx, + action=_my_notes_data_action, + render=render_creator_note_data, + as_json=as_json, + as_yaml=as_yaml, + ) + + @click.command("delete") @click.argument("id_or_url") @structured_output_options @@ -123,3 +169,80 @@ def delete(ctx, id_or_url: str, as_json: bool, as_yaml: bool, yes: bool): print_success(f"Deleted note {note_id}") except Exception as exc: exit_for_error(exc, as_json=as_json, as_yaml=as_yaml) + + +@click.command("post-video") +@click.option("--title", required=True, help="Video note title") +@click.option("--body", required=True, help="Video note body text") +@click.option("--video", required=True, help="Video file path (.mp4/.mov)") +@click.option("--cover", help="Cover image file path (optional)") +@click.option("--topic", "topics_flag", multiple=True, help="Topic(s)/hashtag(s) to search and attach") +@click.option("--private", "is_private", is_flag=True, help="Publish as private note") +@structured_output_options +@click.pass_context +def post_video( + ctx, + title: str, + body: str, + video: str, + cover: str | None, + topics_flag: tuple[str, ...], + is_private: bool, + as_json: bool, + as_yaml: bool, +): + """Publish a video note.""" + def _publish(client): + # Validate all local files before requesting permits or uploading. + if not Path(video).is_file(): + raise XhsApiError(f"Video file not found: {video}") + if Path(video).suffix.lower() not in {".mp4", ".mov"}: + raise XhsApiError("Video file must be .mp4 or .mov") + if Path(video).stat().st_size == 0: + raise XhsApiError("Video file is empty") + if cover and not Path(cover).is_file(): + raise XhsApiError(f"Cover image not found: {cover}") + + # 上传视频 + click.echo(f"Uploading video {video}...", err=True) + video_permit = client.get_video_upload_permit() + client.upload_video(video_permit["fileId"], video_permit["token"], video) + video_file_id = video_permit["fileId"] + click.echo(f"Uploaded video: {video}", err=True) + + # 上传封面(如果提供) + cover_file_id = None + if cover: + click.echo(f"Uploading cover {cover}...", err=True) + cover_permit = client.get_upload_permit() + client.upload_file(cover_permit["fileId"], cover_permit["token"], cover) + cover_file_id = cover_permit["fileId"] + click.echo(f"Uploaded cover: {cover}", err=True) + + # 处理话题 + topics = [] + tag_map = {} + for topic in list(dict.fromkeys([*topics_flag, *extract_hashtags(body)]))[:10]: + topic_data = client.search_topics(topic) + payloads = [p for p in select_topic_payload(topic_data, topic) if p.get("id") and p.get("name")] + if payloads: + topics.extend(payloads) + tag_map[topic] = payloads[0]["name"] + + # 创建视频笔记 + return client.create_video_note( + title=title, + desc=format_body_hashtags(body, tag_map), + video_file_id=video_file_id, + cover_file_id=cover_file_id, + topics=topics, + is_private=is_private, + ) + + handle_command( + ctx, + action=_publish, + render=lambda _data: print_success(f"Video note published: {title}" + (" (private)" if is_private else "")), + as_json=as_json, + as_yaml=as_yaml, + ) diff --git a/xhs_cli/commands/history.py b/xhs_cli/commands/history.py new file mode 100644 index 0000000..37db457 --- /dev/null +++ b/xhs_cli/commands/history.py @@ -0,0 +1,54 @@ +"""Read the current account's server-side note browsing history.""" + +from datetime import datetime + +import click +from rich.table import Table + +from ..formatter import console, print_info +from ._common import handle_command, structured_output_options + + +def _browse_time(value): + try: + timestamp = float(value) + if timestamp > 100_000_000_000: + timestamp /= 1000 + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (TypeError, ValueError, OSError, OverflowError): + return str(value or "") + + +@click.command("history") +@click.option("--cursor", default="", help="Continuation cursor returned by the previous page") +@click.option("--note-index", default=0, type=click.IntRange(min=0), help="Previous page's next_note_index") +@structured_output_options +@click.pass_context +def history(ctx, cursor: str, note_index: int, as_json: bool, as_yaml: bool): + """Read the current account's note browsing history (web profile history tab).""" + def _render(data): + table = Table(title="笔记浏览历史") + table.add_column("浏览时间") + table.add_column("标题") + table.add_column("作者") + table.add_column("笔记 ID") + for note in data["notes"]: + user = note.get("user") or {} + table.add_row( + _browse_time(note.get("browse_time")), str(note.get("title", "")), + str(user.get("nickname", "")), str(note.get("id", "")), + ) + console.print(table) + if data["has_more"]: + if data["cursor"]: + print_info(f"More history — use --cursor {data['cursor']} --note-index {data['next_note_index']}") + else: + print_info("API reports more history but returned no continuation cursor") + + handle_command( + ctx, + action=lambda client: client.get_browsing_history(cursor=cursor, note_index=note_index), + render=_render, + as_json=as_json, + as_yaml=as_yaml, + ) diff --git a/xhs_cli/commands/messages.py b/xhs_cli/commands/messages.py new file mode 100644 index 0000000..8f2ecb9 --- /dev/null +++ b/xhs_cli/commands/messages.py @@ -0,0 +1,107 @@ +"""Direct-message commands: conversation list, history, and send. + +读走 ``/api/im/web/`` 的 GET 端点;发送走 HTTP short-link 兜底(protobuf + RSA, +不需要 WebSocket)。发送是敏感写操作,受本地写入限流保护。 +""" + +import click + +from ..command_normalizers import ( + normalize_chat_messages, + normalize_chats, + resolve_current_user_id, +) +from ..formatter import console, print_success +from ._common import handle_command, run_client_action, structured_output_options + + +@click.command() +@click.option("--limit", default=50, help="Conversations per page") +@click.option("--page", default=0, help="Page number") +@click.option( + "--unread/--no-unread", "with_unread", default=True, + help="Merge per-peer unread counts (one extra request)", +) +@structured_output_options +@click.pass_context +def chats(ctx, limit: int, page: int, with_unread: bool, as_json: bool, as_yaml: bool): + """List direct-message conversations.""" + def _load(client): + raw = client.get_chats(limit=limit, page=page) + counts = {} + if with_unread: + counts = (client.get_im_unread() or {}).get("user_chat_unread_counts", {}) + return normalize_chats(raw, counts) + + def _render(data): + console.print( + f"💬 会话 [bold]{len(data['chats'])}[/bold]/{data['total']}" + f" 未读 [bold]{data['unread_total']}[/bold]" + ) + for chat in data["chats"]: + badge = f" [red]({chat['unread']})[/red]" if chat["unread"] else "" + console.print( + f" {chat['nickname'] or chat['user_id']}{badge}" + f" [dim]{' '.join(chat['last_msg'].split())[:40]}[/dim]" + ) + console.print(f" [dim]{chat['user_id']}[/dim]") + + handle_command(ctx, action=_load, render=_render, as_json=as_json, as_yaml=as_yaml) + + +@click.command() +@click.argument("user_id") +@click.option("--limit", default=30, help="Messages to fetch") +@structured_output_options +@click.pass_context +def messages(ctx, user_id: str, limit: int, as_json: bool, as_yaml: bool): + """Read the message history of one conversation.""" + me_id = "" + try: + me_id = resolve_current_user_id(run_client_action(ctx, lambda c: c.get_self_info())) + except Exception: # noqa: BLE001 — direction labels are best-effort + me_id = "" + + def _render(items): + if not items: + console.print("[dim](无消息)[/dim]") + return + for item in reversed(items): # API 返回最新在前,展示按时间正序 + arrow = {"sent": "→", "received": "←", "unknown": "?"}[item["direction"]] + tag = " [dim](已撤回)[/dim]" if item["revoked"] else "" + # 正文可能自带换行,单行展示时压平(--json 输出保持原样) + flat = " ".join(item["text"].split()) + console.print(f" {arrow} {flat[:70]}{tag}") + + handle_command( + ctx, + action=lambda client: normalize_chat_messages( + client.get_chat_messages(user_id, limit=limit), me_id + ), + render=_render, + as_json=as_json, + as_yaml=as_yaml, + ) + + +@click.command() +@click.argument("user_id") +@click.option("--content", "-c", required=True, help="Message text") +@structured_output_options +@click.pass_context +def send(ctx, user_id: str, content: str, as_json: bool, as_yaml: bool): + """Send a direct message to a user (from `xhs chats`). + + 发送是敏感写操作,默认受本地限流保护(1 条/分钟); + 可用环境变量 XHS_WRITE_RPM 调整或设为 0 关闭。 + """ + handle_command( + ctx, + action=lambda client: client.send_direct_message(user_id, content), + render=lambda data: print_success( + f"私信已发送 → {data['receiver_id']} " + f"[dim]message_id {data['message_id'] or '?'}[/dim]" + ), + as_json=as_json, + as_yaml=as_yaml, + ) diff --git a/xhs_cli/commands/reading.py b/xhs_cli/commands/reading.py index 33f5980..09f6334 100644 --- a/xhs_cli/commands/reading.py +++ b/xhs_cli/commands/reading.py @@ -1,12 +1,16 @@ """Reading commands: search, read, comments, sub-comments, user, user-posts, feed, hot, topics, search-user.""" +from pathlib import Path + import click from ..command_normalizers import normalize_paged_notes from ..cookies import cache_note_context +from ..downloads import DEFAULT_DOWNLOAD_DIR, download_note_images from ..formatter import ( maybe_print_structured, print_info, + print_success, render_comments, render_feed, render_note, @@ -54,10 +58,24 @@ def _cache_tokens_from_items(data: dict, *, xsec_source: str) -> None: @click.argument("keyword") @click.option("--sort", type=click.Choice(["general", "popular", "latest"]), default="general", help="Sort order") @click.option("--type", "note_type", type=click.Choice(["all", "video", "image"]), default="all", help="Note type") +@click.option("--time", "note_time", default="不限", show_default=True, help="Search time filter tag") +@click.option("--range", "note_range", default="不限", show_default=True, help="Search range filter tag") +@click.option("--distance", default="不限", show_default=True, help="Search distance filter tag") @click.option("--page", default=1, help="Page number") @structured_output_options @click.pass_context -def search(ctx, keyword: str, sort: str, note_type: str, page: int, as_json: bool, as_yaml: bool): +def search( + ctx, + keyword: str, + sort: str, + note_type: str, + note_time: str, + note_range: str, + distance: str, + page: int, + as_json: bool, + as_yaml: bool, +): """Search notes by keyword.""" def _search_action(client): result = client.search_notes( @@ -65,6 +83,11 @@ def _search_action(client): page=page, sort=SORT_MAP[sort], note_type=TYPE_MAP[note_type], + filter_overrides={ + "filter_note_time": note_time, + "filter_note_range": note_range, + "filter_pos_distance": distance, + }, ) _cache_tokens_from_items(result, xsec_source="pc_search") save_index_from_items(result, xsec_source="pc_search") @@ -82,9 +105,26 @@ def _search_action(client): @click.command() @click.argument("id_or_url") @click.option("--xsec-token", default="", help="Security token (or reuse a cached token for this note)") +@click.option("--save-images", is_flag=True, help="Download note images after reading") +@click.option( + "--output", + "output_dir", + default=DEFAULT_DOWNLOAD_DIR, + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + show_default=True, + help="Directory for --save-images downloads", +) @structured_output_options @click.pass_context -def read(ctx, id_or_url: str, xsec_token: str, as_json: bool, as_yaml: bool): +def read( + ctx, + id_or_url: str, + xsec_token: str, + save_images: bool, + output_dir: Path, + as_json: bool, + as_yaml: bool, +): """Read a note by ID, URL, or short index.""" note_id, token, url_source = resolve_note_reference(id_or_url, xsec_token=xsec_token) xsec_source = url_source or "pc_feed" @@ -97,6 +137,30 @@ def _read_action(client): kwargs["xsec_source"] = url_source return client.get_note_detail(note_id, **kwargs) + if save_images: + try: + data = run_client_action(ctx, _read_action) + saved_images = download_note_images(data, output_dir) + if not maybe_print_structured( + { + "note": data, + "saved_images": [ + {"index": image.index, "url": image.url, "path": str(image.path)} + for image in saved_images + ], + }, + as_json=as_json, + as_yaml=as_yaml, + ): + render_note(data) + if saved_images: + print_success(f"Saved {len(saved_images)} image(s) to {saved_images[0].path.parent}") + else: + print_info("No images to save") + return + except Exception as exc: + exit_for_error(exc, as_json=as_json, as_yaml=as_yaml) + handle_command( ctx, action=_read_action, @@ -226,16 +290,27 @@ def topics(ctx, keyword: str, as_json: bool, as_yaml: bool): @click.command("sub-comments") -@click.argument("note_id") +@click.argument("id_or_url") @click.argument("comment_id") @click.option("--cursor", default="", help="Pagination cursor") +@click.option("--xsec-token", default="", help="Security token") @structured_output_options @click.pass_context -def sub_comments(ctx, note_id: str, comment_id: str, cursor: str, as_json: bool, as_yaml: bool): - """View replies to a specific comment.""" +def sub_comments(ctx, id_or_url: str, comment_id: str, cursor: str, xsec_token: str, as_json: bool, as_yaml: bool): + """View replies to a specific comment on a note (by ID, URL, or short index).""" + note_id, token, url_source = resolve_note_reference(id_or_url, xsec_token=xsec_token) + if token: + cache_note_context(note_id, token, url_source or "pc_feed") + + def _load_sub_comments(client): + kwargs = {"xsec_token": token} + if url_source: + kwargs["xsec_source"] = url_source + return client.get_sub_comments(note_id, comment_id, cursor=cursor, **kwargs) + handle_command( ctx, - action=lambda client: client.get_sub_comments(note_id, comment_id, cursor=cursor), + action=_load_sub_comments, render=render_comments, as_json=as_json, as_yaml=as_yaml, @@ -244,13 +319,16 @@ def sub_comments(ctx, note_id: str, comment_id: str, cursor: str, as_json: bool, @click.command("search-user") @click.argument("keyword") +@click.option("--page", default=1, type=click.IntRange(min=1), show_default=True, help="Page number (1-indexed)") +@click.option("--page-size", default=20, type=click.IntRange(min=1), show_default=True, help="Users per page") +@click.option("--search-id", default="", help="Reuse a search session ID across pages (optional)") @structured_output_options @click.pass_context -def search_user(ctx, keyword: str, as_json: bool, as_yaml: bool): +def search_user(ctx, keyword: str, page: int, page_size: int, search_id: str, as_json: bool, as_yaml: bool): """Search for users by keyword.""" handle_command( ctx, - action=lambda client: client.search_users(keyword), + action=lambda client: client.search_users(keyword, page=page, page_size=page_size, search_id=search_id), render=render_users, as_json=as_json, as_yaml=as_yaml, diff --git a/xhs_cli/commands/social.py b/xhs_cli/commands/social.py index 46c2295..24b39b7 100644 --- a/xhs_cli/commands/social.py +++ b/xhs_cli/commands/social.py @@ -4,9 +4,10 @@ from typing import Any import click +from rich.table import Table from ..command_normalizers import normalize_paged_notes, resolve_current_user_id -from ..formatter import print_info, print_success, render_user_posts +from ..formatter import console, print_info, print_success, render_user_posts from ..note_refs import save_index_from_notes from ._common import handle_command, run_client_action, structured_output_options @@ -105,3 +106,29 @@ def likes(ctx, user_id: str | None, cursor: str, as_json: bool, as_yaml: bool): user_id=user_id, cursor=cursor, as_json=as_json, as_yaml=as_yaml, ) + +@click.command("following") +@click.option("--page", default=1, type=click.IntRange(min=1), show_default=True) +@click.option("--page-size", default=200, type=click.IntRange(1, 200), show_default=True) +@structured_output_options +@click.pass_context +def following(ctx, page: int, page_size: int, as_json: bool, as_yaml: bool): + """List the current account's following users (web IM endpoint).""" + def _render(data): + table = Table(title="关注列表") + table.add_column("昵称") + table.add_column("用户 ID") + for item in data["users"]: + table.add_row(str(item.get("nick_name", "")), str(item.get("user_id", ""))) + console.print(table) + if data["has_more"]: + print_info(f"More users may be available — use --page {data['next_page']} --page-size {page_size}") + + handle_command( + ctx, + action=lambda client: client.get_following(page=page, page_size=page_size), + render=_render, + as_json=as_json, + as_yaml=as_yaml, + ) + diff --git a/xhs_cli/cookies.py b/xhs_cli/cookies.py index 96665a4..3c54a55 100644 --- a/xhs_cli/cookies.py +++ b/xhs_cli/cookies.py @@ -436,6 +436,8 @@ def extract_browser_cookies(source: str = "auto") -> tuple[str, dict[str, str]] Returns ``(browser_name, cookies)`` on success, or ``None``. """ + return None # HERMES PATCH: never decrypt browser cookies (avoids macOS keychain prompts) + if source != "auto": cookies = _extract_in_process(source) if cookies: diff --git a/xhs_cli/downloads.py b/xhs_cli/downloads.py new file mode 100644 index 0000000..5eb921a --- /dev/null +++ b/xhs_cli/downloads.py @@ -0,0 +1,68 @@ +"""Helpers for downloading media referenced by note payloads.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +import httpx + +from .formatter_normalizers import normalize_note_detail + +DEFAULT_DOWNLOAD_DIR = "xhs-downloads" + + +@dataclass(frozen=True) +class SavedImage: + index: int + url: str + path: Path + + +def _safe_path_part(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip(".-") + return cleaned or fallback + + +def _image_extension(url: str) -> str: + parsed = urlparse(url) + path = parsed.path.split("!", 1)[0] + suffix = Path(path).suffix.lower() + if suffix in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"}: + return suffix + return ".jpg" + + +def _note_id_from_detail(data: dict) -> str: + items = data.get("items", []) + if not items: + return "note" + note_card = items[0].get("note_card", {}) + note_id = items[0].get("id") or note_card.get("note_id") + return _safe_path_part(str(note_id or ""), "note") + + +def download_note_images(data: dict, output_dir: Path | str = DEFAULT_DOWNLOAD_DIR) -> list[SavedImage]: + """Download images referenced by a note detail payload.""" + note = normalize_note_detail(data) + if not note: + return [] + + image_urls = note.get("image_urls", []) + if not image_urls: + return [] + + note_dir = Path(output_dir) / _note_id_from_detail(data) + note_dir.mkdir(parents=True, exist_ok=True) + + saved_images = [] + with httpx.Client(follow_redirects=True, timeout=30) as client: + for index, url in enumerate(image_urls, 1): + response = client.get(url) + response.raise_for_status() + image_path = note_dir / f"image-{index}{_image_extension(url)}" + image_path.write_bytes(response.content) + saved_images.append(SavedImage(index=index, url=url, path=image_path)) + return saved_images diff --git a/xhs_cli/error_codes.py b/xhs_cli/error_codes.py index a3c37db..7d19069 100644 --- a/xhs_cli/error_codes.py +++ b/xhs_cli/error_codes.py @@ -6,6 +6,7 @@ IpBlockedError, NeedVerifyError, NoCookieError, + RateLimitedError, SessionExpiredError, SignatureError, UnsupportedOperationError, @@ -25,6 +26,8 @@ def error_code_for_exception(exc: Exception) -> str: return "signature_error" if isinstance(exc, UnsupportedOperationError): return "unsupported_operation" + if isinstance(exc, RateLimitedError): + return "rate_limited" if isinstance(exc, XhsApiError): if exc.code in { "not_authenticated", @@ -32,6 +35,7 @@ def error_code_for_exception(exc: Exception) -> str: "ip_blocked", "signature_error", "unsupported_operation", + "rate_limited", "api_error", }: return str(exc.code) diff --git a/xhs_cli/exceptions.py b/xhs_cli/exceptions.py index a2f2715..692dae9 100644 --- a/xhs_cli/exceptions.py +++ b/xhs_cli/exceptions.py @@ -33,6 +33,13 @@ def __init__(self): super().__init__("IP blocked by XHS — try a different network", code=300012) +class RateLimitedError(XhsApiError): + """Raised when the local write throttle rejects a sensitive operation.""" + + def __init__(self, message: str): + super().__init__(message, code="rate_limited") + + class SignatureError(XhsApiError): """Raised when signature verification fails.""" diff --git a/xhs_cli/formatter.py b/xhs_cli/formatter.py index d7d8766..ce1693a 100644 --- a/xhs_cli/formatter.py +++ b/xhs_cli/formatter.py @@ -7,6 +7,7 @@ from .formatter_renderers import ( # noqa: F401 render_comments, + render_creator_note_data, render_creator_notes, render_feed, render_note, diff --git a/xhs_cli/formatter_normalizers.py b/xhs_cli/formatter_normalizers.py index 0e1e8b9..609a143 100644 --- a/xhs_cli/formatter_normalizers.py +++ b/xhs_cli/formatter_normalizers.py @@ -5,6 +5,41 @@ from typing import Any +def _extract_image_url(image: dict[str, Any]) -> str: + for key in ("url_default", "url_pre", "url"): + value = image.get(key) + if isinstance(value, str) and value: + return value + + for key in ("url_list", "info_list"): + values = image.get(key) + if not isinstance(values, list): + continue + for value in values: + if isinstance(value, str) and value: + return value + if isinstance(value, dict): + url = _extract_image_url(value) + if url: + return url + + return "" + + +def _extract_image_urls(images: Any) -> list[str]: + if not isinstance(images, list): + return [] + urls = [] + for image in images: + if isinstance(image, dict): + url = _extract_image_url(image) + if url: + urls.append(url) + elif isinstance(image, str) and image: + urls.append(image) + return urls + + def _coerce_int(value: Any, default: int = 0) -> int: if isinstance(value, bool): return int(value) @@ -47,6 +82,8 @@ def normalize_note_detail(data: dict[str, Any]) -> dict[str, Any] | None: interact = note.get("interact_info", {}) tags = note.get("tag_list", []) + image_list = note.get("image_list", []) + return { "title": note.get("title", "Untitled"), "desc": note.get("desc", ""), @@ -56,7 +93,8 @@ def normalize_note_detail(data: dict[str, Any]) -> dict[str, Any] | None: "comment_count": interact.get("comment_count", "0"), "share_count": interact.get("share_count", "0"), "tags": [tag.get("name", "") for tag in tags if tag.get("name")], - "image_count": len(note.get("image_list", [])), + "image_count": len(image_list) if isinstance(image_list, list) else 0, + "image_urls": _extract_image_urls(image_list), } @@ -173,6 +211,33 @@ def normalize_creator_notes(data: Any) -> list[dict[str, Any]]: return normalized +def normalize_creator_note_data(data: Any) -> list[dict[str, Any]]: + if isinstance(data, dict) and isinstance(data.get("result"), dict): + data = data["result"] + if isinstance(data, dict) and isinstance(data.get("data"), (dict, list)): + data = data["data"] + notes = data if isinstance(data, list) else data.get("notes", data.get("note_list", data.get("note_infos", []))) + normalized = [] + for note in notes or []: + rate = note.get("coverClickRate") + try: + click_rate = f"{float(rate) * 100:.1f}%" if rate is not None else "-" + except (TypeError, ValueError): + click_rate = "-" + normalized.append({ + "title": note.get("title", "")[:40], + "note_id": note.get("id", note.get("note_id", "")), + "view_count": str(note.get("read_count", note.get("view_count", ""))), + "imp_count": str(note.get("imp_count", "")), + "liked_count": str(note.get("like_count", note.get("liked_count", ""))), + "collected_count": str(note.get("fav_count", note.get("collected_count", ""))), + "comment_count": str(note.get("comment_count", "")), + "share_count": str(note.get("share_count", "")), + "click_rate": click_rate, + }) + return normalized + + def normalize_notifications(data: dict[str, Any]) -> list[dict[str, Any]]: normalized = [] for message in data.get("message_list", []): diff --git a/xhs_cli/formatter_renderers.py b/xhs_cli/formatter_renderers.py index c29669a..b5666fc 100644 --- a/xhs_cli/formatter_renderers.py +++ b/xhs_cli/formatter_renderers.py @@ -9,6 +9,7 @@ from .formatter_normalizers import ( normalize_comments, + normalize_creator_note_data, normalize_creator_notes, normalize_feed, normalize_note_detail, @@ -125,6 +126,8 @@ def render_note(data: dict[str, Any]) -> None: if note["image_count"]: table.add_row("图片", f"{note['image_count']} 张") + for index, image_url in enumerate(note.get("image_urls", []), 1): + table.add_row(f"图片 {index}", f"[link={image_url}]{image_url}[/link]") console.print(Panel(table, title=f"📝 {title}", border_style="green")) @@ -286,6 +289,40 @@ def render_creator_notes(data: Any) -> None: console.print(table) +def render_creator_note_data(data: Any) -> None: + """Render creator's note analyze data.""" + notes = normalize_creator_note_data(data) + if not notes: + print_info("No data found") + return + + table = Table(title="笔记分析数据", show_lines=True) + table.add_column("#", style="dim", width=3) + table.add_column("标题", width=30) + table.add_column("点击率", justify="right", width=8) + table.add_column("曝光", justify="right", width=8) + table.add_column("👁️", justify="right", width=8) + table.add_column("❤️", justify="right", width=6) + table.add_column("⭐", justify="right", width=6) + table.add_column("💬", justify="right", width=6) + table.add_column("🔗", justify="right", width=6) + + for i, note in enumerate(notes, 1): + table.add_row( + str(i), + note["title"], + note["click_rate"], + note["imp_count"], + note["view_count"], + note["liked_count"], + note["collected_count"], + note["comment_count"], + note["share_count"], + ) + + console.print(table) + + def render_notifications(data: dict[str, Any], notif_type: str) -> None: """Render notification messages.""" import time as _time diff --git a/xhs_cli/im_codec.py b/xhs_cli/im_codec.py new file mode 100644 index 0000000..b7fc156 --- /dev/null +++ b/xhs_cli/im_codec.py @@ -0,0 +1,147 @@ +"""Codec for Xiaohongshu's short-link direct-message transport. + +A direct message can be delivered over plain HTTP instead of the IM WebSocket: +the payload is a protobuf ``ChatSendMessage`` envelope, RSA-encrypted and POSTed +to ``/api/im/web/short_link/send_message``. No WebSocket handshake is involved, +which is why this CLI uses that route. + +Implemented from published specifications: + +* protobuf wire format — varints and length-delimited fields + (https://protobuf.dev/programming-guides/encoding/) +* RSAES-PKCS1-v1_5 — RFC 8017 §7.2.1, applied by ``pycryptodome`` rather than + by hand + +The two inputs that cannot come from a specification are the server's public key +and the field numbers of the message. Both are observable facts about the wire +format rather than program logic, and both are recorded below alongside the +layout they describe. + +Note that this envelope emits its default-valued fields explicitly: an empty +``token`` / ``nickname`` / ``ref_id`` still occupies a tag with length 0, and +``group_chat`` is sent as an explicit ``0``. Proto3 encoders normally omit +defaults; this transport does not, and acceptance depends on the exact byte +layout, so ``encode_chat_message`` reproduces it. +""" + +from __future__ import annotations + +from Crypto.Cipher import PKCS1_v1_5 +from Crypto.PublicKey import RSA + +# Server-side public key for the short-link sender (1024-bit). +_MODULUS = int( + "d321555d67813eace010dc27e72ab14876a9b671c7d58d3c9c2064cd60f7e9f7" + "9ad3799657b35a1b7654d82725408a71549d5ade11e74bbf1ec39b549ed32116" + "affd4f6b03f2c9c44d91f84157b159a8a225150916e2716cc82dc8fd62385e5a" + "01c83b784c139462a1dd45d47d96ebb4f5068c42b3de8590123a03565a9e5aed", + 16, +) +_EXPONENT = 65537 +_PUBLIC_KEY = RSA.construct((_MODULUS, _EXPONENT)) + +# RFC 8017 §7.2.1: a PKCS#1 v1.5 block spends 11 bytes on the header and the +# minimum padding, so a k-byte key carries at most k-11 bytes of plaintext. +_CHUNK_BYTES = _PUBLIC_KEY.size_in_bytes() - 11 + +# ``ChatSendMessage`` field numbers. Field 10 (a command sub-message) exists in +# the format but is unused for plain text, so it is never emitted. +_MID = 1 +_TIMESTAMP = 2 +_TOKEN = 3 +_SENDER = 4 +_RECEIVER = 5 +_CONTENT = 6 +_CONTENT_TYPE = 7 +_NICKNAME = 8 +_GROUP_CHAT = 9 +_REF_ID = 11 +_TRIGGER_SOURCE = 12 + +# Outer envelope: a discriminator followed by the message itself. +_ENVELOPE_KIND = 1 +_ENVELOPE_BODY = 9 +_ENVELOPE_KIND_SEND = 1 + +_WIRE_VARINT = 0 +_WIRE_LENGTH_DELIMITED = 2 + +# Values the web client always sends for a plain text message. +_TRIGGER_SOURCE_DEFAULT = 1 +CONTENT_TYPE_TEXT = 1 + + +def _varint(value: int) -> bytes: + """Encode a non-negative integer as a protobuf base-128 varint.""" + if value < 0: + raise ValueError(f"varint cannot encode a negative value: {value}") + chunks = bytearray() + remaining = value + while True: + seven_bits = remaining & 0x7F + remaining >>= 7 + chunks.append(seven_bits | (0x80 if remaining else 0x00)) + if not remaining: + return bytes(chunks) + + +def _tag(field_number: int, wire_type: int) -> bytes: + return _varint((field_number << 3) | wire_type) + + +def _varint_field(field_number: int, value: int) -> bytes: + return _tag(field_number, _WIRE_VARINT) + _varint(value) + + +def _bytes_field(field_number: int, raw: bytes) -> bytes: + return _tag(field_number, _WIRE_LENGTH_DELIMITED) + _varint(len(raw)) + raw + + +def _text_field(field_number: int, value: str) -> bytes: + return _bytes_field(field_number, value.encode("utf-8")) + + +def encode_chat_message( + *, + local_mid: str, + timestamp_ms: int, + sender_id: str, + receiver_id: str, + content: str, + content_type: int = CONTENT_TYPE_TEXT, +) -> bytes: + """Build the protobuf payload for one outgoing direct message. + + ``local_mid`` is a client-generated tracking id. The server issues its own + message id in the response, and that is the only delivery proof. + """ + body = b"".join(( + _text_field(_MID, local_mid), + _varint_field(_TIMESTAMP, timestamp_ms), + _text_field(_TOKEN, ""), + _text_field(_SENDER, sender_id), + _text_field(_RECEIVER, receiver_id), + _text_field(_CONTENT, content), + _varint_field(_CONTENT_TYPE, content_type), + _text_field(_NICKNAME, ""), + _varint_field(_GROUP_CHAT, 0), + _text_field(_REF_ID, ""), + _varint_field(_TRIGGER_SOURCE, _TRIGGER_SOURCE_DEFAULT), + )) + return ( + _varint_field(_ENVELOPE_KIND, _ENVELOPE_KIND_SEND) + + _bytes_field(_ENVELOPE_BODY, body) + ) + + +def encrypt_payload(payload: bytes) -> bytes: + """RSA-encrypt the payload in key-sized blocks. + + PKCS#1 v1.5 caps one block below the key size, so a longer payload is split + and the ciphertext blocks are concatenated, matching the web client. + """ + cipher = PKCS1_v1_5.new(_PUBLIC_KEY) + return b"".join( + cipher.encrypt(payload[offset:offset + _CHUNK_BYTES]) + for offset in range(0, len(payload), _CHUNK_BYTES) + ) diff --git a/xhs_cli/limiter.py b/xhs_cli/limiter.py new file mode 100644 index 0000000..110aac9 --- /dev/null +++ b/xhs_cli/limiter.py @@ -0,0 +1,99 @@ +"""Cross-process write throttle for sensitive XHS operations. + +The CLI is a fresh process per command, so a pure in-memory limiter would +reset every invocation and protect nothing. This module keeps a rolling-window +token bucket on disk under the CLI config directory, shared by every process. + +Default budget is one write per minute (matching the account-safety guidance +in the README). Override with ``XHS_WRITE_RPM=``; set ``XHS_WRITE_RPM=0`` +to disable throttling entirely (tests, or callers that pace themselves). +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +from .cookies import get_config_dir +from .exceptions import RateLimitedError + +WINDOW_SECONDS = 60.0 +DEFAULT_WRITE_RPM = 1 +STATE_FILE = "limiter.json" + + +def _write_rpm() -> int: + raw = os.environ.get("XHS_WRITE_RPM") + if raw is None: + return DEFAULT_WRITE_RPM + try: + return max(0, int(raw)) + except ValueError: + return DEFAULT_WRITE_RPM + + +def _state_path() -> Path: + return get_config_dir() / STATE_FILE + + +def _load() -> dict[str, list[float]]: + try: + state = json.loads(_state_path().read_text()) + except (OSError, json.JSONDecodeError): + return {} + return state if isinstance(state, dict) else {} + + +def _save(state: dict[str, list[float]]) -> None: + path = _state_path() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.tmp") + tmp.write_text(json.dumps(state)) + tmp.replace(path) + + +def check_write_budget(bucket: str) -> None: + """Consume one token from ``bucket``; raise ``RateLimitedError`` if full. + + The check is a read-modify-write of a small JSON file, so two truly + concurrent processes can race by one token. That is acceptable for a + human-paced CLI and avoids pulling in a file-locking dependency. + """ + rpm = _write_rpm() + if rpm <= 0: + return + + now = time.time() + state = _load() + raw_hits = state.get(bucket, []) + hits: list[float] = [] + if isinstance(raw_hits, list): + for entry in raw_hits: + try: + stamp = float(entry) + except (TypeError, ValueError): + continue + if now - stamp < WINDOW_SECONDS: + hits.append(stamp) + if len(hits) >= rpm: + wait = WINDOW_SECONDS - (now - hits[0]) + raise RateLimitedError( + f"写入限流:bucket={bucket} 每 {WINDOW_SECONDS:.0f}s 最多 {rpm} 次," + f"请 {wait:.1f}s 后再试(XHS_WRITE_RPM=0 可关闭)" + ) + + hits.append(now) + state[bucket] = hits + _save(state) + + +def reset(bucket: str | None = None) -> None: + """Clear throttling state; mainly for tests and manual troubleshooting.""" + state = _load() + if bucket is None: + state = {} + else: + state.pop(bucket, None) + _save(state) diff --git a/xhs_cli/qr_login.py b/xhs_cli/qr_login.py index e7b189a..e1e3f4f 100644 --- a/xhs_cli/qr_login.py +++ b/xhs_cli/qr_login.py @@ -337,6 +337,16 @@ def _ensure_camoufox_ready() -> None: ) +def _camoufox_launch_options() -> dict[str, Any]: + """Return Camoufox launch options for browser-assisted QR login.""" + from camoufox import DefaultAddons + + return { + "headless": False, + "exclude_addons": [DefaultAddons.UBO], + } + + def _browser_assisted_qrcode_login( *, on_status: callable[[str], None] | None = None, @@ -347,6 +357,7 @@ def _browser_assisted_qrcode_login( try: from camoufox.sync_api import Camoufox + launch_options = _camoufox_launch_options() except ImportError as exc: raise BrowserQrLoginUnavailable( "Camoufox sync API is unavailable in the current environment." @@ -356,7 +367,7 @@ def _browser_assisted_qrcode_login( _emit_status(on_status, "🔑 Starting browser-assisted QR login...") - with Camoufox(headless=False) as browser: + with Camoufox(**launch_options) as browser: page = browser.new_page() def _handle_response(response) -> None: