diff --git a/README.md b/README.md index 3ad8b5f..655fd39 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A CLI for Xiaohongshu (小纒书) β€” search, read, interact, and post via rever - πŸ” **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 1` or `xhs comments 1` - πŸ“° **Feed** β€” recommendation feed, hot/trending by category - πŸ‘₯ **Social** β€” follow/unfollow, favorites - πŸ‘ **Interactions** β€” like, favorite, comment, reply, delete @@ -78,8 +79,10 @@ 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 # 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 "" # View comments β€” paste URL to cache/reuse xsec_token xhs comments "" --all # Fetch ALL comments (auto-paginate all pages) xhs comments "" --all --json # All comments as JSON @@ -97,6 +100,11 @@ 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 +xhs search "黑丝" +xhs read 1 +xhs comments 1 + # ─── Social ─────────────────────────────────────── xhs favorites # My bookmarked notes (current user) xhs favorites # Other user's bookmarked notes @@ -143,6 +151,14 @@ 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. +### Short-Index Navigation + +After any listing command such as `search`, `feed`, `hot`, or `user-posts`, the CLI stores the latest ordered note list in `~/.xiaohongshu-cli/index_cache.json`. + +- `xhs read ` opens the Nth note from the latest listing +- `xhs comments ` opens comments for the Nth note from the latest listing +- Empty listings clear the index cache, so old results are not reused by accident + ## Environment Variables | Variable | Default | Description | diff --git a/SKILL.md b/SKILL.md index 487bf5f..cb0692c 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.2" tags: - xiaohongshu - xhs @@ -85,8 +85,8 @@ 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 comments ` | Get comments (xsec_token required β€” paste URL) | `xhs comments "https://...?xsec_token=..."` | +| `xhs read ` | Read a note by ID, URL, or short index | `xhs read 1` / `xhs read "https://...?xsec_token=xxx"` | +| `xhs comments ` | Get comments by ID, URL, or short index | `xhs comments 1` / `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` | | `xhs user ` | View user profile | `xhs user 5f2e123` | @@ -181,6 +181,11 @@ xhs comments "$NOTE_URL" --all --json | jq '[.data.comments[] | select(.content # Browse recommendation feed xhs feed --yaml +# Interactive short-index workflow +xhs search "ζ—…θ‘Œ" +xhs read 1 +xhs comments 1 + # Browse trending by category xhs hot -c food --yaml xhs hot -c travel --yaml diff --git a/tests/test_cli.py b/tests/test_cli.py index 98713f5..9e059e2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,26 @@ 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.""" @@ -239,3 +259,123 @@ def test_feed_rich_output_shortens_visible_links(self, monkeypatch): 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.commands.reading.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.commands.reading.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.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_search_empty_results_clear_previous_index(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": []}, xsec_source="pc_search") + + assert saved == [[]] + + def test_user_posts_saves_index_entries(self, monkeypatch): + saved = [] + monkeypatch.setattr("xhs_cli.commands.reading.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": ""}, + ]] diff --git a/tests/test_cookies.py b/tests/test_cookies.py index bbbeba0..aae7636 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -13,10 +13,13 @@ 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, ) @@ -132,3 +135,48 @@ def test_expired_note_context_is_not_returned(self, tmp_config_dir): ) 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": "", + } diff --git a/tests/test_smoke.py b/tests/test_smoke.py index ed50282..d3b4954 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -197,3 +197,16 @@ def test_search_read_comments_feed_read_reread(self): 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 diff --git a/xhs_cli/commands/reading.py b/xhs_cli/commands/reading.py index e1386a4..4fc130b 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_note_context +from ..cookies import cache_note_context, get_note_by_index, save_note_index from ..formatter import ( maybe_print_structured, parse_note_reference, @@ -35,6 +35,53 @@ def _cache_tokens_from_items(data: dict, *, xsec_source: str) -> None: if note_id and token: cache_note_context(note_id, token, xsec_source) + +def _save_index_from_items(data: dict, *, xsec_source: str) -> None: + """Persist ordered note references from list-style responses.""" + 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, + "xsec_source": xsec_source if token else "", + }) + save_note_index(entries) + + +def _save_index_from_notes(notes: list[dict]) -> None: + """Persist ordered note references from paged note payloads.""" + save_note_index([ + { + "note_id": str(note.get("note_id", "")).strip(), + "xsec_token": str(note.get("xsec_token", "")).strip(), + "xsec_source": "", + } + for note in notes + if str(note.get("note_id", "")).strip() + ]) + + +def _resolve_note_reference(id_or_url: str, *, xsec_token: str = "") -> tuple[str, str, str]: + """Resolve a note reference from URL/ID or last listing index.""" + if id_or_url.isdigit(): + entry = get_note_by_index(int(id_or_url)) + if entry is None: + raise click.UsageError( + f"Index {id_or_url} not found β€” run a listing command first (search / feed / hot / user-posts)" + ) + return ( + entry["note_id"], + xsec_token or entry.get("xsec_token", ""), + entry.get("xsec_source", ""), + ) + + note_id, url_token, url_source = parse_note_reference(id_or_url) + return note_id, xsec_token or url_token, url_source + # ─── Sort mapping ──────────────────────────────────────────────────────────── SORT_MAP = { @@ -67,6 +114,7 @@ def _search_action(client): note_type=TYPE_MAP[note_type], ) _cache_tokens_from_items(result, xsec_source="pc_search") + _save_index_from_items(result, xsec_source="pc_search") return result handle_command( @@ -84,9 +132,8 @@ 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, url_source = parse_note_reference(id_or_url) - token = xsec_token or url_token + """Read a note by ID, URL, or short index.""" + note_id, token, url_source = _resolve_note_reference(id_or_url, xsec_token=xsec_token) xsec_source = url_source or "pc_feed" if token: cache_note_context(note_id, token, xsec_source) @@ -114,9 +161,8 @@ def _read_action(client): @structured_output_options @click.pass_context def comments(ctx, id_or_url: str, cursor: str, xsec_token: str, fetch_all: bool, as_json: bool, as_yaml: bool): - """View comments on a note. Use --all to fetch all pages.""" - note_id, url_token, url_source = parse_note_reference(id_or_url) - token = xsec_token or url_token + """View comments on a note by ID, URL, or short index.""" + note_id, token, url_source = _resolve_note_reference(id_or_url, xsec_token=xsec_token) xsec_source = url_source or "pc_feed" if token: cache_note_context(note_id, token, xsec_source) @@ -170,6 +216,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) + page = normalize_paged_notes(data) + _save_index_from_notes(page["notes"]) + return data + def _render_user_posts(data): page = normalize_paged_notes(data) render_user_posts(page["notes"]) @@ -178,7 +230,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, @@ -193,6 +245,7 @@ def feed(ctx, as_json: bool, as_yaml: bool): def _feed_action(client): result = client.get_home_feed() _cache_tokens_from_items(result, xsec_source="pc_feed") + _save_index_from_items(result, xsec_source="pc_feed") return result handle_command( @@ -279,6 +332,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, xsec_source="pc_feed") + _save_index_from_items(result, xsec_source="pc_feed") 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 ba73c97..96665a4 100644 --- a/xhs_cli/cookies.py +++ b/xhs_cli/cookies.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Any -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__) @@ -44,6 +44,11 @@ def get_token_cache_path() -> Path: return get_config_dir() / TOKEN_CACHE_FILE +def get_index_cache_path() -> Path: + """Get note index cache file path.""" + return get_config_dir() / INDEX_CACHE_FILE + + def load_saved_cookies() -> dict[str, str] | None: """Load cookies from local storage.""" cookie_path = get_cookie_path() @@ -240,6 +245,54 @@ def invalidate_note_context(note_id: str) -> None: logger.debug("Invalidated cached note context for %s", note_id) +def _normalize_index_entry(value: Any) -> dict[str, str] | None: + if not isinstance(value, dict): + return None + + note_id = str(value.get("note_id", "")).strip() + if not note_id: + return None + + return { + "note_id": note_id, + "xsec_token": str(value.get("xsec_token", "")).strip(), + "xsec_source": str(value.get("xsec_source", "")).strip(), + } + + +def save_note_index(items: list[dict[str, str]]) -> None: + """Persist the latest ordered note index for short-index navigation.""" + path = get_index_cache_path() + normalized = [ + entry + for entry in (_normalize_index_entry(item) for item in items) + if entry + ] + path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False)) + path.chmod(0o600) + logger.debug("Saved note index with %d entries", len(normalized)) + + +def get_note_by_index(index: int) -> dict[str, str] | None: + """Resolve a 1-based short index to a cached note reference.""" + if index <= 0: + return None + + path = get_index_cache_path() + if not path.exists(): + return None + + try: + data = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + + if not isinstance(data, list) or index > len(data): + return None + + return _normalize_index_entry(data[index - 1]) + + def cache_xsec_token(note_id: str, xsec_token: str) -> None: """Backwards-compatible wrapper for token-only caching.""" cache_note_context(note_id, xsec_token)