Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ dist/
.pytest_cache/
.playwright-mcp/
xhs_login_page.png
.idea
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -78,7 +79,8 @@ xhs search-user "用户名" # Search users
xhs topics "美食" # Search hashtags/topics

# ─── Reading ──────────────────────────────────────
xhs read <note_id> # Read a note (API only)
xhs read 2 # Read by short index (from last search/feed/hot/user-posts)
xhs read <note_id> # Read a note by ID
xhs read "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" # Read by URL (uses URL token)
xhs comments "<url>" # View comments — paste URL to cache/reuse xsec_token
xhs comments "<url>" --all # Fetch ALL comments (auto-paginate all pages)
Expand Down Expand Up @@ -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 <N>` 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:
Expand Down
26 changes: 24 additions & 2 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.0"
tags:
- xiaohongshu
- xhs
Expand Down Expand Up @@ -85,7 +85,7 @@ 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 read <id_or_url_or_index>` | Read a note by ID, URL, or listing index | `xhs read 2` / `xhs read "https://...?xsec_token=xxx"` |
| `xhs comments <id_or_url>` | Get comments (xsec_token required — paste URL) | `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` |
Expand Down Expand Up @@ -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 <N>` 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
Expand Down
135 changes: 135 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N>` 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
75 changes: 75 additions & 0 deletions tests/test_cookies.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
clear_cookies,
cookies_to_string,
get_cookies,
get_note_by_index,
load_saved_cookies,
save_cookies,
save_note_index,
)


Expand Down Expand Up @@ -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"] == ""
16 changes: 15 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading