193 lines
6.2 KiB
Python
193 lines
6.2 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from tempfile import NamedTemporaryFile
|
|
from typing import Optional
|
|
|
|
|
|
def clamp_percent(value):
|
|
value = max(0.0, min(100.0, float(value)))
|
|
return round(value, 2)
|
|
|
|
|
|
def iso_passthrough(value):
|
|
if value is None:
|
|
return None
|
|
return str(value)
|
|
|
|
|
|
def discover_zen_cookie_db(home_dir: Path) -> Optional[Path]:
|
|
zen_root = home_dir / ".zen"
|
|
if not zen_root.exists():
|
|
return None
|
|
|
|
for path in sorted(zen_root.glob("*/cookies.sqlite")):
|
|
if path.is_file():
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
def read_claude_cookies(cookie_db: Path, context_prefix: str) -> dict[str, str]:
|
|
with NamedTemporaryFile(delete=False) as handle:
|
|
temp_db = Path(handle.name)
|
|
|
|
try:
|
|
shutil.copy2(cookie_db, temp_db)
|
|
conn = sqlite3.connect(temp_db)
|
|
try:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT name, value
|
|
FROM moz_cookies
|
|
WHERE host LIKE ?
|
|
AND originAttributes LIKE ?
|
|
ORDER BY name
|
|
""",
|
|
("%claude.ai", f"{context_prefix}%"),
|
|
).fetchall()
|
|
finally:
|
|
conn.close()
|
|
finally:
|
|
temp_db.unlink(missing_ok=True)
|
|
|
|
return {name: value for name, value in rows}
|
|
|
|
|
|
def fetch_json(url: str, headers: dict[str, str]) -> dict:
|
|
request = urllib.request.Request(url, headers=headers)
|
|
with urllib.request.urlopen(request, timeout=20) as response:
|
|
return json.load(response)
|
|
|
|
|
|
def load_cached_org_uuid(output_path: Path) -> Optional[str]:
|
|
try:
|
|
with output_path.open("r", encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
org_uuid = payload.get("organization_uuid")
|
|
if isinstance(org_uuid, str) and org_uuid:
|
|
return org_uuid
|
|
|
|
return None
|
|
|
|
|
|
def discover_org_uuid(base_headers: dict[str, str]) -> Optional[str]:
|
|
bootstrap = fetch_json("https://claude.ai/api/bootstrap", base_headers)
|
|
account = bootstrap.get("account") or {}
|
|
memberships = account.get("memberships") or []
|
|
for membership in memberships:
|
|
org = membership.get("organization") or {}
|
|
if org.get("uuid"):
|
|
return org["uuid"]
|
|
|
|
return None
|
|
|
|
|
|
def main():
|
|
output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("/var/lib/hass/claude_usage.json")
|
|
home_dir = Path(os.environ.get("HOME", str(Path.home())))
|
|
|
|
cookie_db = Path(
|
|
os.environ.get("CLAUDE_COOKIE_DB")
|
|
or discover_zen_cookie_db(home_dir)
|
|
or ""
|
|
)
|
|
context_prefix = os.environ.get("CLAUDE_COOKIE_CONTEXT_PREFIX", "^userContextId=1")
|
|
|
|
if not cookie_db.is_file():
|
|
print("No claude.ai cookie database found", file=sys.stderr)
|
|
return 1
|
|
|
|
cookies = read_claude_cookies(cookie_db, context_prefix)
|
|
if not cookies:
|
|
print("No claude.ai cookies found", file=sys.stderr)
|
|
return 1
|
|
|
|
cookie_header = "; ".join(f"{name}={value}" for name, value in cookies.items())
|
|
base_headers = {
|
|
"Accept": "application/json",
|
|
"Cookie": cookie_header,
|
|
"Referer": "https://claude.ai/settings/usage",
|
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0",
|
|
}
|
|
|
|
org_uuid = load_cached_org_uuid(output_path)
|
|
if not org_uuid:
|
|
try:
|
|
org_uuid = discover_org_uuid(base_headers)
|
|
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
|
|
print(f"Failed to fetch bootstrap: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
if not org_uuid:
|
|
print("No organization UUID available for Claude usage export", file=sys.stderr)
|
|
return 1
|
|
|
|
try:
|
|
usage_data = fetch_json(
|
|
f"https://claude.ai/api/organizations/{org_uuid}/usage",
|
|
base_headers,
|
|
)
|
|
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as e:
|
|
if load_cached_org_uuid(output_path) == org_uuid:
|
|
try:
|
|
org_uuid = discover_org_uuid(base_headers)
|
|
usage_data = fetch_json(
|
|
f"https://claude.ai/api/organizations/{org_uuid}/usage",
|
|
base_headers,
|
|
)
|
|
except (OSError, urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError) as retry_error:
|
|
print(f"Failed to fetch usage from org {org_uuid}: {retry_error}", file=sys.stderr)
|
|
return 1
|
|
else:
|
|
print(f"Failed to fetch usage from org {org_uuid}: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
# Response shape:
|
|
# {
|
|
# "five_hour": {"utilization": 78.0, "resets_at": "2026-05-17T20:40:00+00:00"},
|
|
# "seven_day": {"utilization": 16.0, "resets_at": "2026-05-18T06:59:59+00:00"},
|
|
# ...
|
|
# }
|
|
five_hour = usage_data.get("five_hour") or {}
|
|
seven_day = usage_data.get("seven_day") or {}
|
|
|
|
if "utilization" not in five_hour or "utilization" not in seven_day:
|
|
print(f"Unexpected usage structure. Keys: {list(usage_data.keys())}", file=sys.stderr)
|
|
print(json.dumps(usage_data, indent=2), file=sys.stderr)
|
|
return 1
|
|
|
|
status = {
|
|
"five_hour_left_percent": clamp_percent(100.0 - float(five_hour["utilization"])),
|
|
"five_hour_resets_at": iso_passthrough(five_hour.get("resets_at")),
|
|
"seven_day_left_percent": clamp_percent(100.0 - float(seven_day["utilization"])),
|
|
"seven_day_resets_at": iso_passthrough(seven_day.get("resets_at")),
|
|
"organization_uuid": org_uuid,
|
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
|
"source": "claude_web",
|
|
}
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with NamedTemporaryFile("w", encoding="utf-8", dir=output_path.parent, delete=False) as handle:
|
|
json.dump(status, handle, ensure_ascii=False, separators=(",", ":"))
|
|
handle.write("\n")
|
|
temp_path = Path(handle.name)
|
|
|
|
temp_path.replace(output_path)
|
|
output_path.chmod(0o644)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|