diff --git a/pyproject.toml b/pyproject.toml index 10784a2..9111d30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "twitter-cli" -version = "0.4.3" +version = "0.4.4" description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal" readme = "README.md" license = "Apache-2.0" diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 33ec707..3a4cf51 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -17,9 +17,7 @@ import subprocess import sys from typing import Dict, Optional -from curl_cffi import requests as _cffi_requests - -from .constants import BEARER_TOKEN, USER_AGENT +from .constants import BEARER_TOKEN, get_user_agent logger = logging.getLogger(__name__) @@ -49,7 +47,7 @@ def verify_cookies(auth_token, ct0, cookie_string=None): Tries multiple endpoints. Only raises on clear auth failures (401/403). For other errors (404, network), returns empty dict (proceed without verification). """ - from .client import _best_chrome_target + from .client import _get_cffi_session urls = [ "https://api.x.com/1.1/account/verify_credentials.json", @@ -65,14 +63,11 @@ def verify_cookies(auth_token, ct0, cookie_string=None): "X-Csrf-Token": ct0, "X-Twitter-Active-User": "yes", "X-Twitter-Auth-Type": "OAuth2Session", - "User-Agent": USER_AGENT, + "User-Agent": get_user_agent(), } - proxy = os.environ.get("TWITTER_PROXY", "") - session = _cffi_requests.Session( - impersonate=_best_chrome_target(), - proxies={"https": proxy, "http": proxy} if proxy else None, - ) + # Reuse the shared curl_cffi session for consistent TLS fingerprint + session = _get_cffi_session() for url in urls: try: diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index 0915bc2..95e7c83 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -244,8 +244,9 @@ def user(screen_name): @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.") -def user_posts(screen_name, max_count, as_json): - # type: (str, int, bool) -> None +@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.") +def user_posts(screen_name, max_count, as_json, output_file): + # type: (str, int, bool, Optional[str]) -> None """List a user's tweets. SCREEN_NAME is the @handle (without @).""" screen_name = screen_name.lstrip("@") config = load_config() @@ -258,7 +259,7 @@ def user_posts(screen_name, max_count, as_json): sys.exit(1) _fetch_and_display( lambda count: client.fetch_user_tweets(profile.id, count), - "@%s tweets" % screen_name, "📝", max_count, as_json, None, False, config, + "@%s tweets" % screen_name, "📝", max_count, as_json, output_file, False, config, ) @@ -277,15 +278,16 @@ SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "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("--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.") -def search(query, product, max_count, as_json, do_filter): - # type: (str, str, int, bool, bool) -> None +def search(query, product, max_count, as_json, output_file, do_filter): + # type: (str, str, int, bool, Optional[str], bool) -> None """Search tweets by QUERY string.""" config = load_config() client = _get_client(config) _fetch_and_display( lambda count: client.fetch_search(query, count, product), - "'%s' (%s)" % (query, product), "🔍", max_count, as_json, None, do_filter, config, + "'%s' (%s)" % (query, product), "🔍", max_count, as_json, output_file, do_filter, config, ) @@ -293,9 +295,10 @@ def search(query, product, max_count, as_json, do_filter): @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("--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.") -def likes(screen_name, max_count, as_json, do_filter): - # type: (str, int, bool, bool) -> None +def likes(screen_name, max_count, as_json, output_file, do_filter): + # type: (str, int, bool, Optional[str], bool) -> None """Show tweets liked by a user. SCREEN_NAME is the @handle (without @).""" screen_name = screen_name.lstrip("@") config = load_config() @@ -308,7 +311,7 @@ def likes(screen_name, max_count, as_json, do_filter): sys.exit(1) _fetch_and_display( lambda count: client.fetch_user_likes(profile.id, count), - "@%s likes" % screen_name, "❤️", max_count, as_json, None, do_filter, config, + "@%s likes" % screen_name, "❤️", max_count, as_json, output_file, do_filter, config, ) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 05501ed..bc879df 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -15,7 +15,10 @@ from curl_cffi import requests as _cffi_requests from x_client_transaction import ClientTransaction from x_client_transaction.utils import generate_headers as _gen_ct_headers, get_ondemand_file_url -from .constants import BEARER_TOKEN, USER_AGENT, SEC_CH_UA, SEC_CH_UA_MOBILE, SEC_CH_UA_PLATFORM +from .constants import ( + BEARER_TOKEN, SEC_CH_UA_MOBILE, SEC_CH_UA_PLATFORM, + get_sec_ch_ua, get_user_agent, sync_chrome_version, +) from .models import Author, Metrics, Tweet, TweetMedia, UserProfile logger = logging.getLogger(__name__) @@ -124,6 +127,7 @@ def _get_cffi_session(): import os proxy = os.environ.get("TWITTER_PROXY", "") target = _best_chrome_target() + sync_chrome_version(target) # align UA/sec-ch-ua with impersonate target _cffi_session = _cffi_requests.Session( impersonate=target, proxies={"https": proxy, "http": proxy} if proxy else None, @@ -174,7 +178,7 @@ def _scan_bundles(): _bundles_scanned = True try: - html = _url_fetch("https://x.com", {"user-agent": USER_AGENT}) + html = _url_fetch("https://x.com", {"user-agent": get_user_agent()}) script_pattern = re.compile( r'(?:src|href)=["\']' r'(https://abs\.twimg\.com/responsive-web/client-web[^"\']+\.js)' @@ -685,12 +689,12 @@ class TwitterClient: "X-Twitter-Active-User": "yes", "X-Twitter-Auth-Type": "OAuth2Session", "X-Twitter-Client-Language": "en", - "User-Agent": USER_AGENT, + "User-Agent": get_user_agent(), "Origin": "https://x.com", "Referer": "https://x.com", "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", - "sec-ch-ua": SEC_CH_UA, + "sec-ch-ua": get_sec_ch_ua(), "sec-ch-ua-mobile": SEC_CH_UA_MOBILE, "sec-ch-ua-platform": SEC_CH_UA_PLATFORM, "Sec-Fetch-Dest": "empty", diff --git a/twitter_cli/constants.py b/twitter_cli/constants.py index ae52e94..0114bb4 100644 --- a/twitter_cli/constants.py +++ b/twitter_cli/constants.py @@ -1,17 +1,50 @@ """Shared constants for twitter-cli.""" +import re + BEARER_TOKEN = ( "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs" "%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA" ) -USER_AGENT = ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/133.0.0.0 Safari/537.36" -) +# Default Chrome version — updated by _best_chrome_target() at runtime +_DEFAULT_CHROME_VERSION = "133" +_chrome_version = _DEFAULT_CHROME_VERSION # mutable, set by sync_chrome_version() -# Chrome Client Hints — sent by modern Chrome on every request -SEC_CH_UA = '"Chromium";v="133", "Not(A:Brand";v="99", "Google Chrome";v="133"' + +def sync_chrome_version(impersonate_target): + # type: (str) -> None + """Sync USER_AGENT / SEC_CH_UA with the actual impersonate target. + + Called once when _get_cffi_session() picks a target (e.g. "chrome136"). + """ + global _chrome_version + match = re.search(r"(\d+)", impersonate_target) + if match: + _chrome_version = match.group(1) + + +def get_user_agent(): + # type: () -> str + return ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/%s.0.0.0 Safari/537.36" % _chrome_version + ) + + +def get_sec_ch_ua(): + # type: () -> str + return '"Chromium";v="%s", "Not(A:Brand";v="99", "Google Chrome";v="%s"' % ( + _chrome_version, _chrome_version, + ) + + +# Static Client Hints SEC_CH_UA_MOBILE = "?0" SEC_CH_UA_PLATFORM = '"macOS"' + +# Legacy aliases — modules that import these get the default value. +# _build_headers() should use get_user_agent() / get_sec_ch_ua() instead. +USER_AGENT = get_user_agent() +SEC_CH_UA = get_sec_ch_ua() diff --git a/twitter_cli/filter.py b/twitter_cli/filter.py index 94d338f..b74e37b 100644 --- a/twitter_cli/filter.py +++ b/twitter_cli/filter.py @@ -21,7 +21,7 @@ DEFAULT_WEIGHTS = { def score_tweet(tweet, weights=None): - # type: (Tweet, Optional[FilterWeights]) -> float + # type: (Tweet, Optional[Dict[str, float]]) -> float """Calculate engagement score for a single tweet. Formula: @@ -30,15 +30,18 @@ def score_tweet(tweet, weights=None): + w_replies × replies + w_bookmarks × bookmarks + w_views_log × log10(views) + + Args: + weights: Pre-built weight dict. If None, uses DEFAULT_WEIGHTS. """ - weight_map = _build_weights(weights or {}) + w = weights or DEFAULT_WEIGHTS m = tweet.metrics return ( - weight_map["likes"] * m.likes - + weight_map["retweets"] * m.retweets - + weight_map["replies"] * m.replies - + weight_map["bookmarks"] * m.bookmarks - + weight_map["views_log"] * math.log10(max(m.views, 1)) + w.get("likes", 1.0) * m.likes + + w.get("retweets", 3.0) * m.retweets + + w.get("replies", 2.0) * m.replies + + w.get("bookmarks", 5.0) * m.bookmarks + + w.get("views_log", 0.5) * math.log10(max(m.views, 1)) ) diff --git a/twitter_cli/models.py b/twitter_cli/models.py index d07880a..0f9dce0 100644 --- a/twitter_cli/models.py +++ b/twitter_cli/models.py @@ -49,7 +49,7 @@ class Tweet: lang: str = "" retweeted_by: Optional[str] = None quoted_tweet: Optional[Tweet] = None - score: float = 0.0 + score: Optional[float] = None @dataclass diff --git a/twitter_cli/serialization.py b/twitter_cli/serialization.py index 5137cc0..ece85cd 100644 --- a/twitter_cli/serialization.py +++ b/twitter_cli/serialization.py @@ -112,7 +112,7 @@ def tweet_from_dict(data: Dict[str, Any]) -> Tweet: lang=str(data.get("lang") or ""), retweeted_by=_optional_str(data.get("retweetedBy")), quoted_tweet=quoted_tweet, - score=float(data.get("score") or 0.0), + score=float(data["score"]) if data.get("score") is not None else None, ) diff --git a/uv.lock b/uv.lock index c36b806..b4dcd7f 100644 --- a/uv.lock +++ b/uv.lock @@ -950,7 +950,7 @@ wheels = [ [[package]] name = "twitter-cli" -version = "0.4.2" +version = "0.4.3" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },