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:
jackwener
2026-03-09 21:47:23 +08:00
parent bbe0bc8de3
commit 7fb02ba18c
4 changed files with 86 additions and 10 deletions

View File

@@ -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"

View File

@@ -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.
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)

View File

@@ -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,6 +239,9 @@ def user(screen_name):
console.print("[red]❌ %s[/red]" % exc)
sys.exit(1)
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)

2
uv.lock generated
View File

@@ -950,7 +950,7 @@ wheels = [
[[package]]
name = "twitter-cli"
version = "0.4.4"
version = "0.4.6"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },