Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A CLI for Xiaohongshu (小红书) — search, read, interact, and post via rever
## Features

- 🔐 **Auth** — auto-extract browser cookies, QR code login, status check, whoami
- 🌐 **Domain target** — works against `xiaohongshu.com` (default) or `rednote.com` (international re-skin) via `XHS_TARGET`
- 🔍 **Search** — notes by keyword, user search, topic search
- 📖 **Reading** — note detail, comments, sub-comments, user profiles
- 🔢 **Short-index navigation** — open recent list results with `xhs read 1` or `xhs comments 1`
Expand Down Expand Up @@ -160,6 +161,18 @@ Other authenticated commands automatically retry once with fresh browser cookies

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.

### Targeting rednote.com (international)

Set `XHS_TARGET=rednote` to point the CLI at the international `rednote.com` domain instead of `xiaohongshu.com`. Both share the same backend, API surface, and signing scheme — only host names and cookie domain differ.

```bash
XHS_TARGET=rednote xhs login # extract cookies from a logged-in rednote.com browser session
XHS_TARGET=rednote xhs login --qrcode # browser-assisted QR via www.rednote.com/login
XHS_TARGET=rednote xhs whoami
```

Sessions are stored separately (`cookies.rednote.json` vs `cookies.json`) so you can keep both logged in. Default behavior is unchanged when `XHS_TARGET` is unset.

