Домены и сети Discord в geosite.dat и geoip.dat для Xray/3x-ui, плюс sing-box, Clash/mihomo, AdGuard, текст и готовый фрагмент routing. Рукописная база живёт в data/discord-base.txt и data/ip/discord-base.txt, остальное tools/sync.py тянет из пяти публичных апстримов: JSON-исходники парсятся напрямую, скомпилированные .srs распаковываются через sing-box. Каждый источник кладётся в свою категорию, недоступный источник не обнуляет данные, файлы без шапки GENERATED скрипт не трогает. Ограничения по протоколу и портам (директивы !network / !port) доезжают до sing-box rule-set и до xray/routing.json. Такие категории намеренно не входят в сводную discord: там сети вроде 172.64.0.0/13, и без привязки к UDP-портам правило утащило бы в туннель чужой трафик. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
388 lines
16 KiB
Python
388 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""Синхронизирует data/ с апстримными rule-set из tools/sources.json.
|
||
|
||
Каждый источник кладётся в свой файл — data/<target>.txt для доменов и
|
||
data/ip/<target>.txt для сетей, — а сводные категории (data/discord.txt,
|
||
data/ip/discord.txt) пересобираются как список include-ов. Рукописные
|
||
списки (data/discord-base.txt) остаются нетронутыми: sync перезаписывает
|
||
только файлы со своей шапкой GENERATED.
|
||
|
||
python3 tools/sync.py # синхронизировать всё
|
||
python3 tools/sync.py --only legiz # только один источник
|
||
python3 tools/sync.py --strict # падать, если хоть один источник недоступен
|
||
|
||
Источники в формате .srs скомпилированы: для них нужен бинарь sing-box
|
||
(`rule-set decompile`). Если его нет — источник пропускается, а ранее
|
||
синхронизированный файл остаётся на месте: устаревшие данные лучше пустых.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ipaddress
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import urllib.error
|
||
import urllib.request
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
import build
|
||
from build import BuildError, Net, Options, Rule
|
||
|
||
TOOLS = Path(__file__).resolve().parent
|
||
USER_AGENT = "discord-geodata-sync (+https://github.com/)"
|
||
TIMEOUT = 60
|
||
|
||
GENERATED_HEADER = f"# {build.GENERATED_MARK} tools/sync.py — не редактируйте вручную."
|
||
|
||
|
||
class SyncError(Exception):
|
||
"""Источник не удалось получить или разобрать."""
|
||
|
||
|
||
@dataclass
|
||
class Parsed:
|
||
domains: list[Rule] = field(default_factory=list)
|
||
groups: dict[Options, list[Net]] = field(default_factory=dict)
|
||
skipped: list[str] = field(default_factory=list)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Получение
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def fetch(url: str) -> bytes:
|
||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
||
return response.read()
|
||
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
||
raise SyncError(f"не удалось скачать {url}: {exc}") from exc
|
||
|
||
|
||
def decompile_srs(blob: bytes, singbox: str | None) -> dict:
|
||
""".srs — это zlib-контейнер sing-box; читать его умеет только сам sing-box."""
|
||
if not singbox:
|
||
raise SyncError("формат .srs требует бинаря sing-box (--sing-box или в PATH)")
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
src = Path(tmp) / "rule-set.srs"
|
||
dst = Path(tmp) / "rule-set.json"
|
||
src.write_bytes(blob)
|
||
result = subprocess.run(
|
||
[singbox, "rule-set", "decompile", "--output", str(dst), str(src)],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
if result.returncode != 0:
|
||
raise SyncError(f"sing-box rule-set decompile: {result.stderr.strip() or result.stdout.strip()}")
|
||
return json.loads(dst.read_text(encoding="utf-8"))
|
||
|
||
|
||
def load_source(source: dict, singbox: str | None) -> dict:
|
||
url = source.get("json_url") or source["url"]
|
||
blob = fetch(url)
|
||
if url.endswith(".srs") or blob[:3] == b"SRS":
|
||
return decompile_srs(blob, singbox)
|
||
try:
|
||
return json.loads(blob.decode("utf-8"))
|
||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||
raise SyncError(f"{url}: не похоже на JSON rule-set: {exc}") from exc
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Разбор sing-box rule-set
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def iter_default_rules(rules: list, where: str):
|
||
"""Разворачивает logical-правила: вложенные условия нас интересуют как список."""
|
||
for rule in rules:
|
||
if not isinstance(rule, dict):
|
||
raise SyncError(f"{where}: правило не является объектом")
|
||
if rule.get("type") == "logical":
|
||
yield from iter_default_rules(rule.get("rules", []), where)
|
||
else:
|
||
yield rule
|
||
|
||
|
||
def listify(value: object, where: str, key: str) -> list:
|
||
"""В sing-box почти любое поле правила — «listable»: строка или массив строк."""
|
||
if value is None:
|
||
return []
|
||
if isinstance(value, (str, int)):
|
||
return [value]
|
||
if isinstance(value, list):
|
||
return value
|
||
raise SyncError(f"{where}: поле '{key}' неожиданного типа {type(value).__name__}")
|
||
|
||
|
||
def rule_field(rule: dict, key: str, where: str) -> list:
|
||
return listify(rule.get(key), where, key)
|
||
|
||
|
||
def rule_options(rule: dict, where: str) -> Options:
|
||
networks = sorted({str(n).lower() for n in rule_field(rule, "network", where)})
|
||
for network in networks:
|
||
if network not in build.NETWORKS:
|
||
raise SyncError(f"{where}: неизвестный network '{network}'")
|
||
|
||
ports: list[tuple[int, int]] = []
|
||
for port in rule_field(rule, "port", where):
|
||
ports.append((int(port), int(port)))
|
||
for span in rule_field(rule, "port_range", where):
|
||
match = build.PORT_RE.match(str(span))
|
||
if not match:
|
||
raise SyncError(f"{where}: невалидный port_range '{span}'")
|
||
start = int(match.group(1))
|
||
ports.append((start, int(match.group(2) or start)))
|
||
|
||
return Options(tuple(networks), build.merge_ports(ports))
|
||
|
||
|
||
def parse_ruleset(payload: dict, where: str) -> Parsed:
|
||
if not isinstance(payload, dict) or "rules" not in payload:
|
||
raise SyncError(f"{where}: нет ключа 'rules'")
|
||
|
||
parsed = Parsed()
|
||
domains: dict[Rule, None] = {}
|
||
|
||
for index, rule in enumerate(iter_default_rules(payload["rules"], where)):
|
||
spot = f"{where}#{index}"
|
||
if rule.get("invert"):
|
||
parsed.skipped.append(f"{spot}: invert-правило не выразимо в geosite/geoip")
|
||
continue
|
||
|
||
for key, template in (
|
||
("domain", "full:{}"),
|
||
("domain_suffix", "{}"),
|
||
("domain_keyword", "keyword:{}"),
|
||
("domain_regex", "regexp:{}"),
|
||
):
|
||
for value in rule_field(rule, key, spot):
|
||
# Ведущая точка в sing-box значит «только поддомены», а в geosite
|
||
# суффикс всегда покрывает и сам домен — точку просто снимаем.
|
||
token = template.format(str(value).lstrip(".") if key == "domain_suffix" else value)
|
||
try:
|
||
domains.setdefault(build.parse_token(token, spot), None)
|
||
except BuildError as exc:
|
||
parsed.skipped.append(str(exc))
|
||
|
||
cidrs: list[Net] = []
|
||
for value in rule_field(rule, "ip_cidr", spot):
|
||
try:
|
||
network = ipaddress.ip_network(str(value), strict=False)
|
||
except ValueError as exc:
|
||
parsed.skipped.append(f"{spot}: невалидная сеть '{value}': {exc}")
|
||
continue
|
||
cidrs.append(Net(network.network_address.packed, network.prefixlen))
|
||
|
||
for key in ("source_ip_cidr", "source_port", "source_port_range"):
|
||
if rule.get(key):
|
||
parsed.skipped.append(f"{spot}: '{key}' игнорируется")
|
||
|
||
if cidrs:
|
||
parsed.groups.setdefault(rule_options(rule, spot), []).extend(cidrs)
|
||
|
||
parsed.domains = sorted(domains, key=lambda r: r.sort_key)
|
||
return parsed
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Чистка
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
# Чистка внутри файла — та же, что build применяет к развёрнутой категории:
|
||
# покрытые суффиксом домены и вложенные сети до .dat всё равно не доедут.
|
||
prune_domains = build.collapse_rules
|
||
prune_nets = build.collapse_nets
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Запись
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def is_generated(path: Path) -> bool:
|
||
if not path.is_file():
|
||
return True
|
||
head = path.read_text(encoding="utf-8").splitlines()[:3]
|
||
return any(build.GENERATED_MARK in line for line in head)
|
||
|
||
|
||
def write_generated(path: Path, notes: list[str], body: list[str]) -> None:
|
||
if not is_generated(path):
|
||
raise SyncError(f"{path} создан вручную — sync его не трогает")
|
||
content = "\n".join([GENERATED_HEADER, *(f"# {n}" for n in notes), "", *body, ""])
|
||
build.write(path, content)
|
||
|
||
|
||
def short_suffix(options: Options) -> str:
|
||
return "-".join(options.networks) or "port"
|
||
|
||
|
||
def long_suffix(options: Options) -> str:
|
||
"""Различает наборы, у которых совпал протокол, но разошлись порты."""
|
||
ports = "-".join(f"{a}" if a == b else f"{a}-{b}" for a, b in options.ports)
|
||
return "-".join(part for part in (short_suffix(options), ports) if part)
|
||
|
||
|
||
def group_names(groups: list[Options], target: str) -> dict[Options, str]:
|
||
"""Имя категории на группу: короткое, а при совпадении — с портами."""
|
||
taken = [short_suffix(o) for o in groups]
|
||
names = {}
|
||
for options in groups:
|
||
suffix = short_suffix(options)
|
||
if taken.count(suffix) > 1:
|
||
suffix = long_suffix(options)
|
||
names[options] = f"{target}-{suffix}"
|
||
if len(set(names.values())) != len(names):
|
||
raise SyncError(f"{target}: не удалось развести имена категорий по ограничениям")
|
||
return names
|
||
|
||
|
||
def sync_source(source: dict, singbox: str | None) -> tuple[list[Path], list[str]]:
|
||
"""Возвращает записанные файлы и предупреждения."""
|
||
target = source["target"]
|
||
note = source.get("note", source["name"])
|
||
url = source.get("json_url") or source["url"]
|
||
|
||
parsed = parse_ruleset(load_source(source, singbox), source["name"])
|
||
|
||
written: list[Path] = []
|
||
domains = prune_domains(parsed.domains)
|
||
if domains:
|
||
write_generated(
|
||
build.DATA_DIR / f"{target}.txt",
|
||
[f"Источник: {note}", f"URL: {url}", f"Правил: {len(domains)}"],
|
||
[str(rule) for rule in domains],
|
||
)
|
||
written.append(build.DATA_DIR / f"{target}.txt")
|
||
|
||
ordered = sorted(parsed.groups, key=lambda o: (o.networks, o.ports))
|
||
names = group_names([o for o in ordered if o.restricted], target)
|
||
for options in ordered:
|
||
nets = prune_nets(parsed.groups[options])
|
||
if not nets:
|
||
continue
|
||
name = names[options] if options.restricted else target
|
||
|
||
directives = [f"!network: {n}" for n in options.networks]
|
||
if options.ports:
|
||
directives.append(f"!port: {options.xray_ports}")
|
||
path = build.DATA_DIR / build.IP_SUBDIR / f"{name}.txt"
|
||
write_generated(
|
||
path,
|
||
[f"Источник: {note}", f"URL: {url}", f"Сетей: {len(nets)}", f"Ограничения: {options}"],
|
||
[*directives, *([""] if directives else []), *(str(net) for net in nets)],
|
||
)
|
||
written.append(path)
|
||
|
||
return written, parsed.skipped
|
||
|
||
|
||
def drop_stale(target: str, written: set[Path]) -> list[Path]:
|
||
"""Убирает файлы источника, которые в этот раз не появились (апстрим сузился)."""
|
||
removed = []
|
||
for directory in (build.DATA_DIR, build.DATA_DIR / build.IP_SUBDIR):
|
||
for path in sorted(directory.glob(f"{target}*.txt")):
|
||
stem = path.stem
|
||
if stem != target and not stem.startswith(f"{target}-"):
|
||
continue
|
||
if path in written or not is_generated(path):
|
||
continue
|
||
path.unlink()
|
||
removed.append(path)
|
||
return removed
|
||
|
||
|
||
def write_aggregates(aggregate: str) -> None:
|
||
"""Сводные категории — просто список include-ов по тому, что есть на диске."""
|
||
for directory, restrict in ((build.DATA_DIR, False), (build.DATA_DIR / build.IP_SUBDIR, True)):
|
||
names = []
|
||
for path in sorted(directory.glob("*.txt")):
|
||
name = path.stem
|
||
if name == aggregate:
|
||
continue
|
||
if restrict:
|
||
# В сводную попадают только безусловные списки: подмешать к ним
|
||
# «udp + порты 50000-50099» значило бы снять это ограничение с
|
||
# чужих сетей (там половина Cloudflare) — такие категории живут
|
||
# отдельными тегами.
|
||
_, options = build.resolve_ip(name)
|
||
if options.restricted:
|
||
continue
|
||
names.append(name)
|
||
if not names:
|
||
continue
|
||
write_generated(
|
||
directory / f"{aggregate}.txt",
|
||
["Сводная категория: рукописный список плюс всё, что притянул sync."],
|
||
[f"include:{name}" for name in names],
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Точка входа
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
for stream in (sys.stdout, sys.stderr):
|
||
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||
)
|
||
parser.add_argument("--sources", type=Path, default=TOOLS / "sources.json")
|
||
parser.add_argument("--only", action="append", default=[], help="синхронизировать только эти источники")
|
||
parser.add_argument("--sing-box", dest="singbox", help="путь к бинарю sing-box для .srs")
|
||
parser.add_argument("--strict", action="store_true", help="ненулевой код возврата при любой ошибке")
|
||
args = parser.parse_args(argv)
|
||
|
||
config = json.loads(args.sources.read_text(encoding="utf-8"))
|
||
singbox = args.singbox or shutil.which("sing-box")
|
||
sources = [s for s in config["sources"] if not args.only or s["name"] in args.only]
|
||
if not sources:
|
||
print("error: подходящих источников нет", file=sys.stderr)
|
||
return 1
|
||
|
||
failed = []
|
||
for source in sources:
|
||
try:
|
||
written, skipped = sync_source(source, singbox)
|
||
removed = drop_stale(source["target"], set(written))
|
||
files = ", ".join(p.relative_to(build.ROOT).as_posix() for p in written) or "нет данных"
|
||
print(f"{source['name']}: {files}")
|
||
for path in removed:
|
||
print(f" удалён устаревший {path.relative_to(build.ROOT).as_posix()}")
|
||
for warning in skipped[:10]:
|
||
print(f" пропущено — {warning}")
|
||
if len(skipped) > 10:
|
||
print(f" … и ещё {len(skipped) - 10} пропущенных записей")
|
||
except (SyncError, BuildError) as exc:
|
||
failed.append(source["name"])
|
||
print(f"{source['name']}: ОШИБКА — {exc}", file=sys.stderr)
|
||
print(" оставлены прежние данные", file=sys.stderr)
|
||
|
||
try:
|
||
write_aggregates(config["aggregate"])
|
||
except (SyncError, BuildError) as exc:
|
||
print(f"error: не удалось пересобрать сводные категории: {exc}", file=sys.stderr)
|
||
return 1
|
||
|
||
if failed:
|
||
print(f"источников с ошибкой: {len(failed)} из {len(sources)} ({', '.join(failed)})", file=sys.stderr)
|
||
if args.strict or len(failed) == len(sources):
|
||
return 1
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|