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
+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()