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
This commit is contained in:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "twitter-cli"
|
name = "twitter-cli"
|
||||||
version = "0.6.6"
|
version = "0.7.0"
|
||||||
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
|
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -425,6 +425,70 @@ def test_cli_unfollow_command(monkeypatch) -> None:
|
|||||||
assert actions == [("unfollow", "42")]
|
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:
|
def test_cli_compact_mode(tmp_path, tweet_factory) -> None:
|
||||||
json_path = tmp_path / "tweets.json"
|
json_path = tmp_path / "tweets.json"
|
||||||
json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8")
|
json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8")
|
||||||
|
|||||||
86
tests/test_search.py
Normal file
86
tests/test_search.py
Normal file
@@ -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"
|
||||||
@@ -5,6 +5,7 @@ Read commands:
|
|||||||
twitter feed -t following # following feed
|
twitter feed -t following # following feed
|
||||||
twitter bookmarks # bookmarks
|
twitter bookmarks # bookmarks
|
||||||
twitter search "query" # search tweets
|
twitter search "query" # search tweets
|
||||||
|
twitter search "query" --from user # advanced search
|
||||||
twitter user elonmusk # user profile
|
twitter user elonmusk # user profile
|
||||||
twitter user-posts elonmusk # user tweets
|
twitter user-posts elonmusk # user tweets
|
||||||
twitter likes elonmusk # user likes
|
twitter likes elonmusk # user likes
|
||||||
@@ -87,6 +88,8 @@ logger = logging.getLogger(__name__)
|
|||||||
console = Console(stderr=True)
|
console = Console(stderr=True)
|
||||||
FEED_TYPES = ["for-you", "following"]
|
FEED_TYPES = ["for-you", "following"]
|
||||||
SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"]
|
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:
|
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()
|
@cli.command()
|
||||||
@click.argument("query")
|
@click.argument("query", default="")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--type",
|
"--type",
|
||||||
"-t",
|
"-t",
|
||||||
@@ -546,23 +549,70 @@ def user_posts(ctx, screen_name, max_count, as_json, as_yaml, output_file, full_
|
|||||||
default="Top",
|
default="Top",
|
||||||
help="Search tab: Top, Latest, Photos, or Videos.",
|
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.")
|
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
|
||||||
@structured_output_options
|
@structured_output_options
|
||||||
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
|
@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("--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.option("--full-text", is_flag=True, help="Show full tweet text in table output.")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def search(ctx, query, product, max_count, as_json, as_yaml, output_file, do_filter, full_text):
|
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, int, bool, bool, Optional[str], bool, bool) -> None
|
# 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."""
|
"""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)
|
compact = ctx.obj.get("compact", False)
|
||||||
config = load_config()
|
config = load_config()
|
||||||
def _run():
|
def _run():
|
||||||
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact)
|
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)
|
client = _get_client_for_output(config, quiet=not rich_output)
|
||||||
_fetch_and_display(
|
_fetch_and_display(
|
||||||
lambda count: client.fetch_search(query, count, product),
|
lambda count: client.fetch_search(composed_query, count, product),
|
||||||
"'%s' (%s)" % (query, product), "🔍", max_count, as_json, as_yaml, output_file, do_filter, config,
|
"'%s' (%s)" % (composed_query, product), "🔍", max_count, as_json, as_yaml, output_file, do_filter, config,
|
||||||
compact=compact, full_text=full_text,
|
compact=compact, full_text=full_text,
|
||||||
)
|
)
|
||||||
_run_guarded(_run)
|
_run_guarded(_run)
|
||||||
|
|||||||
79
twitter_cli/search.py
Normal file
79
twitter_cli/search.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user