feat: add loon and mihomo compatibility outputs from surge source

This commit is contained in:
袁震
2026-04-06 13:39:12 +08:00
parent b7bdad17d3
commit f8ff7279c6
10 changed files with 514 additions and 23 deletions
+98 -18
View File
@@ -24,7 +24,10 @@ DEFAULT_LIMIT = 100
UNSUPPORTED_CLASH_TYPES = {
"USER-AGENT",
"URL-REGEX",
"DEST-PORT", # Surge alias, Clash usually uses DST-PORT
}
UNSUPPORTED_MIHOMO_TYPES = {
"USER-AGENT",
"URL-REGEX",
}
@@ -41,6 +44,7 @@ class Config:
include_categories: list[str]
exclude_categories: list[str]
clash_no_resolve: bool
mihomo_no_resolve: bool
@dataclass(frozen=True)
@@ -152,6 +156,7 @@ def load_config(path: Path) -> Config:
include_categories=source.get("include_categories", []),
exclude_categories=source.get("exclude_categories", []),
clash_no_resolve=output.get("clash_no_resolve", False),
mihomo_no_resolve=output.get("mihomo_no_resolve", False),
)
@@ -177,19 +182,25 @@ def parse_rules(content: str) -> list[RuleLine]:
return rules
def to_clash_payload_line(rule: RuleLine, no_resolve: bool) -> str | None:
def to_payload_line(
rule: RuleLine,
no_resolve: bool,
unsupported_types: set[str],
type_mapping: dict[str, str] | None = None,
) -> str | None:
parts = [p.strip() for p in rule.raw.split(",") if p.strip()]
if not parts:
return None
rule_type = parts[0].upper()
parts[0] = rule_type
mapped_type = (type_mapping or {}).get(rule_type, rule_type)
parts[0] = mapped_type
if rule_type in UNSUPPORTED_CLASH_TYPES:
if rule_type in unsupported_types:
return None
if rule_type in {"IP-CIDR", "IP-CIDR6"}:
payload = [rule_type, parts[1]] if len(parts) >= 2 else parts
if mapped_type in {"IP-CIDR", "IP-CIDR6"}:
payload = [mapped_type, parts[1]] if len(parts) >= 2 else parts
if no_resolve:
payload.append("no-resolve")
return ",".join(payload)
@@ -217,7 +228,33 @@ def format_surge(name: str, rules: list[RuleLine], source_path: str) -> str:
return "\n".join(header + body) + "\n"
def format_clash(name: str, rules: list[RuleLine], source_path: str, no_resolve: bool) -> str:
def format_loon(name: str, rules: list[RuleLine], source_path: str) -> str:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
count = Counter(rule.rule_type for rule in rules)
header = [
f"# NAME: {name}",
"# AUTHOR: gitea-shunt-rules",
f"# SOURCE: {source_path}",
f"# UPDATED: {now}",
]
for k in sorted(count):
header.append(f"# {k}: {count[k]}")
header.append(f"# TOTAL: {len(rules)}")
body = [rule.raw for rule in rules]
return "\n".join(header + body) + "\n"
def format_yaml_payload(
name: str,
rules: list[RuleLine],
source_path: str,
no_resolve: bool,
unsupported_types: set[str],
type_mapping: dict[str, str] | None = None,
author_name: str = "gitea-shunt-rules",
) -> tuple[str, int]:
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
payload: list[str] = []
@@ -225,7 +262,12 @@ def format_clash(name: str, rules: list[RuleLine], source_path: str, no_resolve:
skipped: Counter[str] = Counter()
for rule in rules:
converted = to_clash_payload_line(rule, no_resolve=no_resolve)
converted = to_payload_line(
rule,
no_resolve=no_resolve,
unsupported_types=unsupported_types,
type_mapping=type_mapping,
)
if converted is None:
skipped[rule.rule_type] += 1
continue
@@ -234,7 +276,7 @@ def format_clash(name: str, rules: list[RuleLine], source_path: str, no_resolve:
lines = [
f"# NAME: {name}",
"# AUTHOR: gitea-shunt-rules",
f"# AUTHOR: {author_name}",
f"# SOURCE: {source_path}",
f"# UPDATED: {now}",
]
@@ -246,7 +288,7 @@ def format_clash(name: str, rules: list[RuleLine], source_path: str, no_resolve:
lines.append("payload:")
lines.extend(f" - {item}" for item in payload)
return "\n".join(lines) + "\n"
return "\n".join(lines) + "\n", len(payload)
def should_include_category(name: str, cfg: Config, cli_names: set[str]) -> bool:
@@ -285,7 +327,7 @@ def find_categories(client: GiteaClient, cfg: Config, cli_names: set[str]) -> li
return sorted(categories)
def build_one_category(client: GiteaClient, cfg: Config, name: str, base_out: Path) -> tuple[int, int]:
def build_one_category(client: GiteaClient, cfg: Config, name: str, base_out: Path) -> tuple[int, int, int, int]:
filename_base = cfg.source_filename_pattern.format(name=name)
candidate_filenames = [
filename_base,
@@ -318,21 +360,48 @@ def build_one_category(client: GiteaClient, cfg: Config, name: str, base_out: Pa
rules = parse_rules(source_content)
surge_out = base_out / "surge" / f"{name}.list"
loon_out = base_out / "loon" / f"{name}.list"
clash_out = base_out / "clash" / f"{name}.yaml"
mihomo_out = base_out / "mihomo" / f"{name}.yaml"
surge_out.parent.mkdir(parents=True, exist_ok=True)
loon_out.parent.mkdir(parents=True, exist_ok=True)
clash_out.parent.mkdir(parents=True, exist_ok=True)
mihomo_out.parent.mkdir(parents=True, exist_ok=True)
surge_out.write_text(format_surge(name, rules, source_rel_path), encoding="utf-8")
clash_out.write_text(
format_clash(name, rules, source_rel_path, no_resolve=cfg.clash_no_resolve),
loon_out.write_text(format_loon(name, rules, source_rel_path), encoding="utf-8")
clash_text, clash_cnt = format_yaml_payload(
name,
rules,
source_rel_path,
no_resolve=cfg.clash_no_resolve,
unsupported_types=UNSUPPORTED_CLASH_TYPES,
type_mapping={"DEST-PORT": "DST-PORT"},
author_name="gitea-shunt-rules",
)
clash_out.write_text(clash_text, encoding="utf-8")
mihomo_text, mihomo_cnt = format_yaml_payload(
name,
rules,
source_rel_path,
no_resolve=cfg.mihomo_no_resolve,
unsupported_types=UNSUPPORTED_MIHOMO_TYPES,
type_mapping={"DEST-PORT": "DST-PORT"},
author_name="gitea-shunt-rules-mihomo",
)
mihomo_out.write_text(
mihomo_text,
encoding="utf-8",
)
return len(rules), sum(1 for r in rules if to_clash_payload_line(r, no_resolve=cfg.clash_no_resolve) is not None)
# source and loon keep the same parsed set
return len(rules), len(rules), clash_cnt, mihomo_cnt
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Generate Surge/Clash rules from Gitea source repo.")
p = argparse.ArgumentParser(description="Generate Surge/Loon/Clash/Mihomo rules from Gitea source repo.")
p.add_argument("--config", default="config.toml", help="Path to config TOML file")
p.add_argument("--names", default="", help="Comma-separated category names, e.g. YouTube,Netflix")
return p.parse_args()
@@ -354,19 +423,30 @@ def main() -> int:
out_dir.mkdir(parents=True, exist_ok=True)
total_source = 0
total_loon = 0
total_clash = 0
total_mihomo = 0
print(f"Found {len(categories)} categories under {cfg.source_root}")
for idx, name in enumerate(categories, start=1):
try:
s_cnt, c_cnt = build_one_category(client, cfg, name, out_dir)
s_cnt, l_cnt, c_cnt, m_cnt = build_one_category(client, cfg, name, out_dir)
total_source += s_cnt
total_loon += l_cnt
total_clash += c_cnt
print(f"[{idx}/{len(categories)}] {name}: source={s_cnt}, clash={c_cnt}")
total_mihomo += m_cnt
print(f"[{idx}/{len(categories)}] {name}: source={s_cnt}, loon={l_cnt}, clash={c_cnt}, mihomo={m_cnt}")
except Exception as exc:
print(f"[{idx}/{len(categories)}] {name}: failed: {exc}", file=sys.stderr)
print(f"Done. source_rules={total_source}, clash_rules={total_clash}, output={out_dir.resolve()}")
print(
"Done. "
f"source_rules={total_source}, "
f"loon_rules={total_loon}, "
f"clash_rules={total_clash}, "
f"mihomo_rules={total_mihomo}, "
f"output={out_dir.resolve()}"
)
return 0