diff --git a/pyproject.toml b/pyproject.toml index c918a23..f2ba6d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "twitter-cli" -version = "0.4.5" +version = "0.4.6" 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 3a4cf51..170807b 100644 --- a/twitter_cli/auth.py +++ b/twitter_cli/auth.py @@ -252,7 +252,7 @@ def extract_from_browser() -> Optional[Dict[str, str]]: def get_cookies() -> Dict[str, str]: - """Get Twitter cookies. Priority: env vars -> browser extraction (Chrome/Edge/Firefox/Brave). + """Get Twitter cookies. Priority: env vars -> cache file -> browser extraction. Raises RuntimeError if no cookies found. """ @@ -263,9 +263,17 @@ def get_cookies() -> Dict[str, str]: if cookies: logger.info("Loaded cookies from environment variables") - # 2. Try browser extraction (auto-detect) + # 2. Try cached cookies (file cache with TTL) + if not cookies: + cookies = _load_cookie_cache() + if cookies: + logger.info("Loaded cookies from cache") + + # 3. Try browser extraction (auto-detect) if not cookies: cookies = extract_from_browser() + if cookies: + _save_cookie_cache(cookies) if not cookies: raise RuntimeError( @@ -275,7 +283,69 @@ def get_cookies() -> Dict[str, str]: ) # Verify only for explicit auth failures; transient endpoint issues are tolerated. - verify_cookies(cookies["auth_token"], cookies["ct0"], cookies.get("cookie_string")) + try: + verify_cookies(cookies["auth_token"], cookies["ct0"], cookies.get("cookie_string")) + except RuntimeError: + # Auth failure — invalidate cache and re-extract from browser + logger.info("Cookie verification failed, invalidating cache and re-extracting") + invalidate_cookie_cache() + fresh_cookies = extract_from_browser() + if fresh_cookies: + _save_cookie_cache(fresh_cookies) + # Verify fresh cookies — if this also fails, let it raise + verify_cookies(fresh_cookies["auth_token"], fresh_cookies["ct0"], fresh_cookies.get("cookie_string")) + return fresh_cookies + raise return cookies +# ── Cookie file cache ─────────────────────────────────────────────────── + +_CACHE_DIR = os.path.join(os.path.expanduser("~"), ".cache", "twitter-cli") +_CACHE_FILE = os.path.join(_CACHE_DIR, "cookies.json") +_CACHE_TTL_SECONDS = 24 * 3600 # 24 hours + + +def _load_cookie_cache(): + # type: () -> Optional[Dict[str, str]] + """Load cookies from file cache if within TTL.""" + try: + if not os.path.exists(_CACHE_FILE): + return None + import time as _time + mtime = os.path.getmtime(_CACHE_FILE) + if _time.time() - mtime > _CACHE_TTL_SECONDS: + logger.debug("Cookie cache expired (>%ds)", _CACHE_TTL_SECONDS) + return None + with open(_CACHE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "auth_token" in data and "ct0" in data: + return data + except Exception as exc: + logger.debug("Failed to load cookie cache: %s", exc) + return None + + +def _save_cookie_cache(cookies): + # type: (Dict[str, str]) -> None + """Save cookies to file cache.""" + try: + os.makedirs(_CACHE_DIR, exist_ok=True) + with open(_CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(cookies, f, ensure_ascii=False) + # Restrict permissions — cookies are sensitive + os.chmod(_CACHE_FILE, 0o600) + logger.info("Saved cookies to cache (%s)", _CACHE_FILE) + except Exception as exc: + logger.debug("Failed to save cookie cache: %s", exc) + + +def invalidate_cookie_cache(): + # type: () -> None + """Delete the cookie cache file.""" + try: + if os.path.exists(_CACHE_FILE): + os.remove(_CACHE_FILE) + logger.info("Cookie cache invalidated") + except Exception as exc: + logger.debug("Failed to invalidate cookie cache: %s", exc) diff --git a/twitter_cli/cli.py b/twitter_cli/cli.py index cac833a..532d358 100644 --- a/twitter_cli/cli.py +++ b/twitter_cli/cli.py @@ -28,6 +28,8 @@ import sys import time from pathlib import Path +import json + import click from rich.console import Console @@ -43,7 +45,7 @@ from .formatter import ( print_user_profile, print_user_table, ) -from .serialization import tweets_from_json, tweets_to_json, users_to_json +from .serialization import tweets_from_json, tweets_to_json, user_profile_to_dict, users_to_json console = Console(stderr=True) @@ -223,8 +225,9 @@ def favorites(max_count, as_json, output_file, do_filter): @cli.command() @click.argument("screen_name") -def user(screen_name): - # type: (str,) -> None +@click.option("--json", "as_json", is_flag=True, help="Output as JSON.") +def user(screen_name, as_json): + # type: (str, bool) -> None """View a user's profile. SCREEN_NAME is the @handle (without @).""" screen_name = screen_name.lstrip("@") config = load_config() @@ -236,8 +239,11 @@ def user(screen_name): console.print("[red]❌ %s[/red]" % exc) sys.exit(1) - console.print() - print_user_profile(profile, console) + if as_json: + click.echo(json.dumps(user_profile_to_dict(profile), ensure_ascii=False, indent=2)) + else: + console.print() + print_user_profile(profile, console) @cli.command("user-posts") diff --git a/uv.lock b/uv.lock index c982d3a..b909e2c 100644 --- a/uv.lock +++ b/uv.lock @@ -950,7 +950,7 @@ wheels = [ [[package]] name = "twitter-cli" -version = "0.4.4" +version = "0.4.6" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },