#!/usr/bin/env python3 """Parse cookies from Netscape, JSON, or Cookie header formats.""" from __future__ import annotations import argparse from http.cookies import SimpleCookie import json import sys from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Iterable from urllib.parse import unquote @dataclass(frozen=True) class NetscapeCookie: domain: str include_subdomains: bool path: str secure: bool expires: float | None name: str value: str http_only: bool = False host_only: bool | None = None same_site: str | None = None session: bool | None = None store_id: str | None = None @property def expires_iso(self) -> str | None: if self.expires is None or self.expires == 0: return None return datetime.fromtimestamp(self.expires, tz=timezone.utc).isoformat() def to_dict(self) -> dict[str, object]: data = asdict(self) data["expires_iso"] = self.expires_iso return data def parse_bool(value: str, *, line_number: int, field_name: str) -> bool: normalized = value.strip().upper() if normalized == "TRUE": return True if normalized == "FALSE": return False raise ValueError(f"Line {line_number}: {field_name} must be TRUE or FALSE, got {value!r}") def parse_expires(value: str, *, line_number: int) -> int | None: try: expires = int(value) except ValueError as exc: raise ValueError(f"Line {line_number}: expires must be an integer, got {value!r}") from exc return None if expires == 0 else expires def maybe_decode(value: str, decode_value: bool) -> str: return unquote(value) if decode_value else value def parse_netscape_cookie_line(line: str, *, line_number: int, decode_value: bool = False) -> NetscapeCookie | None: stripped = line.rstrip("\n") if not stripped or stripped.startswith("# Netscape") or stripped.startswith("# http") or stripped.startswith("# This"): return None http_only = False if stripped.startswith("#HttpOnly_"): http_only = True stripped = stripped.removeprefix("#HttpOnly_") elif stripped.startswith("#"): return None fields = stripped.split("\t") if len(fields) != 7: raise ValueError(f"Line {line_number}: expected 7 tab-separated fields, got {len(fields)}") domain, include_subdomains, path, secure, expires, name, value = fields return NetscapeCookie( domain=domain, include_subdomains=parse_bool(include_subdomains, line_number=line_number, field_name="include_subdomains"), path=path, secure=parse_bool(secure, line_number=line_number, field_name="secure"), expires=parse_expires(expires, line_number=line_number), name=name, value=maybe_decode(value, decode_value), http_only=http_only, host_only=not parse_bool(include_subdomains, line_number=line_number, field_name="include_subdomains"), session=expires == "0", ) def parse_netscape_cookies(text: str, *, decode_value: bool = False) -> list[NetscapeCookie]: cookies: list[NetscapeCookie] = [] for line_number, line in enumerate(text.splitlines(), start=1): cookie = parse_netscape_cookie_line(line, line_number=line_number, decode_value=decode_value) if cookie is not None: cookies.append(cookie) return cookies def parse_json_cookies(text: str, *, decode_value: bool = False) -> list[NetscapeCookie]: try: payload = json.loads(text) except json.JSONDecodeError as exc: raise ValueError(f"Invalid JSON: {exc}") from exc if isinstance(payload, dict) and isinstance(payload.get("cookies"), list): payload = payload["cookies"] if not isinstance(payload, list): raise ValueError("JSON input must be a cookie array or an object with a cookies array") cookies: list[NetscapeCookie] = [] for index, item in enumerate(payload, start=1): if not isinstance(item, dict): raise ValueError(f"JSON cookie #{index}: expected object, got {type(item).__name__}") name = item.get("name") value = item.get("value", "") if not isinstance(name, str) or not name: raise ValueError(f"JSON cookie #{index}: name must be a non-empty string") if not isinstance(value, str): value = str(value) domain = str(item.get("domain", "")) host_only = item.get("hostOnly") include_subdomains = not bool(host_only) if host_only is not None else domain.startswith(".") expiration_date = item.get("expirationDate") session = bool(item.get("session", expiration_date in (None, 0))) cookies.append( NetscapeCookie( domain=domain, include_subdomains=include_subdomains, path=str(item.get("path", "/")), secure=bool(item.get("secure", False)), expires=None if expiration_date in (None, 0) else float(expiration_date), name=name, value=maybe_decode(value, decode_value), http_only=bool(item.get("httpOnly", False)), host_only=bool(host_only) if host_only is not None else not include_subdomains, same_site=item.get("sameSite"), session=session, store_id=str(item["storeId"]) if item.get("storeId") is not None else None, ) ) return cookies def parse_cookie_header(text: str, *, decode_value: bool = False) -> list[NetscapeCookie]: header = text.strip().rstrip(";") if header.lower().startswith("cookie:"): header = header.split(":", 1)[1].strip().rstrip(";") if not header: return [] parsed = SimpleCookie() try: parsed.load(header) except Exception as exc: raise ValueError(f"Invalid Cookie header: {exc}") from exc if not parsed: raise ValueError("Cookie header did not contain any name=value pairs") return [ NetscapeCookie( domain="", include_subdomains=False, path="/", secure=False, expires=None, name=morsel.key, value=maybe_decode(morsel.value, decode_value), host_only=True, session=True, ) for morsel in parsed.values() ] def detect_input_format(text: str) -> str: stripped = text.lstrip() if not stripped: return "header" if stripped.startswith("[") or stripped.startswith("{"): return "json" if any("\t" in line for line in stripped.splitlines()): return "netscape" return "header" def parse_cookies(text: str, *, input_format: str = "auto", decode_value: bool = False) -> list[NetscapeCookie]: selected_format = detect_input_format(text) if input_format == "auto" else input_format if selected_format == "netscape": return parse_netscape_cookies(text, decode_value=decode_value) if selected_format == "json": return parse_json_cookies(text, decode_value=decode_value) if selected_format == "header": return parse_cookie_header(text, decode_value=decode_value) raise ValueError(f"Unsupported input format: {input_format}") def build_cookie_header(cookies: Iterable[NetscapeCookie]) -> str: return "; ".join(f"{cookie.name}={cookie.value}" for cookie in cookies) def read_input(path: str | None) -> str: if path: return Path(path).read_text(encoding="utf-8") return sys.stdin.read() def main() -> int: parser = argparse.ArgumentParser( description="Parse cookies from Netscape, JSON, or Cookie header formats.", ) parser.add_argument("file", nargs="?", help="Path to cookies.txt. If omitted, reads stdin.") parser.add_argument("--decode", action="store_true", help="URL-decode cookie values.") parser.add_argument( "--input-format", choices=("auto", "netscape", "json", "header"), default="auto", help="Input format. Default: auto.", ) parser.add_argument( "--format", choices=("json", "header"), default="json", help="Output format. Default: json.", ) args = parser.parse_args() try: cookies = parse_cookies(read_input(args.file), input_format=args.input_format, decode_value=args.decode) except OSError as exc: print(f"Cannot read input: {exc}", file=sys.stderr) return 1 except ValueError as exc: print(f"Cannot parse cookies: {exc}", file=sys.stderr) return 2 if args.format == "header": print(build_cookie_header(cookies)) else: print(json.dumps([cookie.to_dict() for cookie in cookies], ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())