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]
|
[project]
|
||||||
name = "twitter-cli"
|
name = "twitter-cli"
|
||||||
version = "0.4.5"
|
version = "0.4.6"
|
||||||
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
|
description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ def extract_from_browser() -> Optional[Dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def get_cookies() -> 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.
|
Raises RuntimeError if no cookies found.
|
||||||
"""
|
"""
|
||||||
@@ -263,9 +263,17 @@ def get_cookies() -> Dict[str, str]:
|
|||||||
if cookies:
|
if cookies:
|
||||||
logger.info("Loaded cookies from environment variables")
|
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:
|
if not cookies:
|
||||||
cookies = extract_from_browser()
|
cookies = extract_from_browser()
|
||||||
|
if cookies:
|
||||||
|
_save_cookie_cache(cookies)
|
||||||
|
|
||||||
if not cookies:
|
if not cookies:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -275,7 +283,69 @@ def get_cookies() -> Dict[str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify only for explicit auth failures; transient endpoint issues are tolerated.
|
# Verify only for explicit auth failures; transient endpoint issues are tolerated.
|
||||||
|
try:
|
||||||
verify_cookies(cookies["auth_token"], cookies["ct0"], cookies.get("cookie_string"))
|
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
|
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
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ from .formatter import (
|
|||||||
print_user_profile,
|
print_user_profile,
|
||||||
print_user_table,
|
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)
|
console = Console(stderr=True)
|
||||||
@@ -223,8 +225,9 @@ def favorites(max_count, as_json, output_file, do_filter):
|
|||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("screen_name")
|
@click.argument("screen_name")
|
||||||
def user(screen_name):
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||||
# type: (str,) -> None
|
def user(screen_name, as_json):
|
||||||
|
# type: (str, bool) -> None
|
||||||
"""View a user's profile. SCREEN_NAME is the @handle (without @)."""
|
"""View a user's profile. SCREEN_NAME is the @handle (without @)."""
|
||||||
screen_name = screen_name.lstrip("@")
|
screen_name = screen_name.lstrip("@")
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -236,6 +239,9 @@ def user(screen_name):
|
|||||||
console.print("[red]❌ %s[/red]" % exc)
|
console.print("[red]❌ %s[/red]" % exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(json.dumps(user_profile_to_dict(profile), ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
console.print()
|
console.print()
|
||||||
print_user_profile(profile, console)
|
print_user_profile(profile, console)
|
||||||
|
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -950,7 +950,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "twitter-cli"
|
name = "twitter-cli"
|
||||||
version = "0.4.4"
|
version = "0.4.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
|
|||||||
Reference in New Issue
Block a user