Skip to content
Open
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
21 changes: 21 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,27 @@ def test_search_rich_output_shortens_visible_links(self, monkeypatch):
assert "search_result/69ad061d" in result.output
assert "very-long-token-value" not in result.output

def test_search_accepts_distance_filter_argument(self, monkeypatch):
captured = {}

def fake_handle_command(ctx, action, render, as_json, as_yaml):
class FakeClient:
def search_notes(self, **kwargs):
captured.update(kwargs)
return {"items": [], "has_more": False}

action(FakeClient())
return None

monkeypatch.setattr("xhs_cli.commands.reading.handle_command", fake_handle_command)

result = runner.invoke(cli, ["search", "旅行", "--sort", "popular", "--distance", "附近"])

assert result.exit_code == 0
assert captured["keyword"] == "旅行"
assert captured["sort"] == "popularity_descending"
assert captured["filter_overrides"]["filter_pos_distance"] == "附近"

def test_feed_rich_output_shortens_visible_links(self, monkeypatch):
monkeypatch.setenv("OUTPUT", "rich")
monkeypatch.setattr(
Expand Down
38 changes: 38 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,44 @@ def fake_post(self, uri, data, header_overrides=None):
assert notes_payload["filters"][0]["type"] == "sort_type"
assert notes_payload["filters"][1]["type"] == "filter_note_type"

def test_search_notes_applies_filter_overrides(self, monkeypatch):
calls = []

monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE", OrderedDict())
monkeypatch.setattr("xhs_cli.client_mixins._SEARCH_SESSION_CACHE_LOADED", True)

def fake_get(self, uri, params=None):
calls.append(("GET", uri, params))
return {"ok": True}

def fake_post(self, uri, data, header_overrides=None):
calls.append(("POST", uri, data))
if uri == "/api/sns/web/v1/search/notes":
return {"items": [], "has_more": False}
return {"ok": True}

monkeypatch.setattr(XhsClient, "_main_api_get", fake_get)
monkeypatch.setattr(XhsClient, "_main_api_post", fake_post)

client = XhsClient({"a1": "cookie"})
try:
client.search_notes(
"旅行",
filter_overrides={
"filter_note_time": "一周内",
"filter_note_range": "图文",
"filter_pos_distance": "附近",
},
)
finally:
client.close()

notes_payload = next(call[2] for call in calls if call[1] == "/api/sns/web/v1/search/notes")
filters_by_type = {item["type"]: item["tags"] for item in notes_payload["filters"]}
assert filters_by_type["filter_note_time"] == ["一周内"]
assert filters_by_type["filter_note_range"] == ["图文"]
assert filters_by_type["filter_pos_distance"] == ["附近"]

def test_search_notes_reuses_search_id_across_pages(self, monkeypatch):
calls = []

Expand Down
2 changes: 1 addition & 1 deletion xhs_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Usage:
xhs login / status / logout
xhs search <keyword> [--sort popular|latest] [--type video|image] [--page N]
xhs search <keyword> [--sort popular|latest] [--type video|image] [--time TAG] [--range TAG] [--distance TAG] [--page N]
xhs read <id_or_url> [--xsec-token TOKEN]
xhs comments <id_or_url>
xhs user <user_id>
Expand Down
18 changes: 17 additions & 1 deletion xhs_cli/client_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,21 @@ def get_search_session_stats() -> dict[str, Any]:
}


def _build_search_filters(filter_overrides: dict[str, str] | None = None) -> list[dict[str, Any]]:
filters = [
{"tags": list(item["tags"]), "type": item["type"]}
for item in _SEARCH_DEFAULT_FILTERS
]
if not filter_overrides:
return filters

for item in filters:
filter_value = filter_overrides.get(item["type"], "").strip()
if filter_value:
item["tags"] = [filter_value]
return filters


class ReadingEndpointsMixin:
"""Read-only note, profile, and discovery endpoints."""

Expand Down Expand Up @@ -278,6 +293,7 @@ def search_notes(
page_size: int = 20,
sort: str = "general",
note_type: int = 0,
filter_overrides: dict[str, str] | None = None,
) -> Any:
search_id, is_new_session = _acquire_search_session(keyword, sort, note_type)
if is_new_session:
Expand All @@ -304,7 +320,7 @@ def search_notes(
"sort": sort,
"note_type": note_type,
"ext_flags": [],
"filters": _SEARCH_DEFAULT_FILTERS,
"filters": _build_search_filters(filter_overrides),
"geo": "",
"image_formats": ["jpg", "webp", "avif"],
})
Expand Down
21 changes: 20 additions & 1 deletion xhs_cli/commands/reading.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,36 @@ def _cache_tokens_from_items(data: dict, *, xsec_source: str) -> None:
@click.argument("keyword")
@click.option("--sort", type=click.Choice(["general", "popular", "latest"]), default="general", help="Sort order")
@click.option("--type", "note_type", type=click.Choice(["all", "video", "image"]), default="all", help="Note type")
@click.option("--time", "note_time", default="不限", show_default=True, help="Search time filter tag")
@click.option("--range", "note_range", default="不限", show_default=True, help="Search range filter tag")
@click.option("--distance", default="不限", show_default=True, help="Search distance filter tag")
@click.option("--page", default=1, help="Page number")
@structured_output_options
@click.pass_context
def search(ctx, keyword: str, sort: str, note_type: str, page: int, as_json: bool, as_yaml: bool):
def search(
ctx,
keyword: str,
sort: str,
note_type: str,
note_time: str,
note_range: str,
distance: str,
page: int,
as_json: bool,
as_yaml: bool,
):
"""Search notes by keyword."""
def _search_action(client):
result = client.search_notes(
keyword=keyword,
page=page,
sort=SORT_MAP[sort],
note_type=TYPE_MAP[note_type],
filter_overrides={
"filter_note_time": note_time,
"filter_note_range": note_range,
"filter_pos_distance": distance,
},
)
_cache_tokens_from_items(result, xsec_source="pc_search")
save_index_from_items(result, xsec_source="pc_search")
Expand Down