diff --git a/tests/test_client.py b/tests/test_client.py index 9ef907d..b26098c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -13,16 +13,22 @@ import pytest from twitter_cli.client import ( - FEATURES, _best_chrome_target, + TwitterClient, +) +from twitter_cli.exceptions import TwitterAPIError +from twitter_cli.graphql import ( + FEATURES, _build_graphql_url, + _update_features_from_html, +) +from twitter_cli.parser import ( _deep_get, _extract_cursor, _extract_media, _parse_int, - _update_features_from_html, - TwitterAPIError, - TwitterClient, + parse_tweet_result, + parse_user_result, ) @@ -339,9 +345,9 @@ class TestPaginationBehavior: return [MagicMock(id="tweet-1")], None client._graphql_get = _graphql_get - client._parse_timeline_response = _parse_timeline_response - tweets = client._fetch_timeline("HomeTimeline", 1, lambda data: data) + with patch('twitter_cli.client.parse_timeline_response', side_effect=_parse_timeline_response): + tweets = client._fetch_timeline("HomeTimeline", 1, lambda data: data) assert [tweet.id for tweet in tweets] == ["tweet-1"] @@ -357,9 +363,9 @@ class TestPaginationBehavior: return {"page": len(calls)} client._graphql_get = _graphql_get - client._parse_timeline_response = lambda data, get_instructions: ([], "cursor-same") - tweets = client._fetch_timeline("HomeTimeline", 1, lambda data: data) + with patch('twitter_cli.client.parse_timeline_response', return_value=([], "cursor-same")): + tweets = client._fetch_timeline("HomeTimeline", 1, lambda data: data) assert tweets == [] assert calls == [None, "cursor-same"] @@ -401,9 +407,9 @@ class TestPaginationBehavior: ] client._graphql_get = _graphql_get - client._parse_user_result = _parse_user_result - users = client._fetch_user_list("Followers", "1", 1, _get_instructions) + with patch('twitter_cli.client.parse_user_result', side_effect=_parse_user_result): + users = client._fetch_user_list("Followers", "1", 1, _get_instructions) assert [user.screen_name for user in users] == ["alice"] @@ -453,7 +459,7 @@ class TestParseTweetResult: client._ct_init_attempted = True client._client_transaction = None - tweet = client._parse_tweet_result(copy.deepcopy(self.SAMPLE_TWEET_RESULT)) + tweet = parse_tweet_result(copy.deepcopy(self.SAMPLE_TWEET_RESULT)) assert tweet is not None assert tweet.id == "1234567890" assert tweet.text == "Hello world! This is a test tweet." @@ -475,7 +481,7 @@ class TestParseTweetResult: client._client_transaction = None result = {"__typename": "TweetTombstone"} - assert client._parse_tweet_result(result) is None + assert parse_tweet_result(result) is None @patch("twitter_cli.client._get_cffi_session") @patch("twitter_cli.client._gen_ct_headers", return_value={}) @@ -491,7 +497,7 @@ class TestParseTweetResult: "__typename": "TweetWithVisibilityResults", "tweet": copy.deepcopy(self.SAMPLE_TWEET_RESULT), } - tweet = client._parse_tweet_result(wrapped) + tweet = parse_tweet_result(wrapped) assert tweet is not None assert tweet.id == "1234567890" @@ -505,7 +511,7 @@ class TestParseTweetResult: client._ct_init_attempted = True client._client_transaction = None - assert client._parse_tweet_result(self.SAMPLE_TWEET_RESULT, depth=3) is None + assert parse_tweet_result(self.SAMPLE_TWEET_RESULT, depth=3) is None # ── TwitterAPIError ────────────────────────────────────────────────────── @@ -523,7 +529,7 @@ class TestTwitterAPIError: class TestParseUserResult: def test_coerces_count_fields_to_int(self): - user = TwitterClient._parse_user_result( + user = parse_user_result( { "rest_id": "user-1", "legacy": { diff --git a/tests/test_parser_fixtures.py b/tests/test_parser_fixtures.py index 4d42d49..81f1d3e 100644 --- a/tests/test_parser_fixtures.py +++ b/tests/test_parser_fixtures.py @@ -1,6 +1,9 @@ from __future__ import annotations -from twitter_cli.client import TwitterClient, _deep_get +from unittest.mock import patch + +from twitter_cli.client import TwitterClient +from twitter_cli.parser import _deep_get, parse_timeline_response def _make_client() -> TwitterClient: @@ -15,10 +18,9 @@ def _make_client() -> TwitterClient: def test_parse_home_timeline_fixture(fixture_loader) -> None: - client = _make_client() payload = fixture_loader("home_timeline.json") - tweets, cursor = client._parse_timeline_response( + tweets, cursor = parse_timeline_response( payload, lambda data: _deep_get(data, "data", "home", "home_timeline_urt", "instructions"), ) @@ -34,10 +36,9 @@ def test_parse_home_timeline_fixture(fixture_loader) -> None: def test_parse_tweet_detail_fixture_with_nested_items(fixture_loader) -> None: - client = _make_client() payload = fixture_loader("tweet_detail.json") - tweets, cursor = client._parse_timeline_response( + tweets, cursor = parse_timeline_response( payload, lambda data: _deep_get(data, "data", "threaded_conversation_with_injections_v2", "instructions"), ) @@ -47,10 +48,9 @@ def test_parse_tweet_detail_fixture_with_nested_items(fixture_loader) -> None: def test_parse_search_timeline_fixture_with_module_items(fixture_loader) -> None: - client = _make_client() payload = fixture_loader("search_timeline.json") - tweets, cursor = client._parse_timeline_response( + tweets, cursor = parse_timeline_response( payload, lambda data: _deep_get(data, "data", "search_by_raw_query", "search_timeline", "timeline", "instructions"), ) @@ -62,10 +62,9 @@ def test_parse_search_timeline_fixture_with_module_items(fixture_loader) -> None def test_parse_list_timeline_fixture_with_visibility_wrapper(fixture_loader) -> None: - client = _make_client() payload = fixture_loader("list_timeline.json") - tweets, cursor = client._parse_timeline_response( + tweets, cursor = parse_timeline_response( payload, lambda data: _deep_get(data, "data", "list", "tweets_timeline", "timeline", "instructions"), ) diff --git a/twitter_cli/auth.py b/twitter_cli/auth.py index 0012fc7..2e93a91 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -353,9 +353,15 @@ def iter_cookie_files(browser_name): if sys.platform == "darwin": root = os.path.join(os.path.expanduser("~"), "Library", "Application Support", base_dir) elif sys.platform == "win32": - root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir, "User Data") + if browser_name == "edge": + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Microsoft", "Edge", "User Data") + else: + root = os.path.join(os.environ.get("LOCALAPPDATA", ""), base_dir) else: - root = os.path.join(os.path.expanduser("~"), ".config", base_dir) + if browser_name == "edge": + root = os.path.join(os.path.expanduser("~"), ".config", "microsoft-edge") + else: + root = os.path.join(os.path.expanduser("~"), ".config", base_dir) if not os.path.isdir(root): return [] env_profile = os.environ.get("TWITTER_CHROME_PROFILE", "").strip() diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index e281cef..f181440 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -737,10 +737,11 @@ def _print_show_hint(): @click.argument("index", type=click.IntRange(1)) @click.option("--max", "-n", "max_count", type=int, default=None, help="Max replies to fetch.") @click.option("--full-text", is_flag=True, help="Show full reply text in table output.") +@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweet detail as JSON to file.") @structured_output_options @click.pass_context -def show(ctx, index, max_count, full_text, as_json, as_yaml): - # type: (Any, int, Optional[int], bool, bool, bool) -> None +def show(ctx, index, max_count, full_text, output_file, as_json, as_yaml): + # type: (Any, int, Optional[int], bool, Optional[str], bool, bool) -> None """View tweet #INDEX from the last feed/search results.""" compact = ctx.obj.get("compact", False) @@ -769,6 +770,11 @@ def show(ctx, index, max_count, full_text, as_json, as_yaml): except RuntimeError as exc: _exit_with_error(exc) + if output_file: + Path(output_file).write_text(tweets_to_json(tweets), encoding="utf-8") + if rich_output: + console.print("💾 Saved to %s\n" % output_file) + _emit_tweet_detail(tweets, compact=compact, as_json=as_json, as_yaml=as_yaml, full_text=full_text) diff --git a/twitter_cli/client.py b/twitter_cli/client.py index 9413b57..d736e74 100644 --- a/twitter_cli/client.py +++ b/twitter_cli/client.py @@ -715,7 +715,7 @@ class TwitterClient: variables["cursor"] = cursor data = self._graphql_get(operation_name, variables, FEATURES, field_toggles=field_toggles) - new_tweets, next_cursor = self._parse_timeline_response(data, get_instructions) + new_tweets, next_cursor = parse_timeline_response(data, get_instructions) for tweet in new_tweets: if tweet.id and tweet.id not in seen_ids: @@ -780,7 +780,7 @@ class TwitterClient: item = content.get("itemContent", {}) user_results = _deep_get(item, "user_results", "result") if user_results: - user = self._parse_user_result(user_results) + user = parse_user_result(user_results) if user: new_users.append(user) elif entry_type == "TimelineTimelineCursor": @@ -1081,28 +1081,3 @@ class TwitterClient: except Exception as exc: logger.debug("Failed to generate transaction id: %s", exc) return headers - - # ── Backward-compatible delegation to parser module ────────────── - - @staticmethod - def _parse_user_result(user_data): - # type: (Dict[str, Any]) -> Optional[UserProfile] - """Parse a user result object into UserProfile.""" - return parse_user_result(user_data) - - def _parse_tweet_result(self, result, depth=0): - # type: (Dict[str, Any], int) -> Optional[Tweet] - """Parse a single TweetResult into a Tweet dataclass.""" - return parse_tweet_result(result, depth) - - def _parse_timeline_response(self, data, get_instructions): - # type: (Any, Callable[[Any], Any]) -> Tuple[List[Tweet], Optional[str]] - """Parse timeline GraphQL response into tweets and next cursor.""" - return parse_timeline_response(data, get_instructions) - - -# ── Backward compatibility re-exports ──────────────────────────────────── -# These keep existing test imports working without modification. - -from .graphql import FALLBACK_QUERY_IDS # noqa: E402, F401 -from .parser import _extract_cursor, _extract_media # noqa: E402, F401 diff --git a/twitter_cli/constants.py b/twitter_cli/constants.py index 294a485..ea8b0de 100644 --- a/twitter_cli/constants.py +++ b/twitter_cli/constants.py @@ -121,8 +121,3 @@ SEC_CH_UA_ARCH = get_sec_ch_ua_arch() SEC_CH_UA_BITNESS = '"64"' SEC_CH_UA_MODEL = '""' SEC_CH_UA_PLATFORM_VERSION = get_sec_ch_ua_platform_version() - -# 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/formatter.py b/twitter_cli/formatter.py index 1d7adba..5bb3165 100644 --- a/twitter_cli/formatter.py +++ b/twitter_cli/formatter.py @@ -10,6 +10,7 @@ from rich.panel import Panel from rich.table import Table from .models import Tweet, UserProfile +from .timeutil import format_local_time, format_relative_time def format_number(n: int) -> str: @@ -77,13 +78,15 @@ def print_tweet_table( text += "\n🔗 x.com/%s/status/%s" % (tweet.author.screen_name, tweet.id) # Stats + rel_time = format_relative_time(tweet.created_at) stats = ( - "❤️ %s 🔄 %s\n💬 %s 👁️ %s" + "❤️ %s 🔄 %s\n💬 %s 👁️ %s\n🕐 %s" % ( format_number(tweet.metrics.likes), format_number(tweet.metrics.retweets), format_number(tweet.metrics.replies), format_number(tweet.metrics.views), + rel_time, ) ) @@ -138,9 +141,11 @@ def print_tweet_detail(tweet: Tweet, console: Optional[Console] = None) -> None: format_number(tweet.metrics.views), ) ) + local_time = format_local_time(tweet.created_at) + rel_time = format_relative_time(tweet.created_at) body_parts.append( - "🕐 %s · https://x.com/%s/status/%s" - % (tweet.created_at, tweet.author.screen_name, tweet.id) + "🕐 %s (%s) · https://x.com/%s/status/%s" + % (local_time, rel_time, tweet.author.screen_name, tweet.id) ) console.print(Panel( diff --git a/twitter_cli/serialization.py b/twitter_cli/serialization.py index d706438..8d8ab12 100644 --- a/twitter_cli/serialization.py +++ b/twitter_cli/serialization.py @@ -6,6 +6,7 @@ import json from typing import Any, Dict, Iterable, List, Optional from .models import Author, Metrics, Tweet, TweetMedia, UserProfile +from .timeutil import format_local_time def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]: @@ -29,6 +30,7 @@ def tweet_to_dict(tweet: Tweet) -> Dict[str, Any]: "bookmarks": tweet.metrics.bookmarks, }, "createdAt": tweet.created_at, + "createdAtLocal": format_local_time(tweet.created_at), "media": [ { "type": media.type, diff --git a/twitter_cli/timeutil.py b/twitter_cli/timeutil.py new file mode 100644 index 0000000..f27295b --- /dev/null +++ b/twitter_cli/timeutil.py @@ -0,0 +1,71 @@ +"""Time formatting utilities for twitter-cli. + +Converts Twitter API timestamps (e.g. "Sat Mar 08 12:00:00 +0000 2026") +into human-friendly local time and relative time strings. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Optional + +logger = logging.getLogger(__name__) + +# Twitter API timestamp format: "Sat Mar 08 12:00:00 +0000 2026" +_TWITTER_TIME_FORMAT = "%a %b %d %H:%M:%S %z %Y" + + +def _parse_twitter_time(created_at: str) -> Optional[datetime]: + """Parse a Twitter API timestamp into a timezone-aware datetime.""" + if not created_at: + return None + try: + return datetime.strptime(created_at, _TWITTER_TIME_FORMAT) + except (ValueError, TypeError): + logger.debug("Failed to parse Twitter timestamp: %s", created_at) + return None + + +def format_local_time(created_at: str) -> str: + """Convert Twitter timestamp to local time string. + + Returns "2026-03-14 21:08" or the original string on parse failure. + """ + dt = _parse_twitter_time(created_at) + if dt is None: + return created_at + local_dt = dt.astimezone() + return local_dt.strftime("%Y-%m-%d %H:%M") + + +def format_relative_time(created_at: str) -> str: + """Convert Twitter timestamp to a relative time string. + + Returns e.g. "2m ago", "3h ago", "5d ago", or the original string on failure. + """ + dt = _parse_twitter_time(created_at) + if dt is None: + return created_at + now = datetime.now(timezone.utc) + delta = now - dt + seconds = int(delta.total_seconds()) + + if seconds < 0: + return "just now" + if seconds < 60: + return "%ds ago" % seconds + minutes = seconds // 60 + if minutes < 60: + return "%dm ago" % minutes + hours = minutes // 60 + if hours < 24: + return "%dh ago" % hours + days = hours // 24 + if days < 30: + return "%dd ago" % days + months = days // 30 + if months < 12: + return "%dmo ago" % months + years = days // 365 + return "%dy ago" % years