a
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
# xiaohongshu-cli
|
||||
|
||||
[](https://github.com/jackwener/xiaohongshu-cli/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/xiaohongshu-cli/)
|
||||
[](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
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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)"]
|
||||
@@ -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()
|
||||
@@ -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": ""},
|
||||
]]
|
||||
@@ -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_")
|
||||
|
||||
@@ -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
|
||||
Generated
+1508
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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user