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:
@@ -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/*
|
||||
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
bin/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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). Список доменов — общедоступные факты, используйте как угодно.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
# ipcheck — сводная категория: всё, что определяет или проверяет IP-адрес.
|
||||
# Именно её используют в роутинге как geosite:ipcheck.
|
||||
|
||||
include:ip-lookup
|
||||
include:leak-test
|
||||
include:geoip-db
|
||||
include:fraud-score
|
||||
@@ -0,0 +1,5 @@
|
||||
# leak-test — тесты утечек: WebRTC, DNS, отпечаток браузера.
|
||||
|
||||
browserleaks.com
|
||||
ipleak.net
|
||||
dnsleaktest.com
|
||||
+516
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user