### 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`.
Expand All @@ -174,6 +187,7 @@ After any listing command such as `search`, `feed`, `hot`, `user-posts`, `favori
| Variable | Default | Description |
|----------|---------|-------------|
| `OUTPUT` | `auto` | Output format: `json`, `yaml`, `rich`, or `auto` (→ YAML when non-TTY) |
| `XHS_TARGET` | `xiaohongshu` | Target domain: `xiaohongshu` (xiaohongshu.com) or `rednote` (rednote.com, international). Cookie/cache files are namespaced per target. |
## Rate Limiting & Anti-Detection

xiaohongshu-cli includes comprehensive anti-risk-control measures designed to minimize detection:
Expand Down Expand Up @@ -285,10 +299,14 @@ uv run ruff check .

**Q: `NoCookieError: No 'a1' cookie found`**

1. Open any browser and visit https://www.xiaohongshu.com/
1. Open any browser and visit https://www.xiaohongshu.com/ (or https://www.rednote.com/ if using `XHS_TARGET=rednote`)
2. Log in with your account
3. Run `xhs login` (auto-detects browser) or `xhs login --cookie-source <browser>`

**Q: I'm in a region where xiaohongshu.com doesn't work / I want to use rednote.com**

Set `XHS_TARGET=rednote` (env var) before any `xhs` command. See [Targeting rednote.com](#targeting-rednotecom-international) above. Both targets use the same backend and signing — switching is just a host swap.

**Q: `NeedVerifyError: Captcha required`**

XHS has triggered a captcha check. Open https://www.xiaohongshu.com/ in your browser, complete the captcha, then retry.
Expand Down Expand Up @@ -317,6 +335,7 @@ The built-in Gaussian jitter delay (~1-1.5s between requests) is intentional to
## 功能特性

- 🔐 **认证** — 自动提取浏览器 Cookie,browser-assisted 二维码扫码登录,状态检查,用户信息
- 🌐 **域名切换** — 默认 `xiaohongshu.com`,通过 `XHS_TARGET=rednote` 可切换到国际版 `rednote.com`
- 🔍 **搜索** — 按关键词搜索笔记、用户、话题
- 📖 **阅读** — 笔记详情、评论、子评论、用户主页
- 📰 **发现** — 推荐 Feed、按分类浏览热门
Expand Down Expand Up @@ -438,6 +457,16 @@ Cookie 保存后有效期 **7 天**,超时后自动尝试从浏览器刷新。

`xhs login` 会自动尝试所有已安装浏览器,使用第一个有有效 Cookie 的浏览器。也可用 `--cookie-source <browser>` 指定浏览器,或 `--qrcode` 使用 browser-assisted 二维码登录。其他需认证命令在 session 过期时会自动重试一次。

### 切换到国际版 rednote.com

设置 `XHS_TARGET=rednote` 即可让 CLI 指向国际版 `rednote.com`(与 `xiaohongshu.com` 共用同一后端、API 和签名方案,仅域名和 Cookie 域不同)。两套 session 分别保存(`cookies.rednote.json` / `cookies.json`),可同时登录。

```bash
XHS_TARGET=rednote xhs login # 从已登录的 rednote.com 浏览器会话提取 Cookie
XHS_TARGET=rednote xhs login --qrcode # 通过 www.rednote.com/login 进行扫码登录
XHS_TARGET=rednote xhs whoami
```

## 常见问题

- `NoCookieError: No 'a1' cookie found` — 请先在任意浏览器打开 https://www.xiaohongshu.com/ 并登录,然后执行 `xhs login`
Expand Down
10 changes: 6 additions & 4 deletions tests/test_qr_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,13 @@ def test_qrcode_login_falls_back_when_browser_backend_unavailable(monkeypatch):


def test_normalize_browser_cookies_uses_allowlist():
from xhs_cli.constants import COOKIE_DOMAIN

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"},
{"name": "a1", "value": "a1-value", "domain": f".{COOKIE_DOMAIN}"},
{"name": "web_session", "value": "session-value", "domain": f".{COOKIE_DOMAIN}"},
{"name": "customer-sso-sid", "value": "skip-me", "domain": f".{COOKIE_DOMAIN}"},
{"name": "creator_only", "value": "skip-me-too", "domain": f"creator.{COOKIE_DOMAIN}"},
])

assert cookies == {
Expand Down
79 changes: 70 additions & 9 deletions xhs_cli/constants.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,68 @@
"""Constants for XHS API client."""
"""Constants for XHS API client.

EDITH_HOST = "https://edith.xiaohongshu.com"
CREATOR_HOST = "https://creator.xiaohongshu.com"
HOME_URL = "https://www.xiaohongshu.com"
UPLOAD_HOST = "https://ros-upload.xiaohongshu.com"
Targets the xiaohongshu.com PC web by default. Set ``XHS_TARGET=rednote`` to
point at the international rednote.com host triplet — the two domains share
the same backend, API surface, and signing scheme; they differ only in host
names and cookie domain.
"""

from __future__ import annotations

import os
from typing import NamedTuple


class _Target(NamedTuple):
name: str
edith_host: str
creator_host: str
home_url: str
upload_host: str
cookie_domain: str
login_url: str


_TARGETS: dict[str, _Target] = {
"xiaohongshu": _Target(
name="xiaohongshu",
edith_host="https://edith.xiaohongshu.com",
creator_host="https://creator.xiaohongshu.com",
home_url="https://www.xiaohongshu.com",
upload_host="https://ros-upload.xiaohongshu.com",
cookie_domain="xiaohongshu.com",
login_url="https://www.xiaohongshu.com/login",
),
"rednote": _Target(
name="rednote",
edith_host="https://webapi.rednote.com",
creator_host="https://webapi.rednote.com",
home_url="https://www.rednote.com",
# Creator endpoints return an uploadAddr per request; this is only the fallback.
upload_host="https://ros-upload.xiaohongshu.com",
cookie_domain="rednote.com",
login_url="https://www.rednote.com/login",
),
}


def _resolve_target() -> _Target:
raw = os.environ.get("XHS_TARGET", "xiaohongshu").strip().lower()
if not raw:
raw = "xiaohongshu"
if raw not in _TARGETS:
valid = ", ".join(sorted(_TARGETS))
raise ValueError(f"Unknown XHS_TARGET={raw!r}. Valid targets: {valid}")
return _TARGETS[raw]


TARGET = _resolve_target()

EDITH_HOST = TARGET.edith_host
CREATOR_HOST = TARGET.creator_host
HOME_URL = TARGET.home_url
UPLOAD_HOST = TARGET.upload_host
COOKIE_DOMAIN = TARGET.cookie_domain
LOGIN_URL = TARGET.login_url

CHROME_VERSION = "145"

Expand All @@ -17,8 +76,10 @@
APP_ID = "xhs-pc-web"
PLATFORM = "macOS"

# Config directory
# Config directory — shared dir, target-namespaced cache files so xiaohongshu
# and rednote sessions can coexist on disk without overwriting each other.
CONFIG_DIR_NAME = ".xiaohongshu-cli"
COOKIE_FILE = "cookies.json"
TOKEN_CACHE_FILE = "token_cache.json"
INDEX_CACHE_FILE = "index_cache.json"
_SUFFIX = "" if TARGET.name == "xiaohongshu" else f".{TARGET.name}"
COOKIE_FILE = f"cookies{_SUFFIX}.json"
TOKEN_CACHE_FILE = f"token_cache{_SUFFIX}.json"
INDEX_CACHE_FILE = f"index_cache{_SUFFIX}.json"
19 changes: 13 additions & 6 deletions xhs_cli/cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
from pathlib import Path
from typing import Any

from .constants import CONFIG_DIR_NAME, COOKIE_FILE, INDEX_CACHE_FILE, TOKEN_CACHE_FILE
from .constants import (
CONFIG_DIR_NAME,
COOKIE_DOMAIN,
COOKIE_FILE,
INDEX_CACHE_FILE,
TOKEN_CACHE_FILE,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -359,12 +365,12 @@ def _extract_in_process(source: str) -> dict[str, str] | None:
return None

try:
jar = loader(domain_name=".xiaohongshu.com")
jar = loader(domain_name=f".{COOKIE_DOMAIN}")
except Exception as exc:
logger.debug("%s in-process extraction failed: %s", source, exc)
return None

cookies = {cookie.name: cookie.value for cookie in jar if "xiaohongshu.com" in (cookie.domain or "")}
cookies = {cookie.name: cookie.value for cookie in jar if COOKIE_DOMAIN in (cookie.domain or "")}
if cookies.get("a1"):
logger.debug("Loaded XHS cookies from %s in-process", source)
return cookies
Expand All @@ -384,14 +390,15 @@ def _extract_via_subprocess(source: str) -> dict[str, str] | None:
sys.exit(0)

source = sys.argv[1]
domain = sys.argv[2]
loader = getattr(bc3, source, None)
if not loader or not callable(loader):
print(json.dumps({"error": f"Unknown browser: {source}"}))
sys.exit(0)

try:
cj = loader(domain_name=".xiaohongshu.com")
cookies = {c.name: c.value for c in cj if "xiaohongshu.com" in (c.domain or "")}
cj = loader(domain_name=f".{domain}")
cookies = {c.name: c.value for c in cj if domain in (c.domain or "")}
if cookies.get("a1"):
print(json.dumps({"browser": source, "cookies": cookies}))
else:
Expand All @@ -402,7 +409,7 @@ def _extract_via_subprocess(source: str) -> dict[str, str] | None:

try:
result = subprocess.run(
[sys.executable, "-c", extract_script, source],
[sys.executable, "-c", extract_script, source, COOKIE_DOMAIN],
capture_output=True,
text=True,
timeout=15,
Expand Down
8 changes: 5 additions & 3 deletions xhs_cli/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,16 @@ class NoCookieError(XhsApiError):
"""Raised when no valid cookies are found."""

def __init__(self, source: str, details: str = ""):
from .constants import COOKIE_DOMAIN, HOME_URL

if source == "auto":
msg = "No 'a1' cookie found for xiaohongshu.com in any installed browser."
msg = f"No 'a1' cookie found for {COOKIE_DOMAIN} in any installed browser."
else:
msg = f"No 'a1' cookie found for xiaohongshu.com in {source}."
msg = f"No 'a1' cookie found for {COOKIE_DOMAIN} in {source}."
if details:
msg += f"\n{details}"
msg += "\n\nTroubleshooting:\n"
msg += " 1. Open a browser and visit https://www.xiaohongshu.com/\n"
msg += f" 1. Open a browser and visit {HOME_URL}/\n"
msg += " 2. Make sure you are logged in\n"
msg += " 3. Try: xhs login --cookie-source <browser>"
super().__init__(msg)
2 changes: 1 addition & 1 deletion xhs_cli/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@

def parse_note_reference(id_or_url: str) -> tuple[str, str, str]:
"""Extract note ID, xsec_token, and xsec_source from a URL or plain ID."""
if "xiaohongshu.com" in id_or_url:
if "xiaohongshu.com" in id_or_url or "rednote.com" in id_or_url:
from urllib.parse import parse_qs, urlparse

parsed = urlparse(id_or_url)
Expand Down
3 changes: 1 addition & 2 deletions xhs_cli/formatter_renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@
normalize_user_posts,
normalize_users,
)
from .constants import HOME_URL
from .formatter_utils import coerce_int, console, format_count, print_error, print_info

HOME_URL = "https://www.xiaohongshu.com"


def _build_note_url(
note_id: str,
Expand Down
4 changes: 2 additions & 2 deletions xhs_cli/qr_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
from typing import Any

from .client import XhsClient
from .constants import COOKIE_DOMAIN, LOGIN_URL
from .cookies import save_cookies
from .exceptions import NeedVerifyError, XhsApiError

logger = logging.getLogger(__name__)

LOGIN_URL = "https://www.xiaohongshu.com/login"
QR_CREATE_ENDPOINT = "/api/sns/web/v1/login/qrcode/create"
QR_USERINFO_ENDPOINT = "/api/qrcode/userinfo"
QR_STATUS_ENDPOINT = "/api/sns/web/v1/login/qrcode/status"
Expand Down Expand Up @@ -111,7 +111,7 @@ def _normalize_browser_cookies(raw_cookies: list[dict[str, Any]]) -> dict[str, s
continue
if name not in BROWSER_EXPORT_COOKIE_NAMES:
continue
if not isinstance(domain, str) or "xiaohongshu.com" not in domain:
if not isinstance(domain, str) or COOKIE_DOMAIN not in domain:
continue
cookies[name] = value
return cookies
Expand Down