From dc832f2ee2959c7e2777f3de29175d8b5fe1e536 Mon Sep 17 00:00:00 2001 From: jackwener Date: Fri, 13 Mar 2026 00:15:53 +0800 Subject: [PATCH] feat: add advanced search options (--from, --to, --lang, --since, --until, --has, --exclude, --min-likes, --min-retweets) Closes #17 - New search.py query builder module - QUERY argument now optional when using advanced filters - 21 unit tests + 3 CLI integration tests for search - Bumped version to 0.7.0 --- pyproject.toml | 2 +- tests/test_cli.py | 64 ++++++++++++++++++++++++++++++++ tests/test_search.py | 86 +++++++++++++++++++++++++++++++++++++++++++ twitter_cli/cli.py | 62 ++++++++++++++++++++++++++++--- twitter_cli/search.py | 79 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 286 insertions(+), 7 deletions(-) create mode 100644 tests/test_search.py create mode 100644 twitter_cli/search.py diff --git a/pyproject.toml b/pyproject.toml index 876f89d..46fdb6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "twitter-cli" -version = "0.6.6" +version = "0.7.0" description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_cli.py b/tests/test_cli.py index 7ed515e..5979f6a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -425,6 +425,70 @@ def test_cli_unfollow_command(monkeypatch) -> None: assert actions == [("unfollow", "42")] +def test_cli_search_advanced_options(monkeypatch) -> None: + captured = {} + + class FakeClient: + def fetch_search(self, query: str, count: int, product: str): + captured["query"] = query + captured["product"] = product + return [] + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr( + "twitter_cli.cli.load_config", + lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}}, + ) + runner = CliRunner() + + result = runner.invoke(cli, [ + "search", "python", + "--from", "elonmusk", + "--lang", "en", + "--since", "2026-01-01", + "--has", "links", + "--exclude", "retweets", + "--min-likes", "100", + "-t", "Latest", + "--json", + ]) + + assert result.exit_code == 0, f"search failed: {result.output}" + assert captured["query"] == ( + "python from:elonmusk lang:en since:2026-01-01 " + "filter:links -filter:retweets min_faves:100" + ) + assert captured["product"] == "Latest" + + +def test_cli_search_operators_only_no_query(monkeypatch) -> None: + captured = {} + + class FakeClient: + def fetch_search(self, query: str, count: int, product: str): + captured["query"] = query + return [] + + monkeypatch.setattr("twitter_cli.cli._get_client", lambda config=None: FakeClient()) + monkeypatch.setattr( + "twitter_cli.cli.load_config", + lambda: {"fetch": {"count": 50}, "filter": {}, "rateLimit": {}}, + ) + runner = CliRunner() + + result = runner.invoke(cli, ["search", "--from", "bbc", "--json"]) + assert result.exit_code == 0, f"search failed: {result.output}" + assert captured["query"] == "from:bbc" + + +def test_cli_search_empty_query_no_options() -> None: + runner = CliRunner() + + result = runner.invoke(cli, ["search"]) + assert result.exit_code != 0 + assert "Provide a QUERY" in result.output + + def test_cli_compact_mode(tmp_path, tweet_factory) -> None: json_path = tmp_path / "tweets.json" json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8") diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..bd131c6 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,86 @@ +"""Unit tests for the advanced search query builder.""" + +from __future__ import annotations + +from twitter_cli.search import build_search_query + + +class TestBuildSearchQuery: + def test_plain_query(self) -> None: + assert build_search_query("python") == "python" + + def test_empty_query(self) -> None: + assert build_search_query("") == "" + + def test_from_user(self) -> None: + assert build_search_query("AI", from_user="elonmusk") == "AI from:elonmusk" + + def test_from_user_strips_at(self) -> None: + assert build_search_query("AI", from_user="@elonmusk") == "AI from:elonmusk" + + def test_to_user(self) -> None: + assert build_search_query("hello", to_user="jack") == "hello to:jack" + + def test_lang(self) -> None: + assert build_search_query("news", lang="fr") == "news lang:fr" + + def test_since(self) -> None: + assert build_search_query("python", since="2026-01-01") == "python since:2026-01-01" + + def test_until(self) -> None: + assert build_search_query("python", until="2026-03-01") == "python until:2026-03-01" + + def test_date_range(self) -> None: + result = build_search_query("rust", since="2026-01-01", until="2026-03-01") + assert result == "rust since:2026-01-01 until:2026-03-01" + + def test_has_links(self) -> None: + assert build_search_query("python", has=["links"]) == "python filter:links" + + def test_has_multiple(self) -> None: + result = build_search_query("art", has=["images", "videos"]) + assert result == "art filter:images filter:videos" + + def test_exclude_retweets(self) -> None: + assert build_search_query("news", exclude=["retweets"]) == "news -filter:retweets" + + def test_exclude_replies(self) -> None: + assert build_search_query("news", exclude=["replies"]) == "news -filter:replies" + + def test_exclude_multiple(self) -> None: + result = build_search_query("news", exclude=["retweets", "replies"]) + assert result == "news -filter:retweets -filter:replies" + + def test_min_likes(self) -> None: + assert build_search_query("python", min_likes=100) == "python min_faves:100" + + def test_min_retweets(self) -> None: + assert build_search_query("python", min_retweets=50) == "python min_retweets:50" + + def test_combined_operators(self) -> None: + result = build_search_query( + "machine learning", + from_user="openai", + lang="en", + since="2026-01-01", + has=["links"], + min_likes=50, + exclude=["retweets"], + ) + assert result == ( + "machine learning from:openai lang:en since:2026-01-01 " + "filter:links -filter:retweets min_faves:50" + ) + + def test_operators_only_no_query(self) -> None: + result = build_search_query("", from_user="elonmusk", since="2026-03-01") + assert result == "from:elonmusk since:2026-03-01" + + def test_whitespace_query_trimmed(self) -> None: + assert build_search_query(" python ", lang="en") == "python lang:en" + + def test_empty_has_list(self) -> None: + assert build_search_query("test", has=[]) == "test" + + def test_empty_exclude_list(self) -> None: + assert build_search_query("test", exclude=[]) == "test" diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 658a99d..ae4a8a4 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -5,6 +5,7 @@ Read commands: twitter feed -t following # following feed twitter bookmarks # bookmarks twitter search "query" # search tweets + twitter search "query" --from user # advanced search twitter user elonmusk # user profile twitter user-posts elonmusk # user tweets twitter likes elonmusk # user likes @@ -87,6 +88,8 @@ logger = logging.getLogger(__name__) console = Console(stderr=True) FEED_TYPES = ["for-you", "following"] SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"] +SEARCH_HAS_CHOICES = ["links", "images", "videos", "media"] +SEARCH_EXCLUDE_CHOICES = ["retweets", "replies", "links"] def _agent_user_profile(profile: UserProfile) -> dict: @@ -537,7 +540,7 @@ def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file, full_ @cli.command() -@click.argument("query") +@click.argument("query", default="") @click.option( "--type", "-t", @@ -546,23 +549,70 @@ def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file, full_ default="Top", help="Search tab: Top, Latest, Photos, or Videos.", ) +@click.option("--from", "from_user", type=str, default=None, help="Only tweets from this user.") +@click.option("--to", "to_user", type=str, default=None, help="Only tweets directed at this user.") +@click.option("--lang", type=str, default=None, help="Filter by language (ISO code, e.g. en, fr, ja).") +@click.option("--since", type=str, default=None, help="Tweets since date (YYYY-MM-DD).") +@click.option("--until", type=str, default=None, help="Tweets until date (YYYY-MM-DD).") +@click.option( + "--has", + type=click.Choice(SEARCH_HAS_CHOICES, case_sensitive=False), + multiple=True, + help="Require content type (links, images, videos, media). Repeatable.", +) +@click.option( + "--exclude", + type=click.Choice(SEARCH_EXCLUDE_CHOICES, case_sensitive=False), + multiple=True, + help="Exclude content type (retweets, replies, links). Repeatable.", +) +@click.option("--min-likes", type=int, default=None, help="Minimum number of likes.") +@click.option("--min-retweets", type=int, default=None, help="Minimum number of retweets.") @click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.") @structured_output_options @click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") @click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.") @click.option("--full-text", is_flag=True, help="Show full tweet text in table output.") @click.pass_context -def search(ctx, query, product, max_count, as_json, as_yaml, output_file, do_filter, full_text): - # type: (Any, str, str, int, bool, bool, Optional[str], bool, bool) -> None - """Search tweets by QUERY string.""" +def search(ctx, query, product, from_user, to_user, lang, since, until, has, exclude, min_likes, min_retweets, max_count, as_json, as_yaml, output_file, do_filter, full_text): + # type: (Any, str, str, Optional[str], Optional[str], Optional[str], Optional[str], Optional[str], tuple, tuple, Optional[int], Optional[int], int, bool, bool, Optional[str], bool, bool) -> None + """Search tweets by QUERY string with optional advanced filters. + + QUERY is the search keywords (optional when using advanced filters). + + Advanced search examples: + + \b + twitter search "python" --from elonmusk + twitter search "AI" --lang en --since 2026-01-01 + twitter search "rust" --has links --min-likes 100 + twitter search --from bbc --exclude retweets + """ + from .search import build_search_query + + composed_query = build_search_query( + query, + from_user=from_user, + to_user=to_user, + lang=lang, + since=since, + until=until, + has=list(has) if has else None, + exclude=list(exclude) if exclude else None, + min_likes=min_likes, + min_retweets=min_retweets, + ) + if not composed_query: + raise click.UsageError("Provide a QUERY or at least one advanced filter (e.g. --from, --lang).") + compact = ctx.obj.get("compact", False) config = load_config() def _run(): rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact) client = _get_client_for_output(config, quiet=not rich_output) _fetch_and_display( - lambda count: client.fetch_search(query, count, product), - "'%s' (%s)" % (query, product), "🔍", max_count, as_json, as_yaml, output_file, do_filter, config, + lambda count: client.fetch_search(composed_query, count, product), + "'%s' (%s)" % (composed_query, product), "🔍", max_count, as_json, as_yaml, output_file, do_filter, config, compact=compact, full_text=full_text, ) _run_guarded(_run) diff --git a/twitter_cli/search.py b/twitter_cli/search.py new file mode 100644 index 0000000..517852a --- /dev/null +++ b/twitter_cli/search.py @@ -0,0 +1,79 @@ +"""Advanced search query builder. + +Composes Twitter search operators into a raw query string for the +SearchTimeline GraphQL endpoint. + +Reference: https://help.x.com/en/using-x/x-advanced-search +""" + +from __future__ import annotations + +from typing import List, Optional, Sequence + + +def build_search_query( + query: str = "", + *, + from_user: Optional[str] = None, + to_user: Optional[str] = None, + lang: Optional[str] = None, + since: Optional[str] = None, + until: Optional[str] = None, + has: Optional[Sequence[str]] = None, + exclude: Optional[Sequence[str]] = None, + min_likes: Optional[int] = None, + min_retweets: Optional[int] = None, +) -> str: + """Build an advanced search query string. + + Args: + query: Base search keywords. + from_user: Only tweets from this user (screen_name). + to_user: Only tweets directed at this user. + lang: ISO 639-1 language code (e.g. "en", "fr", "ja"). + since: Start date in YYYY-MM-DD format. + until: End date in YYYY-MM-DD format. + has: List of content types to require. Accepted values: + "links", "images", "videos", "media". + exclude: List of content types to exclude. Accepted values: + "retweets", "replies", "links". + min_likes: Minimum number of likes (faves). + min_retweets: Minimum number of retweets. + + Returns: + Composed query string ready for the rawQuery API parameter. + """ + parts: List[str] = [] + + if query and query.strip(): + parts.append(query.strip()) + + if from_user: + parts.append("from:%s" % from_user.lstrip("@")) + if to_user: + parts.append("to:%s" % to_user.lstrip("@")) + if lang: + parts.append("lang:%s" % lang) + if since: + parts.append("since:%s" % since) + if until: + parts.append("until:%s" % until) + if has: + for item in has: + parts.append("filter:%s" % item) + if exclude: + for item in exclude: + if item == "retweets": + parts.append("-filter:retweets") + elif item == "replies": + parts.append("-filter:replies") + elif item == "links": + parts.append("-filter:links") + else: + parts.append("-filter:%s" % item) + if min_likes is not None: + parts.append("min_faves:%d" % min_likes) + if min_retweets is not None: + parts.append("min_retweets:%d" % min_retweets) + + return " ".join(parts)