feat: сборка geosite для ipcheck-доменов в нескольких форматах

Собирает список сервисов проверки IP (ip-lookup, leak-test, geoip-db,
fraud-score) в geosite.dat для Xray/3x-ui, а также в sing-box, Clash/mihomo,
AdGuard, plain text и JSON.

Сборщик на чистой стандартной библиотеке: пишет protobuf вручную, после
записи разбирает .dat обратно и сверяет с исходником. Формат сверен с
настоящим geosite.dat и проверен реальным Xray 26.3.27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-07 14:11:06 +03:00
co-authored by Claude Opus 5
commit f905a76caf
11 changed files with 1071 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
name: build
on:
push:
branches: [main]
paths:
- "data/**"
- "tools/**"
- ".github/workflows/build.yml"
pull_request:
paths:
- "data/**"
- "tools/**"
- ".github/workflows/build.yml"
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
permissions:
contents: write
env:
GH_TOKEN: ${{ github.token }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Lint sources
run: python tools/build.py --check
- name: Unit tests
run: python -m unittest discover -s tools -t tools
- name: Install sing-box
run: |
mkdir -p "$GITHUB_WORKSPACE/bin"
url=$(gh api repos/SagerNet/sing-box/releases/latest \
--jq '.assets[] | select(.name | test("-linux-amd64\\.tar\\.gz$")) | .browser_download_url')
echo "sing-box: $url"
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$GITHUB_WORKSPACE/bin" --wildcards '*/sing-box'
echo "$GITHUB_WORKSPACE/bin" >> "$GITHUB_PATH"
- name: Install mihomo
run: |
url=$(gh api repos/MetaCubeX/mihomo/releases/latest \
--jq '.assets[] | select(.name | test("^mihomo-linux-amd64-v[0-9.]+\\.gz$")) | .browser_download_url')
echo "mihomo: $url"
curl -fsSL "$url" | gunzip > "$GITHUB_WORKSPACE/bin/mihomo"
chmod +x "$GITHUB_WORKSPACE/bin/mihomo"
- name: Build
run: |
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
python tools/build.py
- name: Validate geosite.dat with Xray
run: |
url=$(gh api repos/XTLS/Xray-core/releases/latest \
--jq '.assets[] | select(.name == "Xray-linux-64.zip") | .browser_download_url')
curl -fsSL -o xray.zip "$url"
unzip -o -j xray.zip xray -d "$GITHUB_WORKSPACE/bin"
chmod +x "$GITHUB_WORKSPACE/bin/xray"
# Роутинг-правило заставляет Xray реально распарсить geosite.dat.
# Битый файл или отсутствующий тег -> ненулевой exit code.
cat > /tmp/xray-test.json <<'EOF'
{
"inbounds": [{"port": 10800, "protocol": "socks"}],
"outbounds": [{"protocol": "freedom", "tag": "direct"}],
"routing": {
"rules": [
{"type": "field", "domain": ["ext:geosite.dat:ipcheck"], "outboundTag": "direct"},
{"type": "field", "domain": ["ext:geosite.dat:leak-test"], "outboundTag": "direct"}
]
}
}
EOF
XRAY_LOCATION_ASSET="$GITHUB_WORKSPACE/dist" xray run -test -c /tmp/xray-test.json
- name: Validate sing-box rule-set
run: |
sing-box rule-set decompile --output /tmp/ipcheck.json dist/sing-box/ipcheck.srs
grep -q '"2ip.ru"' /tmp/ipcheck.json
- name: Release notes
id: notes
run: |
echo "tag=$(date -u '+%Y%m%d%H%M')" >> "$GITHUB_OUTPUT"
{
echo "Собрано из \`${GITHUB_SHA::7}\`."
echo
echo '| Категория | Правил |'
echo '| --- | ---: |'
python -c "import json;[print(f'| {k} | {v} |') for k,v in json.load(open('dist/metadata.json'))['categories'].items()]"
echo
echo "sha256(geosite.dat) = \`$(cut -d' ' -f1 dist/geosite.dat.sha256sum)\`"
} > /tmp/notes.md
cat /tmp/notes.md >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: ipcheck-domains
path: dist/
if-no-files-found: error
- name: Publish to release branch
if: github.event_name != 'pull_request'
run: |
cp README.md dist/README.md
cp LICENSE dist/LICENSE
cd dist
git init -q -b release
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
git commit -q -m "build: $(date -u '+%Y-%m-%d %H:%M UTC') ($GITHUB_SHA)"
git push -qf "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" release
cd ..
rm -f dist/README.md dist/LICENSE
- name: Create release
if: github.event_name != 'pull_request'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.notes.outputs.tag }}
name: ${{ steps.notes.outputs.tag }}
body_path: /tmp/notes.md
files: |
dist/geosite.dat
dist/geosite.dat.sha256sum
dist/metadata.json
dist/sing-box/*.srs
dist/mihomo/*.mrs
dist/clash/*
dist/txt/*
dist/json/*
dist/adguard/*
+4
View File
@@ -0,0 +1,4 @@
dist/
bin/
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Leonid Pershin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+157
View File
@@ -0,0 +1,157 @@
# ipcheck-domains
Список доменов сервисов, которые определяют и проверяют IP-адрес: «какой у меня IP»,
тесты утечек DNS/WebRTC, GeoIP-базы и скоринг прокси/VPN.
Собирается в несколько форматов. Основной — `geosite.dat` (protobuf v2ray/Xray) для 3x-ui.
Сборка идёт автоматически при каждом изменении `data/` и раз в сутки по расписанию.
## Категории
| Категория | Что внутри |
| --- | --- |
| `ipcheck` | всё вместе — то, что обычно и нужно в роутинге |
| `ip-lookup` | «какой у меня IP»: 2ip, whoer, ipinfo, ifconfig.me, ipify… |
| `leak-test` | тесты утечек: browserleaks, ipleak, dnsleaktest |
| `geoip-db` | GeoIP-базы и справочники: maxmind, db-ip, ip2location… |
| `fraud-score` | детекторы VPN/прокси: spur, scamalytics, ipqualityscore… |
## Скачать
Ветка `release` обновляется при каждой сборке — ссылки стабильны:
```
https://raw.githubusercontent.com/mrleo1nid/ipcheck-domains/release/<путь>
```
| Файл | Для чего |
| --- | --- |
| `ipcheck.dat` | Xray / v2ray / 3x-ui — кладётся рядом со штатным `geosite.dat` |
| `geosite.dat` | то же самое, но именем по умолчанию — если хотите заменить штатный файл |
| `sing-box/<категория>.srs` | sing-box, скомпилированный rule-set |
| `sing-box/<категория>.json` | sing-box, исходный rule-set |
| `mihomo/<категория>.mrs` | mihomo, бинарный rule-provider |
| `clash/<категория>.yaml` | Clash/mihomo rule-provider, `behavior: domain` |
| `clash/<категория>.list` | Clash/mihomo rule-provider, `behavior: classical` |
| `txt/<категория>.txt` | просто домены, по одному на строку |
| `json/<категория>.json` | структурированный список с типами правил |
| `adguard/<категория>.txt` | AdGuard Home / uBlock — если нужно блокировать, а не роутить |
| `metadata.json` | время сборки, счётчики, sha256 |
Плюс каждая сборка публикуется как GitHub Release с датой в теге.
## 3x-ui
Файл кладётся туда же, где лежат `geoip.dat` и `geosite.dat` панели. Обычно это
`/usr/local/x-ui/bin` (панель, установленная скриптом) или `/usr/local/share/xray`:
```bash
curl -fsSL -o /usr/local/x-ui/bin/ipcheck.dat https://raw.githubusercontent.com/mrleo1nid/ipcheck-domains/release/ipcheck.dat
```
Дальше в панели: **Xray Settings → Routing Rules → Add Rule**, поле `domain`:
```
ext:ipcheck.dat:ipcheck
```
и нужный `outboundTag`. Тем же правилом в JSON-конфиге:
```json
{
"type": "field",
"domain": ["ext:ipcheck.dat:ipcheck"],
"outboundTag": "proxy"
}
```
Если вы заменили штатный `geosite.dat` нашим файлом, синтаксис короче — `geosite:ipcheck`.
Отдельные категории адресуются так же: `ext:ipcheck.dat:leak-test`.
После замены файла Xray нужно перезапустить (в 3x-ui — **Restart Xray**).
## sing-box
```json
{
"route": {
"rules": [{ "rule_set": "ipcheck", "outbound": "proxy" }],
"rule_set": [
{
"type": "remote",
"tag": "ipcheck",
"format": "binary",
"url": "https://raw.githubusercontent.com/mrleo1nid/ipcheck-domains/release/sing-box/ipcheck.srs",
"download_detour": "direct"
}
]
}
}
```
## Clash / mihomo
```yaml
rule-providers:
ipcheck:
type: http
behavior: domain
format: yaml
url: "https://raw.githubusercontent.com/mrleo1nid/ipcheck-domains/release/clash/ipcheck.yaml"
path: ./ruleset/ipcheck.yaml
interval: 86400
rules:
- RULE-SET,ipcheck,PROXY
```
Для `.mrs` укажите `behavior: domain` и `format: mrs`.
## Как добавить домен
Правьте файлы в `data/` — по одному домену на строку, в подходящую категорию.
Всё остальное соберётся само.
Формат строки (тот же, что у `v2fly/domain-list-community`):
```
example.com # домен и все его поддомены (тип по умолчанию)
domain:example.com # то же самое, явно
full:www.example.com # только этот хост, без поддоменов
keyword:ipcheck # подстрока в имени
regexp:^ip[0-9]+\.com$ # регулярное выражение
include:ip-lookup # подключить другую категорию целиком
example.com @ads # атрибут — попадёт в .dat как geosite:ipcheck@ads
```
Пустые строки и всё после `#` игнорируются.
## Локальная сборка
Нужен только Python 3.10+, зависимостей нет:
```bash
python tools/build.py
```
Результат — в `dist/`. Проверка исходников без сборки (синтаксис, дубликаты,
домены, уже покрытые родительским суффиксом, циклические `include`):
```bash
python tools/build.py --check
```
`.srs` и `.mrs` собираются, только если в `PATH` есть `sing-box` и `mihomo`
без них остальные форматы всё равно соберутся. Пути можно указать явно:
```bash
python tools/build.py --sing-box ./bin/sing-box --mihomo ./bin/mihomo
```
Сборщик проверяет сам себя: после записи `geosite.dat` он разбирает файл обратно
и сверяет с исходником. В CI дополнительно поднимается настоящий Xray с правилом
`ext:geosite.dat:ipcheck` — если файл не читается, сборка падает.
## Лицензия
[MIT](LICENSE). Список доменов — общедоступные факты, используйте как угодно.
+11
View File
@@ -0,0 +1,11 @@
# fraud-score — детекторы прокси/VPN и скоринг "качества" IP.
spur.us
scamalytics.com
ipqualityscore.com
proxycheck.io
vpnapi.io
iphub.info
getipintel.net
fraudguard.io
criminalip.io
+11
View File
@@ -0,0 +1,11 @@
# geoip-db — провайдеры GeoIP-баз и справочных данных об IP.
ipdata.co
ipbase.com
ipwhois.io
ipgeolocation.io
ipregistry.co
bigdatacloud.net
db-ip.com
maxmind.com
ip2location.com
+21
View File
@@ -0,0 +1,21 @@
# ip-lookup — сервисы "какой у меня IP" и общая информация об IP.
# Формат строки: [type:]value [@attr ...] Комментарии — от символа "#".
# Без префикса значение трактуется как domain: (сам домен + все поддомены).
2ip.ru
2ip.io
whoer.net
whoer.com
whatismyipaddress.com
whatismyip.com
myip.com
ipinfo.io
ip-api.com
ipapi.co
ipapi.is
ipify.org
ifconfig.me
ifconfig.co
ifconfig.io
icanhazip.com
ip.sb
+7
View File
@@ -0,0 +1,7 @@
# ipcheck — сводная категория: всё, что определяет или проверяет IP-адрес.
# Именно её используют в роутинге как geosite:ipcheck.
include:ip-lookup
include:leak-test
include:geoip-db
include:fraud-score
+5
View File
@@ -0,0 +1,5 @@
# leak-test — тесты утечек: WebRTC, DNS, отпечаток браузера.
browserleaks.com
ipleak.net
dnsleaktest.com
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env python3
"""Собирает geosite-артефакты из data/ в несколько форматов.
Основной формат — geosite.dat (protobuf v2ray/Xray, подходит для 3x-ui).
Дополнительно: plain text, JSON, sing-box rule-set, Clash/mihomo, AdGuard.
Зависимостей нет — только стандартная библиотека. Внешние бинарники
(sing-box, mihomo) нужны лишь для компиляции .srs/.mrs и опциональны.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = ROOT / "data"
TYPE_DOMAIN = "domain"
TYPE_FULL = "full"
TYPE_KEYWORD = "keyword"
TYPE_REGEXP = "regexp"
# Значения enum Domain.Type из v2ray router protobuf.
PB_TYPE = {TYPE_KEYWORD: 0, TYPE_REGEXP: 1, TYPE_DOMAIN: 2, TYPE_FULL: 3}
# Порядок вывода: сначала суффиксы, потом точные, потом keyword/regexp.
TYPE_ORDER = {TYPE_DOMAIN: 0, TYPE_FULL: 1, TYPE_KEYWORD: 2, TYPE_REGEXP: 3}
LABEL_RE = r"(?!-)[a-z0-9_-]{1,63}(?<!-)"
DOMAIN_RE = re.compile(rf"^{LABEL_RE}(\.{LABEL_RE})+$")
ATTR_RE = re.compile(r"^[a-z0-9_-]+$")
CATEGORY_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
class BuildError(Exception):
"""Ошибка в исходных данных — сборка должна упасть."""
@dataclass(frozen=True)
class Rule:
type: str
value: str
attrs: tuple[str, ...] = ()
@property
def sort_key(self) -> tuple[int, str]:
return (TYPE_ORDER[self.type], self.value)
def __str__(self) -> str:
base = self.value if self.type == TYPE_DOMAIN else f"{self.type}:{self.value}"
return " ".join([base, *(f"@{a}" for a in self.attrs)])
# --------------------------------------------------------------------------- #
# Разбор исходников
# --------------------------------------------------------------------------- #
def source_path(category: str) -> Path:
return DATA_DIR / f"{category}.txt"
def list_categories() -> list[str]:
return sorted(p.stem for p in DATA_DIR.glob("*.txt"))
def parse_token(token: str, where: str) -> Rule:
prefix, sep, value = token.partition(":")
if sep and prefix.lower() in (TYPE_DOMAIN, TYPE_FULL, TYPE_KEYWORD, TYPE_REGEXP):
rtype = prefix.lower()
else:
rtype, value = TYPE_DOMAIN, token
if not value:
raise BuildError(f"{where}: пустое значение в '{token}'")
if rtype in (TYPE_DOMAIN, TYPE_FULL):
try:
value = value.encode("idna").decode("ascii")
except UnicodeError:
pass # ниже отловит DOMAIN_RE
value = value.lower().strip(".")
if not DOMAIN_RE.match(value):
raise BuildError(f"{where}: невалидный домен '{value}'")
elif rtype == TYPE_KEYWORD:
value = value.lower()
if any(c.isspace() for c in value):
raise BuildError(f"{where}: keyword не может содержать пробелы")
elif rtype == TYPE_REGEXP:
try:
re.compile(value)
except re.error as exc:
raise BuildError(f"{where}: невалидный regexp '{value}': {exc}") from exc
return Rule(rtype, value)
def parse_category(category: str) -> list[tuple[str, Rule | str]]:
"""Возвращает список ('rule', Rule) / ('include', category) в порядке файла."""
path = source_path(category)
if not path.is_file():
raise BuildError(f"категория '{category}' не найдена: {path}")
entries: list[tuple[str, Rule | str]] = []
for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.split("#", 1)[0].strip()
if not line:
continue
where = f"{path.name}:{lineno}"
token, *rest = line.split()
attrs = []
for part in rest:
if not part.startswith("@"):
raise BuildError(f"{where}: ожидался атрибут '@name', получено '{part}'")
attr = part[1:].lower()
if not ATTR_RE.match(attr):
raise BuildError(f"{where}: невалидный атрибут '@{attr}'")
attrs.append(attr)
if token.startswith("include:"):
if attrs:
raise BuildError(f"{where}: include не поддерживает атрибуты")
target = token[len("include:") :]
if not CATEGORY_RE.match(target):
raise BuildError(f"{where}: невалидное имя категории '{target}'")
entries.append(("include", target))
continue
rule = parse_token(token, where)
entries.append(("rule", Rule(rule.type, rule.value, tuple(sorted(set(attrs))))))
return entries
def resolve(category: str, stack: tuple[str, ...] = ()) -> list[Rule]:
"""Разворачивает include-и, убирает дубликаты, сортирует детерминированно."""
if category in stack:
chain = " -> ".join([*stack, category])
raise BuildError(f"циклический include: {chain}")
collected: dict[Rule, None] = {}
for kind, payload in parse_category(category):
if kind == "include":
for rule in resolve(str(payload), (*stack, category)):
collected.setdefault(rule, None)
else:
collected.setdefault(payload, None) # type: ignore[arg-type]
return sorted(collected, key=lambda r: r.sort_key)
# --------------------------------------------------------------------------- #
# protobuf: geosite.dat
# --------------------------------------------------------------------------- #
def _varint(value: int) -> bytes:
out = bytearray()
while True:
chunk = value & 0x7F
value >>= 7
if value:
out.append(chunk | 0x80)
else:
out.append(chunk)
return bytes(out)
def _len_field(field: int, payload: bytes) -> bytes:
return _varint(field << 3 | 2) + _varint(len(payload)) + payload
def _str_field(field: int, value: str) -> bytes:
return _len_field(field, value.encode("utf-8"))
def _varint_field(field: int, value: int) -> bytes:
return _varint(field << 3 | 0) + _varint(value)
def encode_geosite_dat(categories: dict[str, list[Rule]]) -> bytes:
"""GeoSiteList{ repeated GeoSite entry = 1 }."""
out = bytearray()
for name in sorted(categories):
entry = bytearray(_str_field(1, name.upper())) # GeoSite.country_code
for rule in categories[name]:
domain = bytearray()
pb_type = PB_TYPE[rule.type]
if pb_type: # proto3 опускает значение по умолчанию (0 == Plain)
domain += _varint_field(1, pb_type)
domain += _str_field(2, rule.value)
for attr in rule.attrs:
# Attribute{ key = 1; bool_value = 2 }
domain += _len_field(3, _str_field(1, attr) + _varint_field(2, 1))
entry += _len_field(2, bytes(domain)) # GeoSite.domain
out += _len_field(1, bytes(entry))
return bytes(out)
def decode_geosite_dat(blob: bytes) -> dict[str, list[Rule]]:
"""Обратный разбор — используется для самопроверки собранного файла."""
pb_to_type = {v: k for k, v in PB_TYPE.items()}
def fields(buf: bytes):
pos = 0
while pos < len(buf):
key, pos = _read_varint(buf, pos)
field, wire = key >> 3, key & 7
if wire == 0:
value, pos = _read_varint(buf, pos)
yield field, value
elif wire == 2:
length, pos = _read_varint(buf, pos)
yield field, buf[pos : pos + length]
pos += length
else:
raise BuildError(f"неподдерживаемый wire type {wire}")
result: dict[str, list[Rule]] = {}
for field, payload in fields(blob):
if field != 1:
continue
name, rules = "", []
for f, p in fields(payload):
if f == 1:
name = p.decode("utf-8")
elif f == 2:
rtype, value, attrs = TYPE_KEYWORD, "", []
for df, dp in fields(p):
if df == 1:
rtype = pb_to_type[dp]
elif df == 2:
value = dp.decode("utf-8")
elif df == 3:
for af, ap in fields(dp):
if af == 1:
attrs.append(ap.decode("utf-8"))
rules.append(Rule(rtype, value, tuple(attrs)))
result[name] = rules
return result
def _read_varint(buf: bytes, pos: int) -> tuple[int, int]:
value = shift = 0
while True:
byte = buf[pos]
pos += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, pos
shift += 7
# --------------------------------------------------------------------------- #
# Текстовые форматы
# --------------------------------------------------------------------------- #
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content.replace("\r\n", "\n"), encoding="utf-8", newline="\n")
def header(category: str, count: int, comment: str = "#") -> str:
return (
f"{comment} ipcheck-domains — категория: {category}\n"
f"{comment} Правил: {count}. Файл генерируется автоматически, не редактируйте вручную.\n"
f"{comment} Источник: data/{category}.txt\n"
)
def render_plain(rules: list[Rule]) -> str:
"""Только «голые» домены — для скриптов и ручной вставки."""
seen = {r.value for r in rules if r.type in (TYPE_DOMAIN, TYPE_FULL)}
return "".join(f"{value}\n" for value in sorted(seen))
def render_source(category: str, rules: list[Rule]) -> str:
return header(category, len(rules)) + "\n" + "".join(f"{r}\n" for r in rules)
def render_json(category: str, rules: list[Rule]) -> str:
payload = {
"category": category,
"count": len(rules),
"rules": [
{"type": r.type, "value": r.value, **({"attrs": list(r.attrs)} if r.attrs else {})}
for r in rules
],
}
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
def render_singbox(rules: list[Rule]) -> str:
"""sing-box rule-set (source). domain: apex + domain_suffix: .apex == v2ray domain:."""
rule: dict[str, list[str]] = {}
for kind, values in (
("domain", sorted({r.value for r in rules if r.type in (TYPE_DOMAIN, TYPE_FULL)})),
("domain_suffix", sorted({f".{r.value}" for r in rules if r.type == TYPE_DOMAIN})),
("domain_keyword", sorted({r.value for r in rules if r.type == TYPE_KEYWORD})),
("domain_regex", sorted({r.value for r in rules if r.type == TYPE_REGEXP})),
):
if values:
rule[kind] = values
return json.dumps({"version": 1, "rules": [rule] if rule else []}, indent=2) + "\n"
def render_clash_yaml(category: str, rules: list[Rule]) -> str:
"""rule-provider с behavior: domain — keyword/regexp этим форматом не поддерживаются."""
lines = [header(category, len(rules)), "payload:\n"]
for rule in rules:
if rule.type == TYPE_DOMAIN:
lines.append(f" - '+.{rule.value}'\n")
elif rule.type == TYPE_FULL:
lines.append(f" - '{rule.value}'\n")
return "".join(lines)
def render_clash_list(category: str, rules: list[Rule]) -> str:
"""rule-provider с behavior: classical."""
mapping = {
TYPE_DOMAIN: "DOMAIN-SUFFIX",
TYPE_FULL: "DOMAIN",
TYPE_KEYWORD: "DOMAIN-KEYWORD",
TYPE_REGEXP: "DOMAIN-REGEX",
}
body = "".join(f"{mapping[r.type]},{r.value}\n" for r in rules)
return header(category, len(rules)) + body
def render_adguard(category: str, rules: list[Rule]) -> str:
lines = [header(category, len(rules), comment="!")]
for rule in rules:
if rule.type == TYPE_DOMAIN:
lines.append(f"||{rule.value}^\n")
elif rule.type == TYPE_FULL:
lines.append(f"||{rule.value}^$important\n")
return "".join(lines)
# --------------------------------------------------------------------------- #
# Компиляция бинарных rule-set (опционально)
# --------------------------------------------------------------------------- #
def compile_binaries(out_dir: Path, categories: list[str], singbox: str | None, mihomo: str | None) -> list[str]:
produced = []
if singbox:
for category in categories:
src = out_dir / "sing-box" / f"{category}.json"
dst = out_dir / "sing-box" / f"{category}.srs"
subprocess.run([singbox, "rule-set", "compile", "--output", str(dst), str(src)], check=True)
produced.append(str(dst.relative_to(out_dir)))
else:
print(" sing-box не найден — .srs пропущены", file=sys.stderr)
if mihomo:
for category in categories:
src = out_dir / "clash" / f"{category}.yaml"
dst = out_dir / "mihomo" / f"{category}.mrs"
dst.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([mihomo, "convert-ruleset", "domain", "yaml", str(src), str(dst)], check=True)
produced.append(str(dst.relative_to(out_dir)))
else:
print(" mihomo не найден — .mrs пропущены", file=sys.stderr)
return produced
# --------------------------------------------------------------------------- #
# Точка входа
# --------------------------------------------------------------------------- #
def build(out_dir: Path, singbox: str | None, mihomo: str | None) -> int:
categories = list_categories()
if not categories:
raise BuildError(f"в {DATA_DIR} нет ни одного .txt")
resolved = {name: resolve(name) for name in categories}
if out_dir.exists():
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True)
dat = encode_geosite_dat(resolved)
(out_dir / "geosite.dat").write_bytes(dat)
# Копия под отдельным именем: её можно положить рядом со штатным
# geosite.dat и адресовать как ext:ipcheck.dat:ipcheck, ничего не перезаписывая.
(out_dir / "ipcheck.dat").write_bytes(dat)
# Самопроверка: файл должен читаться обратно ровно в то, что мы записали.
decoded = decode_geosite_dat(dat)
expected = {name.upper(): rules for name, rules in resolved.items()}
if decoded != expected:
raise BuildError("geosite.dat не проходит round-trip проверку")
digest = hashlib.sha256(dat).hexdigest()
write(out_dir / "geosite.dat.sha256sum", f"{digest} geosite.dat\n")
write(out_dir / "ipcheck.dat.sha256sum", f"{digest} ipcheck.dat\n")
for name, rules in resolved.items():
write(out_dir / "txt" / f"{name}.txt", render_plain(rules))
write(out_dir / "src" / f"{name}.txt", render_source(name, rules))
write(out_dir / "json" / f"{name}.json", render_json(name, rules))
write(out_dir / "sing-box" / f"{name}.json", render_singbox(rules))
write(out_dir / "clash" / f"{name}.yaml", render_clash_yaml(name, rules))
write(out_dir / "clash" / f"{name}.list", render_clash_list(name, rules))
write(out_dir / "adguard" / f"{name}.txt", render_adguard(name, rules))
compile_binaries(out_dir, categories, singbox, mihomo)
epoch = os.environ.get("SOURCE_DATE_EPOCH")
built_at = (
datetime.fromtimestamp(int(epoch), tz=timezone.utc)
if epoch
else datetime.now(tz=timezone.utc)
)
write(
out_dir / "metadata.json",
json.dumps(
{
"built_at": built_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"geosite_dat_sha256": digest,
"geosite_dat_size": len(dat),
"total_rules": sum(len(r) for r in resolved.values()),
"categories": {name: len(rules) for name, rules in resolved.items()},
},
ensure_ascii=False,
indent=2,
)
+ "\n",
)
print(f"geosite.dat: {len(dat)} байт, sha256 {digest[:16]}")
for name in categories:
print(f" {name:<12} {len(resolved[name]):>4} правил")
print(f"Готово: {out_dir}")
return 0
def check() -> int:
categories = list_categories()
if not categories:
raise BuildError(f"в {DATA_DIR} нет ни одного .txt")
problems = []
for name in categories:
if not CATEGORY_RE.match(name):
problems.append(f"{name}: имя категории должно быть [a-z0-9-]")
entries = parse_category(name)
seen: dict[Rule, int] = {}
for index, (kind, payload) in enumerate(entries):
if kind != "rule":
continue
rule: Rule = payload # type: ignore[assignment]
if rule in seen:
problems.append(f"{name}.txt: дубликат '{rule}'")
seen[rule] = index
suffixes = {r.value for r in seen if r.type == TYPE_DOMAIN}
for rule in seen:
if rule.type != TYPE_DOMAIN:
continue
parent = rule.value.split(".", 1)[1] if "." in rule.value else ""
while parent:
if parent in suffixes:
problems.append(f"{name}.txt: '{rule.value}' уже покрыт '{parent}'")
break
parent = parent.split(".", 1)[1] if "." in parent else ""
resolve(name) # ловит циклы и битые include
for problem in problems:
print(f"error: {problem}", file=sys.stderr)
if problems:
return 1
print(f"ok: {len(categories)} категорий, замечаний нет")
return 0
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("--out", type=Path, default=ROOT / "dist", help="каталог сборки (по умолчанию dist/)")
parser.add_argument("--check", action="store_true", help="только проверить исходники, ничего не собирать")
parser.add_argument("--sing-box", dest="singbox", help="путь к бинарю sing-box для сборки .srs")
parser.add_argument("--mihomo", help="путь к бинарю mihomo для сборки .mrs")
args = parser.parse_args(argv)
try:
if args.check:
return check()
return build(
args.out.resolve(),
args.singbox or shutil.which("sing-box"),
args.mihomo or shutil.which("mihomo"),
)
except BuildError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Тесты сборщика: python -m unittest discover -s tools -t tools"""
from __future__ import annotations
import contextlib
import json
import tempfile
import unittest
from pathlib import Path
import build
from build import BuildError, Rule
@contextlib.contextmanager
def data(**files: str):
"""Подменяет data/ временным каталогом с заданными категориями."""
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
for name, content in files.items():
(root / f"{name.replace('_', '-')}.txt").write_text(content, encoding="utf-8")
original = build.DATA_DIR
build.DATA_DIR = root
try:
yield root
finally:
build.DATA_DIR = original
class ParseTest(unittest.TestCase):
def test_default_type_is_domain(self):
with data(a="example.com\n"):
self.assertEqual(build.resolve("a"), [Rule("domain", "example.com")])
def test_all_prefixes(self):
source = "domain:a.com\nfull:b.com\nkeyword:cc\nregexp:^d[0-9]+\\.com$\n"
with data(a=source):
self.assertEqual(
build.resolve("a"),
[
Rule("domain", "a.com"),
Rule("full", "b.com"),
Rule("keyword", "cc"),
Rule("regexp", r"^d[0-9]+\.com$"),
],
)
def test_comments_blank_lines_and_attributes(self):
with data(a="# заголовок\n\na.com @ads @cn # хвост\n"):
self.assertEqual(build.resolve("a"), [Rule("domain", "a.com", ("ads", "cn"))])
def test_normalisation(self):
with data(a="Example.COM.\nDOMAIN:пример.рф\n"):
values = [r.value for r in build.resolve("a")]
self.assertIn("example.com", values)
self.assertIn("xn--e1afmkfd.xn--p1ai", values)
def test_include_dedupes_and_sorts(self):
with data(a="include:b\nz.com\na.com\n", b="a.com\nfull:m.com\n"):
self.assertEqual(
build.resolve("a"),
[Rule("domain", "a.com"), Rule("domain", "z.com"), Rule("full", "m.com")],
)
def test_rejects_bad_input(self):
cases = {
"битый домен": "not_a_domain\n",
"битый regexp": "regexp:[unclosed\n",
"атрибут без @": "a.com ads\n",
"неизвестный include": "include:missing\n",
}
for label, source in cases.items():
with self.subTest(label), data(a=source):
with self.assertRaises(BuildError):
build.resolve("a")
def test_detects_include_cycle(self):
with data(a="include:b\n", b="include:a\n"):
with self.assertRaisesRegex(BuildError, "цикл"):
build.resolve("a")
class ProtobufTest(unittest.TestCase):
def test_known_byte_layout(self):
blob = build.encode_geosite_dat({"t": [Rule("domain", "a.com")]})
# 0a 0e GeoSiteList.entry, 14 байт
# 0a 01 54 country_code = "T" (в .dat теги в верхнем регистре)
# 12 09 GeoSite.domain, 9 байт
# 08 02 type = Domain(2)
# 12 05 612e636f6d value = "a.com"
self.assertEqual(blob.hex(), "0a0e" "0a0154" "1209" "0802" "1205" "612e636f6d")
def test_keyword_type_is_omitted_as_proto3_default(self):
blob = build.encode_geosite_dat({"t": [Rule("keyword", "ip")]})
self.assertNotIn(b"\x08\x00", blob)
self.assertEqual(build.decode_geosite_dat(blob)["T"], [Rule("keyword", "ip")])
def test_round_trip_with_attributes(self):
categories = {
"one": [Rule("domain", "a.com", ("ads",)), Rule("full", "b.com")],
"two": [Rule("keyword", "ip"), Rule("regexp", r"^x\d+$")],
}
decoded = build.decode_geosite_dat(build.encode_geosite_dat(categories))
self.assertEqual(decoded, {k.upper(): v for k, v in categories.items()})
class RenderTest(unittest.TestCase):
def test_singbox_expands_suffix_to_apex_plus_dot(self):
rendered = json.loads(build.render_singbox([Rule("domain", "a.com"), Rule("full", "b.com")]))
self.assertEqual(rendered["rules"][0]["domain"], ["a.com", "b.com"])
self.assertEqual(rendered["rules"][0]["domain_suffix"], [".a.com"])
def test_clash_yaml_skips_unsupported_types(self):
rules = [Rule("domain", "a.com"), Rule("full", "b.com"), Rule("keyword", "ip")]
payload = build.render_clash_yaml("t", rules)
self.assertIn("- '+.a.com'", payload)
self.assertIn("- 'b.com'", payload)
self.assertNotIn("ip", payload.split("payload:")[1])
def test_clash_list_keeps_all_types(self):
rules = [Rule("domain", "a.com"), Rule("keyword", "ip"), Rule("regexp", "^x$")]
body = build.render_clash_list("t", rules)
self.assertIn("DOMAIN-SUFFIX,a.com", body)
self.assertIn("DOMAIN-KEYWORD,ip", body)
self.assertIn("DOMAIN-REGEX,^x$", body)
class CheckTest(unittest.TestCase):
def test_clean_sources_pass(self):
with data(a="a.com\nb.com\n"):
self.assertEqual(build.check(), 0)
def test_duplicate_is_reported(self):
with data(a="a.com\na.com\n"):
self.assertEqual(build.check(), 1)
def test_subdomain_covered_by_parent_is_reported(self):
with data(a="a.com\ndeep.sub.a.com\n"):
self.assertEqual(build.check(), 1)
def test_real_repository_data_is_clean(self):
self.assertEqual(build.check(), 0)
class BuildTest(unittest.TestCase):
def test_produces_every_format(self):
expected = [
"geosite.dat",
"ipcheck.dat",
"geosite.dat.sha256sum",
"metadata.json",
"txt/b.txt",
"src/b.txt",
"json/b.json",
"sing-box/b.json",
"clash/b.yaml",
"clash/b.list",
"adguard/b.txt",
]
with data(a="a.com\n", b="include:a\nfull:c.com\n"), tempfile.TemporaryDirectory() as out:
out_dir = Path(out) / "dist"
self.assertEqual(build.build(out_dir, None, None), 0)
for rel in expected:
self.assertTrue((out_dir / rel).is_file(), rel)
self.assertEqual(
(out_dir / "geosite.dat").read_bytes(), (out_dir / "ipcheck.dat").read_bytes()
)
meta = json.loads((out_dir / "metadata.json").read_text(encoding="utf-8"))
self.assertEqual(meta["categories"], {"a": 1, "b": 2})
if __name__ == "__main__":
unittest.main()