feat: add search and likes commands

- Add 'twitter search' command with --type (Top/Latest/Photos/Videos), --max, --json, --filter
- Add 'twitter likes' command to view tweets liked by a user
- Add SearchTimeline and Likes GraphQL operations with fallback queryIds
- Update README with new command examples (EN + CN)
This commit is contained in:
jackwener
2026-03-07 19:15:37 +08:00
parent 55c48b077b
commit b0866ed8d7
3 changed files with 131 additions and 2 deletions

View File

@@ -244,5 +244,81 @@ def user_posts(screen_name, max_count, as_json):
console.print()
SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"]
@cli.command()
@click.argument("query")
@click.option(
"--type",
"-t",
"product",
type=click.Choice(SEARCH_PRODUCTS, case_sensitive=False),
default="Top",
help="Search tab: Top, Latest, Photos, or Videos.",
)
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
def search(query, product, max_count, as_json, do_filter):
# type: (str, str, int, bool, bool) -> None
"""Search tweets by QUERY string."""
config = load_config()
try:
fetch_count = _resolve_fetch_count(max_count, 20)
client = _get_client(config)
console.print("🔍 Searching '%s' (%s, %d tweets)...\n" % (query, product, fetch_count))
start = time.time()
tweets = client.fetch_search(query, fetch_count, product)
elapsed = time.time() - start
console.print("✅ Found %d tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
filtered = _apply_filter(tweets, do_filter, config)
if as_json:
click.echo(tweets_to_json(filtered))
return
print_tweet_table(filtered, console, title="🔍 '%s'%d tweets" % (query, len(filtered)))
console.print()
@cli.command()
@click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
def likes(screen_name, max_count, as_json, do_filter):
# type: (str, int, bool, bool) -> None
"""Show tweets liked by a user. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@")
config = load_config()
try:
fetch_count = _resolve_fetch_count(max_count, 20)
client = _get_client(config)
console.print("👤 Fetching @%s's profile..." % screen_name)
profile = client.fetch_user(screen_name)
console.print("❤️ Fetching likes (%d)...\n" % fetch_count)
start = time.time()
tweets = client.fetch_user_likes(profile.id, fetch_count)
elapsed = time.time() - start
console.print("✅ Fetched %d liked tweets in %.1fs\n" % (len(tweets), elapsed))
except RuntimeError as exc:
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
filtered = _apply_filter(tweets, do_filter, config)
if as_json:
click.echo(tweets_to_json(filtered))
return
print_tweet_table(filtered, console, title="❤️ @%s likes — %d tweets" % (screen_name, len(filtered)))
console.print()
if __name__ == "__main__":
cli()

View File

@@ -28,6 +28,8 @@ FALLBACK_QUERY_IDS = {
"Bookmarks": "VFdMm9iVZxlU6hD86gfW_A",
"UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A",
"UserTweets": "E3opETHurmVJflFsUBVuUQ",
"SearchTimeline": "VhUd6vHVmLBcw0uX-6jMLA",
"Likes": "lIDpu_NWL7_VhimGGt0o6A",
}
TWITTER_OPENAPI_URL = (
@@ -312,6 +314,44 @@ class TwitterClient:
},
)
def fetch_user_likes(self, user_id, count=20):
# type: (str, int) -> List[Tweet]
"""Fetch tweets liked by a user."""
return self._fetch_timeline(
"Likes",
count,
lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"),
extra_variables={
"userId": user_id,
"includePromotedContent": False,
"withClientEventToken": False,
"withBirdwatchNotes": False,
"withVoice": True,
},
)
def fetch_search(self, query, count=20, product="Top"):
# type: (str, int, str) -> List[Tweet]
"""Search tweets by query.
Args:
query: Search query string.
count: Max number of tweets to return.
product: Search tab — "Top", "Latest", "People", "Photos", "Videos".
"""
return self._fetch_timeline(
"SearchTimeline",
count,
lambda data: _deep_get(
data, "data", "search_by_raw_query", "search_timeline", "timeline", "instructions",
),
extra_variables={
"rawQuery": query,
"querySource": "typed_query",
"product": product,
},
)
def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None):
# type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]]) -> List[Tweet]
"""Generic timeline fetcher with pagination and deduplication."""