From 8b35b5bd300f36962919a18e0cc8fe054b99323f Mon Sep 17 00:00:00 2001 From: Gooyoit Date: Tue, 21 Apr 2026 23:02:24 +0800 Subject: [PATCH] Add CLI search filter tag options --- tests/test_cli.py | 21 ++++++++++++++++++++ tests/test_client.py | 38 +++++++++++++++++++++++++++++++++++++ xhs_cli/cli.py | 2 +- xhs_cli/client_mixins.py | 18 +++++++++++++++++- xhs_cli/commands/reading.py | 21 +++++++++++++++++++- 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index f4bec77..0bb175d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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( diff --git a/tests/test_client.py b/tests/test_client.py index eb55af7..363ebb3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 = [] diff --git a/xhs_cli/cli.py b/xhs_cli/cli.py index a33a08e..c53feb8 100644 --- a/xhs_cli/cli.py +++ b/xhs_cli/cli.py @@ -2,7 +2,7 @@ Usage: xhs login / status / logout - xhs search [--sort popular|latest] [--type video|image] [--page N] + xhs search [--sort popular|latest] [--type video|image] [--time TAG] [--range TAG] [--distance TAG] [--page N] xhs read [--xsec-token TOKEN] xhs comments xhs user diff --git a/xhs_cli/client_mixins.py b/xhs_cli/client_mixins.py index b4dada7..b8e77fa 100644 --- a/xhs_cli/client_mixins.py +++ b/xhs_cli/client_mixins.py @@ -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.""" @@ -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: @@ -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"], }) diff --git a/xhs_cli/commands/reading.py b/xhs_cli/commands/reading.py index 33f5980..93cb5e4 100644 --- a/xhs_cli/commands/reading.py +++ b/xhs_cli/commands/reading.py @@ -54,10 +54,24 @@ 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( @@ -65,6 +79,11 @@ def _search_action(client): 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")