From 84cfbeaf64aa0a95a7ac800e4881a5b2a9896529 Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Thu, 12 Mar 2026 22:54:48 +0000 Subject: [PATCH 1/2] feat: add short-index support for 'xhs read' Introduce short-index navigation so users can open listed notes with `xhs read `. Persist an ordered index from listing commands (search/feed/hot/user-posts) and resolve numeric indices in the `read` command to note_id + xsec_token. Key changes: - Add INDEX_CACHE_FILE constant and .idea to .gitignore. - Implement save_note_index() and get_note_by_index() in xhs_cli/cookies.py (reads/writes index cache, enforces 0o600 perms). - Add _save_index_from_items() and wire it into search, feed, hot, and user-posts in xhs_cli/commands/reading.py; allow `read` to accept numeric indices and raise a usage error when missing. - Add comprehensive tests for CLI index behavior and cookies index handling (tests/test_cli.py, tests/test_cookies.py). - Update docs (README.md and SKILL.md) to document short-index workflow and bump package version to 0.6.0. - Update uv.lock to reflect the version bump and add the xhshow dependency. This change improves interactive ergonomics by avoiding manual copying of long note IDs when opening results from recent listings. --- .gitignore | 1 + README.md | 26 +++++++- SKILL.md | 26 +++++++- tests/test_cli.py | 124 ++++++++++++++++++++++++++++++++++++ tests/test_cookies.py | 75 ++++++++++++++++++++++ uv.lock | 16 ++++- xhs_cli/commands/reading.py | 55 ++++++++++++---- xhs_cli/constants.py | 1 + xhs_cli/cookies.py | 36 ++++++++++- 9 files changed, 344 insertions(+), 16 deletions(-) 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..8ce2e35 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -187,3 +187,127 @@ 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, + ) + monkeypatch.setattr( + "xhs_cli.commands._common.run_client_action", + lambda ctx, action: FAKE_NOTE_RESPONSE, + ) + + result = runner.invoke(cli, ["read", "1", "--yaml"]) + assert result.exit_code == 0 + import yaml + payload = yaml.safe_load(result.output) + assert payload["ok"] is True + + 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..8392ccc 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,40 @@ 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 1 <= index <= len(items): + return items[index - 1] + 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, "") From cba1cb822b0138bb894694ed2c54bc59a35e7c3d Mon Sep 17 00:00:00 2001 From: Pleasurecruise <3196812536@qq.com> Date: Thu, 12 Mar 2026 23:11:38 +0000 Subject: [PATCH 2/2] fix: validate cookie entries; strengthen read test Harden get_note_by_index to ensure the cookies file parses as a list and that each entry is a dict containing both note_id and xsec_token, returning None for malformed data to avoid crashes. Update the read-by-index test to replace the simple run_client_action stub with a fake that captures the note_id and xsec_token passed to the client, and assert those values are forwarded correctly along with the existing YAML output checks. --- tests/test_cli.py | 21 ++++++++++++++++----- xhs_cli/cookies.py | 6 +++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ce2e35..ec456dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -258,16 +258,27 @@ def test_read_index_resolves_to_note_id(self, monkeypatch): "xhs_cli.commands.reading.get_note_by_index", lambda idx: {"note_id": "note_abc", "xsec_token": "tok_abc"} if idx == 1 else None, ) - monkeypatch.setattr( - "xhs_cli.commands._common.run_client_action", - lambda ctx, action: FAKE_NOTE_RESPONSE, - ) + + 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 - import yaml 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( diff --git a/xhs_cli/cookies.py b/xhs_cli/cookies.py index 8392ccc..689e88a 100644 --- a/xhs_cli/cookies.py +++ b/xhs_cli/cookies.py @@ -152,8 +152,12 @@ def get_note_by_index(index: int) -> dict[str, str] | None: return None try: items = json.loads(path.read_text()) + if not isinstance(items, list): + return None if 1 <= index <= len(items): - return items[index - 1] + 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