
Xiaohongshu Cli
- 1.2k installs
- 2.4k repo stars
- Updated March 21, 2026
- jackwener/xiaohongshu-cli
xiaohongshu-cli provides documented workflows for Use xiaohongshu-cli for ALL Xiaohongshu (Little Red Book, 小红书) operations - searching notes, reading content, browsing users, liking, collecting, commenting, fo
About
The xiaohongshu-cli skill 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. # 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.
- Non-TTY stdout → auto YAML
- `--json` / `--yaml` → explicit format
- `OUTPUT=json` env → global override
- `OUTPUT=rich` env → force human output
- `not_authenticated` - cookies expired or missing
Xiaohongshu Cli by the numbers
- 1,161 all-time installs (skills.sh)
- +44 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #211 of 1,048 Mobile Development skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
xiaohongshu-cli capabilities & compatibility
- Capabilities
- non tty stdout → auto yaml · ` json` / ` yaml` → explicit format · `output=json` env → global override · `output=rich` env → force human output · `not_authenticated` cookies expired or missing
- Use cases
- documentation
What xiaohongshu-cli says it does
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).
npx skills add https://github.com/jackwener/xiaohongshu-cli --skill xiaohongshu-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.2k |
|---|---|
| repo stars | ★ 2.4k |
| Security audit | 2 / 3 scanners passed |
| Last updated | March 21, 2026 |
| Repository | jackwener/xiaohongshu-cli ↗ |
How do I use xiaohongshu-cli for the task described in its SKILL.md triggers?
Use xiaohongshu-cli for ALL Xiaohongshu (Little Red Book, 小红书) operations - searching notes, reading content, browsing users, liking, collecting, commenting, following, and posting. Invoke whenever.
Who is it for?
Teams invoking xiaohongshu-cli when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
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 an
What you get
Step-by-step guidance grounded in xiaohongshu-cli documentation and reference files.
- Scheduled Xiaohongshu posts
- Optimized post drafts
By the numbers
- Exposes the `xhs` terminal CLI with pip-based installation
- Issue templates list 4 supported OS options: macOS, Linux, Windows, and Other
Files
xiaohongshu-cli — Xiaohongshu CLI Tool
Binary: xhs Credentials: browser cookies (auto-extracted) or browser-assisted QR login (--qrcode)
Setup
# 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-cliAuthentication
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
xhs status --yaml >/dev/null && echo "AUTH_OK" || echo "AUTH_NEEDED"If AUTH_OK, skip to 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. Supported browsers: Chrome, Arc, Edge, Firefox, Safari, Brave, Chromium, Opera, Opera GX, Vivaldi, LibreWolf, Lynx, w3m. Then:
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 outputVerify with:
xhs status
xhs whoamiStep 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. Payloads live under .data.
- Non-TTY stdout → auto YAML
--json/--yaml→ explicit formatOUTPUT=jsonenv → global overrideOUTPUT=richenv → 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
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
xhs hot -c food --json | jq '.data.items[:5] | .[].note_card | {title, likes: .interact_info.liked_count}'Get user info then follow
xhs user 5f2e123 --json | jq '.data.basic_info | {nickname, user_id}'
xhs follow 5f2e123Check notifications
xhs unread --json | jq '.data'
xhs notifications --type mentions --json | jq '.data.message_list[:5]'Analyze all comments on a note
# 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
# 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 --yamlQR code login
# 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 cookiesURL to insights pipeline
# 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 --jsonHot 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 missingverification_required— captcha/verification neededip_blocked— IP rate limitedsignature_error— request signing failedapi_error— upstream API errorunsupported_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
NeedVerifyErroroccurs, 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.
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug"]
body:
- type: input
id: version
attributes:
label: Version
description: "Run `xhs --version` or `pip show xiaohongshu-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating System
options:
- macOS
- Linux
- Windows
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug clearly.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: "Commands or steps to reproduce the issue. Use `xhs -v <command>` for debug output."
render: bash
- type: textarea
id: logs
attributes:
label: Error output / logs
description: Paste any error messages or verbose output here.
render: text
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: input
id: version
attributes:
label: Current version
description: "Run `xhs --version` or `pip show xiaohongshu-cli | grep Version`"
placeholder: "e.g. 1.0.0"
validations:
required: false
- type: textarea
id: description
attributes:
label: Describe the feature
description: What would you like to see added or changed?
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use case
description: Why do you need this feature? How would you use it?
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
lint-and-test:
name: Lint and test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --extra dev
- name: Run ruff
run: uv run ruff check .
- name: Run tests
run: uv run python -m pytest -q --ignore=tests/test_integration.py
build:
name: Build package
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install dependencies
run: uv sync --extra dev
- name: Build distribution
run: uv build
name: Publish to PyPI
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
verify:
uses: ./.github/workflows/ci.yml
publish:
needs: verify
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Build package
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
__pycache__/
*.pyc
.venv/
dist/
*.egg-info/
.ruff_cache/
.mypy_cache/
.pytest_cache/
.playwright-mcp/
xhs_login_page.png
[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)"]
xiaohongshu-cli
  
