Коммит.
This commit is contained in:
parent
c3f0eaf509
commit
f655edf578
115
netscape_cookies/README.md
Normal file
115
netscape_cookies/README.md
Normal file
@ -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 могут давать доступ к аккаунту пользователя. Не отправляйте их в логи, чаты или сторонние сервисы без явного согласия пользователя.
|
||||
258
netscape_cookies/parse_netscape_cookies.py
Normal file
258
netscape_cookies/parse_netscape_cookies.py
Normal file
@ -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())
|
||||
Loading…
x
Reference in New Issue
Block a user