Коммит.
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