sub-customizer 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __version__ = "0.0.1"
2
+
3
+ from .customizer import ClashSubCustomizer, RemoteConfigParser
4
+
5
+ __all__ = [
6
+ "ClashSubCustomizer",
7
+ "RemoteConfigParser",
8
+ ]
@@ -0,0 +1,394 @@
1
+ import configparser
2
+ import io
3
+ import logging
4
+ import re
5
+ from collections import OrderedDict
6
+ from functools import cached_property, lru_cache
7
+ from typing import IO, TYPE_CHECKING, List, Literal, Optional, TypedDict
8
+ from urllib import parse
9
+
10
+ import requests
11
+ import yaml
12
+ from pydantic import ValidationError
13
+
14
+ from .datastructures import ClashConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @lru_cache(None)
20
+ def is_url(url: str) -> bool:
21
+ try:
22
+ result = parse.urlparse(url)
23
+ return result.scheme in ["http", "https", "ftp"] and bool(result.netloc)
24
+ except ValueError:
25
+ return False
26
+
27
+
28
+ class ConfigParserMultiValues(OrderedDict):
29
+ def __setitem__(self, key, value):
30
+ if key in self and isinstance(value, list):
31
+ self[key].extend(value)
32
+ else:
33
+ super().__setitem__(key, value)
34
+
35
+ @staticmethod
36
+ def getlist(value):
37
+ return value.splitlines()
38
+
39
+
40
+ class ConfigParser(configparser.RawConfigParser):
41
+ if TYPE_CHECKING:
42
+
43
+ def getlist(self, section: str, option: str, **kwargs) -> list[str]: # type: ignore
44
+ ...
45
+
46
+ def __init__(
47
+ self, strict=False, dict_type=ConfigParserMultiValues, converters=None, **kwargs
48
+ ):
49
+ if converters is None:
50
+ converters = {"list": ConfigParserMultiValues.getlist}
51
+ super().__init__(
52
+ strict=strict, dict_type=dict_type, converters=converters, **kwargs
53
+ )
54
+
55
+
56
+ class RulesetParseResultT(TypedDict, total=False):
57
+ group: str
58
+ type: Literal["surge", "quanx", "clash-domain", "clash-ipcidr", "clash-classic"]
59
+ rule: str
60
+ is_url: bool
61
+ interval: Optional[int]
62
+
63
+
64
+ class CustomProxyGroupParseResultT(TypedDict, total=False):
65
+ name: str
66
+ type: Literal["select", "url-test", "fallback", "load-balance"]
67
+ rules: list[str]
68
+ test_url: Optional[str]
69
+ interval: Optional[int]
70
+ timeout: Optional[int]
71
+ tolerance: Optional[int]
72
+
73
+
74
+ class RulesetParser:
75
+ # 定义支持的类型和前缀映射
76
+ VALID_TYPES = ["surge", "quanx", "clash-domain", "clash-ipcidr", "clash-classic"]
77
+
78
+ def __init__(self):
79
+ self.ruleset_pattern = re.compile(
80
+ r"^(?P<group>.+?),"
81
+ r"(?:\[(?P<type>[a-zA-Z0-9\-]+)])?" # 匹配类型(可选)
82
+ r"(?P<rule>.*?)" # 匹配规则部分
83
+ r"(?:,(\d+))?$" # 匹配可选的更新间隔(秒)
84
+ )
85
+
86
+ def parse(self, rulesets: List[str]) -> List[RulesetParseResultT]:
87
+ """
88
+ 解析规则集,返回处理后的字典列表。
89
+ """
90
+ parsed_rules = []
91
+ for ruleset in rulesets:
92
+ ruleset = ruleset.strip()
93
+ match = self.ruleset_pattern.match(ruleset)
94
+ if match:
95
+ group = match.group("group").strip()
96
+ rule_content = match.group("rule").strip()
97
+ interval = int(i) if (i := match.group(4)) else None
98
+
99
+ # 获取类型和去除前缀后的规则内容
100
+ rule_type, cleaned_rule_content = self._get_type_and_rule(rule_content)
101
+
102
+ parsed_rules.append(
103
+ {
104
+ "group": group,
105
+ "type": rule_type,
106
+ "rule": cleaned_rule_content,
107
+ "interval": interval,
108
+ "is_url": is_url(cleaned_rule_content),
109
+ }
110
+ )
111
+ return parsed_rules
112
+
113
+ def _get_type_and_rule(self, rule_content: str) -> (str, str):
114
+ """
115
+ 根据规则内容获取对应的类型和去除前缀后的规则内容。
116
+ """
117
+ for rule_type in self.VALID_TYPES:
118
+ prefix = f"{rule_type}:"
119
+ if rule_content.startswith(prefix):
120
+ return rule_type, rule_content[len(prefix) :]
121
+ return "surge", rule_content # 默认是 surge 类型
122
+
123
+
124
+ class CustomProxyGroupParser:
125
+ support_types = {"select", "url-test", "fallback", "load-balance"}
126
+
127
+ def _parse_rest(self, rest):
128
+ rules = []
129
+ test_url = interval = timeout = tolerance = None
130
+ for i, item in enumerate(rest):
131
+ item = item.strip()
132
+ if not is_url(item):
133
+ rules.append(item)
134
+ else:
135
+ test_url = item
136
+ interval_params = rest[i + 1].split(",")
137
+ interval = interval_params[0]
138
+ timeout = interval_params[1] if len(interval_params) > 1 else None
139
+ tolerance = interval_params[2] if len(interval_params) > 2 else None
140
+ break
141
+ r = {"rules": rules}
142
+ if test_url:
143
+ r["test_url"] = test_url
144
+ r["interval"] = interval
145
+ if timeout:
146
+ r["timeout"] = timeout
147
+ if tolerance:
148
+ r["tolerance"] = tolerance
149
+ return r
150
+
151
+ def parse(self, groups: list[str]) -> list[CustomProxyGroupParseResultT]:
152
+ """
153
+ 用于自定义组的选项 会覆盖 主程序目录中的配置文件 里的内容
154
+ 使用以下模式生成 Clash 代理组,带有 "[]" 前缀将直接添加
155
+ Format: Group_Name`select`Rule_1`Rule_2`...
156
+ Group_Name`url-test|fallback|load-balance`Rule_1`Rule_2`...`test_url`interval[,timeout][,tolerance]
157
+ Rule with "[]" prefix will be added directly.
158
+ """
159
+ parsed_groups = []
160
+ for group_str in groups:
161
+ group_str = group_str.strip()
162
+ parts = group_str.split("`")
163
+ if len(parts) < 3:
164
+ continue
165
+ group_name, type_, *rest = parts
166
+ if type_ not in self.support_types:
167
+ continue
168
+ try:
169
+ r = self._parse_rest(rest)
170
+ except Exception as e:
171
+ logger.exception(e)
172
+ continue
173
+ group = {"name": group_name, "type": type_}
174
+ group.update(r)
175
+ parsed_groups.append(group)
176
+ return parsed_groups
177
+
178
+
179
+ class RemoteConfigParser:
180
+ sections = ["custom"]
181
+ supported_options = [
182
+ "ruleset",
183
+ "custom_proxy_group",
184
+ "overwrite_original_rules",
185
+ "enable_rule_generator",
186
+ ]
187
+ supported_override_options = [
188
+ "port",
189
+ "socks-port",
190
+ "redir-port",
191
+ "tproxy-port",
192
+ "mixed-port",
193
+ "allow-lan",
194
+ "bind-address",
195
+ "mode",
196
+ "log-level",
197
+ "ipv6",
198
+ "external-controller",
199
+ "external-ui",
200
+ "secret",
201
+ "interface-name",
202
+ "routing-mark",
203
+ "hosts",
204
+ "profile",
205
+ "dns",
206
+ ]
207
+
208
+ def __init__(self, ini_str, clash_config: dict = None):
209
+ self.ini_str = ini_str
210
+ self.config = ConfigParser()
211
+ self.config.read_string(ini_str)
212
+ self.clash_config = clash_config or {}
213
+
214
+ @classmethod
215
+ def from_url(cls, url: str, **init_kws):
216
+ res = requests.get(url)
217
+ return cls(res.text, **init_kws)
218
+
219
+ @cached_property
220
+ def options(self):
221
+ rulesets = []
222
+ custom_proxy_groups = []
223
+ overwrite_original_rules = False
224
+ enable_rule_generator = True
225
+ override_options = {}
226
+ for section in self.sections:
227
+ for option in self.supported_override_options:
228
+ if (
229
+ opt_value := self.config.get(section, option, fallback=None)
230
+ ) is not None:
231
+ override_options.setdefault(option, opt_value)
232
+ overwrite_original_rules = self.config.getboolean(
233
+ section, "overwrite_original_rules", fallback=overwrite_original_rules
234
+ )
235
+ enable_rule_generator = self.config.getboolean(
236
+ section, "enable_rule_generator", fallback=enable_rule_generator
237
+ )
238
+
239
+ rulesets.extend(self.config.getlist(section, "ruleset", fallback=[]))
240
+ custom_proxy_groups.extend(
241
+ self.config.getlist(section, "custom_proxy_group", fallback=[])
242
+ )
243
+ return {
244
+ "rulesets": rulesets,
245
+ "custom_proxy_groups": custom_proxy_groups,
246
+ "overwrite_original_rules": overwrite_original_rules,
247
+ "enable_rule_generator": enable_rule_generator,
248
+ "override_options": override_options,
249
+ }
250
+
251
+ @cached_property
252
+ def all_clash_proxies(self) -> dict[str, str]:
253
+ all_proxies = {p["name"]: p for p in self.clash_config.get("proxies") or []}
254
+ return all_proxies
255
+
256
+ def parse_rulesets(self):
257
+ rulesets = self.options["rulesets"]
258
+ parser = RulesetParser()
259
+ return parser.parse(rulesets)
260
+
261
+ def parse_custom_proxy_groups(self):
262
+ custom_proxy_groups = self.options["custom_proxy_groups"]
263
+ parser = CustomProxyGroupParser()
264
+ return parser.parse(custom_proxy_groups)
265
+
266
+ def _convert_rules_text(self, rules_text: str, group: str) -> list:
267
+ lines = rules_text.strip().splitlines()
268
+ results = []
269
+ for line in lines:
270
+ line = line.strip()
271
+ if not line or line.startswith("#"):
272
+ continue
273
+ parts = line.split(",")
274
+ if len(parts) < 2:
275
+ continue
276
+ parts.insert(2, group)
277
+ results.append(",".join(parts))
278
+ return results
279
+
280
+ def extract_rules(self, rulesets: list[RulesetParseResultT]):
281
+ session = requests.Session()
282
+ rules = []
283
+ for rule_set in rulesets:
284
+ if rule_set["is_url"]:
285
+ url = rule_set["rule"]
286
+ try:
287
+ resp = session.get(url)
288
+ resp.raise_for_status()
289
+ except requests.RequestException:
290
+ continue
291
+ rules.extend(self._convert_rules_text(resp.text, rule_set["group"]))
292
+ elif rule_set["rule"].startswith("[]"):
293
+ rule = rule_set["rule"][2:]
294
+ if rule.lower() == "final":
295
+ rule = "MATCH"
296
+ rules.append(f"{rule},{rule_set['group']}")
297
+ return rules
298
+
299
+ @lru_cache(None)
300
+ def _get_proxies_by_regex(self, regex: str):
301
+ proxies = []
302
+ for proxy in self.all_clash_proxies:
303
+ if re.search(regex, proxy):
304
+ proxies.append(proxy)
305
+ return proxies
306
+
307
+ def extract_proxy_groups(self, proxy_groups: list[CustomProxyGroupParseResultT]):
308
+ groups = []
309
+ for proxy_group in proxy_groups:
310
+ group = {"name": proxy_group["name"], "type": proxy_group["type"]}
311
+ rules = proxy_group["rules"]
312
+ proxies = []
313
+ for rule in rules:
314
+ if rule.startswith("[]"):
315
+ proxies.append(rule[2:])
316
+ else:
317
+ proxies.extend(self._get_proxies_by_regex(rule))
318
+ group["proxies"] = proxies
319
+ for k in {
320
+ "test_url": "url",
321
+ "interval": "interval",
322
+ "timeout": "timeout",
323
+ "tolerance": "tolerance",
324
+ }:
325
+ if k in proxy_group:
326
+ group[k] = proxy_group[k]
327
+ groups.append(group)
328
+ return groups
329
+
330
+ def get_rules(self):
331
+ rulesets = self.parse_rulesets()
332
+ return self.extract_rules(rulesets)
333
+
334
+ def get_proxy_groups(self):
335
+ groups = self.parse_custom_proxy_groups()
336
+ return self.extract_proxy_groups(groups)
337
+
338
+ def get_override_options(self):
339
+ override_options = self.options["override_options"]
340
+ try:
341
+ inst = ClashConfig.model_validate(override_options)
342
+ valid_options = inst.model_dump(
343
+ mode="json", by_alias=True, exclude_unset=True
344
+ )
345
+ return valid_options
346
+ except ValidationError as e:
347
+ logger.exception(e)
348
+ return {}
349
+
350
+
351
+ class ClashSubCustomizer:
352
+ headers = {"User-Agent": "Clash"}
353
+
354
+ def __init__(self, yaml_str):
355
+ self.yaml_str = yaml_str
356
+ self.config = yaml.safe_load(yaml_str)
357
+
358
+ @classmethod
359
+ def from_url(cls, url: str, no_proxy=False):
360
+ proxies = None
361
+ if no_proxy:
362
+ parsed = parse.urlparse(url)
363
+ proxies = {"no_proxy": parsed.hostname}
364
+ res = requests.get(url, headers=cls.headers, proxies=proxies)
365
+ return cls(res.text)
366
+
367
+ def write_remote_config(self, remote_url) -> IO[bytes]:
368
+ parser = RemoteConfigParser.from_url(remote_url, clash_config=self.config)
369
+ proxy_groups = parser.get_proxy_groups()
370
+ if proxy_groups:
371
+ self.config["proxy-groups"] = proxy_groups
372
+ if parser.options["enable_rule_generator"]:
373
+ rules = parser.get_rules()
374
+ if rules:
375
+ if parser.options["overwrite_original_rules"]:
376
+ self.config["rules"] = rules
377
+ else:
378
+ # 这里扩展rules而不是覆盖,远程配置中的rules优先级更高
379
+ self.config["rules"] = rules + self.config.get("rules", [])
380
+ else:
381
+ self.config["rules"] = []
382
+ override_options = parser.get_override_options()
383
+ self.config.update(override_options)
384
+ buffer = io.BytesIO()
385
+ yaml.dump(
386
+ self.config,
387
+ stream=buffer,
388
+ default_flow_style=False,
389
+ allow_unicode=True,
390
+ sort_keys=False,
391
+ encoding="utf-8",
392
+ )
393
+ buffer.seek(0)
394
+ return buffer
@@ -0,0 +1,168 @@
1
+ from enum import Enum
2
+ from typing import List, Optional, Union
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+
7
+ class ModeEnum(str, Enum):
8
+ RULE = "rule"
9
+ GLOBAL = "global"
10
+ DIRECT = "direct"
11
+
12
+
13
+ class LogLevelEnum(str, Enum):
14
+ INFO = "info"
15
+ WARNING = "warning"
16
+ ERROR = "error"
17
+ DEBUG = "debug"
18
+ SILENT = "silent"
19
+
20
+
21
+ class EnhancedModeEnum(str, Enum):
22
+ FAKE_IP = "fake-ip"
23
+
24
+
25
+ class ProxyTypeEnum(str, Enum):
26
+ SS = "ss"
27
+ VMESS = "vmess"
28
+ SOCKS5 = "socks5"
29
+ HTTP = "http"
30
+ SNELL = "snell"
31
+ TROJAN = "trojan"
32
+ SSR = "ssr"
33
+
34
+
35
+ class NetworkEnum(str, Enum):
36
+ TCP = "tcp"
37
+ UDP = "udp"
38
+ WS = "ws"
39
+ H2 = "h2"
40
+ GRPC = "grpc"
41
+
42
+
43
+ class ProxyGroupTypeEnum(str, Enum):
44
+ RELAY = "relay"
45
+ URL_TEST = "url-test"
46
+ FALLBACK = "fallback"
47
+ LOAD_BALANCE = "load-balance"
48
+ SELECT = "select"
49
+
50
+
51
+ class ProxyProviderTypeEnum(str, Enum):
52
+ HTTP = "http"
53
+ FILE = "file"
54
+
55
+
56
+ class RuleTypeEnum(str, Enum):
57
+ DOMAIN_SUFFIX = "DOMAIN-SUFFIX"
58
+ DOMAIN_KEYWORD = "DOMAIN-KEYWORD"
59
+ DOMAIN = "DOMAIN"
60
+ SRC_IP_CIDR = "SRC-IP-CIDR"
61
+ IP_CIDR = "IP-CIDR"
62
+ GEOIP = "GEOIP"
63
+ DST_PORT = "DST-PORT"
64
+ SRC_PORT = "SRC-PORT"
65
+ RULE_SET = "RULE-SET"
66
+ MATCH = "MATCH"
67
+
68
+
69
+ class Proxy(BaseModel):
70
+ name: str
71
+ type: ProxyTypeEnum
72
+ server: str
73
+ port: int
74
+ cipher: Optional[str] = None
75
+ password: Optional[str] = None
76
+ plugin: Optional[str] = None
77
+ plugin_opts: Optional[dict] = Field(None, alias="plugin-opts")
78
+ uuid: Optional[str] = None
79
+ alterId: Optional[int] = None
80
+ network: Optional[NetworkEnum] = None
81
+ tls: Optional[bool] = None
82
+ skip_cert_verify: Optional[bool] = Field(None, alias="skip-cert-verify")
83
+ servername: Optional[str] = None
84
+ ws_opts: Optional[dict] = Field(None, alias="ws-opts")
85
+ h2_opts: Optional[dict] = Field(None, alias="h2-opts")
86
+ grpc_opts: Optional[dict] = Field(None, alias="grpc-opts")
87
+ obfs: Optional[str] = None
88
+ protocol: Optional[str] = None
89
+ obfs_param: Optional[str] = Field(None, alias="obfs-param")
90
+ protocol_param: Optional[str] = Field(None, alias="protocol-param")
91
+ udp: Optional[bool] = None
92
+
93
+
94
+ class ProxyGroup(BaseModel):
95
+ name: str
96
+ type: ProxyGroupTypeEnum
97
+ proxies: List[Union[str, Proxy]]
98
+ tolerance: Optional[int] = None
99
+ lazy: Optional[bool] = None
100
+ url: Optional[str] = None
101
+ interval: Optional[int] = None
102
+ strategy: Optional[str] = None
103
+ interface_name: Optional[str] = Field(None, alias="interface-name")
104
+ routing_mark: Optional[int] = Field(None, alias="routing-mark")
105
+ use: Optional[List[str]] = None
106
+
107
+
108
+ class ProxyProvider(BaseModel):
109
+ type: ProxyProviderTypeEnum
110
+ url: Optional[str] = None
111
+ interval: Optional[int] = None
112
+ path: str
113
+ health_check: Optional[dict] = Field(None, alias="health-check")
114
+
115
+
116
+ class Tunnel(BaseModel):
117
+ network: List[NetworkEnum]
118
+ address: str
119
+ target: str
120
+ proxy: str
121
+
122
+
123
+ class Rule(BaseModel):
124
+ type: RuleTypeEnum
125
+ value: str
126
+ proxy: str
127
+
128
+
129
+ class DNS(BaseModel):
130
+ enable: bool
131
+ listen: str = None
132
+ default_nameserver: List[str] = Field(None, alias="default-nameserver")
133
+ fake_ip_range: str = Field(None, alias="fake-ip-range")
134
+ nameserver: List[str] = None
135
+ fallback: Optional[List[str]] = None
136
+ fallback_filter: Optional[dict] = Field(None, alias="fallback-filter")
137
+ nameserver_policy: Optional[dict] = Field(None, alias="nameserver-policy")
138
+
139
+
140
+ class ClashConfig(BaseModel):
141
+ model_config = ConfigDict(populate_by_name=True)
142
+
143
+ port: int = 7890
144
+ socks_port: int = Field(None, alias="socks-port")
145
+ redir_port: Optional[int] = Field(None, alias="redir-port")
146
+ tproxy_port: Optional[int] = Field(None, alias="tproxy-port")
147
+ mixed_port: Optional[int] = Field(None, alias="mixed-port")
148
+ authentication: Optional[List[str]] = None
149
+ allow_lan: Optional[bool] = Field(None, alias="allow-lan")
150
+ bind_address: Optional[str] = Field(None, alias="bind-address")
151
+ mode: ModeEnum = ModeEnum.RULE
152
+ log_level: Optional[LogLevelEnum] = Field(None, alias="log-level")
153
+ ipv6: Optional[bool] = None
154
+ external_controller: str = Field(None, alias="external-controller")
155
+ external_ui: Optional[str] = Field(None, alias="external-ui")
156
+ secret: Optional[str] = None
157
+ interface_name: Optional[str] = Field(None, alias="interface-name")
158
+ routing_mark: Optional[int] = Field(None, alias="routing-mark")
159
+ hosts: Optional[dict] = None
160
+ profile: Optional[dict] = None
161
+ dns: Optional[DNS] = None
162
+ proxies: List[Proxy] = None
163
+ proxy_groups: List[ProxyGroup] = Field(None, alias="proxy-groups")
164
+ proxy_providers: Optional[List[ProxyProvider]] = Field(
165
+ None, alias="proxy-providers"
166
+ )
167
+ tunnels: Optional[List[Tunnel]] = None
168
+ rules: List[Rule] = None