diff --git a/README.md b/README.md index 69a6168..ca1082f 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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`. @@ -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: @@ -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 ` +**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. @@ -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、按分类浏览热门 @@ -438,6 +457,16 @@ Cookie 保存后有效期 **7 天**,超时后自动尝试从浏览器刷新。 `xhs login` 会自动尝试所有已安装浏览器,使用第一个有有效 Cookie 的浏览器。也可用 `--cookie-source ` 指定浏览器,或 `--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` diff --git a/tests/test_qr_login.py b/tests/test_qr_login.py index 4f397b9..964da3b 100644 --- a/tests/test_qr_login.py +++ b/tests/test_qr_login.py @@ -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 == { diff --git a/xhs_cli/constants.py b/xhs_cli/constants.py index 9286786..602d326 100644 --- a/xhs_cli/constants.py +++ b/xhs_cli/constants.py @@ -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" @@ -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" diff --git a/xhs_cli/cookies.py b/xhs_cli/cookies.py index 96665a4..6b78a4f 100644 --- a/xhs_cli/cookies.py +++ b/xhs_cli/cookies.py @@ -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__) @@ -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 @@ -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: @@ -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, diff --git a/xhs_cli/exceptions.py b/xhs_cli/exceptions.py index a2f2715..87efa2f 100644 --- a/xhs_cli/exceptions.py +++ b/xhs_cli/exceptions.py @@ -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 " super().__init__(msg) diff --git a/xhs_cli/formatter.py b/xhs_cli/formatter.py index d7d8766..e3d5f75 100644 --- a/xhs_cli/formatter.py +++ b/xhs_cli/formatter.py @@ -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) diff --git a/xhs_cli/formatter_renderers.py b/xhs_cli/formatter_renderers.py index c29669a..0c680fc 100644 --- a/xhs_cli/formatter_renderers.py +++ b/xhs_cli/formatter_renderers.py @@ -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, diff --git a/xhs_cli/qr_login.py b/xhs_cli/qr_login.py index e7b189a..344e58f 100644 --- a/xhs_cli/qr_login.py +++ b/xhs_cli/qr_login.py @@ -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" @@ -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