This commit is contained in:
Ubuntu
2026-07-03 16:58:28 +08:00
commit 32b43f85bd
6240 changed files with 1987678 additions and 0 deletions
+474
View File
@@ -0,0 +1,474 @@
# xiaohongshu-cli
[![CI](https://github.com/jackwener/xiaohongshu-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jackwener/xiaohongshu-cli/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/xiaohongshu-cli.svg)](https://pypi.org/project/xiaohongshu-cli/)
[![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue.svg)](https://pypi.org/project/xiaohongshu-cli/)
A CLI for Xiaohongshu (小红书) — search, read, interact, and post via reverse-engineered API 📕
[English](#features) | [中文](#功能特性)
## More Tools
- [bilibili-cli](https://github.com/jackwener/bilibili-cli) — Bilibili CLI for videos, users, search, and feeds
- [twitter-cli](https://github.com/jackwener/twitter-cli) — Twitter/X CLI for timelines, bookmarks, and posting
- [discord-cli](https://github.com/jackwener/discord-cli) — Discord CLI for local-first sync, search, and export
- [tg-cli](https://github.com/jackwener/tg-cli) — Telegram CLI for local-first sync, search, and export
## Features
- 🔐 **Auth** — auto-extract browser cookies, QR code login, status check, whoami
- 🔍 **Search** — notes by keyword, user search, topic search
- 📖 **Reading** — note detail, comments, sub-comments, user profiles
- 🔢 **Short-index navigation** — open recent list results with `xhs read 1` or `xhs comments 1`
- 📰 **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
- 🔔 **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
- 📦 **Stable envelope** — see [SCHEMA.md](./SCHEMA.md) for `ok/schema_version/data/error`
> **AI Agent Tip:** Prefer `--yaml` for structured output unless strict JSON is required. Non-TTY stdout defaults to YAML automatically.
## Installation
```bash
# Recommended: uv tool (fast, isolated)
uv tool install xiaohongshu-cli
# Or: pipx
pipx install xiaohongshu-cli
```
Upgrade to the latest version:
```bash
uv tool upgrade xiaohongshu-cli
# Or: pipx upgrade xiaohongshu-cli
```
> **Tip:** Upgrade regularly to avoid unexpected errors from outdated API handling.
From source:
```bash
git clone git@github.com:jackwener/xiaohongshu-cli.git
cd xiaohongshu-cli
uv sync
```
## Usage
```bash
# ─── Auth ─────────────────────────────────────────
xhs login # Extract cookies from browser
xhs login --qrcode # Browser-assisted QR login, scan in terminal
xhs status # Check login status
xhs whoami # Detailed profile (fans, likes, etc)
xhs whoami --json # Structured JSON envelope
xhs logout # Clear saved cookies
# ─── Search ───────────────────────────────────────
xhs search "美食" # Search notes
xhs search "旅行" --sort popular # Sort: general, popular, latest
xhs search "穿搭" --type video # Filter: all, video, image
xhs search "AI" --page 2 # Pagination
xhs search-user "用户名" # Search users
xhs topics "美食" # Search hashtags/topics
# ─── Reading ──────────────────────────────────────
xhs read 1 # Read the 1st result from the last list command
xhs read <note_id> # Read a note (API only)
xhs read "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" # Read by URL (uses URL token)
xhs comments 1 # Read comments for the 1st result from the last list command
xhs comments "<url>" # View comments — paste URL to cache/reuse xsec_token
xhs comments "<url>" --all # Fetch ALL comments (auto-paginate all pages)
xhs comments "<url>" --all --json # All comments as JSON
xhs comments <note_id> --xsec-token T # Use note_id + explicit xsec_token
xhs comments <note_id> # Reuse cached token if available
xhs sub-comments <note_id> <cmt_id> # View replies to a comment
xhs user <user_id> # User profile
xhs user-posts <user_id> # User's published notes
xhs user-posts <user_id> --cursor X # Paginate with cursor
# ─── Feed & Discovery ────────────────────────────
xhs feed # Recommendation feed
xhs hot # Hot notes (default: food)
xhs hot -c fashion # Categories: fashion, food, cosmetics,
# movie, career, love, home, gaming,
# travel, fitness
# Short index works after list commands such as search/feed/hot/user-posts/favorites/my-notes
xhs search "黑丝"
xhs read 1
xhs comments 1
xhs like 1
xhs favorite 1
# ─── Social ───────────────────────────────────────
xhs favorites # My bookmarked notes (current user)
xhs favorites <user_id> # Other user's bookmarked notes
xhs follow <user_id> # Follow a user
xhs unfollow <user_id> # Unfollow a user
# ─── Interactions ─────────────────────────────────
xhs like 1 # Like the 1st result from the latest note listing
xhs like <note_id> # Like a note
xhs like <note_id> --undo # Unlike
xhs favorite 1 # Favorite the 1st result from the latest note listing
xhs favorite <note_id> # Favorite (bookmark)
xhs unfavorite 1 # Unfavorite the 1st result from the latest note listing
xhs unfavorite <note_id> # Unfavorite
xhs comment 1 -c "好赞!" # Comment on the 1st result from the latest note listing
xhs comment <note_id> -c "好赞!" # Post comment
xhs reply 1 --comment-id X -c "回复" # Reply on the 1st result from the latest note listing
xhs reply <note_id> --comment-id X -c "回复" # Reply to comment
xhs delete-comment <note_id> <cmt_id> # 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 delete <note_id> # Delete note
xhs delete <note_id> -y # Skip confirmation
# ─── Notifications ────────────────────────────────
xhs unread # Unread counts (likes, mentions, follows)
xhs notifications # 评论和@ notifications
xhs notifications --type likes # 赞和收藏 notifications
xhs notifications --type connections # 新增关注 notifications
```
## Authentication
xiaohongshu-cli supports multiple authentication methods:
1. **Saved cookies** — loads from `~/.xiaohongshu-cli/cookies.json`
2. **Browser cookies** — auto-detects installed browsers and extracts cookies (supports Chrome, Arc, Edge, Firefox, Safari, Brave, Chromium, Opera, Vivaldi, and more)
3. **QR code login** — browser-assisted login with terminal QR output (`xhs login --qrcode`)
`xhs login` automatically tries all installed browsers and uses the first one with valid cookies.
Use `--cookie-source <browser>` to specify a browser explicitly, or `--qrcode` for browser-assisted QR login.
Other authenticated commands automatically retry once with fresh browser cookies when the saved session has expired.
### Cookie TTL
Saved cookies are valid for **7 days** by default. After that, the client automatically attempts to refresh from the browser. If browser extraction fails, the existing cookies are used with a warning.
### Short-Index Navigation
After any listing command such as `search`, `feed`, `hot`, `user-posts`, `favorites`, or `my-notes`, the CLI stores the latest ordered note list in `~/.xiaohongshu-cli/index_cache.json`.
- `xhs read <N>` opens the Nth note from the latest listing
- `xhs comments <N>` opens comments for the Nth note from the latest listing
- `xhs like <N>`, `xhs favorite <N>`, `xhs unfavorite <N>`, `xhs comment <N>`, and `xhs reply <N>` reuse the same short index
- Empty listings clear the index cache, so old results are not reused by accident
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT` | `auto` | Output format: `json`, `yaml`, `rich`, or `auto` (→ YAML when non-TTY) |
## Rate Limiting & Anti-Detection
xiaohongshu-cli includes comprehensive anti-risk-control measures designed to minimize detection:
### Request Timing
- **Gaussian jitter**: Delays between requests use a truncated Gaussian distribution (not fixed intervals) to mimic natural browsing patterns
- **Random long pauses**: ~5% of requests include an additional 2-5 second delay simulating reading behavior
- **Auto-retry**: Exponential backoff on HTTP 429/5xx and network errors (up to 3 retries)
### Browser Fingerprint Consistency
- **UA/Platform alignment**: User-Agent, `sec-ch-ua`, `sec-ch-ua-platform`, and fingerprint fields are all consistent (macOS Chrome 145)
- **Session-stable identity**: GPU, screen resolution, CPU cores, and other hardware fingerprint values are generated once per session and reused across all requests (real browsers don't change hardware mid-session)
- **macOS-native values**: GPU vendors (Apple M1/M2/M3, Intel Iris), Retina screen resolutions, `MacIntel` platform — all matching a real macOS browser
### Captcha Cooldown
- **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
### 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
## Structured Output
All `--json` / `--yaml` output uses the shared envelope from [SCHEMA.md](./SCHEMA.md):
```yaml
ok: true
schema_version: "1"
data: { ... }
```
When stdout is not a TTY (e.g., piped or invoked by an AI agent), output defaults to YAML.
Use `OUTPUT=yaml|json|rich|auto` to override.
## Use as AI Agent Skill
xiaohongshu-cli ships with a [`SKILL.md`](./SKILL.md) that teaches AI agents how to use it.
### [Skills CLI](https://github.com/vercel-labs/skills) (Recommended)
```bash
npx skills add jackwener/xiaohongshu-cli
```
| Flag | Description |
| --- | --- |
| `-g` | Install globally (user-level, shared across projects) |
| `-a claude-code` | Target a specific agent |
| `-y` | Non-interactive mode |
### Manual Install
```bash
mkdir -p .agents/skills
git clone git@github.com:jackwener/xiaohongshu-cli.git .agents/skills/xiaohongshu-cli
```
### ~~OpenClaw / ClawHub~~ (Deprecated)
> ⚠️ ClawHub install method is deprecated and no longer supported. Use [Skills CLI](#skills-cli-recommended) or Manual Install above.
## Project Structure
```text
xhs_cli/
├── __init__.py
├── cli.py # Click entry point & command registration
├── client.py # XHS API client (signing, retry, rate-limit, anti-detection)
├── cookies.py # Cookie extraction, TTL management, auto-refresh, token cache
├── signing.py # Main API x-s / x-s-common signature generation
├── creator_signing.py # Creator API AES-128-CBC signature
├── constants.py # URLs, User-Agent, Chrome version, SDK config
├── exceptions.py # Structured exception hierarchy (6 error types)
├── qr_login.py # QR code login (browser-assisted terminal QR + HTTP fallback)
├── formatter.py # Output formatting, schema envelope, Rich rendering
└── commands/
├── _common.py # Shared CLI helpers (structured_output_options, etc.)
├── auth.py # login/logout/status/whoami
├── reading.py # search/read/comments/user/feed/hot/topics/search-user
├── interactions.py # like/favorite/comment/reply/delete-comment
├── social.py # follow/unfollow/favorites
├── creator.py # post/my-notes/delete
└── notifications.py # unread/notifications
```
## Development
```bash
# Install dependencies
uv sync
# Run tests
uv run pytest tests/ -v
# Unit tests only (no network)
uv run pytest tests/ -v --ignore=tests/test_integration.py -m "not smoke"
# Smoke tests (need cookies)
uv run pytest tests/ -v -m smoke
# Integration tests (need cookies)
uv run pytest tests/test_integration.py -v
# Lint
uv run ruff check .
```
## Troubleshooting
**Q: `NoCookieError: No 'a1' cookie found`**
1. Open any browser and visit https://www.xiaohongshu.com/
2. Log in with your account
3. Run `xhs login` (auto-detects browser) or `xhs login --cookie-source <browser>`
**Q: `NeedVerifyError: Captcha required`**
XHS has triggered a captcha check. Open https://www.xiaohongshu.com/ in your browser, complete the captcha, then retry.
**Q: `IpBlockedError: IP blocked by XHS`**
Try a different network (e.g., mobile hotspot or VPN). XHS blocks IPs that make too many requests.
**Q: `SessionExpiredError: Session expired`**
Your cookies have expired. Run `xhs login` to refresh.
**Q: Requests are slow**
The built-in Gaussian jitter delay (~1-1.5s between requests) is intentional to mimic natural browsing and avoid triggering XHS's risk control. Aggressive request patterns may lead to captcha triggers or IP blocks.
---
## 推荐项目
- [bilibili-cli](https://github.com/jackwener/bilibili-cli) — Bilibili 视频、用户、搜索与动态 CLI
- [twitter-cli](https://github.com/jackwener/twitter-cli) — Twitter/X 时间线、书签和发推 CLI
- [discord-cli](https://github.com/jackwener/discord-cli) — Discord 本地优先同步、检索与导出 CLI
- [tg-cli](https://github.com/jackwener/tg-cli) — Telegram 本地优先同步、检索与导出 CLI
## 功能特性
- 🔐 **认证** — 自动提取浏览器 Cookie,browser-assisted 二维码扫码登录,状态检查,用户信息
- 🔍 **搜索** — 按关键词搜索笔记、用户、话题
- 📖 **阅读** — 笔记详情、评论、子评论、用户主页
- 📰 **发现** — 推荐 Feed、按分类浏览热门
- 👥 **社交** — 关注/取关、收藏夹
- 👍 **互动** — 点赞、收藏、评论、回复、删除
- ✍️ **创作者** — 发布图文笔记、我的笔记列表、删除
- 🔔 **通知** — 未读数、@、点赞、新关注
- 🛡️ **反风控** — macOS Chrome 指纹一致性、session 级浏览器身份持久化、高斯抖动延迟、验证码自动冷却、指数退避重试
- 📊 **结构化输出** — `--yaml` / `--json`,非 TTY 默认输出 YAML
- 📦 **稳定 envelope** — 参见 [SCHEMA.md](./SCHEMA.md)
## 安装
```bash
# 推荐:uv tool(快速、隔离环境)
uv tool install xiaohongshu-cli
# 或者:pipx
pipx install xiaohongshu-cli
```
升级到最新版本:
```bash
uv tool upgrade xiaohongshu-cli
# 或:pipx upgrade xiaohongshu-cli
```
> **提示:** 建议定期升级,避免因版本过旧导致的 API 调用异常。
从源码安装:
```bash
git clone git@github.com:jackwener/xiaohongshu-cli.git
cd xiaohongshu-cli
uv sync
```
## 使用示例
```bash
# 认证
xhs login # 从浏览器提取 Cookie
xhs login --qrcode # browser-assisted 二维码扫码登录(终端显示二维码)
xhs status # 检查登录状态
xhs whoami # 查看用户资料
xhs logout # 清除缓存的 Cookie
# 搜索
xhs search "美食" # 搜索笔记
xhs search "旅行" --sort popular # 排序:general, popular, latest
xhs search-user "用户名" # 搜索用户
xhs topics "美食" # 搜索话题
# 阅读
xhs read 1 # 阅读最近一次列表里的第 1 条笔记
xhs read <note_id> # 阅读笔记(仅走 API)
xhs read "https://...?xsec_token=..." # 粘贴网页 URL 直接阅读(使用 URL token)
xhs comments 1 # 查看最近一次列表里的第 1 条笔记评论
xhs comments "<url>" # 查看评论 — 粘贴 URL 以缓存/复用 xsec_token
xhs comments "<url>" --all # 获取全部评论(自动翻页)
xhs comments "<url>" --all --json # 全部评论,JSON 格式
xhs comments <note_id> --xsec-token T # 用 note_id + 显式 xsec_token
xhs comments <note_id> # 如果之前访问过 URL,会复用缓存 token
xhs sub-comments <note_id> <cmt_id> # 查看评论的回复
xhs user <user_id> # 用户主页
xhs user-posts <user_id> # 用户发布的笔记
# 发现
xhs feed # 推荐 Feed
xhs hot -c food # 热门笔记(按分类)
xhs hot -c travel # 分类: fashion, food, cosmetics, movie, career,
# love, home, gaming, travel, fitness
# 社交
xhs favorites # 我的收藏(自动识别当前用户)
xhs favorites <user_id> # 其他用户的收藏
xhs follow <user_id> # 关注
xhs unfollow <user_id> # 取消关注
# 互动
xhs like 1 # 给最近一次列表里的第 1 条笔记点赞
xhs like <note_id> # 点赞
xhs like <note_id> --undo # 取消点赞
xhs favorite 1 # 收藏最近一次列表里的第 1 条笔记
xhs favorite <note_id> # 收藏
xhs unfavorite 1 # 取消收藏最近一次列表里的第 1 条笔记
xhs unfavorite <note_id> # 取消收藏
xhs comment 1 -c "好棒!" # 给最近一次列表里的第 1 条笔记发评论
xhs comment <note_id> -c "好棒!" # 发评论
xhs reply 1 --comment-id X -c "谢谢" # 给最近一次列表里的第 1 条笔记回复评论
xhs reply <note_id> --comment-id X -c "谢谢" # 回复评论
xhs delete-comment <note_id> <cmt_id> # 删除自己的评论
# 创作者
xhs my-notes # 我的笔记列表
xhs post --title "标题" --body "正文" --images img.jpg # 发布笔记
xhs delete <note_id> # 删除笔记
xhs delete <note_id> -y # 跳过确认
# 通知
xhs unread # 未读数
xhs notifications # 评论和 @ 通知
xhs notifications --type likes # 赞和收藏通知
xhs notifications --type connections # 新增关注通知
```
## 认证策略
xiaohongshu-cli 支持多种认证方式:
1. **已保存 Cookie** — 从 `~/.xiaohongshu-cli/cookies.json` 加载
2. **浏览器 Cookie** — 自动检测已安装浏览器并提取(支持 Chrome、Arc、Edge、Firefox、Safari、Brave、Chromium、Opera、Vivaldi 等)
3. **二维码扫码登录** — browser-assisted 登录,终端显示二维码,用小红书 App 扫码(`xhs login --qrcode`)
Cookie 保存后有效期 **7 天**,超时后自动尝试从浏览器刷新。
`xhs login` 会自动尝试所有已安装浏览器,使用第一个有有效 Cookie 的浏览器。也可用 `--cookie-source <browser>` 指定浏览器,或 `--qrcode` 使用 browser-assisted 二维码登录。其他需认证命令在 session 过期时会自动重试一次。
## 常见问题
- `NoCookieError: No 'a1' cookie found` — 请先在任意浏览器打开 https://www.xiaohongshu.com/ 并登录,然后执行 `xhs login`
- `NeedVerifyError` — 触发了验证码,请到浏览器中完成验证后重试
- `IpBlockedError` — IP 被限制,尝试切换网络(手机热点或 VPN)
- `SessionExpiredError` — Cookie 过期,执行 `xhs login` 刷新
- 请求较慢是正常的 — 内置高斯随机延迟(~1-1.5s)是为了模拟人类浏览行为,避免触发风控
## 作为 AI Agent Skill 使用
xiaohongshu-cli 自带 [`SKILL.md`](./SKILL.md),让 AI Agent 能自动学习并使用本工具。
### [Skills CLI](https://github.com/vercel-labs/skills)(推荐)
```bash
npx skills add jackwener/xiaohongshu-cli
```
| 参数 | 说明 |
| --- | --- |
| `-g` | 全局安装(用户级别,跨项目共享) |
| `-a claude-code` | 指定目标 Agent |
| `-y` | 非交互模式 |
### 手动安装
```bash
mkdir -p .agents/skills
git clone git@github.com:jackwener/xiaohongshu-cli.git .agents/skills/xiaohongshu-cli
```
### ~~OpenClaw / ClawHub~~(已过时)
> ⚠️ ClawHub 安装方式已过时,不再支持。请使用上方的 Skills CLI 或手动安装。
## License
Apache-2.0
+30
View File
@@ -0,0 +1,30 @@
# Structured Output Schema
`xiaohongshu-cli` uses a shared agent-friendly envelope for machine-readable output.
## Success
```yaml
ok: true
schema_version: "1"
data: ...
```
## Error
```yaml
ok: false
schema_version: "1"
error:
code: not_authenticated
message: need login
```
## Notes
- `--yaml` and `--json` both use this envelope
- non-TTY stdout defaults to YAML
- 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`
+252
View File
@@ -0,0 +1,252 @@
---
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
---
# xiaohongshu-cli — Xiaohongshu CLI Tool
**Binary:** `xhs`
**Credentials:** browser cookies (auto-extracted) or browser-assisted QR login (`--qrcode`)
## Setup
```bash
# Install (requires Python 3.10+)
uv tool install xiaohongshu-cli
# Or: pipx install xiaohongshu-cli
# Upgrade to latest (recommended to avoid API errors)
uv tool upgrade xiaohongshu-cli
# Or: pipx upgrade xiaohongshu-cli
```
## Authentication
**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
```bash
xhs status --yaml >/dev/null && echo "AUTH_OK" || echo "AUTH_NEEDED"
```
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.
### Step 1: Guide user to authenticate
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
```
Verify with:
```bash
xhs status
xhs whoami
```
### 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
## Command Reference
### Reading
| Command | Description | Example |
|---------|-------------|---------|
| `xhs search <keyword>` | Search notes | `xhs search "美食" --sort popular --type video` |
| `xhs read <id_or_url_or_index>` | Read a note by ID, URL, or short index | `xhs read 1` / `xhs read "https://...?xsec_token=xxx"` |
| `xhs comments <id_or_url_or_index>` | Get comments by ID, URL, or short index | `xhs comments 1` / `xhs comments "https://...?xsec_token=..."` |
| `xhs comments <id_or_url> --all` | Get ALL comments (auto-paginate) | `xhs comments "<url>" --all --json` |
| `xhs sub-comments <note_id> <comment_id>` | Get replies to comment | `xhs sub-comments abc 123` |
| `xhs user <user_id>` | View user profile | `xhs user 5f2e123` |
| `xhs user-posts <user_id>` | 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 <keyword>` | Search topics/hashtags | `xhs topics "旅行"` |
| `xhs search-user <keyword>` | 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 <id_or_url_or_index>` | Like a note | `xhs like 1` / `xhs like abc123` |
| `xhs like <id_or_url_or_index> --undo` | Unlike a note | `xhs like 1 --undo` |
| `xhs favorite <id_or_url_or_index>` | Bookmark a note | `xhs favorite 1` |
| `xhs unfavorite <id_or_url_or_index>` | Remove bookmark | `xhs unfavorite 1` |
| `xhs comment <id_or_url_or_index> -c "text"` | Post a comment | `xhs comment 1 -c "好看!"` |
| `xhs reply <id_or_url_or_index> --comment-id ID -c "text"` | Reply to comment | `xhs reply 1 --comment-id 456 -c "谢谢"` |
| `xhs delete-comment <note_id> <comment_id>` | Delete own comment | `xhs delete-comment abc 123 -y` |
### Social
| Command | Description | Example |
|---------|-------------|---------|
| `xhs follow <user_id>` | Follow a user | `xhs follow 5f2e123` |
| `xhs unfollow <user_id>` | 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 <id_or_url>` | 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 <category>`:
`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.
+63
View File
@@ -0,0 +1,63 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "xiaohongshu-cli"
version = "0.6.4"
description = "A CLI for Xiaohongshu (Red) — search, read, interact via reverse-engineered API"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
keywords = ["xiaohongshu", "xhs", "redbook", "cli", "api"]
classifiers = [
"Development Status :: 3 - Alpha",
"Environment :: Console",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Topic :: Utilities",
]
dependencies = [
"httpx>=0.27",
"click>=8.0",
"rich>=13.0",
"browser-cookie3>=0.19",
"pycryptodome>=3.20",
"xhshow>=0.1.9",
"PyYAML>=6.0",
"qrcode>=7.0",
"camoufox>=0.4.11",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.11.0", "mypy>=1.15.0"]
[project.urls]
Homepage = "https://github.com/jackwener/xiaohongshu-cli"
Repository = "https://github.com/jackwener/xiaohongshu-cli"
Issues = "https://github.com/jackwener/xiaohongshu-cli/issues"
[project.scripts]
xhs = "xhs_cli.cli:cli"
[tool.hatch.build.targets.wheel]
packages = ["xhs_cli"]
[tool.ruff]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
python_version = "3.10"
ignore_missing_imports = true
check_untyped_defs = true
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-m 'not smoke'"
markers = ["smoke: real-API integration tests (run with: pytest -m smoke)"]
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
import os
os.environ.setdefault("OUTPUT", "rich")
@@ -0,0 +1,104 @@
"""Tests for anti-detection measures: UA/platform alignment, header completeness, jitter."""
from xhs_cli.client import XhsClient
from xhs_cli.constants import CHROME_VERSION, PLATFORM, USER_AGENT
from xhs_cli.signing import sign_main_api
class TestUAPlatformConsistency:
"""UA, sec-ch-ua, and fingerprint must all agree on macOS Chrome."""
def test_ua_is_macos_chrome(self):
assert "Macintosh" in USER_AGENT
assert "Chrome/" in USER_AGENT
assert "Edg/" not in USER_AGENT
assert "Windows" not in USER_AGENT
def test_platform_is_macos(self):
assert PLATFORM == "macOS"
def test_base_headers_match_ua(self):
client = XhsClient({"a1": "test"})
try:
headers = client._base_headers()
# sec-ch-ua must reference Chrome, not Edge
assert "Google Chrome" in headers["sec-ch-ua"]
assert CHROME_VERSION in headers["sec-ch-ua"]
assert "Edge" not in headers["sec-ch-ua"]
# Platform must match
assert "macOS" in headers["sec-ch-ua-platform"]
finally:
client.close()
class TestSigningHeaders:
"""Verify sign_main_api returns all required headers."""
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
def test_xs_has_xys_prefix(self):
cookies = {"a1": "test_a1_12345678901234567890123456789012345678901234"}
headers = sign_main_api("GET", "/api/test", cookies)
assert headers["x-s"].startswith("XYS_")
def test_get_and_post_return_different_xs(self):
cookies = {"a1": "test_a1_12345678901234567890123456789012345678901234"}
h_get = sign_main_api("GET", "/api/test", cookies)
h_post = sign_main_api("POST", "/api/test", cookies, payload={"key": "value"})
# Different method/payload → different signature
assert h_get["x-s"] != h_post["x-s"]
class TestClientJitter:
"""Verify jitter produces variable delays (not fixed intervals)."""
def test_request_delay_default(self):
client = XhsClient({"a1": "test"})
try:
assert client._request_delay == 1.0
assert client._base_request_delay == 1.0
finally:
client.close()
def test_verify_count_starts_at_zero(self):
client = XhsClient({"a1": "test"})
try:
assert client._verify_count == 0
assert client._request_count == 0
finally:
client.close()
class TestBaseHeadersCompleteness:
"""Ensure all anti-detection headers are present."""
def test_has_dnt_header(self):
client = XhsClient({"a1": "test"})
try:
headers = client._base_headers()
assert headers.get("dnt") == "1"
finally:
client.close()
def test_has_priority_header(self):
client = XhsClient({"a1": "test"})
try:
headers = client._base_headers()
assert "priority" in headers
finally:
client.close()
def test_has_all_sec_fetch_headers(self):
client = XhsClient({"a1": "test"})
try:
headers = client._base_headers()
assert "sec-fetch-dest" in headers
assert "sec-fetch-mode" in headers
assert "sec-fetch-site" in headers
finally:
client.close()
+544
View File
@@ -0,0 +1,544 @@
"""Tests for CLI commands using Click's test runner."""
import pytest
import yaml
from click.testing import CliRunner
from xhs_cli.cli import cli
from xhs_cli.exceptions import NoCookieError, SessionExpiredError, UnsupportedOperationError
runner = CliRunner()
FAKE_NOTE_RESPONSE = {
"items": [
{
"note_card": {
"title": "Test Note",
"desc": "body",
"user": {"nickname": "Author"},
"interact_info": {
"liked_count": "100",
"collected_count": "50",
"comment_count": "10",
"share_count": "5",
},
"tag_list": [],
"image_list": [],
}
}
]
}
class TestCliBasic:
"""Test CLI basics without requiring cookies."""
def test_version(self):
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert "0." in result.output # dynamic version from importlib.metadata
def test_help(self):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "xhs" in result.output
assert "search" in result.output
assert "read" in result.output
def test_search_help(self):
result = runner.invoke(cli, ["search", "--help"])
assert result.exit_code == 0
assert "keyword" in result.output.lower() or "KEYWORD" in result.output
def test_read_help(self):
result = runner.invoke(cli, ["read", "--help"])
assert result.exit_code == 0
def test_login_help(self):
result = runner.invoke(cli, ["login", "--help"])
assert result.exit_code == 0
def test_status_help(self):
result = runner.invoke(cli, ["status", "--help"])
assert result.exit_code == 0
def test_all_commands_registered(self):
result = runner.invoke(cli, ["--help"])
commands_expected = [
# Auth
"login", "status", "logout", "whoami",
# Reading
"search", "read", "comments", "sub-comments", "user", "user-posts",
"feed", "hot", "topics", "search-user", "my-notes",
"notifications", "unread",
# Interactions
"like", "favorite", "unfavorite", "comment", "reply", "delete-comment",
# Social
"follow", "unfollow", "favorites",
# Creator
"post", "delete",
]
for cmd in commands_expected:
assert cmd in result.output, f"Command '{cmd}' not found in CLI help"
def test_whoami_help(self):
result = runner.invoke(cli, ["whoami", "--help"])
assert result.exit_code == 0
def test_hot_help(self):
result = runner.invoke(cli, ["hot", "--help"])
assert result.exit_code == 0
assert "category" in result.output.lower()
def test_unread_help(self):
result = runner.invoke(cli, ["unread", "--help"])
assert result.exit_code == 0
def test_my_notes_help(self):
result = runner.invoke(cli, ["my-notes", "--help"])
assert result.exit_code == 0
def test_status_auto_yaml_when_stdout_is_not_tty(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr(
"xhs_cli.commands.auth.run_client_action",
lambda ctx, action: {"nickname": "Alice", "red_id": "alice001"},
)
result = runner.invoke(cli, ["status"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["schema_version"] == "1"
assert payload["data"]["authenticated"] is True
assert payload["data"]["user"]["name"] == "Alice"
def test_whoami_auto_yaml_when_stdout_is_not_tty(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr(
"xhs_cli.commands.auth.run_client_action",
lambda ctx, action: {"nickname": "Alice", "red_id": "alice001", "user_id": "u-1"},
)
result = runner.invoke(cli, ["whoami"])
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["user"]["username"] == "alice001"
def test_read_error_yaml_when_not_logged_in(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
monkeypatch.setattr(
"xhs_cli.commands._common.get_cookies",
lambda source, force_refresh=False: (_ for _ in ()).throw(NoCookieError(source)),
)
result = runner.invoke(cli, ["read", "abc", "--yaml"])
assert result.exit_code != 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is False
assert payload["error"]["code"] == "not_authenticated"
def test_status_reports_not_authenticated_when_session_expired(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "auto")
def fake_run_client_action(ctx, action):
raise SessionExpiredError()
monkeypatch.setattr("xhs_cli.commands.auth.run_client_action", fake_run_client_action)
result = runner.invoke(cli, ["status", "--yaml"])
assert result.exit_code != 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is False
assert payload["error"]["code"] == "not_authenticated"
def test_logout_supports_structured_output(self):
from xhs_cli.commands import auth
original_clear_cookies = auth.clear_cookies
auth.clear_cookies = lambda: None
try:
result = runner.invoke(cli, ["logout", "--yaml"])
finally:
auth.clear_cookies = original_clear_cookies
assert result.exit_code == 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is True
assert payload["data"]["logged_out"] is True
def test_delete_reports_unsupported_operation(self, monkeypatch):
monkeypatch.setattr(
"xhs_cli.commands.creator.run_client_action",
lambda ctx, action: (_ for _ in ()).throw(
UnsupportedOperationError("Delete note is currently unavailable from the public web API.")
),
)
result = runner.invoke(cli, ["delete", "note-123", "--yes", "--yaml"])
assert result.exit_code != 0
payload = yaml.safe_load(result.output)
assert payload["ok"] is False
assert payload["error"]["code"] == "unsupported_operation"
def test_comments_rich_output_handles_string_reply_counts(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
monkeypatch.setattr(
"xhs_cli.commands.reading.run_client_action",
lambda ctx, action: {
"comments": [
{
"user_info": {"nickname": "tester"},
"content": "hello",
"like_count": "12",
"sub_comment_count": "2",
}
]
},
)
result = runner.invoke(cli, ["comments", "note-123"])
assert result.exit_code == 0
assert "tester" in result.output
assert "2 replies" in result.output
def test_search_rich_output_shortens_visible_links(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
monkeypatch.setattr(
"xhs_cli.commands.reading.handle_command",
lambda ctx, action, render, as_json, as_yaml: render({
"items": [
{
"id": "69ad061d000000002603326d",
"xsec_token": "very-long-token-value",
"note_card": {
"title": "测试标题",
"user": {"nickname": "tester"},
"interact_info": {"liked_count": "12"},
"type": "normal",
},
}
],
"has_more": False,
}),
)
result = runner.invoke(cli, ["search", "openclaw"])
assert result.exit_code == 0
assert "search_result/69ad061d" in result.output
assert "very-long-token-value" not in result.output
def test_feed_rich_output_shortens_visible_links(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
monkeypatch.setattr(
"xhs_cli.commands.reading.handle_command",
lambda ctx, action, render, as_json, as_yaml: render({
"items": [
{
"id": "69ad061d000000002603326d",
"xsec_token": "another-very-long-token",
"note_card": {
"title": "推荐内容",
"user": {"nickname": "tester"},
"interact_info": {"liked_count": "9"},
},
}
]
}),
)
result = runner.invoke(cli, ["feed"])
assert result.exit_code == 0
assert "explore/69ad061d" in result.output
assert "another-very-long-token" not in result.output
def test_read_help_mentions_short_index(self):
result = runner.invoke(cli, ["read", "--help"])
assert result.exit_code == 0
assert "index" in result.output.lower()
def test_comments_help_mentions_short_index(self):
result = runner.invoke(cli, ["comments", "--help"])
assert result.exit_code == 0
assert "index" in result.output.lower()
def test_read_index_resolves_note_context(self, monkeypatch):
monkeypatch.setattr(
"xhs_cli.note_refs.get_note_by_index",
lambda idx: {
"note_id": "note-abc",
"xsec_token": "token-abc",
"xsec_source": "pc_search",
} if idx == 1 else None,
)
called = {}
class FakeClient:
def get_note_detail(self, note_id, **kwargs):
called["note_id"] = note_id
called["kwargs"] = kwargs
return FAKE_NOTE_RESPONSE
def fake_handle_command(ctx, action, render, as_json, as_yaml):
action(FakeClient())
return None
monkeypatch.setattr("xhs_cli.commands.reading.handle_command", fake_handle_command)
result = runner.invoke(cli, ["read", "1"])
assert result.exit_code == 0
assert called["note_id"] == "note-abc"
assert called["kwargs"]["xsec_token"] == "token-abc"
assert called["kwargs"]["xsec_source"] == "pc_search"
def test_comments_index_resolves_note_context(self, monkeypatch):
monkeypatch.setattr(
"xhs_cli.note_refs.get_note_by_index",
lambda idx: {
"note_id": "note-abc",
"xsec_token": "token-abc",
"xsec_source": "pc_search",
} if idx == 1 else None,
)
called = {}
class FakeClient:
def get_comments(self, note_id, cursor="", **kwargs):
called["note_id"] = note_id
called["cursor"] = cursor
called["kwargs"] = kwargs
return {"comments": []}
def fake_run_client_action(ctx, action):
return action(FakeClient())
monkeypatch.setattr("xhs_cli.commands.reading.run_client_action", fake_run_client_action)
result = runner.invoke(cli, ["comments", "1", "--yaml"])
assert result.exit_code == 0
assert called["note_id"] == "note-abc"
assert called["kwargs"]["xsec_token"] == "token-abc"
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)
result = runner.invoke(cli, ["read", "999"])
assert result.exit_code != 0
assert "999" in result.output
def test_search_empty_results_clear_previous_index(self, monkeypatch):
from xhs_cli.note_refs import save_index_from_items
saved = []
monkeypatch.setattr("xhs_cli.note_refs.save_note_index", lambda items: saved.append(items))
save_index_from_items({"items": []}, xsec_source="pc_search")
assert saved == [[]]
def test_user_posts_saves_index_entries(self, monkeypatch):
saved = []
monkeypatch.setattr("xhs_cli.note_refs.save_note_index", lambda items: saved.append(items))
def fake_handle_command(ctx, action, render, as_json, as_yaml):
class FakeClient:
def get_user_notes(self, user_id, cursor=""):
return {
"notes": [
{"note_id": "note-1"},
{"note_id": "note-2", "xsec_token": "ignored"},
],
"has_more": False,
"cursor": "",
}
data = action(FakeClient())
render(data)
return None
monkeypatch.setattr("xhs_cli.commands.reading.handle_command", fake_handle_command)
result = runner.invoke(cli, ["user-posts", "user-1"])
assert result.exit_code == 0
assert saved == [[
{"note_id": "note-1", "xsec_token": "", "xsec_source": ""},
{"note_id": "note-2", "xsec_token": "ignored", "xsec_source": ""},
]]
@pytest.mark.parametrize(
("command", "extra_args", "method_name"),
[
("like", [], "like_note"),
("like", ["--undo"], "unlike_note"),
("favorite", [], "favorite_note"),
("unfavorite", [], "unfavorite_note"),
],
)
def test_short_index_resolves_for_note_actions(self, monkeypatch, command, extra_args, method_name):
monkeypatch.setattr(
"xhs_cli.note_refs.get_note_by_index",
lambda idx: {
"note_id": "note-abc",
"xsec_token": "token-abc",
"xsec_source": "pc_search",
} if idx == 1 else None,
)
called = {}
class FakeClient:
def __getattr__(self, name):
if name != method_name:
raise AttributeError(name)
def _call(note_id):
called["method"] = name
called["note_id"] = note_id
return {"ok": True}
return _call
def fake_handle_command(ctx, action, render, as_json, as_yaml):
action(FakeClient())
return None
monkeypatch.setattr("xhs_cli.commands.interactions.handle_command", fake_handle_command)
result = runner.invoke(cli, [command, "1", *extra_args])
assert result.exit_code == 0
assert called == {"method": method_name, "note_id": "note-abc"}
def test_short_index_resolves_for_comment(self, monkeypatch):
monkeypatch.setattr(
"xhs_cli.note_refs.get_note_by_index",
lambda idx: {
"note_id": "note-abc",
"xsec_token": "token-abc",
"xsec_source": "pc_search",
} if idx == 1 else None,
)
called = {}
class FakeClient:
def post_comment(self, note_id, content):
called["note_id"] = note_id
called["content"] = content
return {"ok": True}
def fake_handle_command(ctx, action, render, as_json, as_yaml):
action(FakeClient())
return None
monkeypatch.setattr("xhs_cli.commands.interactions.handle_command", fake_handle_command)
result = runner.invoke(cli, ["comment", "1", "-c", "hello"])
assert result.exit_code == 0
assert called == {"note_id": "note-abc", "content": "hello"}
def test_short_index_resolves_for_reply(self, monkeypatch):
monkeypatch.setattr(
"xhs_cli.note_refs.get_note_by_index",
lambda idx: {
"note_id": "note-abc",
"xsec_token": "token-abc",
"xsec_source": "pc_search",
} if idx == 1 else None,
)
called = {}
class FakeClient:
def reply_comment(self, note_id, comment_id, content):
called["note_id"] = note_id
called["comment_id"] = comment_id
called["content"] = content
return {"ok": True}
def fake_handle_command(ctx, action, render, as_json, as_yaml):
action(FakeClient())
return None
monkeypatch.setattr("xhs_cli.commands.interactions.handle_command", fake_handle_command)
result = runner.invoke(cli, ["reply", "1", "--comment-id", "c-1", "-c", "hello"])
assert result.exit_code == 0
assert called == {"note_id": "note-abc", "comment_id": "c-1", "content": "hello"}
def test_favorites_saves_index_entries(self, monkeypatch):
saved = []
monkeypatch.setattr("xhs_cli.note_refs.save_note_index", lambda items: saved.append(items))
monkeypatch.setattr("xhs_cli.commands.social._resolve_user_id", lambda ctx, user_id: "user-1")
def fake_handle_command(ctx, action, render, as_json, as_yaml):
class FakeClient:
def get_user_favorites(self, user_id, cursor=""):
return {
"notes": [
{"note_id": "note-1"},
{"id": "note-2", "xsec_token": "token-2"},
],
"has_more": False,
"cursor": "",
}
data = action(FakeClient())
render(data)
return None
monkeypatch.setattr("xhs_cli.commands.social.handle_command", fake_handle_command)
result = runner.invoke(cli, ["favorites"])
assert result.exit_code == 0
assert saved == [[
{"note_id": "note-1", "xsec_token": "", "xsec_source": ""},
{"note_id": "note-2", "xsec_token": "token-2", "xsec_source": ""},
]]
def test_my_notes_saves_index_entries(self, monkeypatch):
saved = []
monkeypatch.setattr("xhs_cli.note_refs.save_note_index", lambda items: saved.append(items))
def fake_handle_command(ctx, action, render, as_json, as_yaml):
class FakeClient:
def get_creator_note_list(self, page=0):
return {
"note_list": [
{"note_id": "note-1"},
{"id": "note-2"},
]
}
data = action(FakeClient())
render(data)
return None
monkeypatch.setattr("xhs_cli.commands.creator.handle_command", fake_handle_command)
result = runner.invoke(cli, ["my-notes"])
assert result.exit_code == 0
assert saved == [[
{"note_id": "note-1", "xsec_token": "", "xsec_source": ""},
{"note_id": "note-2", "xsec_token": "", "xsec_source": ""},
]]
+278
View File
@@ -0,0 +1,278 @@
"""Unit tests for XHS client request payloads, cookies, and endpoint selection."""
from collections import OrderedDict
import httpx
import pytest
from xhs_cli.client import XhsClient
from xhs_cli.cookies import cache_note_context, get_cached_note_context
from xhs_cli.exceptions import UnsupportedOperationError, XhsApiError
class TestFavorites:
def test_unfavorite_uses_note_ids_payload(self, monkeypatch):
captured = {}
def fake_post(self, uri, data, header_overrides=None):
captured["uri"] = uri
captured["data"] = data
return True
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post)
client = XhsClient({"a1": "cookie"})
try:
client.unfavorite_note("note-123")
finally:
client.close()
assert captured["uri"] == "/api/sns/web/v1/note/uncollect"
assert captured["data"] == {"note_ids": "note-123"}
class TestCreatorEndpoints:
def test_creator_note_list_uses_v2_endpoint(self, monkeypatch):
captured = {}
def fake_get(self, uri, params=None):
captured["uri"] = uri
captured["params"] = params
return {"notes": [], "page": -1}
monkeypatch.setattr(XhsClient, "_creator_get", fake_get)
client = XhsClient({"a1": "cookie"})
try:
result = client.get_creator_note_list(page=2)
finally:
client.close()
assert result["page"] == -1
assert captured["uri"] == "/api/galaxy/v2/creator/note/user/posted"
assert captured["params"] == {"tab": 0, "page": 2}
def test_delete_note_raises_unsupported_for_404(self, monkeypatch):
def fake_post(self, uri, data):
raise XhsApiError(
"API error: {\"status\": 404}",
response={"status": 404},
)
monkeypatch.setattr(XhsClient, "_creator_post", fake_post)
client = XhsClient({"a1": "cookie"})
try:
with pytest.raises(UnsupportedOperationError, match="Delete note is currently unavailable"):
client.delete_note("note-123")
finally:
client.close()
class TestTransportCookies:
def test_request_with_retry_merges_response_cookies(self, monkeypatch):
request = httpx.Request("POST", "https://edith.xiaohongshu.com/api/test")
response = httpx.Response(
200,
headers=[
("set-cookie", "web_session=real-session; Path=/; Domain=.xiaohongshu.com"),
("set-cookie", "web_session_sec=real-sec; Path=/; Domain=.xiaohongshu.com"),
],
json={"success": True, "data": {"ok": True}},
request=request,
)
class _FakeHttpClient:
def request(self, method, url, **kwargs):
return response
def close(self):
return None
client = XhsClient({"a1": "cookie", "web_session": "guest-session"}, request_delay=0)
client._http = _FakeHttpClient()
try:
resp = client._request_with_retry("POST", "https://edith.xiaohongshu.com/api/test")
finally:
client.close()
assert resp is response
assert client.cookies["web_session"] == "real-session"
assert client.cookies["web_session_sec"] == "real-sec"
class TestReadingEndpointBehavior:
def test_get_note_detail_prefers_cached_xsec_source(self, monkeypatch):
captured = {}
def fake_get_note_by_id(self, note_id, xsec_token="", xsec_source="pc_feed"):
captured["note_id"] = note_id
captured["token"] = xsec_token
captured["source"] = xsec_source
return {"items": [{"note_card": {"title": "ok"}}]}
monkeypatch.setattr(XhsClient, "get_note_by_id", fake_get_note_by_id)
cache_note_context("note-123", "token-xyz", "pc_search")
client = XhsClient({"a1": "cookie"})
try:
client.get_note_detail("note-123")
finally:
client.close()
assert captured == {
"note_id": "note-123",
"token": "token-xyz",
"source": "pc_search",
}
def test_search_notes_uses_browser_like_prewarm_sequence(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("openclaw prompt", page=2)
finally:
client.close()
assert [call[1] for call in calls] == [
"/api/sns/web/v1/search/onebox",
"/api/sns/web/v1/search/filter",
"/api/sns/web/v1/search/notes",
"/api/sns/web/v1/search/recommend",
]
notes_payload = calls[2][2]
assert notes_payload["page"] == 2
assert notes_payload["filters"][0]["type"] == "sort_type"
assert notes_payload["filters"][1]["type"] == "filter_note_type"
def test_search_notes_reuses_search_id_across_pages(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("openclaw", page=1)
client.search_notes("openclaw", page=2)
finally:
client.close()
notes_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/notes"]
assert len(notes_calls) == 2
assert notes_calls[0][2]["search_id"] == notes_calls[1][2]["search_id"]
onebox_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/onebox"]
filter_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/filter"]
recommend_calls = [call for call in calls if call[1] == "/api/sns/web/v1/search/recommend"]
assert len(onebox_calls) == 1
assert len(filter_calls) == 1
assert len(recommend_calls) == 1
def test_search_notes_reuses_search_id_after_cache_reload(self, monkeypatch, tmp_path):
first_calls = []
second_calls = []
monkeypatch.setattr("xhs_cli.client_mixins.get_config_dir", lambda: tmp_path)
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", False)
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_PATH", None)
def fake_get_first(self, uri, params=None):
first_calls.append(("GET", uri, params))
return {"ok": True}
def fake_post_first(self, uri, data, header_overrides=None):
first_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_first)
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post_first)
client = XhsClient({"a1": "cookie"})
try:
client.search_notes("openclaw", page=1)
finally:
client.close()
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", False)
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_PATH", None)
def fake_get_second(self, uri, params=None):
second_calls.append(("GET", uri, params))
return {"ok": True}
def fake_post_second(self, uri, data, header_overrides=None):
second_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_second)
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post_second)
client = XhsClient({"a1": "cookie"})
try:
client.search_notes("openclaw", page=2)
finally:
client.close()
first_notes_call = next(call for call in first_calls if call[1] == "/api/sns/web/v1/search/notes")
second_notes_call = next(call for call in second_calls if call[1] == "/api/sns/web/v1/search/notes")
assert first_notes_call[2]["search_id"] == second_notes_call[2]["search_id"]
assert [call[1] for call in second_calls] == ["/api/sns/web/v1/search/notes"]
def test_get_note_detail_invalidates_cached_context_after_feed_failure(self, monkeypatch):
cache_note_context("note-123", "stale-token", "pc_search")
def fake_get_note_by_id(self, note_id, xsec_token="", xsec_source="pc_feed"):
raise XhsApiError("stale token")
def fake_get_note_from_html(self, note_id, xsec_token="", xsec_source="pc_feed"):
return {"items": [{"note_card": {"title": "html-ok"}}]}
monkeypatch.setattr(XhsClient, "get_note_by_id", fake_get_note_by_id)
monkeypatch.setattr(XhsClient, "get_note_from_html", fake_get_note_from_html)
client = XhsClient({"a1": "cookie"})
try:
result = client.get_note_detail("note-123")
finally:
client.close()
assert result["items"][0]["note_card"]["title"] == "html-ok"
assert get_cached_note_context("note-123") == {}
@@ -0,0 +1,182 @@
"""Unit tests for cookie management (no network required)."""
import time
import pytest
from xhs_cli.cookies import (
NOTE_CONTEXT_TTL_SECONDS,
cache_note_context,
clear_cookies,
cookies_to_string,
get_cached_note_context,
get_cached_xsec_token,
get_cookies,
get_index_cache_path,
get_note_by_index,
get_token_cache_path,
load_saved_cookies,
load_token_cache,
save_cookies,
save_note_index,
)
@pytest.fixture
def tmp_config_dir(tmp_path, monkeypatch):
"""Override config dir to use temp directory."""
monkeypatch.setattr("xhs_cli.cookies.get_config_dir", lambda: tmp_path)
monkeypatch.setattr("xhs_cli.cookies.get_cookie_path", lambda: tmp_path / "cookies.json")
monkeypatch.setattr("xhs_cli.cookies._TOKEN_CACHE_MEMORY", None)
monkeypatch.setattr("xhs_cli.cookies._TOKEN_CACHE_PATH", None)
return tmp_path
class TestSaveCookies:
def test_save_and_load(self, tmp_config_dir):
cookies = {"a1": "test_value", "web_session": "sess123"}
save_cookies(cookies)
loaded = load_saved_cookies()
assert loaded is not None
assert loaded["a1"] == "test_value"
assert loaded["web_session"] == "sess123"
def test_file_permissions(self, tmp_config_dir):
cookies = {"a1": "test"}
save_cookies(cookies)
cookie_file = tmp_config_dir / "cookies.json"
stat = cookie_file.stat()
assert stat.st_mode & 0o777 == 0o600
class TestLoadSavedCookies:
def test_no_file(self, tmp_config_dir):
assert load_saved_cookies() is None
def test_invalid_json(self, tmp_config_dir):
(tmp_config_dir / "cookies.json").write_text("not json")
assert load_saved_cookies() is None
def test_missing_a1(self, tmp_config_dir):
(tmp_config_dir / "cookies.json").write_text('{"web_session": "x"}')
assert load_saved_cookies() is None
class TestClearCookies:
def test_clear_existing(self, tmp_config_dir):
save_cookies({"a1": "test"})
clear_cookies()
assert load_saved_cookies() is None
def test_clear_nonexistent(self, tmp_config_dir):
# Should not raise
clear_cookies()
class TestCookiesToString:
def test_format(self):
result = cookies_to_string({"a1": "v1", "web_session": "v2"})
assert "a1=v1" in result
assert "web_session=v2" in result
assert "; " in result
class TestGetCookies:
def test_prefers_saved_cookies_by_default(self, monkeypatch):
monkeypatch.setattr("xhs_cli.cookies.load_saved_cookies", lambda: {"a1": "saved"})
monkeypatch.setattr(
"xhs_cli.cookies.extract_browser_cookies",
lambda source: ("chrome", {"a1": "fresh"}),
)
browser, cookies = get_cookies("chrome")
assert browser == "saved"
assert cookies == {"a1": "saved"}
def test_force_refresh_bypasses_saved_cookies(self, monkeypatch):
monkeypatch.setattr("xhs_cli.cookies.load_saved_cookies", lambda: {"a1": "saved"})
monkeypatch.setattr(
"xhs_cli.cookies.extract_browser_cookies",
lambda source: ("chrome", {"a1": "fresh"}),
)
saved = []
monkeypatch.setattr("xhs_cli.cookies.save_cookies", lambda cookies: saved.append(cookies))
browser, cookies = get_cookies("chrome", force_refresh=True)
assert browser == "chrome"
assert cookies == {"a1": "fresh"}
assert saved == [{"a1": "fresh"}]
class TestNoteContextCache:
def test_cache_persists_token_and_source(self, tmp_config_dir):
cache_note_context("note-1", "token-1", "pc_search", context="search")
assert get_cached_xsec_token("note-1") == "token-1"
context = get_cached_note_context("note-1")
assert context["token"] == "token-1"
assert context["source"] == "pc_search"
assert context["context"] == "search"
def test_load_token_cache_keeps_legacy_entries_compatible(self, tmp_config_dir):
get_token_cache_path().write_text('{"note-1":"legacy-token"}')
cache = load_token_cache()
assert cache["note-1"]["token"] == "legacy-token"
assert cache["note-1"]["source"] == ""
def test_expired_note_context_is_not_returned(self, tmp_config_dir):
stale_ts = time.time() - NOTE_CONTEXT_TTL_SECONDS - 10
get_token_cache_path().write_text(
f'{{"note-1":{{"token":"stale-token","source":"pc_search","ts":{stale_ts}}}}}'
)
assert get_cached_note_context("note-1") == {}
class TestNoteIndexCache:
def test_save_and_resolve_index_with_source(self, tmp_config_dir):
save_note_index([
{
"note_id": "note-1",
"xsec_token": "token-1",
"xsec_source": "pc_search",
}
])
assert get_note_by_index(1) == {
"note_id": "note-1",
"xsec_token": "token-1",
"xsec_source": "pc_search",
}
def test_save_empty_index_clears_previous_entries(self, tmp_config_dir):
save_note_index([
{
"note_id": "note-1",
"xsec_token": "token-1",
"xsec_source": "pc_search",
}
])
save_note_index([])
assert get_note_by_index(1) is None
assert get_index_cache_path().read_text() == "[]"
def test_index_file_permissions(self, tmp_config_dir):
save_note_index([{"note_id": "note-1", "xsec_token": "", "xsec_source": ""}])
stat = get_index_cache_path().stat()
assert stat.st_mode & 0o777 == 0o600
def test_index_normalizes_missing_optional_fields(self, tmp_config_dir):
get_index_cache_path().write_text('[{"note_id":"note-1"}]')
assert get_note_by_index(1) == {
"note_id": "note-1",
"xsec_token": "",
"xsec_source": "",
}
@@ -0,0 +1,76 @@
"""Unit tests for creator signing (no network required)."""
import base64
import json
from xhs_cli.creator_signing import _aes_encrypt, sign_creator
class TestAesEncrypt:
def test_returns_hex(self):
result = _aes_encrypt("hello world")
assert isinstance(result, str)
# Should be valid hex
int(result, 16)
def test_deterministic(self):
r1 = _aes_encrypt("test data")
r2 = _aes_encrypt("test data")
assert r1 == r2
def test_different_inputs(self):
r1 = _aes_encrypt("hello")
r2 = _aes_encrypt("world")
assert r1 != r2
class TestSignCreator:
def test_generates_headers(self):
result = sign_creator("url=/api/test", None, "test_a1")
assert "x-s" in result
assert "x-t" in result
def test_xs_prefix(self):
result = sign_creator("url=/api/test", None, "test_a1")
assert result["x-s"].startswith("XYW_")
def test_xt_is_timestamp(self):
result = sign_creator("url=/api/test", None, "test_a1")
ts = int(result["x-t"])
import time
now_ms = int(time.time() * 1000)
assert abs(ts - now_ms) < 5000
def test_with_data(self):
result = sign_creator(
"url=/web_api/sns/v1/search/topic",
{"keyword": "test"},
"test_a1",
)
assert result["x-s"].startswith("XYW_")
def test_envelope_structure(self):
result = sign_creator("url=/api/test", None, "test_a1")
# Decode the XYW_ envelope
xs = result["x-s"]
assert xs.startswith("XYW_")
envelope_b64 = xs[4:]
envelope = json.loads(base64.b64decode(envelope_b64))
assert envelope["signSvn"] == "56"
assert envelope["signType"] == "x2"
assert envelope["appId"] == "ugc"
assert envelope["signVersion"] == "1"
assert "payload" in envelope
def test_get_vs_post(self):
# GET: data is None
r1 = sign_creator("url=/api/test", None, "a1_val")
# POST: data is provided
r2 = sign_creator("url=/api/test", {"key": "val"}, "a1_val")
# Should produce different signatures because content differs
# (x-t might also differ so just check x-s)
# The payloads should be different
env1 = json.loads(base64.b64decode(r1["x-s"][4:]))
env2 = json.loads(base64.b64decode(r2["x-s"][4:]))
assert env1["payload"] != env2["payload"]
@@ -0,0 +1,62 @@
"""Unit tests for formatter (no network required)."""
from xhs_cli.formatter import coerce_int, extract_note_id, format_count, parse_note_reference
class TestFormatCount:
def test_small_number(self):
assert format_count(123) == "123"
def test_wan(self):
assert format_count(12345) == "1.2万"
def test_yi(self):
assert format_count(123456789) == "1.2亿"
def test_string_input(self):
assert format_count("5678") == "5678"
def test_string_large(self):
assert format_count("50000") == "5.0万"
class TestCoerceInt:
def test_int_input(self):
assert coerce_int(3) == 3
def test_string_input(self):
assert coerce_int("42") == 42
def test_invalid_string_falls_back(self):
assert coerce_int("10+") == 0
class TestExtractNoteId:
def test_plain_id(self):
assert extract_note_id("abc123def") == "abc123def"
def test_explore_url(self):
result = extract_note_id("https://www.xiaohongshu.com/explore/abc123def")
assert result == "abc123def"
def test_url_with_params(self):
result = extract_note_id("https://www.xiaohongshu.com/explore/abc123?xsec_token=xxx")
assert result == "abc123"
def test_discovery_url(self):
result = extract_note_id("https://www.xiaohongshu.com/discovery/item/abc123")
assert result == "abc123"
def test_trailing_slash(self):
result = extract_note_id("https://www.xiaohongshu.com/explore/abc123/")
assert result == "abc123"
class TestParseNoteReference:
def test_extracts_token_and_source(self):
note_id, token, source = parse_note_reference(
"https://www.xiaohongshu.com/explore/abc123?xsec_token=token-1&xsec_source=pc_search"
)
assert note_id == "abc123"
assert token == "token-1"
assert source == "pc_search"
@@ -0,0 +1,443 @@
"""
Integration tests for XHS API client.
These tests require actual XHS cookies (a logged-in browser session).
They test against the real XHS API to verify the complete signing + request pipeline.
Run with: uv run pytest tests/test_integration.py -v
Skip if no cookies available.
Write operations are tested as self-cleaning PAIRS:
- like + unlike → net effect = zero
- favorite + unfavorite → net effect = zero
- comment + delete-comment → net effect = zero
The undo operation always runs in a `finally` block.
"""
import time
import pytest
from xhs_cli.client import XhsClient
from xhs_cli.cookies import get_cookies
from xhs_cli.exceptions import NoCookieError, SessionExpiredError, XhsApiError
def _get_test_cookies():
"""Try to get valid cookies for integration testing."""
try:
cookies = get_cookies("chrome")
with XhsClient(cookies) as client:
client.get_self_info()
return cookies
except SessionExpiredError:
try:
cookies = get_cookies("chrome", force_refresh=True)
with XhsClient(cookies) as client:
client.get_self_info()
return cookies
except (NoCookieError, SessionExpiredError, XhsApiError, Exception):
return None
except (NoCookieError, Exception):
return None
# Skip all integration tests if no valid cookies are available
cookies = _get_test_cookies()
pytestmark = pytest.mark.skipif(cookies is None, reason="No valid XHS cookies available for integration testing")
# Cached values (discovered once, reused across tests)
_CACHED_NOTE_ID = None
_CACHED_XSEC_TOKEN = None
_CACHED_USER_ID = None
_CACHED_OTHER_USER_ID = None
def _ensure_test_note(client: XhsClient) -> tuple[str, str]:
"""Discover a note ID from search results for testing. Cached across tests."""
global _CACHED_NOTE_ID, _CACHED_XSEC_TOKEN
if _CACHED_NOTE_ID:
return _CACHED_NOTE_ID, _CACHED_XSEC_TOKEN or ""
search_data = client.search_notes("美食")
items = search_data.get("items", [])
if not items:
pytest.skip("No search results available for testing")
_CACHED_NOTE_ID = items[0].get("id", "")
_CACHED_XSEC_TOKEN = items[0].get("xsec_token", "")
if not _CACHED_NOTE_ID:
pytest.skip("No note ID in search results")
return _CACHED_NOTE_ID, _CACHED_XSEC_TOKEN or ""
def _ensure_self_user_id(client: XhsClient) -> str:
"""Get current user's ID. Cached across tests."""
global _CACHED_USER_ID
if _CACHED_USER_ID:
return _CACHED_USER_ID
info = client.get_self_info()
_CACHED_USER_ID = info.get("user_id", "")
if not _CACHED_USER_ID:
pytest.skip("Cannot determine current user ID")
return _CACHED_USER_ID
def _ensure_other_user_id(client: XhsClient) -> str:
"""Find another user ID for safe follow/unfollow testing."""
global _CACHED_OTHER_USER_ID
if _CACHED_OTHER_USER_ID:
return _CACHED_OTHER_USER_ID
self_user_id = _ensure_self_user_id(client)
data = client.search_users("美食达人")
users = data.get("user_info_dtos", []) if isinstance(data, dict) else []
for item in users:
user_id = item.get("user_base_dto", {}).get("user_id", "")
if user_id and user_id != self_user_id:
_CACHED_OTHER_USER_ID = user_id
return user_id
pytest.skip("No other user found for follow/unfollow testing")
@pytest.fixture
def client():
"""Create a test client with real cookies."""
assert cookies is not None
c = XhsClient(cookies)
yield c
c.close()
# ─── Auth ────────────────────────────────────────────────────────────────────
class TestAuth:
"""Test authentication and user info."""
def test_get_self_info(self, client: XhsClient):
info = client.get_self_info()
assert isinstance(info, dict)
assert any(k in info for k in ["nickname", "red_id", "user_id"])
def test_cookies_have_a1(self):
assert cookies is not None
assert "a1" in cookies
assert len(cookies["a1"]) > 0
# ─── Search ──────────────────────────────────────────────────────────────────
class TestSearch:
"""Test search functionality."""
def test_search_notes(self, client: XhsClient):
data = client.search_notes("美食", page=1)
items = data.get("items", [])
assert len(items) > 0
def test_search_with_sort(self, client: XhsClient):
time.sleep(1)
data = client.search_notes("旅行", sort="popularity_descending")
assert data is not None
def test_search_returns_note_cards(self, client: XhsClient):
time.sleep(1)
data = client.search_notes("咖啡")
items = data.get("items", [])
if items:
first = items[0]
assert "note_card" in first or "model_type" in first
def test_search_users(self, client: XhsClient):
"""Search for users by keyword."""
time.sleep(1)
data = client.search_users("美食达人")
assert data is not None
def test_search_topics(self, client: XhsClient):
time.sleep(1)
data = client.search_topics("美食")
assert data is not None
# ─── Feed ────────────────────────────────────────────────────────────────────
class TestFeed:
"""Test feed functionality."""
def test_get_home_feed(self, client: XhsClient):
time.sleep(1)
data = client.get_home_feed()
items = data.get("items", [])
assert len(items) > 0
def test_feed_items_have_structure(self, client: XhsClient):
time.sleep(1)
data = client.get_home_feed()
items = data.get("items", [])
if items:
assert "note_card" in items[0] or "id" in items[0]
def test_hot_feed(self, client: XhsClient):
"""Hot/trending feed by category."""
time.sleep(1)
data = client.get_hot_feed("homefeed.food_v3")
items = data.get("items", [])
assert len(items) > 0
# ─── Note Reading ────────────────────────────────────────────────────────────
class TestNoteRead:
"""Test reading individual notes."""
def test_read_note(self, client: XhsClient):
time.sleep(1)
note_id, xsec_token = _ensure_test_note(client)
time.sleep(1)
data = client.get_note_by_id(note_id, xsec_token=xsec_token)
assert data is not None
def test_get_comments(self, client: XhsClient):
time.sleep(1)
note_id, xsec_token = _ensure_test_note(client)
time.sleep(1)
data = client.get_comments(note_id, xsec_token=xsec_token)
assert data is not None
# ─── User Content ────────────────────────────────────────────────────────────
class TestUserContent:
"""Test user content queries."""
def test_get_user_info(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_self_user_id(client)
data = client.get_user_info(user_id)
assert isinstance(data, dict)
def test_get_user_notes(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_self_user_id(client)
data = client.get_user_notes(user_id)
assert data is not None
def test_get_user_favorites(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_self_user_id(client)
data = client.get_user_favorites(user_id)
assert data is not None
def test_get_creator_note_list(self, client: XhsClient):
time.sleep(1)
data = client.get_creator_note_list()
assert data is not None
assert "notes" in data
assert "page" in data
# ─── Notifications ───────────────────────────────────────────────────────────
class TestNotifications:
"""Test notification endpoints (reverse-engineered)."""
def test_unread_count(self, client: XhsClient):
time.sleep(1)
data = client.get_unread_count()
assert isinstance(data, dict)
def test_notification_mentions(self, client: XhsClient):
time.sleep(1)
data = client.get_notification_mentions()
assert data is not None
def test_notification_likes(self, client: XhsClient):
time.sleep(1)
data = client.get_notification_likes()
assert data is not None
def test_notification_connections(self, client: XhsClient):
time.sleep(1)
data = client.get_notification_connections()
assert data is not None
# ─── Paired Write Tests (self-cleaning) ──────────────────────────────────────
class TestLikePair:
"""like + unlike → net effect = zero."""
def test_like_then_unlike(self, client: XhsClient):
time.sleep(1)
note_id, _ = _ensure_test_note(client)
time.sleep(1)
try:
result = client.like_note(note_id)
assert result is not None or result is True
finally:
time.sleep(1)
try:
client.unlike_note(note_id)
except XhsApiError:
pass # Best effort cleanup
class TestFavoritePair:
"""favorite + unfavorite → net effect = zero."""
def test_favorite_then_unfavorite(self, client: XhsClient):
time.sleep(1)
note_id, _ = _ensure_test_note(client)
time.sleep(1)
try:
result = client.favorite_note(note_id)
assert result is not None or result is True
finally:
time.sleep(1)
try:
client.unfavorite_note(note_id)
except XhsApiError:
pass # Best effort cleanup
class TestCommentPair:
"""comment + delete-comment → net effect = zero."""
def test_comment_then_delete(self, client: XhsClient):
time.sleep(1)
note_id, _ = _ensure_test_note(client)
time.sleep(1)
comment_data = client.post_comment(note_id, "🤖 Integration test — will delete shortly")
assert comment_data is not None
# Extract comment ID from response
comment_id = ""
if isinstance(comment_data, dict):
comment_id = (
comment_data.get("comment", {}).get("id", "")
or comment_data.get("id", "")
or comment_data.get("comment_id", "")
)
try:
assert comment_id, f"Should get comment ID back, got: {comment_data}"
finally:
if comment_id:
time.sleep(1)
try:
client.delete_comment(note_id, comment_id)
except XhsApiError:
pass # Best effort cleanup
# ─── End-to-End Workflows ────────────────────────────────────────────────────
class TestEndToEnd:
"""Full workflow tests combining multiple operations."""
def test_search_read_comments_workflow(self, client: XhsClient):
"""search → read note → get comments."""
time.sleep(1)
result = client.search_notes("Python编程")
items = result.get("items", [])
if not items:
pytest.skip("No search results")
first = items[0]
note_id = first.get("id", "")
xsec_token = first.get("xsec_token", "")
assert note_id
time.sleep(1)
note = client.get_note_by_id(note_id, xsec_token=xsec_token)
assert note is not None
time.sleep(1)
comments = client.get_comments(note_id, xsec_token=xsec_token)
assert comments is not None
def test_like_favorite_workflow(self, client: XhsClient):
"""Like + favorite → unfavorite + unlike. Full interaction lifecycle."""
time.sleep(1)
note_id, _ = _ensure_test_note(client)
liked = False
favorited = False
try:
time.sleep(1)
client.like_note(note_id)
liked = True
time.sleep(1)
client.favorite_note(note_id)
favorited = True
finally:
if favorited:
time.sleep(1)
try:
client.unfavorite_note(note_id)
except XhsApiError:
pass
if liked:
time.sleep(1)
try:
client.unlike_note(note_id)
except XhsApiError:
pass
class TestFollowPair:
"""follow + unfollow → net effect = zero."""
def test_follow_then_unfollow(self, client: XhsClient):
time.sleep(1)
user_id = _ensure_other_user_id(client)
time.sleep(1)
followed = False
try:
result = client.follow_user(user_id)
followed = True
assert isinstance(result, dict)
assert result.get("fstatus") in {"follows", "followed"}
finally:
if followed:
time.sleep(1)
try:
undo = client.unfollow_user(user_id)
assert isinstance(undo, dict)
assert undo.get("fstatus") in {"none", "unfollowed"}
except XhsApiError:
pass
def test_notifications_workflow(self, client: XhsClient):
"""unread → mentions → likes → connections."""
time.sleep(1)
unread = client.get_unread_count()
assert isinstance(unread, dict)
time.sleep(1)
assert client.get_notification_mentions(num=5) is not None
time.sleep(1)
assert client.get_notification_likes(num=5) is not None
time.sleep(1)
assert client.get_notification_connections(num=5) is not None
@@ -0,0 +1,257 @@
"""Unit tests for QR code login flow."""
import pytest
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
class _FakeQrClient:
instances = []
def __init__(self, cookies, request_delay=0, **kwargs):
self.cookies = dict(cookies)
self.activate_calls = 0
self.status_calls = 0
self.complete_calls = 0
self.create_seen_web_session = None
self.status_seen_web_session = None
self.complete_seen_web_sessions = []
self.self_info_calls = 0
type(self).instances.append(self)
def __enter__(self):
return self
def __exit__(self, *args):
return False
def login_activate(self):
self.activate_calls += 1
if self.activate_calls == 1:
return {"session": "guest-session", "secure_session": "guest-sec", "user_id": "guest-user"}
return {"session": "unexpected-session", "secure_session": "unexpected-sec", "user_id": "unexpected-user"}
def create_qr_login(self):
self.create_seen_web_session = self.cookies.get("web_session")
return {"qr_id": "qr-1", "code": "code-1", "url": "https://example.com/qr"}
def check_qr_status(self, qr_id, code):
self.status_calls += 1
self.status_seen_web_session = self.cookies.get("web_session")
return {"codeStatus": 2, "userId": "real-user"}
def complete_qr_login(self, qr_id, code):
self.complete_calls += 1
self.complete_seen_web_sessions.append(self.cookies.get("web_session"))
self.cookies["web_session"] = "real-session"
self.cookies["web_session_sec"] = "real-sec"
return {
"code_status": 2,
"login_info": {
"user_id": "real-user",
"session": "real-session",
"secure_session": "real-sec",
},
}
def get_self_info(self):
self.self_info_calls += 1
return {
"user_id": "real-user",
"basic_info": {
"user_id": "real-user",
"nickname": "Alice",
"red_id": "alice001",
},
}
class _MismatchQrClient(_FakeQrClient):
def complete_qr_login(self, qr_id, code):
self.complete_calls += 1
self.complete_seen_web_sessions.append(self.cookies.get("web_session"))
self.cookies["web_session"] = "wrong-session"
self.cookies["web_session_sec"] = "wrong-sec"
return {
"code_status": 2,
"login_info": {
"user_id": "guest-user",
"session": "wrong-session",
"secure_session": "wrong-sec",
},
}
def get_self_info(self):
self.self_info_calls += 1
return {
"user_id": "guest-user",
"basic_info": {
"user_id": "guest-user",
"nickname": "Guest",
"red_id": "",
},
}
class _SelfInfoFallbackQrClient(_FakeQrClient):
def complete_qr_login(self, qr_id, code):
self.complete_calls += 1
self.complete_seen_web_sessions.append(self.cookies.get("web_session"))
self.cookies["web_session"] = "real-session"
self.cookies["web_session_sec"] = "real-sec"
return {
"code_status": 2,
"login_info": {
"user_id": "guest-user",
"session": "real-session",
"secure_session": "real-sec",
},
}
def test_qrcode_login_completes_after_confirmation_and_saves_real_session(monkeypatch):
saved = []
monkeypatch.setattr("xhs_cli.qr_login.XhsClient", _FakeQrClient)
monkeypatch.setattr("xhs_cli.qr_login._generate_a1", lambda: "a1-fixed")
monkeypatch.setattr("xhs_cli.qr_login._generate_webid", lambda: "webid-fixed")
monkeypatch.setattr("xhs_cli.qr_login._display_qr_in_terminal", lambda data: True)
monkeypatch.setattr("xhs_cli.qr_login.time.sleep", lambda seconds: None)
monkeypatch.setattr("xhs_cli.qr_login.save_cookies", lambda cookies: saved.append(cookies))
cookies = qrcode_login(timeout_s=1)
client = _FakeQrClient.instances[-1]
assert client.create_seen_web_session == "guest-session"
assert client.status_seen_web_session == "guest-session"
assert client.activate_calls == 1
assert client.complete_calls == 1
assert client.complete_seen_web_sessions == ["guest-session"]
assert cookies == {
"a1": "a1-fixed",
"webId": "webid-fixed",
"web_session": "real-session",
"web_session_sec": "real-sec",
}
assert saved == [cookies]
def test_qrcode_login_accepts_confirmed_user_from_self_info_fallback(monkeypatch):
saved = []
monkeypatch.setattr("xhs_cli.qr_login.XhsClient", _SelfInfoFallbackQrClient)
monkeypatch.setattr("xhs_cli.qr_login._generate_a1", lambda: "a1-fixed")
monkeypatch.setattr("xhs_cli.qr_login._generate_webid", lambda: "webid-fixed")
monkeypatch.setattr("xhs_cli.qr_login._display_qr_in_terminal", lambda data: True)
monkeypatch.setattr("xhs_cli.qr_login.time.sleep", lambda seconds: None)
monkeypatch.setattr("xhs_cli.qr_login.save_cookies", lambda cookies: saved.append(cookies))
cookies = qrcode_login(timeout_s=1)
client = _SelfInfoFallbackQrClient.instances[-1]
assert client.activate_calls == 1
assert client.complete_calls == 1
assert client.self_info_calls >= 1
assert cookies == {
"a1": "a1-fixed",
"webId": "webid-fixed",
"web_session": "real-session",
"web_session_sec": "real-sec",
}
assert saved == [cookies]
def test_qrcode_login_rejects_mismatched_confirmed_user(monkeypatch):
monkeypatch.setattr("xhs_cli.qr_login.XhsClient", _MismatchQrClient)
monkeypatch.setattr("xhs_cli.qr_login._generate_a1", lambda: "a1-fixed")
monkeypatch.setattr("xhs_cli.qr_login._generate_webid", lambda: "webid-fixed")
monkeypatch.setattr("xhs_cli.qr_login._display_qr_in_terminal", lambda data: True)
monkeypatch.setattr("xhs_cli.qr_login.time.sleep", lambda seconds: None)
monkeypatch.setattr("xhs_cli.qr_login.save_cookies", lambda cookies: None)
with pytest.raises(XhsApiError, match="completion never returned"):
qrcode_login(timeout_s=1)
def test_qrcode_login_prefers_browser_assisted_backend(monkeypatch):
saved = []
monkeypatch.setattr(
"xhs_cli.qr_login._browser_assisted_qrcode_login",
lambda **kwargs: {
"a1": "a1-browser",
"webId": "webid-browser",
"web_session": "0400-browser",
"web_session_sec": "secure-browser",
"id_token": "token-browser",
},
)
cookies = qrcode_login(timeout_s=1, prefer_browser_assisted=True)
assert cookies["web_session"] == "0400-browser"
assert cookies["id_token"] == "token-browser"
assert saved == []
def test_qrcode_login_falls_back_when_browser_backend_unavailable(monkeypatch):
monkeypatch.setattr(
"xhs_cli.qr_login._browser_assisted_qrcode_login",
lambda **kwargs: (_ for _ in ()).throw(BrowserQrLoginUnavailable("missing camoufox")),
)
monkeypatch.setattr(
"xhs_cli.qr_login._http_qrcode_login",
lambda **kwargs: {
"a1": "a1-http",
"webId": "webid-http",
"web_session": "http-session",
},
)
cookies = qrcode_login(timeout_s=1, prefer_browser_assisted=True)
assert cookies == {
"a1": "a1-http",
"webId": "webid-http",
"web_session": "http-session",
}
def test_normalize_browser_cookies_uses_allowlist():
cookies = _normalize_browser_cookies([
{"name": "a1", "value": "a1-value", "domain": ".xiaohongshu.com"},
{"name": "web_session", "value": "session-value", "domain": ".xiaohongshu.com"},
{"name": "customer-sso-sid", "value": "skip-me", "domain": ".xiaohongshu.com"},
{"name": "creator_only", "value": "skip-me-too", "domain": "creator.xiaohongshu.com"},
])
assert cookies == {
"a1": "a1-value",
"web_session": "session-value",
}
def test_normalize_xhs_user_payload_reads_basic_info():
user = normalize_xhs_user_payload({
"guest": False,
"basic_info": {
"user_id": "user-1",
"nickname": "Alice",
"red_id": "alice001",
"ip_location": "上海",
"desc": "hello",
},
})
assert user == {
"id": "user-1",
"name": "Alice",
"username": "alice001",
"nickname": "Alice",
"red_id": "alice001",
"ip_location": "上海",
"desc": "hello",
"guest": False,
}
@@ -0,0 +1,81 @@
"""Unit tests for signing adapter (no network required).
The cryptographic internals are tested by xhshow's own test suite.
These tests verify our adapter's public interface and config overrides.
"""
import time
import pytest
from xhs_cli.signing import build_get_uri, extract_uri, sign_main_api
class TestBuildUri:
def test_no_params(self):
assert build_get_uri("/api/test") == "/api/test"
def test_with_params(self):
result = build_get_uri("/api/test", {"a": "1", "b": 2})
assert "/api/test?" in result
assert "a=1" in result
assert "b=2" in result
def test_with_list_params(self):
result = build_get_uri("/api/test", {"types": ["a", "b"]})
assert "types=a,b" in result
class TestExtractUri:
def test_full_url(self):
assert extract_uri("https://example.com/api/test") == "/api/test"
def test_path_only(self):
assert extract_uri("/api/test") == "/api/test"
class TestSignMainApi:
def test_generates_all_headers(self):
cookies = {"a1": "test_a1_value_1234567890abcdef1234567890abcdef1234567890ab"}
headers = sign_main_api("GET", "/api/sns/web/v2/user/me", cookies)
assert "x-s" in headers
assert "x-s-common" in headers
assert "x-t" in headers
assert "x-b3-traceid" in headers
assert "x-xray-traceid" in headers
def test_xs_prefix(self):
cookies = {"a1": "test_a1_value_1234567890abcdef1234567890abcdef1234567890ab"}
headers = sign_main_api("GET", "/api/test", cookies)
assert headers["x-s"].startswith("XYS_")
def test_xt_is_timestamp(self):
cookies = {"a1": "test_a1_value_1234567890abcdef1234567890abcdef1234567890ab"}
headers = sign_main_api("GET", "/api/test", cookies)
ts = int(headers["x-t"])
now_ms = int(time.time() * 1000)
assert abs(ts - now_ms) < 5000 # within 5 seconds
def test_post_signing(self):
cookies = {"a1": "test_a1_value_1234567890abcdef1234567890abcdef1234567890ab"}
headers = sign_main_api(
"POST",
"/api/sns/web/v1/search/notes",
cookies,
payload={"keyword": "test", "page": 1},
)
assert headers["x-s"].startswith("XYS_")
def test_missing_a1_raises(self):
with pytest.raises(ValueError, match="Missing 'a1'"):
sign_main_api("GET", "/api/test", {})
def test_with_params(self):
cookies = {"a1": "test_a1_value_1234567890abcdef1234567890abcdef1234567890ab"}
headers = sign_main_api(
"GET", "/api/test", cookies,
params={"user_id": "12345"},
)
assert headers["x-s"].startswith("XYS_")
+212
View File
@@ -0,0 +1,212 @@
"""Integration smoke tests for xiaohongshu-cli.
These tests invoke the real CLI commands with ``--yaml`` against the live
Xiaohongshu API using your local browser cookies. They are **skipped by
default** and only run when explicitly requested::
uv run pytest -m smoke -v
Only read-only operations are tested — no writes.
"""
from __future__ import annotations
import pytest
import yaml
from click.testing import CliRunner
from xhs_cli.cli import cli
smoke = pytest.mark.smoke
runner = CliRunner()
def _invoke(*args: str):
"""Run a CLI command with --yaml and return parsed payload."""
result = runner.invoke(cli, [*args, "--yaml"])
if result.output:
payload = yaml.safe_load(result.output)
else:
payload = None
return result, payload
def _pick_note_reference(payload: dict) -> tuple[str, str]:
"""Extract ``(note_id, xsec_token)`` from a search/feed payload."""
data = payload["data"]
items = data.get("items", [])
if not items:
pytest.skip("No note items available in payload")
first = items[0]
note_card = first.get("note_card", {})
note_id = first.get("id", note_card.get("note_id", ""))
xsec_token = first.get("xsec_token", note_card.get("xsec_token", ""))
if not note_id:
pytest.skip("No note_id found in payload")
return note_id, xsec_token
# ── Auth ────────────────────────────────────────────────────────────────
@smoke
class TestAuth:
"""Verify authentication is working end-to-end."""
def test_status(self):
result, payload = _invoke("status")
assert result.exit_code == 0, f"status failed: {result.output}"
assert payload["ok"] is True
assert payload["data"]["authenticated"] is True
def test_whoami(self):
result, payload = _invoke("whoami")
assert result.exit_code == 0, f"whoami failed: {result.output}"
assert payload["ok"] is True
# ── Read-only queries ───────────────────────────────────────────────────
@smoke
class TestReadOnly:
"""Read-only CLI smoke tests."""
def test_search(self):
result, payload = _invoke("search", "美食")
assert result.exit_code == 0, f"search failed: {result.output}"
assert payload["ok"] is True
def test_feed(self):
result, payload = _invoke("feed")
assert result.exit_code == 0, f"feed failed: {result.output}"
assert payload["ok"] is True
def test_hot(self):
result, payload = _invoke("hot", "-c", "food")
assert result.exit_code == 0, f"hot failed: {result.output}"
assert payload["ok"] is True
def test_topics(self):
result, payload = _invoke("topics", "旅行")
assert result.exit_code == 0, f"topics failed: {result.output}"
assert payload["ok"] is True
def test_search_feed_read_roundtrip(self):
search_result, search_payload = _invoke("search", "黑丝")
assert search_result.exit_code == 0, f"search 黑丝 failed: {search_result.output}"
assert search_payload["ok"] is True
search_note_id, search_xsec_token = _pick_note_reference(search_payload)
first_read_result, first_read_payload = _invoke(
"read",
search_note_id,
"--xsec-token",
search_xsec_token,
)
assert first_read_result.exit_code == 0, f"first read failed: {first_read_result.output}"
assert first_read_payload["ok"] is True
feed_result, feed_payload = _invoke("feed")
assert feed_result.exit_code == 0, f"feed failed: {feed_result.output}"
assert feed_payload["ok"] is True
feed_note_id, feed_xsec_token = _pick_note_reference(feed_payload)
feed_read_result, feed_read_payload = _invoke(
"read",
feed_note_id,
"--xsec-token",
feed_xsec_token,
)
assert feed_read_result.exit_code == 0, f"feed read failed: {feed_read_result.output}"
assert feed_read_payload["ok"] is True
second_read_result, second_read_payload = _invoke("read", search_note_id)
assert second_read_result.exit_code == 0, f"second read failed: {second_read_result.output}"
assert second_read_payload["ok"] is True
def test_search_then_comments(self):
search_result, search_payload = _invoke("search", "黑丝")
assert search_result.exit_code == 0, f"search 黑丝 failed: {search_result.output}"
assert search_payload["ok"] is True
note_id, xsec_token = _pick_note_reference(search_payload)
comments_result, comments_payload = _invoke(
"comments",
note_id,
"--xsec-token",
xsec_token,
)
assert comments_result.exit_code == 0, f"comments after search failed: {comments_result.output}"
assert comments_payload["ok"] is True
def test_feed_comments_reread_same_note(self):
feed_result, feed_payload = _invoke("feed")
assert feed_result.exit_code == 0, f"feed failed: {feed_result.output}"
assert feed_payload["ok"] is True
note_id, xsec_token = _pick_note_reference(feed_payload)
comments_result, comments_payload = _invoke(
"comments",
note_id,
"--xsec-token",
xsec_token,
)
assert comments_result.exit_code == 0, f"comments after feed failed: {comments_result.output}"
assert comments_payload["ok"] is True
reread_result, reread_payload = _invoke("read", note_id)
assert reread_result.exit_code == 0, f"reread after comments failed: {reread_result.output}"
assert reread_payload["ok"] is True
def test_search_read_comments_feed_read_reread(self):
search_result, search_payload = _invoke("search", "黑丝")
assert search_result.exit_code == 0, f"search 黑丝 failed: {search_result.output}"
assert search_payload["ok"] is True
search_note_id, search_xsec_token = _pick_note_reference(search_payload)
read_result, read_payload = _invoke(
"read",
search_note_id,
"--xsec-token",
search_xsec_token,
)
assert read_result.exit_code == 0, f"search read failed: {read_result.output}"
assert read_payload["ok"] is True
comments_result, comments_payload = _invoke("comments", search_note_id)
assert comments_result.exit_code == 0, f"comments after read failed: {comments_result.output}"
assert comments_payload["ok"] is True
feed_result, feed_payload = _invoke("feed")
assert feed_result.exit_code == 0, f"feed failed: {feed_result.output}"
assert feed_payload["ok"] is True
feed_note_id, feed_xsec_token = _pick_note_reference(feed_payload)
feed_read_result, feed_read_payload = _invoke(
"read",
feed_note_id,
"--xsec-token",
feed_xsec_token,
)
assert feed_read_result.exit_code == 0, f"feed read failed: {feed_read_result.output}"
assert feed_read_payload["ok"] is True
reread_result, reread_payload = _invoke("read", search_note_id)
assert reread_result.exit_code == 0, f"final reread failed: {reread_result.output}"
assert reread_payload["ok"] is True
def test_short_index_search_read_comments_roundtrip(self):
search_result, search_payload = _invoke("search", "黑丝")
assert search_result.exit_code == 0, f"search 黑丝 failed: {search_result.output}"
assert search_payload["ok"] is True
read_result, read_payload = _invoke("read", "1")
assert read_result.exit_code == 0, f"read by short index failed: {read_result.output}"
assert read_payload["ok"] is True
comments_result, comments_payload = _invoke("comments", "1")
assert comments_result.exit_code == 0, f"comments by short index failed: {comments_result.output}"
assert comments_payload["ok"] is True
+1508
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
"""xiaohongshu-cli: Xiaohongshu CLI via reverse-engineered API."""
try:
from importlib.metadata import version
__version__ = version("xiaohongshu-cli")
except Exception:
__version__ = "0.0.0"
@@ -0,0 +1,6 @@
"""Module entry point for ``python -m xhs_cli``."""
from .cli import cli
if __name__ == "__main__":
cli()
+105
View File
@@ -0,0 +1,105 @@
"""CLI entry point for xiaohongshu-cli.
Usage:
xhs login / status / logout
xhs search <keyword> [--sort popular|latest] [--type video|image] [--page N]
xhs read <id_or_url> [--xsec-token TOKEN]
xhs comments <id_or_url>
xhs user <user_id>
xhs user-posts <user_id> [--cursor CURSOR]
xhs feed
xhs hot [--category CATEGORY]
xhs topics <keyword>
xhs like <id_or_url> [--undo]
xhs favorite <id_or_url>
xhs unfavorite <id_or_url>
xhs comment <id_or_url> --content "..."
xhs reply <id_or_url> --comment-id ID --content "..."
xhs favorites [user_id]
xhs my-notes [--page N]
xhs notifications [--type mentions|likes|connections]
xhs unread
xhs post --title "..." --body "..." --images img.png
xhs delete <id_or_url> [-y]
"""
from __future__ import annotations
import logging
import click
from . import __version__
from .commands import auth, creator, interactions, notifications, reading, social
@click.group()
@click.version_option(version=__version__, prog_name="xhs")
@click.option("-v", "--verbose", is_flag=True, help="Enable debug logging")
@click.option(
"--cookie-source",
type=str,
default="auto",
show_default=True,
help="Browser to read cookies from (auto = try all installed browsers)",
)
@click.pass_context
def cli(ctx, verbose: bool, cookie_source: str):
"""xhs — Xiaohongshu CLI via reverse-engineered API 📕"""
ctx.ensure_object(dict)
ctx.obj["cookie_source"] = cookie_source
if verbose:
logging.basicConfig(level=logging.DEBUG, format="%(name)s %(message)s")
else:
logging.basicConfig(level=logging.WARNING)
# ─── Auth commands ───────────────────────────────────────────────────────────
cli.add_command(auth.login)
cli.add_command(auth.status)
cli.add_command(auth.logout)
cli.add_command(auth.whoami)
# ─── Reading commands ────────────────────────────────────────────────────────
cli.add_command(reading.search)
cli.add_command(reading.read)
cli.add_command(reading.comments)
cli.add_command(reading.sub_comments)
cli.add_command(reading.user)
cli.add_command(reading.user_posts)
cli.add_command(reading.feed)
cli.add_command(reading.hot)
cli.add_command(reading.topics)
cli.add_command(reading.search_user)
# ─── Interaction commands ────────────────────────────────────────────────────
cli.add_command(interactions.like)
cli.add_command(interactions.favorite)
cli.add_command(interactions.unfavorite)
cli.add_command(interactions.comment)
cli.add_command(interactions.reply)
cli.add_command(interactions.delete_comment)
# ─── Social commands ────────────────────────────────────────────────────────
cli.add_command(social.follow)
cli.add_command(social.unfollow)
cli.add_command(social.favorites)
# ─── Creator commands ───────────────────────────────────────────────────────
cli.add_command(creator.post)
cli.add_command(creator.my_notes)
cli.add_command(creator.delete)
# ─── Notification commands ──────────────────────────────────────────────────
cli.add_command(notifications.notifications)
cli.add_command(notifications.unread)
if __name__ == "__main__":
cli()
+256
View File
@@ -0,0 +1,256 @@
"""
XHS API client transport, signing, and retry primitives.
Domain-specific endpoint methods live in ``client_mixins.py``.
"""
from __future__ import annotations
import json
import logging
import random
import time
from typing import Any
import httpx
from .client_mixins import (
AuthEndpointsMixin,
CreatorEndpointsMixin,
InteractionEndpointsMixin,
NotificationEndpointsMixin,
ReadingEndpointsMixin,
SocialEndpointsMixin,
)
from .constants import CHROME_VERSION, CREATOR_HOST, EDITH_HOST, HOME_URL, USER_AGENT
from .cookies import cookies_to_string
from .creator_signing import sign_creator
from .exceptions import (
IpBlockedError,
NeedVerifyError,
SessionExpiredError,
SignatureError,
XhsApiError,
)
from .signing import build_get_uri, sign_main_api
logger = logging.getLogger(__name__)
class XhsClient(
ReadingEndpointsMixin,
InteractionEndpointsMixin,
CreatorEndpointsMixin,
SocialEndpointsMixin,
NotificationEndpointsMixin,
AuthEndpointsMixin,
):
"""Xiaohongshu API client with automatic signing, rate limiting, and retry."""
def __init__(
self,
cookies: dict[str, str],
timeout: float = 30.0,
request_delay: float = 1.0,
max_retries: int = 3,
):
self.cookies = cookies
self._http = httpx.Client(timeout=timeout, follow_redirects=True)
self._request_delay = request_delay
self._base_request_delay = request_delay
self._max_retries = max_retries
self._last_request_time = 0.0
self._verify_count = 0
self._request_count = 0
def close(self) -> None:
self._http.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def _rate_limit_delay(self) -> None:
"""Enforce minimum delay with Gaussian jitter to mimic human browsing."""
if self._request_delay <= 0:
return
elapsed = time.time() - self._last_request_time
if elapsed < self._request_delay:
jitter = max(0, random.gauss(0.3, 0.15))
if random.random() < 0.05:
jitter += random.uniform(2.0, 5.0)
sleep_time = self._request_delay - elapsed + jitter
logger.debug("Rate-limit delay: %.2fs", sleep_time)
time.sleep(sleep_time)
def _mark_request(self) -> None:
self._last_request_time = time.time()
self._request_count += 1
def _base_headers(self) -> dict[str, str]:
return {
"user-agent": USER_AGENT,
"content-type": "application/json;charset=UTF-8",
"cookie": cookies_to_string(self.cookies),
"origin": HOME_URL,
"referer": f"{HOME_URL}/",
"sec-ch-ua": f'"Not:A-Brand";v="99", "Google Chrome";v="{CHROME_VERSION}", "Chromium";v="{CHROME_VERSION}"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"accept": "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"dnt": "1",
"priority": "u=1, i",
}
def _handle_response(self, resp: httpx.Response) -> Any:
if resp.status_code in (461, 471):
self._verify_count += 1
cooldown = min(30, 5 * (2 ** (self._verify_count - 1)))
logger.warning(
"Captcha triggered (count=%d), cooling down %.0fs before raising",
self._verify_count, cooldown,
)
self._request_delay = max(self._request_delay, self._base_request_delay * 2)
time.sleep(cooldown)
raise NeedVerifyError(
verify_type=resp.headers.get("verifytype", "unknown"),
verify_uuid=resp.headers.get("verifyuuid", "unknown"),
)
self._verify_count = 0
text = resp.text
if not text:
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
raise XhsApiError(f"Non-JSON response: {text[:200]}") from None
if data.get("success"):
return data.get("data", data.get("success"))
code = data.get("code")
if code == 300012:
raise IpBlockedError()
if code == 300015:
raise SignatureError()
if code == -100:
raise SessionExpiredError()
raise XhsApiError(
f"API error: {json.dumps(data)[:300]}",
code=code,
response=data,
)
def _merge_response_cookies(self, resp: httpx.Response) -> None:
"""Persist response cookies back into the in-memory session jar."""
for name, value in resp.cookies.items():
if not value:
continue
self.cookies[name] = value
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
self._rate_limit_delay()
last_exc: Exception | None = None
for attempt in range(self._max_retries):
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):
wait = (2 ** attempt) + random.uniform(0, 1)
logger.warning(
"HTTP %d from %s, retrying in %.1fs (attempt %d/%d)",
resp.status_code, url[:80], wait, attempt + 1, self._max_retries,
)
time.sleep(wait)
continue
return resp
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_exc = exc
wait = (2 ** attempt) + random.uniform(0, 1)
logger.warning(
"Network error: %s, retrying in %.1fs (attempt %d/%d)",
exc, wait, attempt + 1, self._max_retries,
)
time.sleep(wait)
if last_exc:
raise XhsApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc
raise XhsApiError(f"Request failed after {self._max_retries} retries: HTTP {resp.status_code}")
def _main_api_get(
self,
uri: str,
params: dict[str, str | int | list[str]] | None = None,
) -> Any:
sign_headers = sign_main_api("GET", uri, self.cookies, params=params)
full_uri = build_get_uri(uri, params)
url = f"{EDITH_HOST}{full_uri}"
logger.debug("GET %s", url)
resp = self._request_with_retry("GET", url, headers={**self._base_headers(), **sign_headers})
return self._handle_response(resp)
def _main_api_post(
self,
uri: str,
data: dict[str, Any],
header_overrides: dict[str, str] | None = None,
) -> Any:
sign_headers = sign_main_api("POST", uri, self.cookies, payload=data)
url = f"{EDITH_HOST}{uri}"
headers = {**self._base_headers(), **sign_headers}
if header_overrides:
headers.update(header_overrides)
logger.debug("POST %s", url)
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
resp = self._request_with_retry("POST", url, headers=headers, content=body)
return self._handle_response(resp)
def _creator_host(self, uri: str) -> str:
return CREATOR_HOST if uri.startswith("/api/galaxy/") else EDITH_HOST
def _creator_get(
self,
uri: str,
params: dict[str, str | int] | None = None,
) -> Any:
full_uri = build_get_uri(uri, params)
sign = sign_creator(f"url={full_uri}", None, self.cookies["a1"])
host = self._creator_host(uri)
url = f"{host}{full_uri}"
headers = {
**self._base_headers(),
"x-s": sign["x-s"],
"x-t": sign["x-t"],
"origin": CREATOR_HOST,
"referer": f"{CREATOR_HOST}/",
}
logger.debug("Creator GET %s", url)
resp = self._request_with_retry("GET", url, headers=headers)
return self._handle_response(resp)
def _creator_post(self, uri: str, data: dict[str, Any]) -> Any:
sign = sign_creator(f"url={uri}", data, self.cookies["a1"])
host = self._creator_host(uri)
url = f"{host}{uri}"
headers = {
**self._base_headers(),
"x-s": sign["x-s"],
"x-t": sign["x-t"],
"origin": CREATOR_HOST,
"referer": f"{CREATOR_HOST}/",
}
logger.debug("Creator POST %s", url)
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
resp = self._request_with_retry("POST", url, headers=headers, content=body)
return self._handle_response(resp)
@@ -0,0 +1,694 @@
"""Domain-specific endpoint mixins for XhsClient."""
from __future__ import annotations
import json
import logging
import mimetypes
import random
import re
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any
from .constants import CREATOR_HOST, HOME_URL, UPLOAD_HOST, USER_AGENT
from .cookies import (
cache_note_context,
cookies_to_string,
get_cached_note_context,
get_config_dir,
invalidate_note_context,
)
from .exceptions import NeedVerifyError, UnsupportedOperationError, XhsApiError
from .html_parser import extract_note_from_html
logger = logging.getLogger(__name__)
_SEARCH_DEFAULT_FILTERS = [
{"tags": ["general"], "type": "sort_type"},
{"tags": ["不限"], "type": "filter_note_type"},
{"tags": ["不限"], "type": "filter_note_time"},
{"tags": ["不限"], "type": "filter_note_range"},
{"tags": ["不限"], "type": "filter_pos_distance"},
]
_SEARCH_SESSION_TTL_SECONDS = 600
_SEARCH_SESSION_MAX_SIZE = 128
_SEARCH_SESSION_LOCK = threading.RLock()
_SEARCH_SESSION_CACHE: OrderedDict[tuple[str, str, int], dict[str, Any]] = OrderedDict()
_SEARCH_SESSION_CACHE_PATH: Path | None = None
_SEARCH_SESSION_CACHE_LOADED = False
def _generate_search_id() -> str:
"""Generate a unique search ID (base36 of timestamp << 64 + random)."""
e = int(time.time() * 1000) << 64
t = random.randint(0, 2147483646)
num = e + t
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if num == 0:
return "0"
result = ""
while num > 0:
result = alphabet[num % 36] + result
num //= 36
return result
def _search_session_key(keyword: str, sort: str, note_type: int) -> tuple[str, str, int]:
return (keyword.strip(), sort, note_type)
def _search_session_path() -> Path:
return get_config_dir() / "search_sessions.json"
def _serialize_search_session_key(key: tuple[str, str, int]) -> str:
return json.dumps([key[0], key[1], key[2]], ensure_ascii=False)
def _deserialize_search_session_key(value: str) -> tuple[str, str, int] | None:
try:
keyword, sort, note_type = json.loads(value)
except (TypeError, ValueError, json.JSONDecodeError):
return None
if not isinstance(keyword, str) or not isinstance(sort, str):
return None
try:
normalized_note_type = int(note_type)
except (TypeError, ValueError):
return None
return (keyword, sort, normalized_note_type)
def _load_search_session_cache_from_disk(path: Path) -> OrderedDict[tuple[str, str, int], dict[str, Any]]:
if not path.exists():
return OrderedDict()
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return OrderedDict()
if not isinstance(data, dict):
return OrderedDict()
normalized: list[tuple[tuple[str, str, int], dict[str, Any]]] = []
for raw_key, value in data.items():
key = _deserialize_search_session_key(raw_key)
if not key or not isinstance(value, dict):
continue
if not value.get("search_id"):
continue
normalized.append((key, {
"search_id": str(value["search_id"]),
"created_at": float(value.get("created_at", 0) or 0),
"last_used_at": float(value.get("last_used_at", 0) or 0),
}))
normalized.sort(key=lambda item: float(item[1].get("last_used_at", 0)))
return OrderedDict(normalized)
def _save_search_session_cache(path: Path) -> None:
payload = OrderedDict(
(
_serialize_search_session_key(key),
dict(value),
)
for key, value in _SEARCH_SESSION_CACHE.items()
)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
path.chmod(0o600)
def _ensure_search_session_cache_loaded() -> None:
global _SEARCH_SESSION_CACHE_LOADED, _SEARCH_SESSION_CACHE_PATH, _SEARCH_SESSION_CACHE
path = _search_session_path()
if _SEARCH_SESSION_CACHE_LOADED and _SEARCH_SESSION_CACHE_PATH == path:
return
_SEARCH_SESSION_CACHE = _load_search_session_cache_from_disk(path)
_SEARCH_SESSION_CACHE_PATH = path
_SEARCH_SESSION_CACHE_LOADED = True
def _prune_search_sessions(now: float) -> None:
expired_keys = [
key
for key, value in _SEARCH_SESSION_CACHE.items()
if now - float(value.get("last_used_at", 0)) > _SEARCH_SESSION_TTL_SECONDS
]
for key in expired_keys:
_SEARCH_SESSION_CACHE.pop(key, None)
while len(_SEARCH_SESSION_CACHE) > _SEARCH_SESSION_MAX_SIZE:
_SEARCH_SESSION_CACHE.popitem(last=False)
def _acquire_search_session(keyword: str, sort: str, note_type: int) -> tuple[str, bool]:
now = time.time()
key = _search_session_key(keyword, sort, note_type)
with _SEARCH_SESSION_LOCK:
_ensure_search_session_cache_loaded()
_prune_search_sessions(now)
existing = _SEARCH_SESSION_CACHE.get(key)
if existing:
existing["last_used_at"] = now
_SEARCH_SESSION_CACHE.move_to_end(key)
_save_search_session_cache(_SEARCH_SESSION_CACHE_PATH or _search_session_path())
return str(existing["search_id"]), False
search_id = _generate_search_id()
_SEARCH_SESSION_CACHE[key] = {
"search_id": search_id,
"created_at": now,
"last_used_at": now,
}
_save_search_session_cache(_SEARCH_SESSION_CACHE_PATH or _search_session_path())
return search_id, True
def get_search_session_stats() -> dict[str, Any]:
"""Return lightweight debug stats for the in-memory search session cache."""
now = time.time()
with _SEARCH_SESSION_LOCK:
_ensure_search_session_cache_loaded()
_prune_search_sessions(now)
if not _SEARCH_SESSION_CACHE:
return {
"active_count": 0,
"last_keyword": "",
"last_sort": "",
"last_note_type": None,
}
last_key = next(reversed(_SEARCH_SESSION_CACHE))
return {
"active_count": len(_SEARCH_SESSION_CACHE),
"last_keyword": last_key[0],
"last_sort": last_key[1],
"last_note_type": last_key[2],
}
class ReadingEndpointsMixin:
"""Read-only note, profile, and discovery endpoints."""
def _search_request_id(self) -> str:
return f"{random.randint(1_000_000_000, 2_147_483_647)}-{int(time.time() * 1000)}"
def _fetch_note_html(
self,
note_id: str,
xsec_token: str = "",
xsec_source: str = "pc_feed",
) -> str:
if xsec_token:
url = f"{HOME_URL}/explore/{note_id}?xsec_token={xsec_token}&xsec_source={xsec_source}"
else:
url = f"{HOME_URL}/explore/{note_id}"
resp = self._request_with_retry(
"GET",
url,
headers={
"user-agent": USER_AGENT,
"referer": f"{HOME_URL}/",
"cookie": cookies_to_string(self.cookies),
},
)
return resp.text
def resolve_xsec_context(
self,
note_id: str,
preferred_token: str = "",
preferred_source: str = "",
) -> tuple[str, str]:
"""Resolve xsec_token/xsec_source from input, cache, or note page metadata."""
if preferred_token:
cache_note_context(note_id, preferred_token, preferred_source)
return preferred_token, preferred_source
cached = get_cached_note_context(note_id)
if cached.get("token"):
return cached["token"], cached.get("source", "")
html = self._fetch_note_html(note_id)
patterns = [
r'"xsec_token"\s*:\s*"([^"]+)"',
r"xsec_token=([^&\"']+)",
r"'xsec_token':'([^']+)'",
]
for pattern in patterns:
match = re.search(pattern, html)
if match:
token = match.group(1)
source_match = re.search(r"xsec_source=([^&\"']+)", html)
source = source_match.group(1) if source_match else preferred_source
cache_note_context(note_id, token, source)
return token, source
return "", preferred_source
def resolve_xsec_token(self, note_id: str, preferred_token: str = "") -> str:
"""Resolve xsec_token from explicit input, cache, or note page metadata."""
token, _source = self.resolve_xsec_context(note_id, preferred_token)
return token
def get_self_info(self) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v2/user/me")
def get_user_info(self, user_id: str) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v1/user/otherinfo", {
"target_user_id": user_id,
})
def get_user_notes(self, user_id: str, cursor: str = "") -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v1/user_posted", {
"num": 30,
"cursor": cursor,
"user_id": user_id,
"image_scenes": "FD_WM_WEBP",
})
def search_notes(
self,
keyword: str,
page: int = 1,
page_size: int = 20,
sort: str = "general",
note_type: int = 0,
) -> Any:
search_id, is_new_session = _acquire_search_session(keyword, sort, note_type)
if is_new_session:
request_id = self._search_request_id()
try:
self._main_api_post("/api/sns/web/v1/search/onebox", {
"keyword": keyword,
"search_id": search_id,
"biz_type": "web_search_user",
"request_id": request_id,
})
self._main_api_get("/api/sns/web/v1/search/filter", {
"keyword": keyword,
"search_id": search_id,
})
except XhsApiError as exc:
logger.debug("Search prewarm failed, continuing with search/notes: %s", exc)
result = self._main_api_post("/api/sns/web/v1/search/notes", {
"keyword": keyword,
"page": page,
"page_size": page_size,
"search_id": search_id,
"sort": sort,
"note_type": note_type,
"ext_flags": [],
"filters": _SEARCH_DEFAULT_FILTERS,
"geo": "",
"image_formats": ["jpg", "webp", "avif"],
})
if is_new_session:
try:
self._main_api_get("/api/sns/web/v1/search/recommend", {"keyword": keyword})
except XhsApiError as exc:
logger.debug("Search recommend prefetch failed: %s", exc)
return result
def get_note_by_id(
self,
note_id: str,
xsec_token: str = "",
xsec_source: str = "pc_feed",
) -> Any:
if xsec_token:
cache_note_context(note_id, xsec_token, xsec_source)
return self._main_api_post("/api/sns/web/v1/feed", {
"source_note_id": note_id,
"image_formats": ["jpg", "webp", "avif"],
"extra": {"need_body_topic": "1"},
"xsec_source": xsec_source,
"xsec_token": xsec_token,
})
def get_note_from_html(
self,
note_id: str,
xsec_token: str = "",
xsec_source: str = "pc_feed",
) -> dict[str, Any]:
"""Fetch note by parsing server-rendered HTML (no xsec_token required)."""
html = self._fetch_note_html(note_id, xsec_token=xsec_token, xsec_source=xsec_source)
return extract_note_from_html(html, note_id)
def get_note_detail(
self,
note_id: str,
xsec_token: str = "",
xsec_source: str = "",
) -> dict[str, Any]:
"""Read a note via the best available channel.
Strategy:
- Has xsec_token → try feed API first, fall back to HTML on error
- No xsec_token → go straight to HTML (feed API would reject)
"""
cached = get_cached_note_context(note_id)
token = xsec_token or cached.get("token", "")
source = xsec_source or cached.get("source", "") or "pc_feed"
used_cached_context = not xsec_token and bool(cached.get("token"))
if token:
try:
return self.get_note_by_id(note_id, xsec_token=token, xsec_source=source)
except (NeedVerifyError, XhsApiError) as exc:
logger.info("Feed API failed (%s), falling back to HTML", exc)
if used_cached_context:
invalidate_note_context(note_id)
token = ""
return self.get_note_from_html(note_id, xsec_token=token or "", xsec_source=source)
def get_home_feed(self, category: str = "homefeed_recommend") -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/homefeed", {
"cursor_score": "",
"num": 40,
"refresh_type": 1,
"note_index": 0,
"unread_begin_note_id": "",
"unread_end_note_id": "",
"unread_note_count": 0,
"category": category,
"search_key": "",
"need_num": 40,
"image_scenes": ["FD_PRV_WEBP", "FD_WM_WEBP"],
})
def get_hot_feed(self, category: str = "homefeed.fashion_v3") -> dict[str, Any]:
return self.get_home_feed(category=category)
def get_comments(
self,
note_id: str,
cursor: str = "",
xsec_token: str = "",
top_comment_id: str = "",
xsec_source: str = "",
) -> Any:
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."
)
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,
})
except (NeedVerifyError, XhsApiError):
if not used_cached_context:
raise
invalidate_note_context(note_id)
refreshed_token, refreshed_source = self.resolve_xsec_context(note_id, "", xsec_source)
if not refreshed_token:
raise
if refreshed_source:
cache_note_context(note_id, refreshed_token, refreshed_source)
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,
})
def get_all_comments(
self,
note_id: str,
xsec_token: str = "",
xsec_source: str = "",
max_pages: int = 20,
) -> dict[str, Any]:
all_comments: list[dict[str, Any]] = []
cursor = ""
pages = 0
while pages < max_pages:
data = self.get_comments(
note_id,
cursor=cursor,
xsec_token=xsec_token,
xsec_source=xsec_source,
)
if not isinstance(data, dict):
break
comments = data.get("comments", [])
all_comments.extend(comments)
pages += 1
has_more = data.get("has_more", False)
next_cursor = data.get("cursor", "")
if not has_more or not next_cursor:
break
cursor = next_cursor
return {
"comments": all_comments,
"has_more": False,
"cursor": "",
"total_fetched": len(all_comments),
"pages_fetched": pages,
}
def get_sub_comments(
self,
note_id: str,
root_comment_id: str,
num: int = 30,
cursor: 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,
})
class InteractionEndpointsMixin:
"""Mutating note interaction endpoints."""
def post_comment(self, note_id: str, content: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/comment/post", {
"note_id": note_id,
"content": content,
"at_users": [],
})
def reply_comment(self, note_id: str, target_comment_id: str, content: str) -> Any:
return self._main_api_post("/api/sns/web/v1/comment/post", {
"note_id": note_id,
"content": content,
"target_comment_id": target_comment_id,
"at_users": [],
})
def like_note(self, note_id: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/note/like", {"note_oid": note_id})
def unlike_note(self, note_id: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/note/dislike", {"note_oid": note_id})
def favorite_note(self, note_id: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/note/collect", {"note_id": note_id})
def unfavorite_note(self, note_id: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/note/uncollect", {"note_ids": note_id})
def delete_comment(self, note_id: str, comment_id: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/comment/delete", {
"note_id": note_id,
"comment_id": comment_id,
})
class CreatorEndpointsMixin:
"""Creator platform search, upload, and publishing endpoints."""
def search_topics(self, keyword: str) -> dict[str, Any]:
return self._creator_post("/web_api/sns/v1/search/topic", {
"keyword": keyword,
"suggest_topic_request": {"title": "", "desc": ""},
"page": {"page_size": 20, "page": 1},
})
def search_users(self, keyword: str) -> dict[str, Any]:
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},
})
def get_upload_permit(self, file_type: str = "image", count: int = 1) -> dict[str, str]:
data = self._creator_get("/api/media/v1/upload/web/permit", {
"biz_name": "spectrum",
"scene": file_type,
"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,
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 "application/octet-stream"
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"Upload failed: {resp.status_code} {resp.reason_phrase}")
def create_image_note(
self,
title: str,
desc: str,
image_file_ids: list[str],
topics: list[dict[str, str]] | None = None,
is_private: bool = False,
) -> Any:
images = [{"file_id": fid, "metadata": {"source": -1}} for fid in image_file_ids]
business_binds = {
"version": 1,
"noteId": 0,
"noteOrderBind": {},
"notePostTiming": {"postTime": None},
"noteCollectionBind": {"id": ""},
}
data = {
"common": {
"type": "normal",
"title": title,
"note_id": "",
"desc": desc,
"source": '{"type":"web","ids":"","extraInfo":"{\\"subType\\":\\"official\\"}"}',
"business_binds": json.dumps(business_binds),
"ats": [],
"hash_tag": topics or [],
"post_loc": {},
"privacy_info": {"op_type": 1, "type": 1 if is_private else 0},
},
"image_info": {"images": images},
"video_info": None,
}
return self._main_api_post("/web_api/sns/v2/note", data, {
"origin": CREATOR_HOST,
"referer": f"{CREATOR_HOST}/",
})
def delete_note(self, note_id: str) -> dict[str, Any]:
try:
return self._creator_post("/api/galaxy/creator/note/delete", {
"note_id": note_id,
})
except XhsApiError as exc:
response = exc.response if isinstance(exc.response, dict) else {}
if response.get("status") == 404 or "404" in str(exc):
raise UnsupportedOperationError(
"Delete note is currently unavailable from the public web API. "
"The command remains experimental until the new endpoint is re-captured."
) from None
raise
def get_creator_note_list(self, tab: int = 0, page: int = 0) -> dict[str, Any]:
return self._creator_get("/api/galaxy/v2/creator/note/user/posted", {
"tab": tab,
"page": page,
})
class SocialEndpointsMixin:
"""Social graph and saved-content endpoints."""
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})
def unfollow_user(self, user_id: str) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/user/unfollow", {"target_user_id": user_id})
def get_user_favorites(self, user_id: str, cursor: str = "") -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v2/note/collect/page", {
"user_id": user_id,
"cursor": cursor,
"num": 30,
})
class NotificationEndpointsMixin:
"""Notification and unread-count endpoints."""
def get_unread_count(self) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/unread_count", {})
def get_notification_mentions(self, cursor: str = "", num: int = 20) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v1/you/mentions", {
"num": num,
"cursor": cursor,
})
def get_notification_likes(self, cursor: str = "", num: int = 20) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v1/you/likes", {
"num": num,
"cursor": cursor,
})
def get_notification_connections(self, cursor: str = "", num: int = 20) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v1/you/connections", {
"num": num,
"cursor": cursor,
})
class AuthEndpointsMixin:
"""Authentication-specific endpoints."""
def login_activate(self) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/login/activate", {})
def create_qr_login(self) -> dict[str, Any]:
return self._main_api_post("/api/sns/web/v1/login/qrcode/create", {"qr_type": 1})
def check_qr_status(self, qr_id: str, code: str) -> dict[str, Any]:
return self._main_api_post("/api/qrcode/userinfo", {
"qrId": qr_id,
"code": code,
}, {
"service-tag": "webcn",
})
def complete_qr_login(self, qr_id: str, code: str) -> dict[str, Any]:
return self._main_api_get("/api/sns/web/v1/login/qrcode/status", {
"qr_id": qr_id,
"code": code,
})
@@ -0,0 +1,65 @@
"""Helpers that normalize raw client responses for command-layer use."""
from __future__ import annotations
from typing import Any
def normalize_xhs_user_payload(info: dict[str, Any]) -> dict[str, object]:
"""Normalize Xiaohongshu user info for structured command output."""
basic = info.get("basic_info", info) if isinstance(info, dict) else {}
if not isinstance(basic, dict):
basic = {}
user_id = (
basic.get("user_id")
or info.get("user_id")
or info.get("userid")
or basic.get("red_id")
or info.get("red_id")
or ""
)
return {
"id": user_id,
"name": basic.get("nickname") or info.get("nickname", "Unknown"),
"username": basic.get("red_id") or info.get("red_id", ""),
"nickname": basic.get("nickname") or info.get("nickname", "Unknown"),
"red_id": basic.get("red_id") or info.get("red_id", ""),
"ip_location": basic.get("ip_location") or info.get("ip_location", ""),
"desc": basic.get("desc") or info.get("desc", ""),
"guest": bool(info.get("guest", False)),
}
def normalize_unread_summary(data: dict[str, Any]) -> dict[str, int]:
return {
"mentions": int(data.get("mentions", 0)),
"likes": int(data.get("likes", 0)),
"connections": int(data.get("connections", 0)),
"unread_count": int(data.get("unread_count", 0)),
}
def normalize_paged_notes(data: dict[str, Any]) -> dict[str, Any]:
return {
"notes": data.get("notes", []),
"has_more": bool(data.get("has_more", False)),
"cursor": data.get("cursor", ""),
}
def select_topic_payload(topic_data: Any, fallback_name: str) -> list[dict[str, str]]:
topics = topic_data if isinstance(topic_data, list) else topic_data.get("topic_info_dtos", [])
if not topics:
return []
first = topics[0]
return [{
"id": first.get("id", ""),
"name": first.get("name", fallback_name),
"type": "topic",
}]
def resolve_current_user_id(info: dict[str, Any]) -> str:
return info.get("user_id", "") if isinstance(info, dict) else ""
@@ -0,0 +1,104 @@
"""Common helpers for CLI commands."""
from __future__ import annotations
from collections.abc import Callable
from typing import TypeVar
import click
from ..client import XhsClient
from ..cookies import get_cookies
from ..error_codes import error_code_for_exception
from ..exceptions import (
NoCookieError,
SessionExpiredError,
XhsApiError,
)
from ..formatter import emit_error, print_error
T = TypeVar("T")
def structured_output_options(command: Callable) -> Callable:
"""Add --json/--yaml options to a Click command."""
command = click.option("--yaml", "as_yaml", is_flag=True, help="Output as YAML.")(command)
command = click.option("--json", "as_json", is_flag=True, help="Output as JSON.")(command)
return command
def _cookie_source(ctx) -> str:
return ctx.obj.get("cookie_source", "auto") if ctx.obj else "auto"
def get_client(ctx, *, force_refresh: bool = False) -> XhsClient:
"""Get a local client from the click context."""
_browser, cookies = get_cookies(_cookie_source(ctx), force_refresh=force_refresh)
return XhsClient(cookies)
def run_client_action(ctx, action: Callable[[XhsClient], T]) -> T:
"""Run an authenticated client action and retry once with fresh browser cookies."""
try:
with get_client(ctx) as client:
return action(client)
except SessionExpiredError as exc:
try:
with get_client(ctx, force_refresh=True) as client:
return action(client)
except NoCookieError:
raise exc from None
def handle_command(
ctx,
*,
action: Callable[[XhsClient], T],
render: Callable[[T], None] | None,
as_json: bool,
as_yaml: bool,
prefix: str | None = None,
):
"""Run a client action, emit structured output if requested, else render."""
from ..formatter import maybe_print_structured
try:
data = run_client_action(ctx, action)
if not maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml) and render:
render(data)
return data
except (XhsApiError, NoCookieError) as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml, prefix=prefix)
def handle_errors(
fn: Callable[[], T],
*,
as_json: bool,
as_yaml: bool,
prefix: str | None = None,
) -> T:
"""Run arbitrary command logic and funnel failures through exit_for_error."""
try:
return fn()
except (XhsApiError, NoCookieError) as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml, prefix=prefix)
def exit_for_error(
exc: Exception,
*,
as_json: bool,
as_yaml: bool,
prefix: str | None = None,
) -> None:
"""Emit a structured/non-structured error and terminate the command."""
message = str(exc)
if prefix:
message = f"{prefix}: {message}"
if emit_error(error_code_for_exception(exc), message, as_json=as_json, as_yaml=as_yaml):
raise SystemExit(1) from None
print_error(message)
raise SystemExit(1) from None
@@ -0,0 +1,170 @@
"""Authentication commands: login, status, logout."""
import time
import click
from ..client import XhsClient
from ..command_normalizers import normalize_xhs_user_payload
from ..cookies import clear_cookies, get_cookies
from ..exceptions import XhsApiError
from ..formatter import (
console,
maybe_print_structured,
print_success,
render_user_info,
success_payload,
)
from ._common import handle_errors, run_client_action, structured_output_options
def _emit_payload(data: dict[str, object], *, as_json: bool, as_yaml: bool) -> bool:
"""Emit a structured success payload when requested."""
return maybe_print_structured(success_payload(data), as_json=as_json, as_yaml=as_yaml)
def _is_valid_login(user: dict[str, object]) -> bool:
"""Check whether the normalized user payload represents a real logged-in session."""
if user.get("guest"):
return False
nickname = user.get("nickname", "")
return bool(nickname and nickname != "Unknown")
def _print_login_success(user: dict[str, object]) -> None:
"""Print a concise login success message."""
print_success(f"Logged in as: {user['nickname']} (ID: {user['red_id']})")
def _print_status_summary(user: dict[str, object]) -> None:
"""Render a short authenticated-user summary."""
console.print("[bold green]✓ Logged in[/bold green]")
console.print(f" 昵称: [bold]{user['nickname']}[/bold]")
if user["red_id"]:
console.print(f" 小红书号: {user['red_id']}")
if user["ip_location"]:
console.print(f" IP 属地: {user['ip_location']}")
if user["desc"]:
console.print(f" 简介: {user['desc']}")
@click.command()
@click.option(
"--cookie-source",
type=str,
default=None,
help="Browser to read cookies from (default: auto-detect all installed browsers)",
)
@structured_output_options
@click.option("--qrcode", "use_qrcode", is_flag=True, default=False,
help="Login via QR code (scan with Xiaohongshu app)")
@click.pass_context
def login(ctx, cookie_source: str | None, as_json: bool, as_yaml: bool, use_qrcode: bool):
"""Log in by extracting cookies from browser, or via QR code."""
if use_qrcode:
def _login_with_qrcode() -> None:
from ..qr_login import qrcode_login
cookies = qrcode_login(prefer_browser_assisted=True)
# Verify by fetching user info (may return guest=true briefly)
import time
time.sleep(1) # brief delay for session propagation
with XhsClient(cookies) as client:
info = client.get_self_info()
user = normalize_xhs_user_payload(info)
if user["guest"]:
# Session not yet propagated; still valid
if not _emit_payload(
{"authenticated": True, "user": {"id": user["id"]}},
as_json=as_json,
as_yaml=as_yaml,
):
print_success("Logged in (session saved)")
else:
if not _emit_payload({"authenticated": True, "user": user}, as_json=as_json, as_yaml=as_yaml):
_print_login_success(user)
handle_errors(
_login_with_qrcode,
as_json=as_json,
as_yaml=as_yaml,
prefix="QR login failed",
)
return
# Browser cookie extraction (default)
if cookie_source is None:
cookie_source = ctx.obj.get("cookie_source", "auto") if ctx.obj else "auto"
def _login_with_browser() -> None:
browser, cookies = get_cookies(cookie_source, force_refresh=True)
print_success(f"Cookies extracted from {browser}")
# Verify by fetching user info, retry once if session not yet propagated
with XhsClient(cookies) as client:
info = client.get_self_info()
user = normalize_xhs_user_payload(info)
if not _is_valid_login(user):
time.sleep(2.5)
with XhsClient(cookies) as client:
info = client.get_self_info()
user = normalize_xhs_user_payload(info)
if not _is_valid_login(user):
raise XhsApiError(
"Browser cookies were extracted, but the session appears invalid "
"(guest or incomplete profile). Try: xhs login --qrcode"
)
if not _emit_payload({"authenticated": True, "user": user}, as_json=as_json, as_yaml=as_yaml):
_print_login_success(user)
handle_errors(
_login_with_browser,
as_json=as_json,
as_yaml=as_yaml,
prefix="Login verification failed",
)
@click.command()
@structured_output_options
@click.pass_context
def status(ctx, as_json: bool, as_yaml: bool):
"""Check current login status and user info."""
def _show_status() -> None:
info = run_client_action(ctx, lambda client: client.get_self_info())
user = normalize_xhs_user_payload(info)
if not _emit_payload({"authenticated": True, "user": user}, as_json=as_json, as_yaml=as_yaml):
_print_status_summary(user)
handle_errors(_show_status, as_json=as_json, as_yaml=as_yaml, prefix="Status check failed")
@click.command()
@structured_output_options
@click.pass_context
def logout(ctx, as_json: bool, as_yaml: bool):
"""Clear saved cookies and log out."""
clear_cookies()
if not _emit_payload({"logged_out": True}, as_json=as_json, as_yaml=as_yaml):
print_success("Logged out — cookies cleared")
@click.command()
@structured_output_options
@click.pass_context
def whoami(ctx, as_json: bool, as_yaml: bool):
"""Show detailed profile of current user (level, fans, likes)."""
def _show_profile() -> None:
info = run_client_action(ctx, lambda client: client.get_self_info())
user = normalize_xhs_user_payload(info)
if not _emit_payload({"user": user}, as_json=as_json, as_yaml=as_yaml):
render_user_info(info)
handle_errors(_show_profile, as_json=as_json, as_yaml=as_yaml, prefix="Failed to get profile")
@@ -0,0 +1,105 @@
"""Creator commands: post, my-notes, delete."""
import click
from ..command_normalizers import select_topic_payload
from ..formatter import (
extract_note_id,
maybe_print_structured,
print_info,
print_success,
render_creator_notes,
)
from ..note_refs import save_index_from_notes
from ._common import exit_for_error, handle_command, run_client_action, structured_output_options
@click.command()
@click.option("--title", required=True, help="Note title")
@click.option("--body", required=True, help="Note body text")
@click.option("--images", required=True, multiple=True, help="Image file path(s)")
@click.option("--topic", default=None, help="Topic/hashtag 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(
ctx,
title: str,
body: str,
images: tuple[str, ...],
topic: str | None,
is_private: bool,
as_json: bool,
as_yaml: bool,
):
"""Publish an image note."""
def _publish(client):
file_ids = []
for img_path in images:
print_info(f"Uploading {img_path}...")
permit = client.get_upload_permit()
client.upload_file(permit["fileId"], permit["token"], img_path)
file_ids.append(permit["fileId"])
print_success(f"Uploaded: {img_path}")
topics = []
if topic:
topic_data = client.search_topics(topic)
topics = select_topic_payload(topic_data, topic)
return client.create_image_note(
title=title,
desc=body,
image_file_ids=file_ids,
topics=topics,
is_private=is_private,
)
handle_command(
ctx,
action=_publish,
render=lambda _data: print_success(f"Note published: {title}" + (" (private)" if is_private else "")),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("my-notes")
@click.option("--page", default=0, help="Page number (0-indexed)")
@structured_output_options
@click.pass_context
def my_notes(ctx, page: int, as_json: bool, as_yaml: bool):
"""List your own published notes."""
def _my_notes_action(client):
data = client.get_creator_note_list(page=page)
notes = data.get("notes", data.get("note_list", []))
save_index_from_notes(notes)
return data
handle_command(
ctx,
action=_my_notes_action,
render=render_creator_notes,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("delete")
@click.argument("id_or_url")
@structured_output_options
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
@click.pass_context
def delete(ctx, id_or_url: str, as_json: bool, as_yaml: bool, yes: bool):
"""Delete a note. Experimental: the public web endpoint is unstable."""
note_id = extract_note_id(id_or_url)
if not yes:
click.confirm(f"Delete note {note_id}?", abort=True)
try:
data = run_client_action(ctx, lambda client: client.delete_note(note_id))
if not maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
print_success(f"Deleted note {note_id}")
except Exception as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
@@ -0,0 +1,120 @@
"""Interaction commands: like, collect, comment, reply."""
import click
from ..cookies import cache_note_context
from ..formatter import print_success
from ..note_refs import resolve_note_reference
from ._common import handle_command, structured_output_options
def _resolve_interaction_note(id_or_url: str) -> str:
note_id, token, source = resolve_note_reference(id_or_url)
if token:
cache_note_context(note_id, token, source or "pc_feed")
return note_id
@click.command()
@click.argument("id_or_url")
@click.option("--undo", is_flag=True, help="Unlike instead of like")
@structured_output_options
@click.pass_context
def like(ctx, id_or_url: str, undo: bool, as_json: bool, as_yaml: bool):
"""Like or unlike a note."""
note_id = _resolve_interaction_note(id_or_url)
action = (lambda client: client.unlike_note(note_id)) if undo else (lambda client: client.like_note(note_id))
handle_command(
ctx,
action=action,
render=lambda _data: print_success(f"{'Unliked' if undo else 'Liked'} note {note_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@structured_output_options
@click.pass_context
def favorite(ctx, id_or_url: str, as_json: bool, as_yaml: bool):
"""Favorite (bookmark) a note."""
note_id = _resolve_interaction_note(id_or_url)
handle_command(
ctx,
action=lambda client: client.favorite_note(note_id),
render=lambda _data: print_success(f"Favorited note {note_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@structured_output_options
@click.pass_context
def unfavorite(ctx, id_or_url: str, as_json: bool, as_yaml: bool):
"""Unfavorite (unbookmark) a note."""
note_id = _resolve_interaction_note(id_or_url)
handle_command(
ctx,
action=lambda client: client.unfavorite_note(note_id),
render=lambda _data: print_success(f"Unfavorited note {note_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@click.option("--content", "-c", required=True, help="Comment content")
@structured_output_options
@click.pass_context
def comment(ctx, id_or_url: str, content: str, as_json: bool, as_yaml: bool):
"""Post a comment on a note."""
note_id = _resolve_interaction_note(id_or_url)
handle_command(
ctx,
action=lambda client: client.post_comment(note_id, content),
render=lambda _data: print_success(f"Comment posted on {note_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@click.option("--comment-id", required=True, help="Target comment ID to reply to")
@click.option("--content", "-c", required=True, help="Reply content")
@structured_output_options
@click.pass_context
def reply(ctx, id_or_url: str, comment_id: str, content: str, as_json: bool, as_yaml: bool):
"""Reply to a specific comment."""
note_id = _resolve_interaction_note(id_or_url)
handle_command(
ctx,
action=lambda client: client.reply_comment(note_id, comment_id, content),
render=lambda _data: print_success(f"Reply posted on comment {comment_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("delete-comment")
@click.argument("note_id")
@click.argument("comment_id")
@structured_output_options
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
@click.pass_context
def delete_comment(ctx, note_id: str, comment_id: str, as_json: bool, as_yaml: bool, yes: bool):
"""Delete a comment you posted."""
if not yes:
click.confirm(f"Delete comment {comment_id} on note {note_id}?", abort=True)
handle_command(
ctx,
action=lambda client: client.delete_comment(note_id, comment_id),
render=lambda _data: print_success(f"Deleted comment {comment_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@@ -0,0 +1,57 @@
"""Notification commands: unread counts and notification feeds."""
import click
from ..command_normalizers import normalize_unread_summary
from ..formatter import console, render_notifications
from ._common import handle_command, structured_output_options
@click.command()
@click.option(
"--type", "notif_type",
type=click.Choice(["mentions", "likes", "connections"]),
default="mentions",
help="Notification type: mentions (评论和@), likes (赞和收藏), connections (新增关注)",
)
@click.option("--cursor", default="", help="Pagination cursor")
@click.option("--num", default=20, help="Number of items per page")
@structured_output_options
@click.pass_context
def notifications(ctx, notif_type: str, cursor: str, num: int, as_json: bool, as_yaml: bool):
"""View notifications (mentions, likes, connections)."""
def _load_notifications(client):
if notif_type == "mentions":
return client.get_notification_mentions(cursor=cursor, num=num)
if notif_type == "likes":
return client.get_notification_likes(cursor=cursor, num=num)
return client.get_notification_connections(cursor=cursor, num=num)
handle_command(
ctx,
action=_load_notifications,
render=lambda data: render_notifications(data, notif_type),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@structured_output_options
@click.pass_context
def unread(ctx, as_json: bool, as_yaml: bool):
"""Show unread notification counts."""
def _render_unread(data):
summary = normalize_unread_summary(data)
console.print(f"📬 未读通知: [bold]{summary['unread_count']}[/bold]")
console.print(f" 💬 评论和@: {summary['mentions']}")
console.print(f" ❤️ 赞和收藏: {summary['likes']}")
console.print(f" 👥 新增关注: {summary['connections']}")
handle_command(
ctx,
action=lambda client: client.get_unread_count(),
render=_render_unread,
as_json=as_json,
as_yaml=as_yaml,
)
@@ -0,0 +1,297 @@
"""Reading commands: search, read, comments, sub-comments, user, user-posts, feed, hot, topics, search-user."""
import click
from ..command_normalizers import normalize_paged_notes
from ..cookies import cache_note_context
from ..formatter import (
maybe_print_structured,
print_info,
render_comments,
render_feed,
render_note,
render_search_results,
render_topics,
render_user_info,
render_user_posts,
render_users,
)
from ..note_refs import resolve_note_reference, save_index_from_items, save_index_from_notes
from ._common import exit_for_error, handle_command, run_client_action, structured_output_options
# ─── Token propagation ─────────────────────────────────────────────────────
def _cache_tokens_from_items(data: dict, *, xsec_source: str) -> None:
"""Auto-cache xsec_token from search/feed API results.
Each note item may carry its own xsec_token bound to the source
(search, feed, explore). Caching them lets a subsequent
`xhs read <note_id>` use the correct token automatically.
"""
for item in data.get("items", []):
note_card = item.get("note_card", {})
note_id = item.get("id", note_card.get("note_id", ""))
token = item.get("xsec_token", note_card.get("xsec_token", ""))
if note_id and token:
cache_note_context(note_id, token, xsec_source)
# ─── Sort mapping ────────────────────────────────────────────────────────────
SORT_MAP = {
"general": "general",
"popular": "popularity_descending",
"latest": "time_descending",
}
TYPE_MAP = {
"all": 0,
"video": 1,
"image": 2,
}
@click.command()
@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("--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):
"""Search notes by keyword."""
def _search_action(client):
result = client.search_notes(
keyword=keyword,
page=page,
sort=SORT_MAP[sort],
note_type=TYPE_MAP[note_type],
)
_cache_tokens_from_items(result, xsec_source="pc_search")
save_index_from_items(result, xsec_source="pc_search")
return result
handle_command(
ctx,
action=_search_action,
render=render_search_results,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@click.option("--xsec-token", default="", help="Security token (or reuse a cached token for this note)")
@structured_output_options
@click.pass_context
def read(ctx, id_or_url: str, xsec_token: str, 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"
if token:
cache_note_context(note_id, token, xsec_source)
def _read_action(client):
kwargs = {"xsec_token": token}
if url_source:
kwargs["xsec_source"] = url_source
return client.get_note_detail(note_id, **kwargs)
handle_command(
ctx,
action=_read_action,
render=render_note,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("id_or_url")
@click.option("--cursor", default="", help="Pagination cursor")
@click.option("--xsec-token", default="", help="Security token")
@click.option("--all", "fetch_all", is_flag=True, help="Auto-paginate to fetch ALL comments")
@structured_output_options
@click.pass_context
def comments(ctx, id_or_url: str, cursor: str, xsec_token: str, fetch_all: bool, as_json: bool, as_yaml: bool):
"""View comments on 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"
if token:
cache_note_context(note_id, token, xsec_source)
def _load_comments(client):
common_kwargs = {"xsec_token": token}
if url_source:
common_kwargs["xsec_source"] = url_source
if fetch_all:
return client.get_all_comments(note_id, **common_kwargs)
return client.get_comments(
note_id,
cursor=cursor,
**common_kwargs,
)
def _render_comments(data):
render_comments(data)
if fetch_all and isinstance(data, dict):
total = data.get("total_fetched", 0)
pages = data.get("pages_fetched", 0)
print_info(f"Fetched {total} comments across {pages} pages")
try:
data = run_client_action(ctx, _load_comments)
if not maybe_print_structured(data, as_json=as_json, as_yaml=as_yaml):
_render_comments(data)
except Exception as exc:
exit_for_error(exc, as_json=as_json, as_yaml=as_yaml)
@click.command()
@click.argument("user_id")
@structured_output_options
@click.pass_context
def user(ctx, user_id: str, as_json: bool, as_yaml: bool):
"""View user profile info."""
handle_command(
ctx,
action=lambda client: client.get_user_info(user_id),
render=render_user_info,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("user-posts")
@click.argument("user_id")
@click.option("--cursor", default="", help="Pagination cursor")
@structured_output_options
@click.pass_context
def user_posts(ctx, user_id: str, cursor: str, as_json: bool, as_yaml: bool):
"""List a user's published notes."""
def _user_posts_action(client):
data = client.get_user_notes(user_id, cursor=cursor)
page = normalize_paged_notes(data)
save_index_from_notes(page["notes"])
return data
def _render_user_posts(data):
page = normalize_paged_notes(data)
render_user_posts(page["notes"])
if page["has_more"]:
print_info(f"More notes available — use --cursor {page['cursor']}")
handle_command(
ctx,
action=_user_posts_action,
render=_render_user_posts,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@structured_output_options
@click.pass_context
def feed(ctx, as_json: bool, as_yaml: bool):
"""Browse the recommendation feed."""
def _feed_action(client):
result = client.get_home_feed()
_cache_tokens_from_items(result, xsec_source="pc_feed")
save_index_from_items(result, xsec_source="pc_feed")
return result
handle_command(
ctx,
action=_feed_action,
render=render_feed,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("keyword")
@structured_output_options
@click.pass_context
def topics(ctx, keyword: str, as_json: bool, as_yaml: bool):
"""Search for topics/hashtags."""
handle_command(
ctx,
action=lambda client: client.search_topics(keyword),
render=render_topics,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("sub-comments")
@click.argument("note_id")
@click.argument("comment_id")
@click.option("--cursor", default="", help="Pagination cursor")
@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."""
handle_command(
ctx,
action=lambda client: client.get_sub_comments(note_id, comment_id, cursor=cursor),
render=render_comments,
as_json=as_json,
as_yaml=as_yaml,
)
@click.command("search-user")
@click.argument("keyword")
@structured_output_options
@click.pass_context
def search_user(ctx, keyword: str, as_json: bool, as_yaml: bool):
"""Search for users by keyword."""
handle_command(
ctx,
action=lambda client: client.search_users(keyword),
render=render_users,
as_json=as_json,
as_yaml=as_yaml,
)
HOT_CATEGORIES = {
"fashion": "homefeed.fashion_v3",
"food": "homefeed.food_v3",
"cosmetics": "homefeed.cosmetics_v3",
"movie": "homefeed.movie_and_tv_v3",
"career": "homefeed.career_v3",
"love": "homefeed.love_v3",
"home": "homefeed.household_product_v3",
"gaming": "homefeed.gaming_v3",
"travel": "homefeed.travel_v3",
"fitness": "homefeed.fitness_v3",
}
@click.command()
@click.option(
"--category", "-c",
type=click.Choice(list(HOT_CATEGORIES.keys())),
default="food",
help="Category (fashion, food, cosmetics, movie, career, love, home, gaming, travel, fitness)",
)
@structured_output_options
@click.pass_context
def hot(ctx, category: str, as_json: bool, as_yaml: bool):
"""Browse hot/trending notes by category."""
def _hot_action(client):
result = client.get_hot_feed(HOT_CATEGORIES[category])
_cache_tokens_from_items(result, xsec_source="pc_feed")
save_index_from_items(result, xsec_source="pc_feed")
return result
handle_command(
ctx,
action=_hot_action,
render=render_feed,
as_json=as_json,
as_yaml=as_yaml,
)
@@ -0,0 +1,79 @@
"""Social commands: follow, unfollow, favorites, following, followers."""
import click
from ..command_normalizers import normalize_paged_notes, resolve_current_user_id
from ..formatter import 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
def _resolve_user_id(ctx, user_id: str | None) -> str:
"""Resolve user_id: use provided value or fall back to current user."""
if user_id:
return user_id
info = run_client_action(ctx, lambda client: client.get_self_info())
uid = resolve_current_user_id(info)
if not uid:
raise click.UsageError("Cannot determine current user_id. Please specify user_id explicitly.")
return uid
@click.command()
@click.argument("user_id")
@structured_output_options
@click.pass_context
def follow(ctx, user_id: str, as_json: bool, as_yaml: bool):
"""Follow a user."""
handle_command(
ctx,
action=lambda client: client.follow_user(user_id),
render=lambda _data: print_success(f"Followed user {user_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("user_id")
@structured_output_options
@click.pass_context
def unfollow(ctx, user_id: str, as_json: bool, as_yaml: bool):
"""Unfollow a user."""
handle_command(
ctx,
action=lambda client: client.unfollow_user(user_id),
render=lambda _data: print_success(f"Unfollowed user {user_id}"),
as_json=as_json,
as_yaml=as_yaml,
)
@click.command()
@click.argument("user_id", required=False, default=None)
@click.option("--cursor", default="", help="Pagination cursor")
@structured_output_options
@click.pass_context
def favorites(ctx, user_id: str | None, cursor: str, as_json: bool, as_yaml: bool):
"""List favorited (bookmarked) notes. Defaults to current user if user_id is omitted."""
uid = _resolve_user_id(ctx, user_id)
def _favorites_action(client):
data = client.get_user_favorites(uid, cursor=cursor)
page = normalize_paged_notes(data)
save_index_from_notes(page["notes"])
return data
def _render_favorites(data):
page = normalize_paged_notes(data)
render_user_posts(page["notes"])
if page["has_more"]:
print_info(f"More notes — use --cursor {page['cursor']}")
handle_command(
ctx,
action=_favorites_action,
render=_render_favorites,
as_json=as_json,
as_yaml=as_yaml,
)
@@ -0,0 +1,24 @@
"""Constants for XHS API client."""
EDITH_HOST = "https://edith.xiaohongshu.com"
CREATOR_HOST = "https://creator.xiaohongshu.com"
HOME_URL = "https://www.xiaohongshu.com"
UPLOAD_HOST = "https://ros-upload.xiaohongshu.com"
CHROME_VERSION = "145"
USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
f"Chrome/{CHROME_VERSION}.0.0.0 Safari/537.36"
)
SDK_VERSION = "4.2.6"
APP_ID = "xhs-pc-web"
PLATFORM = "macOS"
# Config directory
CONFIG_DIR_NAME = ".xiaohongshu-cli"
COOKIE_FILE = "cookies.json"
TOKEN_CACHE_FILE = "token_cache.json"
INDEX_CACHE_FILE = "index_cache.json"
+526
View File
@@ -0,0 +1,526 @@
"""Cookie extraction and management for XHS API client."""
from __future__ import annotations
import functools
import json
import logging
import subprocess
import sys
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any
from .constants import CONFIG_DIR_NAME, COOKIE_FILE, INDEX_CACHE_FILE, TOKEN_CACHE_FILE
logger = logging.getLogger(__name__)
# Cookie TTL: warn and attempt browser refresh after 7 days
COOKIE_TTL_DAYS = 7
_COOKIE_TTL_SECONDS = COOKIE_TTL_DAYS * 86400
_TOKEN_CACHE_LOCK = threading.RLock()
_TOKEN_CACHE_MEMORY: OrderedDict[str, dict[str, Any]] | None = None
_TOKEN_CACHE_PATH: Path | None = None
NOTE_CONTEXT_TTL_SECONDS = 86400
def get_config_dir() -> Path:
"""Get or create config directory."""
config_dir = Path.home() / CONFIG_DIR_NAME
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
def get_cookie_path() -> Path:
"""Get cookie file path."""
return get_config_dir() / COOKIE_FILE
def get_token_cache_path() -> Path:
"""Get xsec token cache file path."""
return get_config_dir() / TOKEN_CACHE_FILE
def get_index_cache_path() -> Path:
"""Get note index cache file path."""
return get_config_dir() / INDEX_CACHE_FILE
def load_saved_cookies() -> dict[str, str] | None:
"""Load cookies from local storage."""
cookie_path = get_cookie_path()
if not cookie_path.exists():
return None
try:
data = json.loads(cookie_path.read_text())
if data.get("a1"):
logger.debug("Loaded saved cookies from %s", cookie_path)
return data
except (OSError, json.JSONDecodeError) as e:
logger.debug("Failed to load saved cookies: %s", e)
return None
def save_cookies(cookies: dict[str, str]) -> None:
"""Save cookies to local storage with restricted permissions and TTL timestamp."""
cookie_path = get_cookie_path()
payload = {**cookies, "saved_at": time.time()}
cookie_path.write_text(json.dumps(payload, indent=2))
cookie_path.chmod(0o600)
logger.debug("Saved cookies to %s", cookie_path)
def clear_cookies() -> None:
"""Remove saved cookies."""
cookie_path = get_cookie_path()
if cookie_path.exists():
cookie_path.unlink()
logger.debug("Cleared cookies from %s", cookie_path)
def _normalize_token_entry(value: Any) -> dict[str, Any] | None:
if isinstance(value, str):
return {"token": value, "source": "", "ts": time.time()}
if not isinstance(value, dict):
return None
token = str(value.get("token", "")).strip()
if not token:
return None
source = str(value.get("source", "")).strip()
context = str(value.get("context", "")).strip()
ts = value.get("ts", 0)
try:
ts = float(ts)
except (TypeError, ValueError):
ts = 0.0
entry = {"token": token, "source": source, "ts": ts}
if context:
entry["context"] = context
return entry
def _load_token_cache_from_disk(cache_path: Path) -> OrderedDict[str, dict[str, Any]]:
if not cache_path.exists():
return OrderedDict()
try:
data = json.loads(cache_path.read_text())
except (OSError, json.JSONDecodeError) as exc:
logger.debug("Failed to load token cache: %s", exc)
return OrderedDict()
if not isinstance(data, dict):
return OrderedDict()
normalized: list[tuple[str, dict[str, Any]]] = []
for key, value in data.items():
if not key:
continue
entry = _normalize_token_entry(value)
if entry:
normalized.append((str(key), entry))
normalized.sort(key=lambda item: float(item[1].get("ts", 0)))
return OrderedDict(normalized)
def _prune_token_cache(
cache: OrderedDict[str, dict[str, Any]],
now: float | None = None,
) -> OrderedDict[str, dict[str, Any]]:
now = now or time.time()
pruned = OrderedDict(
(key, value)
for key, value in cache.items()
if now - float(value.get("ts", 0)) <= NOTE_CONTEXT_TTL_SECONDS
)
while len(pruned) > TOKEN_CACHE_MAX_SIZE:
pruned.popitem(last=False)
return pruned
def load_token_cache() -> dict[str, dict[str, Any]]:
"""Load cached note_id -> token context mappings."""
cache_path = get_token_cache_path()
global _TOKEN_CACHE_MEMORY, _TOKEN_CACHE_PATH
with _TOKEN_CACHE_LOCK:
if _TOKEN_CACHE_MEMORY is None or _TOKEN_CACHE_PATH != cache_path:
_TOKEN_CACHE_MEMORY = _prune_token_cache(_load_token_cache_from_disk(cache_path))
_TOKEN_CACHE_PATH = cache_path
return {
key: dict(value)
for key, value in _TOKEN_CACHE_MEMORY.items()
}
def save_token_cache(cache: dict[str, dict[str, Any]]) -> None:
"""Persist xsec token cache with restricted permissions."""
cache_path = get_token_cache_path()
global _TOKEN_CACHE_MEMORY, _TOKEN_CACHE_PATH
normalized = _prune_token_cache(OrderedDict(
sorted(
(
(str(key), dict(value))
for key, value in cache.items()
if key and isinstance(value, dict)
),
key=lambda item: float(item[1].get("ts", 0)),
)
))
with _TOKEN_CACHE_LOCK:
cache_path.write_text(json.dumps(normalized, indent=2))
cache_path.chmod(0o600)
_TOKEN_CACHE_MEMORY = normalized
_TOKEN_CACHE_PATH = cache_path
TOKEN_CACHE_MAX_SIZE = 500
def cache_note_context(
note_id: str,
xsec_token: str,
xsec_source: str = "",
*,
context: str = "",
) -> None:
"""Store a resolved note token and source for later access.
Maintains an LRU-style cache capped at TOKEN_CACHE_MAX_SIZE entries.
Each entry stores token/source/timestamp metadata; overflow evicts the
oldest entries.
"""
if not note_id or not xsec_token:
return
cache = load_token_cache()
existing = cache.get(note_id)
if (
isinstance(existing, dict)
and existing.get("token") == xsec_token
and existing.get("source", "") == xsec_source
and existing.get("context", "") == context
):
existing["ts"] = time.time()
save_token_cache(cache)
return
entry = {
"token": xsec_token,
"source": xsec_source,
"ts": time.time(),
}
if context:
entry["context"] = context
cache[note_id] = entry
# Evict oldest entries if over limit
if len(cache) > TOKEN_CACHE_MAX_SIZE:
sorted_keys = sorted(
cache.keys(),
key=lambda k: cache[k].get("ts", 0) if isinstance(cache[k], dict) else 0,
)
for key in sorted_keys[: len(cache) - TOKEN_CACHE_MAX_SIZE]:
del cache[key]
save_token_cache(cache)
logger.debug("Cached xsec_token for note %s", note_id)
def invalidate_note_context(note_id: str) -> None:
"""Remove cached token/source metadata for a note ID."""
if not note_id:
return
cache = load_token_cache()
if note_id not in cache:
return
del cache[note_id]
save_token_cache(cache)
logger.debug("Invalidated cached note context for %s", note_id)
def _normalize_index_entry(value: Any) -> dict[str, str] | None:
if not isinstance(value, dict):
return None
note_id = str(value.get("note_id", "")).strip()
if not note_id:
return None
return {
"note_id": note_id,
"xsec_token": str(value.get("xsec_token", "")).strip(),
"xsec_source": str(value.get("xsec_source", "")).strip(),
}
def save_note_index(items: list[dict[str, str]]) -> None:
"""Persist the latest ordered note index for short-index navigation."""
path = get_index_cache_path()
normalized = [
entry
for entry in (_normalize_index_entry(item) for item in items)
if entry
]
path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False))
path.chmod(0o600)
logger.debug("Saved note index with %d entries", len(normalized))
def get_note_by_index(index: int) -> dict[str, str] | None:
"""Resolve a 1-based short index to a cached note reference."""
if index <= 0:
return None
path = get_index_cache_path()
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, list) or index > len(data):
return None
return _normalize_index_entry(data[index - 1])
def cache_xsec_token(note_id: str, xsec_token: str) -> None:
"""Backwards-compatible wrapper for token-only caching."""
cache_note_context(note_id, xsec_token)
def get_cached_note_context(note_id: str) -> dict[str, Any]:
"""Get cached token/source metadata for a note ID."""
entry = load_token_cache().get(note_id)
if not isinstance(entry, dict):
return {}
return {
"token": str(entry.get("token", "")),
"source": str(entry.get("source", "")),
"context": str(entry.get("context", "")),
"ts": entry.get("ts", 0.0),
}
def get_cached_xsec_token(note_id: str) -> str:
"""Get a cached xsec token for a note ID."""
return get_cached_note_context(note_id).get("token", "")
@functools.lru_cache(maxsize=1)
def _available_browsers() -> tuple[str, ...]:
"""List all browser names supported by browser_cookie3 (cached)."""
import inspect
import browser_cookie3 as bc3
return tuple(sorted(
name
for name in dir(bc3)
if not name.startswith("_")
and name != "load" # 'load' tries all browsers; skip in auto-detect
and callable(getattr(bc3, name))
and hasattr(getattr(bc3, name), "__code__")
and "domain_name" in inspect.signature(getattr(bc3, name)).parameters
))
def _get_browser_loader(source: str):
"""Get browser cookie loader from browser_cookie3 by name."""
import browser_cookie3 as bc3
loader = getattr(bc3, source, None)
if loader is None or not callable(loader):
available = _available_browsers()
raise ValueError(
f"Unknown browser: {source!r}. Available: {', '.join(available)}"
)
return loader
def _extract_in_process(source: str) -> dict[str, str] | None:
"""Extract cookies in-process for macOS Keychain compatibility."""
try:
loader = _get_browser_loader(source)
except ImportError:
logger.debug("browser_cookie3 not installed, skipping in-process extraction")
return None
except ValueError as exc:
logger.debug("%s", exc)
return None
try:
jar = loader(domain_name=".xiaohongshu.com")
except Exception as exc:
logger.debug("%s in-process extraction failed: %s", source, exc)
return None
cookies = {cookie.name: cookie.value for cookie in jar if "xiaohongshu.com" in (cookie.domain or "")}
if cookies.get("a1"):
logger.debug("Loaded XHS cookies from %s in-process", source)
return cookies
logger.debug("No usable a1 cookie found in %s in-process extraction", source)
return None
def _extract_via_subprocess(source: str) -> dict[str, str] | None:
"""Extract cookies via subprocess to avoid browser SQLite locks."""
extract_script = '''
import json, sys
try:
import browser_cookie3 as bc3
except ImportError:
print(json.dumps({"error": "browser-cookie3 not installed"}))
sys.exit(0)
source = sys.argv[1]
loader = getattr(bc3, source, None)
if not loader or not callable(loader):
print(json.dumps({"error": f"Unknown browser: {source}"}))
sys.exit(0)
try:
cj = loader(domain_name=".xiaohongshu.com")
cookies = {c.name: c.value for c in cj if "xiaohongshu.com" in (c.domain or "")}
if cookies.get("a1"):
print(json.dumps({"browser": source, "cookies": cookies}))
else:
print(json.dumps({"error": "no_a1_cookie"}))
except Exception as e:
print(json.dumps({"error": str(e)}))
'''
try:
result = subprocess.run(
[sys.executable, "-c", extract_script, source],
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
logger.debug("Cookie extraction subprocess failed: %s", result.stderr)
return None
data = json.loads(result.stdout.strip())
if "error" in data:
logger.debug("Cookie extraction error: %s", data["error"])
return None
return data["cookies"]
except subprocess.TimeoutExpired:
logger.debug("Cookie extraction timed out")
return None
except (json.JSONDecodeError, KeyError) as e:
logger.debug("Cookie extraction parse error: %s", e)
return None
def extract_browser_cookies(source: str = "auto") -> tuple[str, dict[str, str]] | None:
"""
Extract XHS cookies from browser using browser-cookie3.
When *source* is ``"auto"``, tries supported browsers with a small
thread pool and returns the first one that has valid cookies.
Returns ``(browser_name, cookies)`` on success, or ``None``.
"""
if source != "auto":
cookies = _extract_in_process(source)
if cookies:
return source, cookies
cookies = _extract_via_subprocess(source)
if cookies:
return source, cookies
return None
# Auto-detect: try all available browsers
try:
browsers = _available_browsers()
except ImportError:
logger.debug("browser_cookie3 not installed")
return None
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
def _try_browser(browser: str) -> tuple[str, dict[str, str]] | None:
logger.debug("Auto-detect: trying %s …", browser)
cookies = _extract_in_process(browser)
if cookies:
return browser, cookies
cookies = _extract_via_subprocess(browser)
if cookies:
return browser, cookies
return None
with ThreadPoolExecutor(max_workers=min(4, len(browsers) or 1)) as pool:
pending = {pool.submit(_try_browser, browser) for browser in browsers}
while pending:
done, pending = wait(pending, return_when=FIRST_COMPLETED)
for future in done:
result = future.result()
if result:
for rest in pending:
rest.cancel()
return result
return None
def get_cookies(
cookie_source: str = "auto", *, force_refresh: bool = False
) -> tuple[str, dict[str, str]]:
"""
Multi-strategy cookie acquisition with TTL-based auto-refresh.
Returns ``(browser_name, cookies)``.
1. Load saved cookies (skip if stale > 7 days)
2. Extract from browser (auto-detect if *cookie_source* is ``"auto"``)
3. Raise error if all fail
"""
# 1. Try saved cookies first
if not force_refresh:
saved = load_saved_cookies()
if saved:
saved_at = saved.pop("saved_at", 0)
if saved_at and (time.time() - float(saved_at)) > _COOKIE_TTL_SECONDS:
logger.info(
"Cookies older than %d days, attempting browser refresh",
COOKIE_TTL_DAYS,
)
result = extract_browser_cookies(cookie_source)
if result:
save_cookies(result[1])
return result
logger.warning(
"Cookie refresh failed; using existing cookies (age: %d+ days)",
COOKIE_TTL_DAYS,
)
return "saved", saved
# 2. Try browser extraction
from .exceptions import NoCookieError
result = extract_browser_cookies(cookie_source)
if result:
save_cookies(result[1])
return result
raise NoCookieError(cookie_source)
def cookies_to_string(cookies: dict[str, str]) -> str:
"""Format cookies as a cookie header string."""
return "; ".join(f"{k}={v}" for k, v in cookies.items())
@@ -0,0 +1,71 @@
"""
Creator platform signing (XYW_ prefix) for creator.xiaohongshu.com
Algorithm: MD5(api + JSON(data)) → AES-128-CBC encrypt → XYW_ prefix
Much simpler than main API signing.
Ported from: ~/readers/redbook/src/lib/creator-signing.ts
"""
import base64
import hashlib
import json
import time
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
AES_KEY = b"7cc4adla5ay0701v"
AES_IV = b"4uzjr7mbsibcaldp"
def _aes_encrypt(data: str) -> str:
"""AES-128-CBC encrypt and return hex string."""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
padded = pad(data.encode("utf-8"), AES.block_size)
encrypted = cipher.encrypt(padded)
return encrypted.hex()
def sign_creator(
api: str,
data: dict | None,
a1: str,
) -> dict[str, str]:
"""
Generate creator platform signature headers.
Args:
api: The API path, prefixed with "url=" (e.g., "url=/web_api/sns/v2/note")
data: Request body data (None for GET requests)
a1: The a1 cookie value
Returns:
Dict with x-s and x-t headers
"""
content = api
if data is not None:
content += json.dumps(data, separators=(",", ":"), ensure_ascii=False)
x1 = hashlib.md5(content.encode("utf-8")).hexdigest()
x2 = "0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0"
x3 = a1
x4 = int(time.time() * 1000)
plaintext = f"x1={x1};x2={x2};x3={x3};x4={x4};"
payload = _aes_encrypt(base64.b64encode(plaintext.encode("utf-8")).decode("utf-8"))
envelope = {
"signSvn": "56",
"signType": "x2",
"appId": "ugc",
"signVersion": "1",
"payload": payload,
}
xs = "XYW_" + base64.b64encode(json.dumps(envelope, separators=(",", ":")).encode("utf-8")).decode("utf-8")
return {
"x-s": xs,
"x-t": str(x4),
}
@@ -0,0 +1,39 @@
"""Stable error-code mapping for structured CLI output."""
from __future__ import annotations
from .exceptions import (
IpBlockedError,
NeedVerifyError,
NoCookieError,
SessionExpiredError,
SignatureError,
UnsupportedOperationError,
XhsApiError,
)
def error_code_for_exception(exc: Exception) -> str:
"""Map domain exceptions to stable structured error codes."""
if isinstance(exc, (NoCookieError, SessionExpiredError)):
return "not_authenticated"
if isinstance(exc, NeedVerifyError):
return "verification_required"
if isinstance(exc, IpBlockedError):
return "ip_blocked"
if isinstance(exc, SignatureError):
return "signature_error"
if isinstance(exc, UnsupportedOperationError):
return "unsupported_operation"
if isinstance(exc, XhsApiError):
if exc.code in {
"not_authenticated",
"verification_required",
"ip_blocked",
"signature_error",
"unsupported_operation",
"api_error",
}:
return str(exc.code)
return "api_error"
return "unknown_error"
@@ -0,0 +1,64 @@
"""Custom exceptions for XHS API client."""
class XhsApiError(Exception):
"""Base exception for XHS API errors."""
def __init__(self, message: str, code: int | str | None = None, response: dict | None = None):
super().__init__(message)
self.code = code
self.response = response
class NeedVerifyError(XhsApiError):
"""Raised when XHS requires captcha verification."""
def __init__(self, verify_type: str, verify_uuid: str):
super().__init__(f"Captcha required: type={verify_type}, uuid={verify_uuid}")
self.verify_type = verify_type
self.verify_uuid = verify_uuid
class SessionExpiredError(XhsApiError):
"""Raised when the session has expired."""
def __init__(self):
super().__init__("Session expired — please re-login with: xhs login", code=-100)
class IpBlockedError(XhsApiError):
"""Raised when IP is blocked by XHS."""
def __init__(self):
super().__init__("IP blocked by XHS — try a different network", code=300012)
class SignatureError(XhsApiError):
"""Raised when signature verification fails."""
def __init__(self):
super().__init__("Signature verification failed", code=300015)
class UnsupportedOperationError(XhsApiError):
"""Raised when the current web API no longer supports an exposed CLI action."""
def __init__(self, message: str):
super().__init__(message, code="unsupported_operation")
class NoCookieError(XhsApiError):
"""Raised when no valid cookies are found."""
def __init__(self, source: str, details: str = ""):
if source == "auto":
msg = "No 'a1' cookie found for xiaohongshu.com in any installed browser."
else:
msg = f"No 'a1' cookie found for xiaohongshu.com in {source}."
if details:
msg += f"\n{details}"
msg += "\n\nTroubleshooting:\n"
msg += " 1. Open a browser and visit https://www.xiaohongshu.com/\n"
msg += " 2. Make sure you are logged in\n"
msg += " 3. Try: xhs login --cookie-source <browser>"
super().__init__(msg)
@@ -0,0 +1,67 @@
"""Rich formatting utilities for XHS CLI output.
Re-exports from formatter_utils (base utilities) and formatter_renderers
(rich display functions). This module is the public API surface that
commands should import from.
"""
from .formatter_renderers import ( # noqa: F401
render_comments,
render_creator_notes,
render_feed,
render_note,
render_notifications,
render_search_results,
render_topics,
render_user_info,
render_user_posts,
render_users,
)
from .formatter_utils import ( # noqa: F401
coerce_int,
console,
emit_error,
error_console,
error_payload,
format_count,
maybe_print_structured,
print_error,
print_info,
print_json,
print_success,
print_yaml,
resolve_output_format,
success_payload,
)
# ─── URL parsing ────────────────────────────────────────────────────────────
def parse_note_reference(id_or_url: str) -> tuple[str, str, str]:
"""Extract note ID, xsec_token, and xsec_source from a URL or plain ID."""
if "xiaohongshu.com" in id_or_url:
from urllib.parse import parse_qs, urlparse
parsed = urlparse(id_or_url)
parts = parsed.path.rstrip("/").split("/")
note_id = parts[-1]
qs = parse_qs(parsed.query)
xsec_token = qs.get("xsec_token", [""])[0]
xsec_source = qs.get("xsec_source", [""])[0]
return note_id, xsec_token, xsec_source
return id_or_url, "", ""
def parse_note_url(id_or_url: str) -> tuple[str, str]:
"""Extract note ID and xsec_token from URL or plain ID.
Returns (note_id, xsec_token). xsec_token may be empty if not in URL.
"""
note_id, xsec_token, _xsec_source = parse_note_reference(id_or_url)
return note_id, xsec_token
def extract_note_id(id_or_url: str) -> str:
"""Extract note ID from URL or return as-is (drops query params)."""
note_id, _ = parse_note_url(id_or_url)
return note_id
@@ -0,0 +1,187 @@
"""Normalize reverse-engineered API payloads into stable renderer-friendly shapes."""
from __future__ import annotations
from typing import Any
def _coerce_int(value: Any, default: int = 0) -> int:
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return default
return default
def normalize_user_info(data: dict[str, Any]) -> dict[str, Any]:
basic = data.get("basic_info", data)
interactions = data.get("interactions", [])
stats = {}
for item in interactions:
stats[item.get("type", "")] = item.get("count", "0")
return {
"nickname": basic.get("nickname", basic.get("nick_name", "Unknown")),
"red_id": basic.get("red_id", ""),
"desc": basic.get("desc", ""),
"ip_location": basic.get("ip_location", ""),
"user_id": basic.get("user_id", data.get("user_id", "")),
"gender": basic.get("gender"),
"stats": stats,
}
def normalize_note_detail(data: dict[str, Any]) -> dict[str, Any] | None:
items = data.get("items", [])
if not items:
return None
note = items[0].get("note_card", {})
user = note.get("user", {})
interact = note.get("interact_info", {})
tags = note.get("tag_list", [])
return {
"title": note.get("title", "Untitled"),
"desc": note.get("desc", ""),
"author": user.get("nickname", "Unknown"),
"liked_count": interact.get("liked_count", "0"),
"collected_count": interact.get("collected_count", "0"),
"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", [])),
}
def normalize_note_summary(item: dict[str, Any]) -> dict[str, Any] | None:
note_card = item.get("note_card", item)
if not isinstance(note_card, dict):
return None
user = note_card.get("user", {})
interact = note_card.get("interact_info", {})
return {
"title": str(note_card.get("title", note_card.get("display_title", "")))[:40],
"author": user.get("nickname", ""),
"liked": str(interact.get("liked_count", "")),
"note_type": "video" if note_card.get("type") == "video" else "image",
"note_id": item.get("id", note_card.get("note_id", "")),
"xsec_token": item.get("xsec_token", note_card.get("xsec_token", "")),
}
def normalize_search_results(data: dict[str, Any]) -> dict[str, Any]:
items = [item for item in (normalize_note_summary(item) for item in data.get("items", [])) if item]
return {
"items": items,
"has_more": bool(data.get("has_more", False)),
}
def normalize_comments(data: dict[str, Any]) -> list[dict[str, Any]]:
normalized = []
for comment in data.get("comments", []):
user = comment.get("user_info", {})
normalized.append({
"nickname": user.get("nickname", "Unknown"),
"content": comment.get("content", ""),
"like_count": comment.get("like_count", "0"),
"sub_comment_count": _coerce_int(comment.get("sub_comment_count", 0)),
})
return normalized
def normalize_feed(data: dict[str, Any]) -> list[dict[str, Any]]:
normalized = []
for item in data.get("items", [])[:20]:
note_card = item.get("note_card", {})
user = note_card.get("user", {})
interact = note_card.get("interact_info", {})
normalized.append({
"title": note_card.get("title", note_card.get("display_title", ""))[:40],
"author": user.get("nickname", ""),
"liked": str(interact.get("liked_count", "")),
"note_id": item.get("id", ""),
"xsec_token": item.get("xsec_token", note_card.get("xsec_token", "")),
})
return normalized
def normalize_user_posts(notes: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized = []
for note in notes:
interact = note.get("interact_info", {})
normalized.append({
"title": note.get("display_title", "")[:40],
"liked": str(interact.get("liked_count", note.get("liked_count", ""))),
"note_type": "video" if note.get("type") == "video" else "image",
"note_id": note.get("note_id", ""),
})
return normalized
def normalize_topics(data: Any) -> list[dict[str, Any]]:
topics = data if isinstance(data, list) else data.get("topic_info_dtos", [])
return [
{
"name": topic.get("name", ""),
"view_num": topic.get("view_num", 0),
"topic_id": topic.get("id", ""),
}
for topic in topics
]
def normalize_users(data: Any) -> list[dict[str, Any]]:
if isinstance(data, list):
users = data
elif isinstance(data, dict):
users = data.get("user_info_dtos") or data.get("users") or data.get("items") or []
else:
users = []
normalized = []
for user in users:
base = user.get("user_base_dto", user)
normalized.append({
"nickname": base.get("user_nickname", base.get("nickname", base.get("nick_name", ""))),
"red_id": base.get("red_id", ""),
"fans": user.get("fans_total", base.get("fans", base.get("fansCount", 0))),
"user_id": base.get("user_id", base.get("id", "")),
})
return normalized
def normalize_creator_notes(data: Any) -> list[dict[str, Any]]:
notes = data if isinstance(data, list) else data.get("notes", data.get("note_list", []))
normalized = []
for note in notes:
interact = note.get("interact_info", {})
normalized.append({
"title": note.get("title", note.get("display_title", ""))[:40],
"liked": str(note.get("liked_count", interact.get("liked_count", ""))),
"comment_count": str(note.get("comment_count", interact.get("comment_count", ""))),
"status": note.get("status"),
"note_id": note.get("note_id", note.get("id", "")),
})
return normalized
def normalize_notifications(data: dict[str, Any]) -> list[dict[str, Any]]:
normalized = []
for message in data.get("message_list", []):
user = message.get("user_info", {}) or {}
item = message.get("item_info", {}) or {}
normalized.append({
"nickname": user.get("nickname", ""),
"title": message.get("title", ""),
"note_content": item.get("content", ""),
"time": message.get("time", 0),
})
return normalized
@@ -0,0 +1,313 @@
"""Rich renderers for human-readable CLI output."""
from __future__ import annotations
from typing import Any
from rich.panel import Panel
from rich.table import Table
from .formatter_normalizers import (
normalize_comments,
normalize_creator_notes,
normalize_feed,
normalize_note_detail,
normalize_notifications,
normalize_search_results,
normalize_topics,
normalize_user_info,
normalize_user_posts,
normalize_users,
)
from .formatter_utils import coerce_int, console, format_count, print_error, print_info
HOME_URL = "https://www.xiaohongshu.com"
def _build_note_url(
note_id: str,
xsec_token: str = "",
source: str = "pc_feed",
route: str = "explore",
) -> str:
"""Build a full note URL, including xsec_token when available."""
if xsec_token:
return f"{HOME_URL}/{route}/{note_id}?xsec_token={xsec_token}&xsec_source={source}"
return note_id # bare ID fallback
def _build_note_link(
note_id: str,
xsec_token: str = "",
source: str = "pc_feed",
route: str = "explore",
) -> str:
url = _build_note_url(note_id, xsec_token=xsec_token, source=source, route=route)
label = f"{route}/{note_id}"
if xsec_token:
return f"[link={url}][dim]{label}[/dim][/link]"
return f"[dim]{label}[/dim]"
def render_user_info(data: dict[str, Any]) -> None:
"""Render user profile info as a Rich panel."""
user = normalize_user_info(data)
nickname = user["nickname"]
red_id = user["red_id"]
desc = user["desc"]
ip_location = user["ip_location"]
user_id = user["user_id"]
gender_val = user["gender"]
gender = "♂️" if gender_val == 0 else "♀️" if gender_val == 1 else ""
stats = user["stats"]
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Key", style="dim")
table.add_column("Value")
table.add_row("昵称", f"[bold]{nickname}[/bold] {gender}")
if red_id:
table.add_row("小红书号", red_id)
if user_id:
table.add_row("User ID", user_id)
if desc:
table.add_row("简介", desc)
if ip_location:
table.add_row("IP 属地", ip_location)
if "fans" in stats:
table.add_row("粉丝", format_count(stats["fans"]))
if "follows" in stats:
table.add_row("关注", format_count(stats["follows"]))
if "interaction" in stats:
table.add_row("获赞与收藏", format_count(stats["interaction"]))
console.print(Panel(table, title=f"👤 {nickname}", border_style="blue"))
def render_note(data: dict[str, Any]) -> None:
"""Render a note as a Rich panel."""
note = normalize_note_detail(data)
if not note:
print_error("No note data found")
return
title = note["title"]
desc = note["desc"]
nickname = note["author"]
liked_count = note["liked_count"]
collected_count = note["collected_count"]
comment_count = note["comment_count"]
share_count = note["share_count"]
table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Key", style="dim")
table.add_column("Value")
table.add_row("作者", f"[bold]{nickname}[/bold]")
table.add_row("标题", f"[bold]{title}[/bold]")
if desc:
display_desc = desc[:500] + "..." if len(desc) > 500 else desc
table.add_row("正文", display_desc)
tags = note["tags"]
if tags:
tag_str = " ".join(f"[cyan]#{tag}[/cyan]" for tag in tags)
table.add_row("标签", tag_str)
stats_str = (
f"❤️ {format_count(liked_count)} "
f"⭐ {format_count(collected_count)} "
f"💬 {format_count(comment_count)} "
f"🔗 {format_count(share_count)}"
)
table.add_row("数据", stats_str)
if note["image_count"]:
table.add_row("图片", f"{note['image_count']} 张")
console.print(Panel(table, title=f"📝 {title}", border_style="green"))
def render_search_results(data: dict[str, Any]) -> None:
"""Render search results as a Rich table."""
normalized = normalize_search_results(data)
items = normalized["items"]
if not items:
print_info("No results found")
return
has_next = normalized["has_more"]
table = Table(title="搜索结果", show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("标题", width=30)
table.add_column("作者", width=10)
table.add_column("❤️", justify="right", width=8)
table.add_column("类型", width=4)
table.add_column("链接", style="cyan", no_wrap=True)
for i, item in enumerate(items, 1):
note_type = "📹" if item["note_type"] == "video" else "📷"
link = _build_note_link(
item["note_id"],
item.get("xsec_token", ""),
source="pc_search",
route="search_result",
)
table.add_row(str(i), item["title"], item["author"], item["liked"], note_type, link)
console.print(table)
if has_next:
print_info("More results available — use --page to paginate")
def render_comments(data: dict[str, Any]) -> None:
"""Render comments as a Rich display."""
comments = normalize_comments(data)
if not comments:
print_info("No comments found")
return
for comment in comments:
nickname = comment["nickname"]
content = comment["content"]
like_count = format_count(comment["like_count"])
sub_comment_count = coerce_int(comment["sub_comment_count"])
header = f"[bold]{nickname}[/bold] [dim]❤️ {like_count}[/dim]"
if sub_comment_count > 0:
header += f" [dim]💬 {sub_comment_count} replies[/dim]"
console.print(header)
console.print(f" {content}")
console.print()
def render_feed(data: dict[str, Any]) -> None:
"""Render feed items as a Rich table."""
items = normalize_feed(data)
if not items:
print_info("No feed items")
return
table = Table(title="推荐页", show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("标题", width=30)
table.add_column("作者", width=10)
table.add_column("❤️", justify="right", width=8)
table.add_column("链接", style="cyan", no_wrap=True)
for i, item in enumerate(items, 1):
link = _build_note_link(item["note_id"], item.get("xsec_token", ""), source="pc_feed")
table.add_row(str(i), item["title"], item["author"], item["liked"], link)
console.print(table)
def render_user_posts(notes: list[dict[str, Any]]) -> None:
"""Render a list of user's notes."""
normalized = normalize_user_posts(notes)
if not normalized:
print_info("No notes 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("类型", width=4)
table.add_column("ID", style="dim", width=24)
for i, note in enumerate(normalized, 1):
note_type = "📹" if note["note_type"] == "video" else "📷"
table.add_row(str(i), note["title"], note["liked"], note_type, note["note_id"])
console.print(table)
def render_topics(data: Any) -> None:
"""Render topic search results."""
topics = normalize_topics(data)
if not topics:
print_info("No topics found")
return
table = Table(title="话题", show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("话题名", width=15)
table.add_column("热度", justify="right", width=10)
table.add_column("ID", style="dim", width=24)
for i, topic in enumerate(topics, 1):
table.add_row(str(i), f"#{topic['name']}", format_count(topic["view_num"]), topic["topic_id"])
console.print(table)
def render_users(data: Any) -> None:
"""Render user search/list results."""
users = normalize_users(data)
if not users:
print_info("No users found")
return
table = Table(title="用户列表", show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("昵称", width=14)
table.add_column("小红书号", width=12)
table.add_column("粉丝", justify="right", width=8)
table.add_column("ID", style="dim", width=24)
for i, user in enumerate(users, 1):
table.add_row(str(i), user["nickname"], user["red_id"], format_count(user["fans"]), user["user_id"])
console.print(table)
def render_creator_notes(data: Any) -> None:
"""Render creator's own note list."""
notes = normalize_creator_notes(data)
if not notes:
print_info("No notes 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=6)
table.add_column("状态", width=6)
table.add_column("ID", style="dim", width=24)
for i, note in enumerate(notes, 1):
status = "✅" if note["status"] in (None, 0, "published") else "⏳"
table.add_row(str(i), note["title"], note["liked"], note["comment_count"], status, note["note_id"])
console.print(table)
def render_notifications(data: dict[str, Any], notif_type: str) -> None:
"""Render notification messages."""
import time as _time
messages = normalize_notifications(data)
if not messages:
print_info("No notifications")
return
table = Table(title=f"通知 — {notif_type}", show_lines=True)
table.add_column("#", style="dim", width=3)
table.add_column("用户", width=12)
table.add_column("内容", width=40)
table.add_column("时间", width=12)
for i, msg in enumerate(messages[:20], 1):
nickname = msg["nickname"]
title = msg["title"]
note_content = msg["note_content"]
display = title + (f"\n{note_content[:30]}" if note_content else "")
ts = msg["time"]
time_str = _time.strftime("%m-%d %H:%M", _time.localtime(ts)) if ts else ""
table.add_row(str(i), nickname, display, time_str)
console.print(table)
@@ -0,0 +1,187 @@
"""Base formatting utilities shared across formatter modules.
This module breaks the circular dependency between formatter.py and
formatter_renderers.py by providing the shared primitives both need.
"""
import json
import os
import sys
from typing import Any
import click
import yaml
from rich.console import Console
console = Console(stderr=True)
error_console = Console(stderr=True)
_stdout = Console()
_OUTPUT_ENV = "OUTPUT"
_SCHEMA_VERSION = "1"
# ─── Output format resolution ──────────────────────────────────────────────
def resolve_output_format(*, as_json: bool, as_yaml: bool) -> str | None:
"""Resolve explicit flags first, then env override, then TTY default."""
if as_json and as_yaml:
raise click.UsageError("Use only one of --json or --yaml.")
if as_yaml:
return "yaml"
if as_json:
return "json"
output_mode = os.getenv(_OUTPUT_ENV, "auto").strip().lower()
if output_mode == "yaml":
return "yaml"
if output_mode == "json":
return "json"
if output_mode == "rich":
return None
if not sys.stdout.isatty():
return "yaml"
return None
# ─── Structured output ─────────────────────────────────────────────────────
def print_json(data: Any) -> None:
"""Print raw JSON output to stdout."""
_stdout.print_json(json.dumps(data, ensure_ascii=False, indent=2))
def print_yaml(data: Any) -> None:
"""Print raw YAML output to stdout."""
click.echo(
yaml.safe_dump(
data,
allow_unicode=True,
sort_keys=False,
default_flow_style=False,
)
)
def success_payload(data: Any) -> dict[str, Any]:
"""Wrap structured success data in the shared agent schema."""
return {
"ok": True,
"schema_version": _SCHEMA_VERSION,
"data": data,
}
def error_payload(code: str, message: str, *, details: Any | None = None) -> dict[str, Any]:
"""Wrap structured error data in the shared agent schema."""
error = {
"code": code,
"message": message,
}
if details is not None:
error["details"] = details
return {
"ok": False,
"schema_version": _SCHEMA_VERSION,
"error": error,
}
def _normalize_success_payload(data: Any) -> Any:
"""Wrap plain structured data in the shared agent success schema."""
if isinstance(data, dict) and data.get("schema_version") == _SCHEMA_VERSION and "ok" in data:
return data
return success_payload(data)
def maybe_print_structured(data: Any, *, as_json: bool, as_yaml: bool) -> bool:
"""Print structured output when requested or when stdout is non-TTY."""
fmt = resolve_output_format(as_json=as_json, as_yaml=as_yaml)
if not fmt:
return False
payload = _normalize_success_payload(data)
if fmt == "json":
print_json(payload)
else:
print_yaml(payload)
return True
def emit_error(
code: str,
message: str,
*,
as_json: bool | None = None,
as_yaml: bool | None = None,
details: Any | None = None,
) -> bool:
"""Emit a structured error when the active output mode is machine-readable."""
if as_json is None or as_yaml is None:
ctx = click.get_current_context(silent=True)
params = ctx.params if ctx is not None else {}
as_json = bool(params.get("as_json", False)) if as_json is None else as_json
as_yaml = bool(params.get("as_yaml", False)) if as_yaml is None else as_yaml
fmt = resolve_output_format(as_json=bool(as_json), as_yaml=bool(as_yaml))
if fmt is None:
return False
payload = error_payload(code, message, details=details)
if fmt == "json":
print_json(payload)
else:
print_yaml(payload)
return True
# ─── UI helpers ─────────────────────────────────────────────────────────────
def print_error(message: str) -> None:
"""Print error message."""
if emit_error("api_error", message):
return
error_console.print(f"[red]✗[/red] {message}")
def print_success(message: str) -> None:
"""Print success message."""
console.print(f"[green]✓[/green] {message}")
def print_info(message: str) -> None:
"""Print info message."""
console.print(f"[dim]ℹ[/dim] {message}")
# ─── Data formatting ───────────────────────────────────────────────────────
def coerce_int(value: Any, default: int = 0) -> int:
"""Best-effort integer coercion for reverse-engineered API fields."""
if isinstance(value, bool):
return int(value)
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return default
return default
def format_count(n: int | str) -> str:
"""Format number for display (e.g., 12345 → 1.2万)."""
if isinstance(n, str):
try:
n = int(n)
except ValueError:
return str(n)
if n >= 100_000_000:
return f"{n / 100_000_000:.1f}亿"
if n >= 10_000:
return f"{n / 10_000:.1f}万"
return str(n)
@@ -0,0 +1,73 @@
"""Parse note data from Xiaohongshu HTML pages (SSR __INITIAL_STATE__).
This module provides an alternative to the feed API for reading notes.
The HTML endpoint embeds note data in a server-rendered `window.__INITIAL_STATE__`
object, which does not require a valid xsec_token to access.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from .exceptions import XhsApiError
logger = logging.getLogger(__name__)
# Regex to extract the __INITIAL_STATE__ JSON blob from the HTML.
_STATE_PATTERN = re.compile(r"window\.__INITIAL_STATE__=({.*?})\s*</script>", re.DOTALL)
def parse_initial_state(html: str) -> dict[str, Any]:
"""Extract and parse `window.__INITIAL_STATE__` from an XHS note page.
The server-rendered HTML contains a global state object with note data.
XHS uses bare `undefined` values in the JS object which are not valid JSON,
so we replace them before parsing.
"""
match = _STATE_PATTERN.search(html)
if not match:
raise XhsApiError("Could not parse __INITIAL_STATE__ from HTML")
raw = match.group(1)
# Replace bare `undefined` with empty string (not valid JSON)
cleaned = re.sub(r":\s*undefined", ':""', raw)
cleaned = re.sub(r",\s*undefined", ',""', cleaned)
try:
return json.loads(cleaned)
except json.JSONDecodeError as exc:
raise XhsApiError(f"Failed to parse __INITIAL_STATE__ JSON: {exc}") from None
def extract_note_from_state(
state: dict[str, Any],
note_id: str,
) -> dict[str, Any]:
"""Extract a single note dict from the parsed __INITIAL_STATE__.
The state structure is:
state.note.noteDetailMap[note_id].note -> full note object
"""
detail_map = state.get("note", {}).get("noteDetailMap", {})
if not detail_map:
raise XhsApiError("Note not found in HTML state: empty noteDetailMap")
# Try exact noteId first, then fall back to first entry
entry = detail_map.get(note_id)
if entry is None:
entry = next(iter(detail_map.values()), None)
if entry and isinstance(entry, dict) and "note" in entry:
return entry["note"]
raise XhsApiError("Note not found in HTML state")
def extract_note_from_html(html: str, note_id: str) -> dict[str, Any]:
"""High-level: parse HTML → extract note in one step."""
state = parse_initial_state(html)
return extract_note_from_state(state, note_id)
@@ -0,0 +1,56 @@
"""Helpers for resolving and persisting note references across commands."""
from __future__ import annotations
import click
from .cookies import get_note_by_index, save_note_index
from .formatter import parse_note_reference
def resolve_note_reference(id_or_url: str, *, xsec_token: str = "") -> tuple[str, str, str]:
"""Resolve a note reference from URL/ID or the last listing index."""
if id_or_url.isdigit():
entry = get_note_by_index(int(id_or_url))
if entry is None:
raise click.UsageError(
f"Index {id_or_url} not found — run a listing command first "
"(search / feed / hot / user-posts / favorites / my-notes)"
)
return (
entry["note_id"],
xsec_token or entry.get("xsec_token", ""),
entry.get("xsec_source", ""),
)
note_id, url_token, url_source = parse_note_reference(id_or_url)
return note_id, xsec_token or url_token, url_source
def save_index_from_items(data: dict, *, xsec_source: str) -> None:
"""Persist ordered note references from list-style responses."""
entries = []
for item in data.get("items", []):
note_card = item.get("note_card", {})
note_id = item.get("id", note_card.get("note_id", ""))
token = item.get("xsec_token", note_card.get("xsec_token", ""))
if note_id:
entries.append({
"note_id": note_id,
"xsec_token": token,
"xsec_source": xsec_source if token else "",
})
save_note_index(entries)
def save_index_from_notes(notes: list[dict]) -> None:
"""Persist ordered note references from paged note payloads."""
save_note_index([
{
"note_id": str(note.get("note_id", note.get("id", ""))).strip(),
"xsec_token": str(note.get("xsec_token", "")).strip(),
"xsec_source": "",
}
for note in notes
if str(note.get("note_id", note.get("id", ""))).strip()
])
+548
View File
@@ -0,0 +1,548 @@
"""QR code login for Xiaohongshu.
Supports two backends:
1. Browser-assisted login via Camoufox. The browser performs the real
QR completion flow, while the CLI renders the QR code in the terminal
from the browser's ``qrcode/create`` response and exports cookies after
login succeeds.
2. Legacy pure-HTTP login flow. This remains as a fallback when the
browser backend is unavailable.
"""
from __future__ import annotations
import logging
import random
import subprocess
import sys
import time
from typing import Any
from .client import XhsClient
from .cookies import save_cookies
from .exceptions import NeedVerifyError, XhsApiError
logger = logging.getLogger(__name__)
LOGIN_URL = "https://www.xiaohongshu.com/login"
QR_CREATE_ENDPOINT = "/api/sns/web/v1/login/qrcode/create"
QR_USERINFO_ENDPOINT = "/api/qrcode/userinfo"
QR_STATUS_ENDPOINT = "/api/sns/web/v1/login/qrcode/status"
# QR code status values
QR_WAITING = 0 # Waiting for scan
QR_SCANNED = 1 # Scanned, awaiting confirmation
QR_CONFIRMED = 2 # Login confirmed
# Poll config
POLL_INTERVAL_S = 2.0
POLL_TIMEOUT_S = 240 # 4 minutes
BROWSER_EXPORT_COOKIE_NAMES = (
"a1",
"webId",
"web_session",
"web_session_sec",
"id_token",
"websectiga",
"sec_poison_id",
"xsecappid",
"gid",
"abRequestId",
"webBuild",
"loadts",
)
class BrowserQrLoginUnavailable(XhsApiError):
"""Raised when the browser-assisted QR backend cannot be started."""
def _emit_status(on_status: callable[[str], None] | None, msg: str) -> None:
"""Send a status message to the callback or stdout."""
if on_status:
on_status(msg)
else:
print(msg)
def _apply_session_cookies(client: XhsClient, payload: dict[str, Any]) -> None:
"""Persist any session cookies returned by the QR login endpoints."""
login_info = payload.get("login_info", {})
if not isinstance(login_info, dict):
login_info = {}
session = payload.get("session") or login_info.get("session", "")
secure_session = payload.get("secure_session") or login_info.get("secure_session", "")
if session:
client.cookies["web_session"] = str(session)
if secure_session:
client.cookies["web_session_sec"] = str(secure_session)
def _build_saved_cookies(a1: str, webid: str, payload: dict[str, Any]) -> dict[str, str]:
"""Build the cookie payload persisted after QR login succeeds."""
login_info = payload.get("login_info", {})
if not isinstance(login_info, dict):
login_info = {}
session = payload.get("session") or login_info.get("session") or payload.get("web_session", "")
secure_session = (
payload.get("secure_session")
or login_info.get("secure_session")
or payload.get("web_session_sec", "")
)
cookies = {
"a1": a1,
"webId": webid,
}
if session:
cookies["web_session"] = str(session)
if secure_session:
cookies["web_session_sec"] = str(secure_session)
return cookies
def _normalize_browser_cookies(raw_cookies: list[dict[str, Any]]) -> dict[str, str]:
"""Convert Playwright cookies into the local persisted cookie shape."""
cookies: dict[str, str] = {}
for entry in raw_cookies:
name = entry.get("name")
value = entry.get("value")
domain = entry.get("domain", "")
if not isinstance(name, str) or not isinstance(value, str):
continue
if name not in BROWSER_EXPORT_COOKIE_NAMES:
continue
if not isinstance(domain, str) or "xiaohongshu.com" not in domain:
continue
cookies[name] = value
return cookies
def _unwrap_browser_response_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return the inner `data` payload when browser responses use the common envelope."""
data = payload.get("data")
if isinstance(data, dict):
return data
return payload
def _browser_response_payload(response: Any) -> dict[str, Any]:
"""Decode a browser response body as JSON and unwrap the common envelope."""
try:
data = response.json()
except Exception as exc:
raise XhsApiError(f"Browser response from {response.url} was not valid JSON.") from exc
if not isinstance(data, dict):
raise XhsApiError(f"Browser response from {response.url} returned unexpected payload: {data!r}")
return _unwrap_browser_response_payload(data)
def _raise_for_browser_response(response: Any) -> None:
"""Map browser QR completion failures onto the same domain errors as the HTTP client."""
status = getattr(response, "status", None)
if status in (461, 471):
raise NeedVerifyError(
verify_type=response.headers.get("verifytype", "unknown"),
verify_uuid=response.headers.get("verifyuuid", "unknown"),
)
if status and status >= 400:
try:
body = response.text()
except Exception:
body = "<unavailable>"
raise XhsApiError(
f"Browser-assisted QR completion failed: HTTP {status} body={body[:300]}"
)
def _wait_for_browser_login_settled(page: Any) -> None:
"""Wait briefly for the browser session and post-login page state to stabilize."""
try:
page.wait_for_url("**/explore*", timeout=5_000)
except Exception:
logger.debug("Browser-assisted QR login did not navigate to /explore before timeout")
try:
response = page.wait_for_response(
lambda resp: "/api/sns/web/v2/user/me" in resp.url and resp.request.method == "GET",
timeout=5_000,
)
except Exception:
logger.debug("Browser-assisted QR login did not observe a post-login user/me response before timeout")
return
try:
payload = _browser_response_payload(response)
except Exception as exc:
logger.debug("Failed to parse browser user/me response after QR login: %s", exc)
return
if bool(payload.get("guest", False)):
logger.debug("Browser-assisted QR login settled with guest=true in user/me payload: %s", payload)
def _resolved_user_id(info: dict[str, object]) -> str:
"""Extract the current user ID from activate/self payloads."""
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"])
if info.get("user_id"):
return str(info["user_id"])
if info.get("userid"):
return str(info["userid"])
return ""
def _complete_confirmed_session(
client: XhsClient,
qr_id: str,
code: str,
confirmed_user_id: str,
*,
retries: int = 5,
wait_s: float = 1.0,
) -> dict[str, object]:
"""Finalize QR login after confirmation until the session switches users."""
last_data: dict[str, object] = {}
last_self_info_user_id = ""
for attempt in range(retries):
completion_data = client.complete_qr_login(qr_id, code)
_apply_session_cookies(client, completion_data)
last_data = completion_data
completed_user_id = _resolved_user_id(completion_data)
self_info_user_id = ""
try:
self_info_user_id = _resolved_user_id(client.get_self_info())
last_self_info_user_id = self_info_user_id
except Exception as exc:
logger.debug(
"QR post-confirm self info check failed attempt=%d: %s",
attempt + 1,
exc,
)
logger.debug(
"QR post-confirm completion attempt=%d confirmed_user_id=%s "
"completion_user_id=%s self_info_user_id=%s cookies=%s data=%s",
attempt + 1,
confirmed_user_id,
completed_user_id,
self_info_user_id,
{
"web_session": client.cookies.get("web_session"),
"web_session_sec": client.cookies.get("web_session_sec"),
},
completion_data,
)
if completed_user_id and completed_user_id == confirmed_user_id:
return completion_data
if self_info_user_id and self_info_user_id == confirmed_user_id:
return completion_data
if attempt + 1 < retries:
time.sleep(wait_s)
raise XhsApiError(
"QR login confirmed, but completion never returned the confirmed user session. "
f"expected={confirmed_user_id} "
f"completion_user={_resolved_user_id(last_data) or 'unknown'} "
f"self_info_user={last_self_info_user_id or 'unknown'} "
f"completion_data={last_data}"
)
def _generate_a1() -> str:
"""Generate a fresh a1 cookie value (52 hex chars with embedded timestamp)."""
prefix = "".join(random.choices("0123456789abcdef", k=24))
ts = str(int(time.time() * 1000))
suffix = "".join(random.choices("0123456789abcdef", k=15))
return prefix + ts + suffix
def _generate_webid() -> str:
"""Generate a webId cookie value (32 hex chars)."""
return "".join(random.choices("0123456789abcdef", k=32))
def _render_qr_half_blocks(matrix: list[list[bool]]) -> str:
"""Render QR matrix using half-block characters (▀▄█ and space)."""
if not matrix:
return ""
size = len(matrix)
lines: list[str] = []
for row_idx in range(0, size, 2):
line = ""
for col_idx in range(size):
top = matrix[row_idx][col_idx]
bot = matrix[row_idx + 1][col_idx] if row_idx + 1 < size else False
if top and bot:
line += "█"
elif top and not bot:
line += "▀"
elif not top and bot:
line += "▄"
else:
line += " "
lines.append(line)
return "\n".join(lines)
def _display_qr_in_terminal(data: str) -> bool:
"""Display *data* as a QR code in the terminal. Returns True on success."""
try:
import qrcode # type: ignore[import-untyped]
except ImportError:
return False
qr = qrcode.QRCode(border=4)
qr.add_data(data)
qr.make(fit=True)
modules = qr.get_matrix()
print(_render_qr_half_blocks(modules))
return True
def _ensure_camoufox_ready() -> None:
"""Validate that the Camoufox package and browser binary are available."""
try:
import camoufox # noqa: F401
except ImportError as exc:
raise BrowserQrLoginUnavailable(
"Browser-assisted QR login requires the `camoufox` package."
) from exc
try:
result = subprocess.run(
[sys.executable, "-m", "camoufox", "path"],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError) as exc:
raise BrowserQrLoginUnavailable(
"Unable to validate the Camoufox browser installation."
) from exc
if result.returncode != 0 or not result.stdout.strip():
raise BrowserQrLoginUnavailable(
"Camoufox browser runtime is missing. Run `python -m camoufox fetch` first."
)
def _browser_assisted_qrcode_login(
*,
on_status: callable[[str], None] | None = None,
timeout_s: int = POLL_TIMEOUT_S,
) -> dict[str, str]:
"""Log in by letting a real browser complete the QR flow, then export cookies."""
_ensure_camoufox_ready()
try:
from camoufox.sync_api import Camoufox
except ImportError as exc:
raise BrowserQrLoginUnavailable(
"Camoufox sync API is unavailable in the current environment."
) from exc
state = {"last_status": -1}
_emit_status(on_status, "🔑 Starting browser-assisted QR login...")
with Camoufox(headless=False) as browser:
page = browser.new_page()
def _handle_response(response) -> None:
url = response.url
if QR_USERINFO_ENDPOINT not in url:
return
try:
payload = _unwrap_browser_response_payload(response.json())
except Exception as exc:
logger.debug("Failed to parse browser QR poll response: %s", exc)
return
code_status = int(payload.get("codeStatus", -1))
if code_status == state["last_status"]:
return
state["last_status"] = code_status
if code_status == QR_SCANNED:
_emit_status(on_status, "📲 Scanned! Waiting for confirmation...")
elif code_status == QR_CONFIRMED:
_emit_status(on_status, "✅ Login confirmed!")
page.on("response", _handle_response)
try:
with page.expect_response(
lambda response: QR_CREATE_ENDPOINT in response.url and response.request.method == "POST",
timeout=20_000,
) as qr_response_info:
page.goto(LOGIN_URL, wait_until="domcontentloaded")
except Exception as exc:
raise XhsApiError("Failed to load Xiaohongshu login page in Camoufox.") from exc
qr_payload = _browser_response_payload(qr_response_info.value)
qr_url = str(qr_payload.get("url", "")).strip()
if not qr_url:
raise XhsApiError(f"Browser-assisted QR login did not expose a QR URL: {qr_payload}")
_emit_status(on_status, "\n📱 Scan the QR code below with the Xiaohongshu app:\n")
if not _display_qr_in_terminal(qr_url):
_emit_status(on_status, "⚠️ Install 'qrcode' for terminal rendering: pip install qrcode")
_emit_status(on_status, f"QR URL: {qr_url}")
_emit_status(on_status, "\n⏳ Waiting for QR code scan...")
try:
with page.expect_response(
lambda response: QR_STATUS_ENDPOINT in response.url and response.request.method == "GET",
timeout=timeout_s * 1000,
) as completion_info:
pass
except Exception as exc:
raise XhsApiError("QR code login timed out while waiting for browser completion.") from exc
_raise_for_browser_response(completion_info.value)
completion_data = _browser_response_payload(completion_info.value)
login_info = completion_data.get("login_info", {})
if not isinstance(login_info, dict):
login_info = {}
_wait_for_browser_login_settled(page)
cookies = _normalize_browser_cookies(page.context.cookies())
session = login_info.get("session")
secure_session = login_info.get("secure_session")
if isinstance(session, str) and session:
cookies["web_session"] = session
if isinstance(secure_session, str) and secure_session:
cookies["web_session_sec"] = secure_session
required = ("a1", "webId", "web_session")
missing = [name for name in required if not cookies.get(name)]
if missing:
raise XhsApiError(
"Browser-assisted QR login succeeded, but exported cookies were incomplete: "
f"missing={', '.join(missing)} completion_data={completion_data}"
)
save_cookies(cookies)
user_id = str(login_info.get("user_id", "")).strip() or _resolved_user_id(completion_data)
if user_id:
_emit_status(on_status, f"👤 User ID: {user_id}")
return cookies
def _http_qrcode_login(
*,
on_status: callable[[str], None] | None = None,
timeout_s: int = POLL_TIMEOUT_S,
) -> dict[str, str]:
"""Run the legacy pure-HTTP QR login flow."""
a1 = _generate_a1()
webid = _generate_webid()
tmp_cookies = {"a1": a1, "webId": webid}
_emit_status(on_status, "🔑 Starting QR code login...")
with XhsClient(tmp_cookies, request_delay=0) as client:
try:
activate_data = client.login_activate()
_apply_session_cookies(client, activate_data)
guest_session = activate_data.get("session", "")
logger.debug(
"Initial activate: session=%s user_id=%s",
guest_session, activate_data.get("user_id"),
)
except Exception as exc:
logger.debug("Initial activate failed (non-fatal): %s", exc)
qr_data = client.create_qr_login()
qr_id = qr_data["qr_id"]
code = qr_data["code"]
qr_url = qr_data["url"]
logger.debug("QR created: qr_id=%s, code=%s", qr_id, code)
_emit_status(on_status, "\n📱 Scan the QR code below with the Xiaohongshu app:\n")
if not _display_qr_in_terminal(qr_url):
_emit_status(on_status, "⚠️ Install 'qrcode' for terminal rendering: pip install qrcode")
_emit_status(on_status, f"QR URL: {qr_url}")
_emit_status(on_status, "\n⏳ Waiting for QR code scan...")
start = time.time()
last_status = -1
consecutive_errors = 0
while (time.time() - start) < timeout_s:
time.sleep(POLL_INTERVAL_S)
try:
status_data = client.check_qr_status(qr_id, code)
except Exception as exc:
logger.debug("QR status check error: %s", exc)
consecutive_errors += 1
if consecutive_errors >= 3:
raise XhsApiError(f"QR status polling failed repeatedly: {exc}") from exc
continue
else:
consecutive_errors = 0
code_status = status_data.get("codeStatus", -1)
logger.debug("QR poll: codeStatus=%s data=%s", code_status, status_data)
if code_status != last_status:
last_status = code_status
if code_status == QR_SCANNED:
_emit_status(on_status, "📲 Scanned! Waiting for confirmation...")
elif code_status == QR_CONFIRMED:
_emit_status(on_status, "✅ Login confirmed!")
if code_status == QR_CONFIRMED:
confirmed_user_id = status_data.get("userId", "")
if not confirmed_user_id:
raise XhsApiError("QR login confirmed but no confirmed userId was returned.")
completion_data = _complete_confirmed_session(
client,
qr_id,
code,
confirmed_user_id,
)
user_id = _resolved_user_id(completion_data) or confirmed_user_id
cookies = _build_saved_cookies(a1, webid, client.cookies)
save_cookies(cookies)
_emit_status(on_status, f"👤 User ID: {user_id}")
return cookies
elapsed = time.time() - start
if int(elapsed) % 30 == 0 and int(elapsed) > 0:
_emit_status(on_status, " Still waiting...")
raise XhsApiError("QR code login timed out after 4 minutes")
def qrcode_login(
*,
on_status: callable[[str], None] | None = None,
timeout_s: int = POLL_TIMEOUT_S,
prefer_browser_assisted: bool = False,
) -> dict[str, str]:
"""Run the QR code login flow."""
if prefer_browser_assisted:
try:
return _browser_assisted_qrcode_login(on_status=on_status, timeout_s=timeout_s)
except BrowserQrLoginUnavailable as exc:
logger.info("Browser-assisted QR login unavailable, falling back to HTTP flow: %s", exc)
return _http_qrcode_login(on_status=on_status, timeout_s=timeout_s)
+85
View File
@@ -0,0 +1,85 @@
"""
Main API signing for edith.xiaohongshu.com
Thin adapter over the xhshow library, configured for macOS/Chrome.
Maintains a persistent SessionManager for realistic session simulation.
Public API (unchanged from previous implementation):
- sign_main_api(method, uri, cookies, ...) -> dict of 5 headers
- build_get_uri(uri, params) -> str
- extract_uri(url) -> str
"""
from __future__ import annotations
from xhshow import CryptoConfig, SessionManager, Xhshow
from xhshow.utils.url_utils import extract_uri # noqa: F401 — re-export
from .constants import APP_ID, PLATFORM, SDK_VERSION, USER_AGENT
# ─── macOS/Chrome configuration ────────────────────────────────────────────
_config = CryptoConfig().with_overrides(
PUBLIC_USERAGENT=USER_AGENT,
SIGNATURE_DATA_TEMPLATE={
"x0": SDK_VERSION,
"x1": APP_ID,
"x2": PLATFORM,
"x3": "",
"x4": "",
},
SIGNATURE_XSCOMMON_TEMPLATE={
"s0": 5,
"s1": "",
"x0": "1",
"x1": SDK_VERSION,
"x2": PLATFORM,
"x3": APP_ID,
"x4": "4.86.0",
"x5": "",
"x6": "",
"x7": "",
"x8": "",
"x9": -596800761,
"x10": 0,
"x11": "normal",
},
)
_xhshow = Xhshow(_config)
_session = SessionManager(_config)
# ─── Public API ─────────────────────────────────────────────────────────────
def sign_main_api(
method: str,
uri: str,
cookies: dict[str, str],
params: dict[str, str | int | list[str]] | None = None,
payload: dict | None = None,
timestamp: float | None = None,
) -> dict[str, str]:
"""
Generate all signing headers for a main API (edith.xiaohongshu.com) request.
Returns dict with keys: x-s, x-s-common, x-t, x-b3-traceid, x-xray-traceid
"""
if method.upper() == "GET":
return _xhshow.sign_headers_get(
uri, cookies, params=params, timestamp=timestamp, session=_session,
)
return _xhshow.sign_headers_post(
uri, cookies, payload=payload, timestamp=timestamp, session=_session,
)
def build_get_uri(
uri: str,
params: dict[str, str | int | list[str]] | None = None,
) -> str:
"""Build URI with query parameters for GET requests."""
if not params:
return uri
return _xhshow.build_url(uri, params)