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,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