diff --git a/.gitignore b/.gitignore index ee72d59..c96cfdb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ .pytest_cache/ .playwright-mcp/ xhs_login_page.png +.idea diff --git a/README.md b/README.md index b04e1c1..526541e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A CLI for Xiaohongshu (小红书) — search, read, interact, and post via rever - ✍️ **Creator** — post image notes, my-notes list, delete - 🔔 **Notifications** — unread count, mentions, likes, new followers - 🛡️ **Anti-detection** — consistent macOS Chrome fingerprint, `sec-ch-ua` alignment, session-stable browser identity, Gaussian jitter, captcha cooldown, exponential backoff +- 🔢 **Short-index navigation** — listing commands assign temporary indices; use `xhs read 2` to open result #2 - 📊 **Structured output** — commands support `--yaml` and `--json`; non-TTY stdout defaults to YAML - 📦 **Stable envelope** — see [SCHEMA.md](./SCHEMA.md) for `ok/schema_version/data/error` @@ -78,7 +79,8 @@ xhs search-user "用户名" # Search users xhs topics "美食" # Search hashtags/topics # ─── Reading ────────────────────────────────────── -xhs read # Read a note (API only) +xhs read 2 # Read by short index (from last search/feed/hot/user-posts) +xhs read # Read a note by ID xhs read "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" # Read by URL (uses URL token) xhs comments "" # View comments — paste URL to cache/reuse xsec_token xhs comments "" --all # Fetch ALL comments (auto-paginate all pages) @@ -126,6 +128,28 @@ xhs notifications --type likes # 赞和收藏 notifications xhs notifications --type connections # 新增关注 notifications ``` +## Short-Index Navigation + +After any listing command (`search`, `feed`, `hot`, `user-posts`), each result is +automatically assigned a short numeric index. Use `xhs read ` to open result N — +no need to copy long note IDs: + +```bash +xhs search "旅行攻略" # shows table with # 1, 2, 3 … +xhs read 2 # opens the 2nd result (xsec_token reused automatically) + +xhs hot -c food +xhs read 1 # opens the 1st trending food note +``` + +The index resets with every new listing command and is stored in +`~/.xiaohongshu-cli/index_cache.json`. The standard ID/URL form still works: + +```bash +xhs read abc123 +xhs read "https://www.xiaohongshu.com/explore/abc123?xsec_token=xxx" +``` + ## Authentication xiaohongshu-cli supports multiple authentication methods: diff --git a/SKILL.md b/SKILL.md index 487bf5f..217fc25 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,7 +2,7 @@ name: xiaohongshu-cli description: Use xiaohongshu-cli for ALL Xiaohongshu (Little Red Book, 小红书) operations — searching notes, reading content, browsing users, liking, collecting, commenting, following, and posting. Invoke whenever the user requests any Xiaohongshu interaction. author: jackwener -version: "0.5.0" +version: "0.6.0" tags: - xiaohongshu - xhs @@ -85,7 +85,7 @@ Payloads live under `.data`. | Command | Description | Example | |---------|-------------|---------| | `xhs search ` | Search notes | `xhs search "美食" --sort popular --type video` | -| `xhs read ` | Read a note (URL auto-extracts xsec_token) | `xhs read "https://...?xsec_token=xxx"` | +| `xhs read ` | Read a note by ID, URL, or listing index | `xhs read 2` / `xhs read "https://...?xsec_token=xxx"` | | `xhs comments ` | Get comments (xsec_token required — paste URL) | `xhs comments "https://...?xsec_token=..."` | | `xhs comments --all` | Get ALL comments (auto-paginate) | `xhs comments "" --all --json` | | `xhs sub-comments ` | Get replies to comment | `xhs sub-comments abc 123` | @@ -141,11 +141,33 @@ Payloads live under `.data`. ### Search → Read → Like pipeline ```bash +# Option A: use short index (simplest for interactive use) +xhs search "美食推荐" +xhs read 1 # read the first result +xhs like 1 # NOT supported yet — still need note_id for interactions + +# Option B: extract note_id from structured output (for scripting) NOTE_ID=$(xhs search "美食推荐" --json | jq -r '.data.items[0].id') xhs read "$NOTE_ID" --json | jq '.data' xhs like "$NOTE_ID" ``` +### Short-index workflow (interactive) + +After any listing command (`search`, `feed`, `hot`, `user-posts`), each result +is automatically assigned a short index. Use `xhs read ` to open result N: + +```bash +xhs search "旅行" +xhs read 3 # open the 3rd search result (xsec_token reused automatically) + +xhs hot -c food +xhs read 1 # open the 1st hot note +``` + +The index is stored in `~/.xiaohongshu-cli/index_cache.json` and **reset on +every listing command**. + ### Browse trending food notes ```bash diff --git a/tests/test_cli.py b/tests/test_cli.py index 994187c..ec456dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -187,3 +187,138 @@ def test_comments_rich_output_handles_string_reply_counts(self, monkeypatch): assert result.exit_code == 0 assert "tester" in result.output assert "2 replies" in result.output + + +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": [], + } + } + ] +} + +FAKE_SEARCH_RESPONSE = { + "items": [ + { + "id": "note_abc", + "xsec_token": "tok_abc", + "note_card": { + "title": "搜索结果一", + "user": {"nickname": "Author1"}, + "interact_info": {"liked_count": "10"}, + "type": "image", + }, + }, + { + "id": "note_def", + "xsec_token": "tok_def", + "note_card": { + "title": "搜索结果二", + "user": {"nickname": "Author2"}, + "interact_info": {"liked_count": "20"}, + "type": "video", + }, + }, + ], + "has_more": False, +} + + +class TestReadByShortIndex: + """Test `xhs read ` short-index feature.""" + + def test_read_help_mentions_index(self): + result = runner.invoke(cli, ["read", "--help"]) + assert result.exit_code == 0 + assert "index" in result.output.lower() + + def test_read_index_not_found_when_no_cache(self, monkeypatch, tmp_path): + monkeypatch.setattr("xhs_cli.cookies.get_index_cache_path", lambda: tmp_path / "index_cache.json") + monkeypatch.setattr("xhs_cli.commands.reading.get_note_by_index", + lambda idx: None) + + result = runner.invoke(cli, ["read", "5"]) + assert result.exit_code != 0 + assert "5" in result.output or "5" in (result.exception and str(result.exception) or "") + + def test_read_index_resolves_to_note_id(self, monkeypatch): + monkeypatch.setattr( + "xhs_cli.commands.reading.get_note_by_index", + lambda idx: {"note_id": "note_abc", "xsec_token": "tok_abc"} if idx == 1 else None, + ) + + called = {} + + def fake_run_client_action(ctx, action): + from unittest.mock import MagicMock + mock_client = MagicMock() + mock_client.get_note_detail.return_value = FAKE_NOTE_RESPONSE + action(mock_client) + call_args = mock_client.get_note_detail.call_args + called["note_id"] = call_args.args[0] + called["xsec_token"] = call_args.kwargs.get("xsec_token") + return FAKE_NOTE_RESPONSE + + monkeypatch.setattr("xhs_cli.commands._common.run_client_action", fake_run_client_action) + + result = runner.invoke(cli, ["read", "1", "--yaml"]) + assert result.exit_code == 0 + payload = yaml.safe_load(result.output) + assert payload["ok"] is True + assert called["note_id"] == "note_abc" + assert called["xsec_token"] == "tok_abc" + + def test_read_index_out_of_range_gives_usage_error(self, monkeypatch): + monkeypatch.setattr( + "xhs_cli.commands.reading.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_save_index_from_items_extracts_note_ids(self, monkeypatch): + from xhs_cli.commands.reading import _save_index_from_items + + saved = [] + monkeypatch.setattr("xhs_cli.commands.reading.save_note_index", lambda items: saved.append(items)) + + _save_index_from_items(FAKE_SEARCH_RESPONSE) + + assert len(saved) == 1 + assert saved[0][0]["note_id"] == "note_abc" + assert saved[0][1]["note_id"] == "note_def" + + def test_save_index_from_items_preserves_tokens(self, monkeypatch): + from xhs_cli.commands.reading import _save_index_from_items + + saved = [] + monkeypatch.setattr("xhs_cli.commands.reading.save_note_index", lambda items: saved.append(items)) + + _save_index_from_items(FAKE_SEARCH_RESPONSE) + + assert saved[0][0]["xsec_token"] == "tok_abc" + assert saved[0][1]["xsec_token"] == "tok_def" + + def test_save_index_from_items_skips_empty_response(self, monkeypatch): + from xhs_cli.commands.reading import _save_index_from_items + + saved = [] + monkeypatch.setattr("xhs_cli.commands.reading.save_note_index", lambda items: saved.append(items)) + + _save_index_from_items({"items": []}) + + assert saved == [] # nothing saved for empty results diff --git a/tests/test_cookies.py b/tests/test_cookies.py index d0444a9..41d5408 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -7,8 +7,10 @@ clear_cookies, cookies_to_string, get_cookies, + get_note_by_index, load_saved_cookies, save_cookies, + save_note_index, ) @@ -96,3 +98,76 @@ def test_force_refresh_bypasses_saved_cookies(self, monkeypatch): assert browser == "chrome" assert cookies == {"a1": "fresh"} assert saved == [{"a1": "fresh"}] + + +@pytest.fixture +def tmp_index_dir(tmp_path, monkeypatch): + """Redirect index cache to a temporary directory.""" + monkeypatch.setattr("xhs_cli.cookies.get_config_dir", lambda: tmp_path) + monkeypatch.setattr("xhs_cli.cookies.get_index_cache_path", lambda: tmp_path / "index_cache.json") + return tmp_path + + +class TestSaveNoteIndex: + def test_saves_entries(self, tmp_index_dir): + items = [ + {"note_id": "aaa111", "xsec_token": "tok_a"}, + {"note_id": "bbb222", "xsec_token": "tok_b"}, + ] + save_note_index(items) + assert (tmp_index_dir / "index_cache.json").exists() + + def test_file_permissions(self, tmp_index_dir): + save_note_index([{"note_id": "x", "xsec_token": ""}]) + stat = (tmp_index_dir / "index_cache.json").stat() + assert stat.st_mode & 0o777 == 0o600 + + def test_overwrites_previous(self, tmp_index_dir): + save_note_index([{"note_id": "old", "xsec_token": ""}]) + save_note_index([{"note_id": "new1", "xsec_token": ""}, {"note_id": "new2", "xsec_token": ""}]) + assert get_note_by_index(1)["note_id"] == "new1" + assert get_note_by_index(3) is None + + +class TestGetNoteByIndex: + def test_first_entry(self, tmp_index_dir): + save_note_index([ + {"note_id": "aaa111", "xsec_token": "tok_a"}, + {"note_id": "bbb222", "xsec_token": "tok_b"}, + ]) + entry = get_note_by_index(1) + assert entry == {"note_id": "aaa111", "xsec_token": "tok_a"} + + def test_last_entry(self, tmp_index_dir): + save_note_index([ + {"note_id": "aaa111", "xsec_token": "tok_a"}, + {"note_id": "bbb222", "xsec_token": "tok_b"}, + ]) + entry = get_note_by_index(2) + assert entry == {"note_id": "bbb222", "xsec_token": "tok_b"} + + def test_out_of_range_returns_none(self, tmp_index_dir): + save_note_index([{"note_id": "aaa111", "xsec_token": ""}]) + assert get_note_by_index(99) is None + + def test_zero_index_returns_none(self, tmp_index_dir): + save_note_index([{"note_id": "aaa111", "xsec_token": ""}]) + assert get_note_by_index(0) is None + + def test_no_cache_file_returns_none(self, tmp_index_dir): + assert get_note_by_index(1) is None + + def test_corrupt_json_returns_none(self, tmp_index_dir): + (tmp_index_dir / "index_cache.json").write_text("not json!!!") + assert get_note_by_index(1) is None + + def test_preserves_xsec_token(self, tmp_index_dir): + save_note_index([{"note_id": "n1", "xsec_token": "ABCDE12345"}]) + entry = get_note_by_index(1) + assert entry["xsec_token"] == "ABCDE12345" + + def test_empty_token_allowed(self, tmp_index_dir): + save_note_index([{"note_id": "n1", "xsec_token": ""}]) + entry = get_note_by_index(1) + assert entry["note_id"] == "n1" + assert entry["xsec_token"] == "" diff --git a/uv.lock b/uv.lock index e4e4284..cbdea5a 100644 --- a/uv.lock +++ b/uv.lock @@ -1453,9 +1453,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/b9/a80d1ed4d115dac8e2ac08d16af046a77ab58e3d186e22395bf2add24090/WMI-1.5.1-py2.py3-none-any.whl", hash = "sha256:1d6b085e5c445141c475476000b661f60fff1aaa19f76bf82b7abb92e0ff4942", size = 28912, upload-time = "2020-04-28T08:22:56.055Z" }, ] +[[package]] +name = "xhshow" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycryptodome" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/9a/ed544bea5b6a3702fc232eb774817d7f03a678b5f2ac3d67d3c7698695e6/xhshow-0.1.9.tar.gz", hash = "sha256:5a17cb510e9ab3ca61ef8ce00bd4cae6cbce39bb7b5f8ef38a5a980011883245", size = 55126, upload-time = "2026-02-17T08:05:27.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/c0/2783314af317b7207422e94f74302dbe32c5c9a1641bcfe11c4c073b0b04/xhshow-0.1.9-py3-none-any.whl", hash = "sha256:c4b0d38f4746b8a3f9c08fcfe4628f81c887b27c76b7ba73fa61ff990d1839dc", size = 31289, upload-time = "2026-02-17T08:05:26.58Z" }, +] + [[package]] name = "xiaohongshu-cli" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "browser-cookie3" }, @@ -1466,6 +1478,7 @@ dependencies = [ { name = "pyyaml" }, { name = "qrcode" }, { name = "rich" }, + { name = "xhshow" }, ] [package.optional-dependencies] @@ -1490,5 +1503,6 @@ requires-dist = [ { name = "qrcode", specifier = ">=7.0" }, { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, + { name = "xhshow", specifier = ">=0.1.9" }, ] provides-extras = ["dev"] diff --git a/xhs_cli/commands/reading.py b/xhs_cli/commands/reading.py index 0e702d7..977b6c1 100644 --- a/xhs_cli/commands/reading.py +++ b/xhs_cli/commands/reading.py @@ -3,7 +3,7 @@ import click from ..command_normalizers import normalize_paged_notes -from ..cookies import cache_xsec_token +from ..cookies import cache_xsec_token, get_note_by_index, save_note_index from ..formatter import ( maybe_print_structured, parse_note_url, @@ -23,12 +23,7 @@ # ─── Token propagation ───────────────────────────────────────────────────── def _cache_tokens_from_items(data: dict) -> None: - """Auto-cache xsec_token from search/feed API results. - - Each note item may carry its own xsec_token bound to the source - (search, feed, explore). Caching them lets a subsequent - `xhs read ` use the correct token automatically. - """ + """Auto-cache xsec_token from search/feed API results.""" for item in data.get("items", []): note_card = item.get("note_card", {}) note_id = item.get("id", note_card.get("note_id", "")) @@ -36,6 +31,23 @@ def _cache_tokens_from_items(data: dict) -> None: if note_id and token: cache_xsec_token(note_id, token) + +def _save_index_from_items(data: dict) -> None: + """Persist short-index cache from search/feed/hot results. + + Saves a list of {note_id, xsec_token} so that `xhs read ` works + regardless of whether stdout is a TTY. + """ + entries = [] + for item in data.get("items", []): + note_card = item.get("note_card", {}) + note_id = item.get("id", note_card.get("note_id", "")) + token = item.get("xsec_token", note_card.get("xsec_token", "")) + if note_id: + entries.append({"note_id": note_id, "xsec_token": token}) + if entries: + save_note_index(entries) + # ─── Sort mapping ──────────────────────────────────────────────────────────── SORT_MAP = { @@ -68,6 +80,7 @@ def _search_action(client): note_type=TYPE_MAP[note_type], ) _cache_tokens_from_items(result) + _save_index_from_items(result) return result handle_command( @@ -85,9 +98,21 @@ def _search_action(client): @structured_output_options @click.pass_context def read(ctx, id_or_url: str, xsec_token: str, as_json: bool, as_yaml: bool): - """Read a note by ID or URL.""" - note_id, url_token = parse_note_url(id_or_url) - token = xsec_token or url_token + """Read a note by ID, URL, or listing index (e.g. 2).""" + # Allow short numeric index from the last listing command + if id_or_url.isdigit(): + entry = get_note_by_index(int(id_or_url)) + if entry is None: + import click as _click + raise _click.UsageError( + f"Index {id_or_url} not found — run a listing command first (search / feed / hot / user-posts)" + ) + note_id = entry["note_id"] + token = xsec_token or entry.get("xsec_token", "") + else: + note_id, url_token = parse_note_url(id_or_url) + token = xsec_token or url_token + if token: cache_xsec_token(note_id, token) @@ -156,6 +181,12 @@ def user(ctx, user_id: str, as_json: bool, as_yaml: bool): @click.pass_context def user_posts(ctx, user_id: str, cursor: str, as_json: bool, as_yaml: bool): """List a user's published notes.""" + def _user_posts_action(client): + data = client.get_user_notes(user_id, cursor=cursor) + notes = data.get("notes", []) + save_note_index([{"note_id": n.get("note_id", ""), "xsec_token": ""} for n in notes if n.get("note_id")]) + return data + def _render_user_posts(data): page = normalize_paged_notes(data) render_user_posts(page["notes"]) @@ -164,7 +195,7 @@ def _render_user_posts(data): handle_command( ctx, - action=lambda client: client.get_user_notes(user_id, cursor=cursor), + action=_user_posts_action, render=_render_user_posts, as_json=as_json, as_yaml=as_yaml, @@ -179,6 +210,7 @@ def feed(ctx, as_json: bool, as_yaml: bool): def _feed_action(client): result = client.get_home_feed() _cache_tokens_from_items(result) + _save_index_from_items(result) return result handle_command( @@ -265,6 +297,7 @@ def hot(ctx, category: str, as_json: bool, as_yaml: bool): def _hot_action(client): result = client.get_hot_feed(HOT_CATEGORIES[category]) _cache_tokens_from_items(result) + _save_index_from_items(result) return result handle_command( diff --git a/xhs_cli/constants.py b/xhs_cli/constants.py index b66b931..9286786 100644 --- a/xhs_cli/constants.py +++ b/xhs_cli/constants.py @@ -21,3 +21,4 @@ CONFIG_DIR_NAME = ".xiaohongshu-cli" COOKIE_FILE = "cookies.json" TOKEN_CACHE_FILE = "token_cache.json" +INDEX_CACHE_FILE = "index_cache.json" diff --git a/xhs_cli/cookies.py b/xhs_cli/cookies.py index c17c9c1..689e88a 100644 --- a/xhs_cli/cookies.py +++ b/xhs_cli/cookies.py @@ -10,7 +10,7 @@ import time from pathlib import Path -from .constants import CONFIG_DIR_NAME, COOKIE_FILE, TOKEN_CACHE_FILE +from .constants import CONFIG_DIR_NAME, COOKIE_FILE, INDEX_CACHE_FILE, TOKEN_CACHE_FILE logger = logging.getLogger(__name__) @@ -125,6 +125,44 @@ def cache_xsec_token(note_id: str, xsec_token: str) -> None: logger.debug("Cached xsec_token for note %s", note_id) +def get_index_cache_path() -> Path: + """Get note index cache file path.""" + return get_config_dir() / INDEX_CACHE_FILE + + +def save_note_index(items: list[dict[str, str]]) -> None: + """Persist an ordered list of {note_id, xsec_token} for short-index lookup. + + Overwrites the cache each time so the index always reflects the most + recent listing command (search / feed / hot / user-posts …). + """ + path = get_index_cache_path() + path.write_text(json.dumps(items, indent=2)) + path.chmod(0o600) + logger.debug("Saved note index with %d entries", len(items)) + + +def get_note_by_index(index: int) -> dict[str, str] | None: + """Return the {note_id, xsec_token} entry for a 1-based index. + + Returns None when the index is out of range or the cache is missing. + """ + path = get_index_cache_path() + if not path.exists(): + return None + try: + items = json.loads(path.read_text()) + if not isinstance(items, list): + return None + if 1 <= index <= len(items): + entry = items[index - 1] + if isinstance(entry, dict) and "note_id" in entry and "xsec_token" in entry: + return entry + except (OSError, json.JSONDecodeError): + pass + return None + + def get_cached_xsec_token(note_id: str) -> str: """Get a cached xsec token for a note ID.""" entry = load_token_cache().get(note_id, "")