259 lines
8.6 KiB
Python
259 lines
8.6 KiB
Python
#!/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 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())
|