From f655edf578d8eea1ae7398ea371c27128f12944f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 15 Jun 2026 13:46:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=BC=D0=B8=D1=82.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- netscape_cookies/README.md | 115 +++++++++ netscape_cookies/parse_netscape_cookies.py | 258 +++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 netscape_cookies/README.md create mode 100644 netscape_cookies/parse_netscape_cookies.py diff --git a/netscape_cookies/README.md b/netscape_cookies/README.md new file mode 100644 index 0000000..3839f14 --- /dev/null +++ b/netscape_cookies/README.md @@ -0,0 +1,115 @@ +# Cookie Parser + +Утилита `parse_netscape_cookies.py` разбирает cookies из нескольких популярных форматов и приводит их к удобному JSON или строке `Cookie` header. + +## Поддерживаемые входные форматы + +### Netscape HTTP Cookie File + +Формат `cookies.txt`, который часто экспортируют браузерные расширения и CLI-инструменты. + +```text +.example.com TRUE / TRUE 1789148954 session_id abc123 +``` + +### JSON-массив cookies + +Формат, похожий на экспорт из Chrome extensions API. + +```json +[ + { + "domain": ".example.com", + "expirationDate": 1789148954.129235, + "hostOnly": false, + "httpOnly": false, + "name": "session_id", + "path": "/", + "sameSite": "lax", + "secure": true, + "session": false, + "storeId": "0", + "value": "abc123" + } +] +``` + +Также поддерживается объект вида: + +```json +{ + "cookies": [] +} +``` + +### Cookie header + +Обычная строка из HTTP-заголовка `Cookie`. + +```text +session_id=abc123; theme=dark; +``` + +У этого формата нет метаданных вроде `domain`, `expires`, `secure` и `httpOnly`, поэтому скрипт заполняет их пустыми или безопасными значениями по умолчанию. + +## Использование + +Вывести JSON: + +```bash +python3 parse_netscape_cookies.py cookies.txt +``` + +Прочитать из stdin: + +```bash +cat cookies.txt | python3 parse_netscape_cookies.py +``` + +Вывести строку `Cookie` header: + +```bash +python3 parse_netscape_cookies.py cookies.json --format header +``` + +URL-decode значений: + +```bash +python3 parse_netscape_cookies.py cookies.txt --decode +``` + +Явно указать входной формат: + +```bash +python3 parse_netscape_cookies.py input.txt --input-format netscape +python3 parse_netscape_cookies.py input.json --input-format json +python3 parse_netscape_cookies.py header.txt --input-format header +``` + +По умолчанию используется `--input-format auto`. + +## Выходной JSON + +Каждая cookie приводится к структуре: + +```json +{ + "domain": ".example.com", + "include_subdomains": true, + "path": "/", + "secure": true, + "expires": 1789148954.129235, + "name": "session_id", + "value": "abc123", + "http_only": false, + "host_only": false, + "same_site": "lax", + "session": false, + "store_id": "0", + "expires_iso": "2026-09-11T17:49:14.129235+00:00" +} +``` + +## Важно + +Cookies могут давать доступ к аккаунту пользователя. Не отправляйте их в логи, чаты или сторонние сервисы без явного согласия пользователя. diff --git a/netscape_cookies/parse_netscape_cookies.py b/netscape_cookies/parse_netscape_cookies.py new file mode 100644 index 0000000..79228f2 --- /dev/null +++ b/netscape_cookies/parse_netscape_cookies.py @@ -0,0 +1,258 @@ +#!/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())