feat: cookie file cache with TTL + user --json
- Cookie cache: save to ~/.cache/twitter-cli/cookies.json (24h TTL) - On 401/403 auth failure: auto-invalidate cache, re-extract from browser - Cache uses 0600 permissions for security - Add --json option to twitter user command for scripting - Priority: env vars → cache file → browser extraction
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user