feat: initial gitea shunt rules generator
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
name: Generate Rules
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- main.py
|
||||||
|
- config.toml
|
||||||
|
- config.json
|
||||||
|
- .gitea/workflows/generate-rules.yml
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Prepare config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -f config.toml ]; then
|
||||||
|
echo "Use existing config.toml"
|
||||||
|
elif [ -f config.json ]; then
|
||||||
|
echo "Use existing config.json"
|
||||||
|
elif [ -f config.example.toml ]; then
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
echo "Generated config.toml from example"
|
||||||
|
else
|
||||||
|
echo "No config file found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate rules
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -f config.toml ]; then
|
||||||
|
python3 main.py --config config.toml
|
||||||
|
else
|
||||||
|
python3 main.py --config config.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit and push dist changes
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ -n "$(git status --porcelain dist)" ]; then
|
||||||
|
git config user.name "gitea-actions[bot]"
|
||||||
|
git config user.email "gitea-actions@localhost"
|
||||||
|
git add dist
|
||||||
|
git commit -m "chore: update generated rules"
|
||||||
|
git push
|
||||||
|
else
|
||||||
|
echo "No changes in dist/"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
name: Publish Rules To External Repo
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "15 3 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Validate required secrets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
missing=0
|
||||||
|
for key in GITEA_BASE_URL GITEA_TOKEN SOURCE_OWNER SOURCE_REPO TARGET_OWNER TARGET_REPO; do
|
||||||
|
if [ -z "${!key}" ]; then
|
||||||
|
echo "Missing required secret: $key" >&2
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$missing" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
SOURCE_OWNER: ${{ secrets.SOURCE_OWNER }}
|
||||||
|
SOURCE_REPO: ${{ secrets.SOURCE_REPO }}
|
||||||
|
TARGET_OWNER: ${{ secrets.TARGET_OWNER }}
|
||||||
|
TARGET_REPO: ${{ secrets.TARGET_REPO }}
|
||||||
|
|
||||||
|
- name: Checkout generator repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Build runtime config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
SOURCE_REF="${SOURCE_REF:-main}"
|
||||||
|
SOURCE_ROOT="${SOURCE_ROOT:-rule/Surge}"
|
||||||
|
CLASH_NO_RESOLVE="${CLASH_NO_RESOLVE:-false}"
|
||||||
|
|
||||||
|
cat > config.runtime.json <<JSON
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"base_url": "${GITEA_BASE_URL}",
|
||||||
|
"owner": "${SOURCE_OWNER}",
|
||||||
|
"repo": "${SOURCE_REPO}",
|
||||||
|
"ref": "${SOURCE_REF}",
|
||||||
|
"token_env": "GITEA_TOKEN"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"root": "${SOURCE_ROOT}",
|
||||||
|
"filename_pattern": "{name}.list",
|
||||||
|
"include_categories": [],
|
||||||
|
"exclude_categories": []
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"dir": "dist",
|
||||||
|
"clash_no_resolve": ${CLASH_NO_RESOLVE}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
env:
|
||||||
|
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||||
|
SOURCE_OWNER: ${{ secrets.SOURCE_OWNER }}
|
||||||
|
SOURCE_REPO: ${{ secrets.SOURCE_REPO }}
|
||||||
|
SOURCE_REF: ${{ vars.SOURCE_REF }}
|
||||||
|
SOURCE_ROOT: ${{ vars.SOURCE_ROOT }}
|
||||||
|
CLASH_NO_RESOLVE: ${{ vars.CLASH_NO_RESOLVE }}
|
||||||
|
|
||||||
|
- name: Generate rules
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: python3 main.py --config config.runtime.json
|
||||||
|
|
||||||
|
- name: Publish dist to target repo branch
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
target_branch="${TARGET_BRANCH:-main}"
|
||||||
|
base="${GITEA_BASE_URL%/}"
|
||||||
|
if [[ "$base" == https://* ]]; then
|
||||||
|
auth_url="https://${GITEA_TOKEN}@${base#https://}"
|
||||||
|
elif [[ "$base" == http://* ]]; then
|
||||||
|
auth_url="http://${GITEA_TOKEN}@${base#http://}"
|
||||||
|
else
|
||||||
|
echo "GITEA_BASE_URL must start with http:// or https://" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
remote_url="${auth_url}/${TARGET_OWNER}/${TARGET_REPO}.git"
|
||||||
|
|
||||||
|
rm -rf /tmp/rules-publish
|
||||||
|
git clone --depth=1 --branch "$target_branch" "$remote_url" /tmp/rules-publish || {
|
||||||
|
git clone --depth=1 "$remote_url" /tmp/rules-publish
|
||||||
|
cd /tmp/rules-publish
|
||||||
|
git checkout -b "$target_branch"
|
||||||
|
cd -
|
||||||
|
}
|
||||||
|
|
||||||
|
rsync -a --delete dist/ /tmp/rules-publish/
|
||||||
|
|
||||||
|
cd /tmp/rules-publish
|
||||||
|
if [ -n "$(git status --porcelain)" ]; then
|
||||||
|
git config user.name "gitea-actions[bot]"
|
||||||
|
git config user.email "gitea-actions@localhost"
|
||||||
|
git add .
|
||||||
|
git commit -m "chore: publish generated rules"
|
||||||
|
git push origin "$target_branch"
|
||||||
|
echo "Published to ${TARGET_OWNER}/${TARGET_REPO}@${target_branch}"
|
||||||
|
else
|
||||||
|
echo "No publish changes"
|
||||||
|
fi
|
||||||
|
env:
|
||||||
|
GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
TARGET_OWNER: ${{ secrets.TARGET_OWNER }}
|
||||||
|
TARGET_REPO: ${{ secrets.TARGET_REPO }}
|
||||||
|
TARGET_BRANCH: ${{ vars.TARGET_BRANCH }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
config.toml
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# gitea-shunt-rules
|
||||||
|
|
||||||
|
一个最小可用的规则生成器:
|
||||||
|
|
||||||
|
- 数据源来自 **Gitea** 仓库
|
||||||
|
- 输入按目录分类(默认读取 `rule/Surge/<Name>/<Name>.list`)
|
||||||
|
- 输出仅包含你要的两种格式:
|
||||||
|
- `dist/surge/<Name>.list`
|
||||||
|
- `dist/clash/<Name>.yaml`
|
||||||
|
|
||||||
|
## 为什么这个方案适合你
|
||||||
|
|
||||||
|
`ShuntRules` 公开仓库基本只留了发布索引,生成器不在仓库中。这个项目按它的输出思路重建了可控版本,并把数据源切到 Gitea,便于你自己托管。
|
||||||
|
|
||||||
|
## 运行环境
|
||||||
|
|
||||||
|
- Python 3.11+(可直接使用 TOML 配置)
|
||||||
|
- Python 3.10 也可用,但建议改用 JSON 配置(`config.example.json`)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1. 复制配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/yuan/Desktop/workspaces/docker/pve/gitea-shunt-rules
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 填写 `config.toml`:
|
||||||
|
|
||||||
|
- `gitea.base_url`:你的 Gitea 地址,例如 `https://git.xxx.com`
|
||||||
|
- `gitea.owner` / `gitea.repo`:规则仓库
|
||||||
|
- `source.root`:源规则根目录,默认 `rule/Surge`
|
||||||
|
|
||||||
|
3. 设置 token(如果仓库私有):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITEA_TOKEN='your-token'
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 生成全部分类:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 main.py --config config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 只生成部分分类:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 main.py --config config.toml --names YouTube,Netflix
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你是 Python 3.10,可用 JSON 配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp config.example.json config.json
|
||||||
|
python3 main.py --config config.json --names YouTube,Netflix
|
||||||
|
```
|
||||||
|
|
||||||
|
## 目录规范(推荐)
|
||||||
|
|
||||||
|
建议你的 Gitea 仓库中按以下结构维护源数据:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rule/Surge/
|
||||||
|
YouTube/
|
||||||
|
YouTube.list
|
||||||
|
Netflix/
|
||||||
|
Netflix.list
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 `.list` 文件示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 注释会被忽略
|
||||||
|
DOMAIN-SUFFIX,youtube.com
|
||||||
|
DOMAIN-KEYWORD,youtube
|
||||||
|
IP-CIDR,172.110.32.0/21,no-resolve
|
||||||
|
USER-AGENT,*youtube*
|
||||||
|
```
|
||||||
|
|
||||||
|
## 转换规则说明
|
||||||
|
|
||||||
|
- Surge 输出:保留源规则(去重、清理空白)
|
||||||
|
- Clash 输出:
|
||||||
|
- 自动移除注释/空行
|
||||||
|
- `USER-AGENT`、`URL-REGEX` 默认跳过(并在头部记录 `SKIPPED-*`)
|
||||||
|
- `IP-CIDR`/`IP-CIDR6` 可通过 `clash_no_resolve` 控制是否追加 `no-resolve`
|
||||||
|
|
||||||
|
## 定时更新建议
|
||||||
|
|
||||||
|
你可以在 Gitea Actions 或系统 `cron` 做定时任务:
|
||||||
|
|
||||||
|
1. 拉取源仓库
|
||||||
|
2. 执行 `python3 main.py --config config.toml`
|
||||||
|
3. 提交 `dist/` 目录到发布仓库或对象存储
|
||||||
|
|
||||||
|
### Gitea Actions(已内置)
|
||||||
|
|
||||||
|
项目已包含工作流文件:
|
||||||
|
|
||||||
|
- `/Users/yuan/Desktop/workspaces/docker/pve/gitea-shunt-rules/.gitea/workflows/generate-rules.yml`
|
||||||
|
|
||||||
|
你只需要做这几步:
|
||||||
|
|
||||||
|
1. 在仓库根目录放好 `config.toml` 或 `config.json`
|
||||||
|
2. 在 Gitea 仓库 Secrets 中添加 `GITEA_TOKEN`(私有源仓库建议必须配置)
|
||||||
|
3. 推送到 `main` 后会自动执行;也可在 Actions 页面手动触发
|
||||||
|
|
||||||
|
当前定时表达式是 `0 3 * * *`(UTC),对应北京时间(UTC+8)每天 `11:00`。
|
||||||
|
|
||||||
|
### 发布到独立仓库/分支(已内置)
|
||||||
|
|
||||||
|
如果你希望“生成仓库”和“发布仓库”分离,使用:
|
||||||
|
|
||||||
|
- `/Users/yuan/Desktop/workspaces/docker/pve/gitea-shunt-rules/.gitea/workflows/publish-rules.yml`
|
||||||
|
|
||||||
|
这个工作流会:
|
||||||
|
|
||||||
|
1. 从你指定的源仓库读取规则并生成 `dist/`
|
||||||
|
2. 把 `dist/` 内容同步到目标仓库的目标分支(可单独设为 `rules` / `gh-pages`)
|
||||||
|
|
||||||
|
需要在 Gitea 仓库中配置:
|
||||||
|
|
||||||
|
- Secrets:
|
||||||
|
- `GITEA_BASE_URL`:例如 `https://gitea.example.com`
|
||||||
|
- `GITEA_TOKEN`:需要有读取源仓库 + 推送目标仓库权限
|
||||||
|
- `SOURCE_OWNER`
|
||||||
|
- `SOURCE_REPO`
|
||||||
|
- `TARGET_OWNER`
|
||||||
|
- `TARGET_REPO`
|
||||||
|
- Variables(可选):
|
||||||
|
- `SOURCE_REF`(默认 `main`)
|
||||||
|
- `SOURCE_ROOT`(默认 `rule/Surge`)
|
||||||
|
- `TARGET_BRANCH`(默认 `main`)
|
||||||
|
- `CLASH_NO_RESOLVE`(默认 `false`)
|
||||||
|
|
||||||
|
该工作流当前定时为 `15 3 * * *`(UTC),对应北京时间每天 `11:15`,也支持手动触发。
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"base_url": "https://gitea.example.com",
|
||||||
|
"owner": "rules",
|
||||||
|
"repo": "ios_rule_script",
|
||||||
|
"ref": "main",
|
||||||
|
"token_env": "GITEA_TOKEN"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"root": "rule/Surge",
|
||||||
|
"filename_pattern": "{name}.list",
|
||||||
|
"include_categories": [],
|
||||||
|
"exclude_categories": []
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"dir": "dist",
|
||||||
|
"clash_no_resolve": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[gitea]
|
||||||
|
base_url = "https://gitea.example.com"
|
||||||
|
owner = "rules"
|
||||||
|
repo = "ios_rule_script"
|
||||||
|
ref = "main"
|
||||||
|
token_env = "GITEA_TOKEN"
|
||||||
|
|
||||||
|
[source]
|
||||||
|
# Usually this is where Surge source rules are stored in your Gitea repo.
|
||||||
|
root = "rule/Surge"
|
||||||
|
filename_pattern = "{name}.list"
|
||||||
|
include_categories = []
|
||||||
|
exclude_categories = []
|
||||||
|
|
||||||
|
[output]
|
||||||
|
dir = "dist"
|
||||||
|
# true: IP-CIDR/IP-CIDR6 in Clash payload include no-resolve
|
||||||
|
clash_no_resolve = false
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError: # Python < 3.11
|
||||||
|
tomllib = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 100
|
||||||
|
UNSUPPORTED_CLASH_TYPES = {
|
||||||
|
"USER-AGENT",
|
||||||
|
"URL-REGEX",
|
||||||
|
"DEST-PORT", # Surge alias, Clash usually uses DST-PORT
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
base_url: str
|
||||||
|
owner: str
|
||||||
|
repo: str
|
||||||
|
ref: str
|
||||||
|
token: str | None
|
||||||
|
source_root: str
|
||||||
|
source_filename_pattern: str
|
||||||
|
output_dir: str
|
||||||
|
include_categories: list[str]
|
||||||
|
exclude_categories: list[str]
|
||||||
|
clash_no_resolve: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuleLine:
|
||||||
|
raw: str
|
||||||
|
rule_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
def __init__(self, base_url: str, token: str | None):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def _request_json(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
if params:
|
||||||
|
query = "&".join(f"{quote(str(k))}={quote(str(v))}" for k, v in params.items())
|
||||||
|
url = f"{url}?{query}"
|
||||||
|
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if self.token:
|
||||||
|
headers["Authorization"] = f"token {self.token}"
|
||||||
|
|
||||||
|
req = Request(url, headers=headers)
|
||||||
|
with urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def list_dir(self, owner: str, repo: str, path: str, ref: str) -> list[dict[str, Any]]:
|
||||||
|
encoded_path = quote(path.strip("/"), safe="/")
|
||||||
|
endpoint = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/contents/{encoded_path}"
|
||||||
|
|
||||||
|
page = 1
|
||||||
|
all_items: list[dict[str, Any]] = []
|
||||||
|
while True:
|
||||||
|
data = self._request_json(endpoint, {"ref": ref, "page": page, "limit": DEFAULT_LIMIT})
|
||||||
|
if isinstance(data, dict):
|
||||||
|
raise RuntimeError(f"Path is not a directory: {path}")
|
||||||
|
items = list(data)
|
||||||
|
all_items.extend(items)
|
||||||
|
if len(items) < DEFAULT_LIMIT:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
def read_file(self, owner: str, repo: str, path: str, ref: str) -> str:
|
||||||
|
encoded_path = quote(path.strip("/"), safe="/")
|
||||||
|
endpoint = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/contents/{encoded_path}"
|
||||||
|
data = self._request_json(endpoint, {"ref": ref})
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise RuntimeError(f"Path is not a file: {path}")
|
||||||
|
|
||||||
|
content = data.get("content")
|
||||||
|
encoding = data.get("encoding")
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError(f"Missing file content for path: {path}")
|
||||||
|
if encoding != "base64":
|
||||||
|
raise RuntimeError(f"Unsupported encoding ({encoding}) for path: {path}")
|
||||||
|
|
||||||
|
return base64.b64decode(content).decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> Config:
|
||||||
|
if path.suffix.lower() == ".json":
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
if tomllib is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TOML config requires Python 3.11+. "
|
||||||
|
"Use Python 3.11+ or provide a JSON config file."
|
||||||
|
)
|
||||||
|
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
gitea = raw.get("gitea", {})
|
||||||
|
source = raw.get("source", {})
|
||||||
|
output = raw.get("output", {})
|
||||||
|
|
||||||
|
token_env = gitea.get("token_env", "GITEA_TOKEN")
|
||||||
|
token = os.getenv(token_env) if token_env else None
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
base_url=gitea["base_url"],
|
||||||
|
owner=gitea["owner"],
|
||||||
|
repo=gitea["repo"],
|
||||||
|
ref=gitea.get("ref", "main"),
|
||||||
|
token=token,
|
||||||
|
source_root=source.get("root", "rule/Surge"),
|
||||||
|
source_filename_pattern=source.get("filename_pattern", "{name}.list"),
|
||||||
|
output_dir=output.get("dir", "dist"),
|
||||||
|
include_categories=source.get("include_categories", []),
|
||||||
|
exclude_categories=source.get("exclude_categories", []),
|
||||||
|
clash_no_resolve=output.get("clash_no_resolve", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rules(content: str) -> list[RuleLine]:
|
||||||
|
rules: list[RuleLine] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for original in content.splitlines():
|
||||||
|
line = original.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line in seen:
|
||||||
|
continue
|
||||||
|
seen.add(line)
|
||||||
|
|
||||||
|
parts = [part.strip() for part in line.split(",") if part.strip()]
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rules.append(RuleLine(raw=",".join(parts), rule_type=parts[0].upper()))
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def to_clash_payload_line(rule: RuleLine, no_resolve: bool) -> 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
|
||||||
|
|
||||||
|
if rule_type in UNSUPPORTED_CLASH_TYPES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if rule_type in {"IP-CIDR", "IP-CIDR6"}:
|
||||||
|
payload = [rule_type, parts[1]] if len(parts) >= 2 else parts
|
||||||
|
if no_resolve:
|
||||||
|
payload.append("no-resolve")
|
||||||
|
return ",".join(payload)
|
||||||
|
|
||||||
|
# Strip no-resolve from non-IP rules if present in source.
|
||||||
|
filtered = [p for p in parts if p.lower() != "no-resolve"]
|
||||||
|
return ",".join(filtered)
|
||||||
|
|
||||||
|
|
||||||
|
def format_surge(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_clash(name: str, rules: list[RuleLine], source_path: str, no_resolve: bool) -> str:
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
|
||||||
|
payload: list[str] = []
|
||||||
|
counter: Counter[str] = Counter()
|
||||||
|
skipped: Counter[str] = Counter()
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
converted = to_clash_payload_line(rule, no_resolve=no_resolve)
|
||||||
|
if converted is None:
|
||||||
|
skipped[rule.rule_type] += 1
|
||||||
|
continue
|
||||||
|
payload.append(converted)
|
||||||
|
counter[rule.rule_type] += 1
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"# NAME: {name}",
|
||||||
|
"# AUTHOR: gitea-shunt-rules",
|
||||||
|
f"# SOURCE: {source_path}",
|
||||||
|
f"# UPDATED: {now}",
|
||||||
|
]
|
||||||
|
for k in sorted(counter):
|
||||||
|
lines.append(f"# {k}: {counter[k]}")
|
||||||
|
for k in sorted(skipped):
|
||||||
|
lines.append(f"# SKIPPED-{k}: {skipped[k]}")
|
||||||
|
lines.append(f"# TOTAL: {len(payload)}")
|
||||||
|
lines.append("payload:")
|
||||||
|
lines.extend(f" - {item}" for item in payload)
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def should_include_category(name: str, cfg: Config, cli_names: set[str]) -> bool:
|
||||||
|
if cli_names and name not in cli_names:
|
||||||
|
return False
|
||||||
|
if cfg.include_categories and name not in cfg.include_categories:
|
||||||
|
return False
|
||||||
|
if name in cfg.exclude_categories:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def find_categories(client: GiteaClient, cfg: Config, cli_names: set[str]) -> list[str]:
|
||||||
|
entries = client.list_dir(cfg.owner, cfg.repo, cfg.source_root, cfg.ref)
|
||||||
|
categories: list[str] = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if entry.get("type") != "dir":
|
||||||
|
continue
|
||||||
|
name = entry.get("name")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if should_include_category(name, cfg, cli_names):
|
||||||
|
categories.append(name)
|
||||||
|
|
||||||
|
return sorted(categories)
|
||||||
|
|
||||||
|
|
||||||
|
def build_one_category(client: GiteaClient, cfg: Config, name: str, base_out: Path) -> tuple[int, int]:
|
||||||
|
source_rel_path = f"{cfg.source_root}/{name}/{cfg.source_filename_pattern.format(name=name)}"
|
||||||
|
source_content = client.read_file(cfg.owner, cfg.repo, source_rel_path, cfg.ref)
|
||||||
|
|
||||||
|
rules = parse_rules(source_content)
|
||||||
|
|
||||||
|
surge_out = base_out / "surge" / f"{name}.list"
|
||||||
|
clash_out = base_out / "clash" / f"{name}.yaml"
|
||||||
|
surge_out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
clash_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),
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description="Generate Surge/Clash 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()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
cfg = load_config(Path(args.config))
|
||||||
|
names = {x.strip() for x in args.names.split(",") if x.strip()}
|
||||||
|
|
||||||
|
client = GiteaClient(cfg.base_url, cfg.token)
|
||||||
|
categories = find_categories(client, cfg, names)
|
||||||
|
|
||||||
|
if not categories:
|
||||||
|
print("No categories found after filtering.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
out_dir = Path(cfg.output_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
total_source = 0
|
||||||
|
total_clash = 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)
|
||||||
|
total_source += s_cnt
|
||||||
|
total_clash += c_cnt
|
||||||
|
print(f"[{idx}/{len(categories)}] {name}: source={s_cnt}, clash={c_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()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user