A CLI for Xiaohongshu (小红书) — search, read, interact, and post via reverse-engineered API 📕
More Tools
- bilibili-cli — Bilibili CLI for videos, users, search, and feeds
- twitter-cli — Twitter/X CLI for timelines, bookmarks, and posting
- discord-cli — Discord CLI for local-first sync, search, and export
- 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 1orxhs 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-uaalignment, session-stable browser identity, Gaussian jitter, captcha cooldown, exponential backoff - 📊 Structured output — commands support
--yamland--json; non-TTY stdout defaults to YAML - 📦 Stable envelope — see 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
# Recommended: uv tool (fast, isolated)
uv tool install xiaohongshu-cli
# Or: pipx
pipx install xiaohongshu-cliUpgrade to the latest version:
uv tool upgrade xiaohongshu-cli
# Or: pipx upgrade xiaohongshu-cliTip: Upgrade regularly to avoid unexpected errors from outdated API handling.
From source:
git clone git@github.com:jackwener/xiaohongshu-cli.git
cd xiaohongshu-cli
uv syncUsage
# ─── 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 likes # My liked notes (current user)
xhs likes <user_id> # Other user's liked 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 listingxhs comments <N>opens comments for the Nth note from the latest listingxhs like <N>,xhs favorite <N>,xhs unfavorite <N>,xhs comment <N>, andxhs 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,
MacIntelplatform — 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-tsignatures (reverse-engineered from web client) x-b3-traceidandx-xray-traceidfor distributed tracing consistency
Structured Output
All --json / --yaml output uses the shared envelope from SCHEMA.md:
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` that teaches AI agents how to use it.
Skills CLI (Recommended)
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
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 or Manual Install above.
Project Structure
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/notificationsDevelopment
# 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 — Bilibili 视频、用户、搜索与动态 CLI
- twitter-cli — Twitter/X 时间线、书签和发推 CLI
- discord-cli — Discord 本地优先同步、检索与导出 CLI
- tg-cli — Telegram 本地优先同步、检索与导出 CLI
功能特性
- 🔐 认证 — 自动提取浏览器 Cookie,browser-assisted 二维码扫码登录,状态检查,用户信息
- 🔍 搜索 — 按关键词搜索笔记、用户、话题
- 📖 阅读 — 笔记详情、评论、子评论、用户主页
- 📰 发现 — 推荐 Feed、按分类浏览热门
- 👥 社交 — 关注/取关、收藏夹
- 👍 互动 — 点赞、收藏、评论、回复、删除
- ✍️ 创作者 — 发布图文笔记、我的笔记列表、删除
- 🔔 通知 — 未读数、@、点赞、新关注
- 🛡️ 反风控 — macOS Chrome 指纹一致性、session 级浏览器身份持久化、高斯抖动延迟、验证码自动冷却、指数退避重试
- 📊 结构化输出 —
--yaml/--json,非 TTY 默认输出 YAML - 📦 稳定 envelope — 参见 SCHEMA.md
安装
# 推荐:uv tool(快速、隔离环境)
uv tool install xiaohongshu-cli
# 或者:pipx
pipx install xiaohongshu-cli升级到最新版本:
uv tool upgrade xiaohongshu-cli
# 或:pipx upgrade xiaohongshu-cli提示: 建议定期升级,避免因版本过旧导致的 API 调用异常。
从源码安装:
git clone git@github.com:jackwener/xiaohongshu-cli.git
cd xiaohongshu-cli
uv sync使用示例
# 认证
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 likes # 我的点赞(自动识别当前用户)
xhs likes <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 loginNeedVerifyError— 触发了验证码,请到浏览器中完成验证后重试IpBlockedError— IP 被限制,尝试切换网络(手机热点或 VPN)SessionExpiredError— Cookie 过期,执行xhs login刷新- 请求较慢是正常的 — 内置高斯随机延迟(~1-1.5s)是为了模拟人类浏览行为,避免触发风控
作为 AI Agent Skill 使用
xiaohongshu-cli 自带 `SKILL.md`,让 AI Agent 能自动学习并使用本工具。
Skills CLI(推荐)
npx skills add jackwener/xiaohongshu-cli| 参数 | 说明 |
|---|---|
-g | 全局安装(用户级别,跨项目共享) |
-a claude-code | 指定目标 Agent |
-y | 非交互模式 |
手动安装
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
Structured Output Schema
xiaohongshu-cli uses a shared agent-friendly envelope for machine-readable output.
Success
ok: true
schema_version: "1"
data: ...Error
ok: false
schema_version: "1"
error:
code: not_authenticated
message: need loginNotes
--yamland--jsonboth use this envelope- non-TTY stdout defaults to YAML
- reading and search commands return their payload under
data statusreturnsdata.authenticatedplusdata.userwhoamireturnsdata.user- common
error.codevalues includenot_authenticated,verification_required,ip_blocked,signature_error,unsupported_operation, andapi_error
from __future__ import annotations
import os
os.environ.setdefault("OUTPUT", "rich")
"""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()
"""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": ""},
]]
"""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") == {}
"""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": "",
}
"""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"]
from xhs_cli.commands.creator import extract_hashtags
def test_extract_hashtags():
# Normal case
assert extract_hashtags("This is a #test and another #hashtag") == ["test", "hashtag"]
# Empty body
assert extract_hashtags("") == []
# URL fragment shouldn't match
assert extract_hashtags("Visit https://example.com#section for more info") == []
# Consecutive tags without spaces — only the first is preceded by whitespace
assert extract_hashtags("Mixed #one#two#three") == ["one"]
# Tags at start of line
assert extract_hashtags("#start of line") == ["start"]
# Mix of languages
assert extract_hashtags("测试 #中文标签 和 #english tag") == ["中文标签", "english"]
# Trailing hashtag
assert extract_hashtags("This is #trailing") == ["trailing"]
# Pure hashtag body
assert extract_hashtags("#a #b #c") == ["a", "b", "c"]
# Emoji hashtag
assert extract_hashtags("Let's go #🎉party") == ["🎉party"]
"""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"
"""
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
"""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,
}
"""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_")
"""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
"""xiaohongshu-cli: Xiaohongshu CLI via reverse-engineered API."""
try:
from importlib.metadata import version
__version__ = version("xiaohongshu-cli")
except Exception:
__version__ = "0.0.0"
"""Module entry point for ``python -m xhs_cli``."""
from .cli import cli
if __name__ == "__main__":
cli()
"""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 sys
import click
from . import __version__
from .commands import auth, creator, interactions, notifications, reading, social
def _fix_windows_encoding() -> None:
"""Force UTF-8 on Windows where the default codepage (936/GBK) garbles output."""
if sys.platform != "win32":
return
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
_fix_windows_encoding()
@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)
cli.add_command(social.likes)
# ─── 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()
"""
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)
Related skills
How it compares
Use xiaohongshu-cli for RED-specific terminal automation; use general social APIs when the target platform is not Xiaohongshu.
FAQ
What does xiaohongshu-cli do?
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
When should I use xiaohongshu-cli?
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
What are common prerequisites?
--- 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.
Is Xiaohongshu Cli safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.