Skip to content
Merged
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <note_id> # 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 "<url>" # View comments — paste URL to cache/reuse xsec_token
xhs comments "<url>" --all # Fetch ALL comments (auto-paginate all pages)
xhs comments "<url>" --all --json # All comments as JSON
Expand All @@ -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 <user_id> # Other user's bookmarked notes
Expand Down Expand Up @@ -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 <N>` opens the Nth note from the latest listing
- `xhs comments <N>` 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 |
Expand Down
11 changes: 8 additions & 3 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,8 +85,8 @@ Payloads live under `.data`.
| Command | Description | Example |
|---------|-------------|---------|
| `xhs search <keyword>` | Search notes | `xhs search "美食" --sort popular --type video` |
| `xhs read <id_or_url>` | Read a note (URL auto-extracts xsec_token) | `xhs read "https://...?xsec_token=xxx"` |
| `xhs comments <id_or_url>` | Get comments (xsec_token required — paste URL) | `xhs comments "https://...?xsec_token=..."` |
| `xhs read <id_or_url_or_index>` | Read a note by ID, URL, or short index | `xhs read 1` / `xhs read "https://...?xsec_token=xxx"` |
| `xhs comments <id_or_url_or_index>` | Get comments by ID, URL, or short index | `xhs comments 1` / `xhs comments "https://...?xsec_token=..."` |
| `xhs comments <id_or_url> --all` | Get ALL comments (auto-paginate) | `xhs comments "<url>" --all --json` |
| `xhs sub-comments <note_id> <comment_id>` | Get replies to comment | `xhs sub-comments abc 123` |
| `xhs user <user_id>` | View user profile | `xhs user 5f2e123` |
Expand Down Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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": ""},
]]
48 changes: 48 additions & 0 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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": "",
}
13 changes: 13 additions & 0 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading