ghlink 0.4.15__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.
ghlink/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """ghlink - GitHub 链路自愈工具。"""
2
+
3
+ __version__ = "0.4.15"
4
+ SCHEMA_VERSION = 1
Binary file
Binary file
Binary file
@@ -0,0 +1,56 @@
1
+ """内置 GitHub520 兜底数据(v0.3.0 新增,李工 2026-08-22 定)。
2
+
3
+ 背景:新安装/首次拉取失败时若无缓存,ghlink 拿不到任何社区 IP →
4
+ GitHub520 段为空、非核心域名无 hosts 条目。内置一份最新快照兜底,
5
+ 保证首装断网/拉取失败也能直接可用。
6
+
7
+ 格式:hosts 文本快照(与 raw.hellogithub.com/hosts 同构),由
8
+ github520.parse_hosts() 解析——数据即字符串,避免大字典触发
9
+ SonarCloud 重复代码块误报(D 级质量门禁)。
10
+
11
+ 更新规则:每次发版前抓取 https://raw.hellogithub.com/hosts 更新本文件。
12
+ 核心域名(github.com/api.github.com)仍由动态自愈兜底,解析时剔除。
13
+ """
14
+
15
+ # 快照时间:2026-08-22T07:51:33+08:00(来源 raw.hellogithub.com/hosts)
16
+ BUILTIN_GITHUB520_HOSTS = """140.82.113.25 alive.github.com
17
+ 20.205.243.168 api.github.com
18
+ 140.82.114.21 api.individual.githubcopilot.com
19
+ 185.199.111.133 avatars.githubusercontent.com
20
+ 185.199.111.133 avatars0.githubusercontent.com
21
+ 185.199.111.133 avatars1.githubusercontent.com
22
+ 185.199.111.133 avatars2.githubusercontent.com
23
+ 185.199.111.133 avatars3.githubusercontent.com
24
+ 185.199.111.133 avatars4.githubusercontent.com
25
+ 185.199.111.133 avatars5.githubusercontent.com
26
+ 185.199.111.133 camo.githubusercontent.com
27
+ 140.82.114.22 central.github.com
28
+ 185.199.111.133 cloud.githubusercontent.com
29
+ 20.205.243.165 codeload.github.com
30
+ 140.82.114.22 collector.github.com
31
+ 185.199.111.133 desktop.githubusercontent.com
32
+ 185.199.111.133 favicons.githubusercontent.com
33
+ 159.106.121.75 gist.github.com
34
+ 16.15.237.95 github-cloud.s3.amazonaws.com
35
+ 52.216.62.41 github-com.s3.amazonaws.com
36
+ 16.15.223.74 github-production-release-asset-2e65be.s3.amazonaws.com
37
+ 54.231.229.17 github-production-repository-file-5c1aeb.s3.amazonaws.com
38
+ 52.217.64.212 github-production-user-asset-6210df.s3.amazonaws.com
39
+ 192.0.66.2 github.blog
40
+ 20.205.243.166 github.com
41
+ 140.82.113.18 github.community
42
+ 185.199.110.215 github.githubassets.com
43
+ 203.111.254.117 github.global.ssl.fastly.net
44
+ 185.199.111.153 github.io
45
+ 185.199.111.133 github.map.fastly.net
46
+ 185.199.111.153 githubstatus.com
47
+ 140.82.112.26 live.github.com
48
+ 185.199.111.133 media.githubusercontent.com
49
+ 185.199.111.133 objects.githubusercontent.com
50
+ 13.107.42.16 pipelines.actions.githubusercontent.com
51
+ 185.199.111.133 raw.githubusercontent.com
52
+ 185.199.111.133 user-images.githubusercontent.com
53
+ 150.171.110.104 vscode.dev
54
+ 140.82.114.21 education.github.com
55
+ 185.199.111.133 private-user-images.githubusercontent.com
56
+ """
ghlink/config.py ADDED
@@ -0,0 +1,73 @@
1
+ """配置加载:JSON 配置,零依赖。
2
+
3
+ config.example.json 为模板。字段:
4
+ - probe: targets(探测域名列表), timeout_sec, round_interval_min
5
+ - trigger: consecutive_failures(默认3), cooldown_min(默认15), verify_success_rounds(默认2)
6
+ - resolver: doh_sources, cache_ttl_sec, max_candidates
7
+ - notify: feishu_webhook(空=关闭), enabled(默认true)
8
+ - state_file, lock_file, hosts_backup_dir
9
+ """
10
+
11
+ import json
12
+ import os
13
+ from typing import Any, Dict
14
+
15
+ DEFAULT_CONFIG: Dict[str, Any] = {
16
+ "probe": {
17
+ "targets": [
18
+ "github.com",
19
+ "api.github.com",
20
+ "codeload.github.com",
21
+ "github.global.ssl.fastly.net",
22
+ # v0.2.18 扩域(赛博 22:20 定案,李工 23:21 批准并入 v0.2.19 规划):
23
+ # GitHub 生态核心域名,覆盖 clone/raw/release 下载链盲区
24
+ "raw.githubusercontent.com",
25
+ "objects.githubusercontent.com",
26
+ "gist.github.com",
27
+ "github.githubassets.com",
28
+ ],
29
+ "timeout_sec": 15, # v0.2.8:5→15(慢链路不误杀,李工 23:15 实测定论)
30
+ # 目标域名健康度管理(v0.2):长期不可达域名自动降级,核心域名优先保证切换成功
31
+ "core_targets": ["github.com", "api.github.com"], # 核心域名永不降级
32
+ "degrade_after_rounds": 3, # v0.2.18:非核心域名连续失败 N 轮 → 降级(1h 粒度 ≈ 3h)
33
+ "recover_rounds": 2, # 降级域名连续成功 N 轮 → 恢复纳入
34
+ },
35
+ "trigger": {
36
+ # v0.2.18(李工 22:27 定:探测 1 小时不频繁):阈值按 1h 粒度核算
37
+ "consecutive_failures": 3, # 连续 3 小时失败才触发切换
38
+ "cooldown_min": 180, # 切换冷却 3 小时(原 15min 在 1h 粒度下无意义)
39
+ "verify_success_rounds": 2, # 切换后连续 2 小时成功才回 normal
40
+ },
41
+ "resolver": {
42
+ "doh_sources": [
43
+ "https://dns.alidns.com/resolve",
44
+ "https://doh.pub/dns-query",
45
+ "https://cloudflare-dns.com/dns-query",
46
+ "https://dns.google/resolve",
47
+ ],
48
+ "cache_ttl_sec": 3600,
49
+ "max_candidates": 5,
50
+ },
51
+ "notify": {"enabled": True, "feishu_webhook": ""},
52
+ "state_file": "ghlink_status.json",
53
+ "lock_file": "ghlink.lock",
54
+ "hosts_backup_dir": "backup",
55
+ }
56
+
57
+
58
+ def load_config(path: str) -> Dict[str, Any]:
59
+ """加载配置,缺失字段回退默认值。"""
60
+ cfg = json.loads(json.dumps(DEFAULT_CONFIG)) # deep copy
61
+ if path and os.path.exists(path):
62
+ with open(path, encoding="utf-8") as f:
63
+ user_cfg = json.load(f)
64
+ _deep_merge(cfg, user_cfg)
65
+ return cfg
66
+
67
+
68
+ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None:
69
+ for k, v in override.items():
70
+ if isinstance(v, dict) and isinstance(base.get(k), dict):
71
+ _deep_merge(base[k], v)
72
+ else:
73
+ base[k] = v
ghlink/github520.py ADDED
@@ -0,0 +1,257 @@
1
+ """GitHub520 hosts 段集成(v0.2.18,赛博 22:20 两步走第二步,李工 23:21 并入)。
2
+
3
+ 设计(赛博定案):
4
+ - 周期拉取 https://raw.hellogithub.com/hosts(默认 1 小时,SwitchHosts 同款)
5
+ - 合入 ghlink 独立段落(# ghlink Start/End),核心域名 ghlink 自愈优先
6
+ - 写入前基础可达性抽检(坏 IP 不入场)
7
+ - 核心域名(github.com/api.github.com)仍走 ghlink 动态验证兜底,
8
+ 非核心域名才用 GitHub520 社区 IP——互补而不互相拖累
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ import urllib.request
15
+ from typing import Any, Dict, List
16
+
17
+ from .builtin_github520 import BUILTIN_GITHUB520_HOSTS # v0.4.2:首装断网/拉取失败兜底
18
+
19
+ # 拉取状态缓存文件(放 state 同目录)
20
+ _CACHE_NAME = "ghlink520_cache.json"
21
+
22
+
23
+ def _cache_path(state_dir: str = "") -> str:
24
+ """缓存文件路径:优先 state 目录,兜底用户目录。"""
25
+ if state_dir:
26
+ return os.path.join(state_dir, _CACHE_NAME)
27
+ return os.path.join(os.path.expanduser("~"), ".ghlink", _CACHE_NAME)
28
+
29
+
30
+ def fetch_hosts(url: str, timeout_sec: float = 30) -> str:
31
+ """拉取 GitHub520 hosts 文本(失败抛异常,由调用方降级)。"""
32
+ req = urllib.request.Request(url, headers={"User-Agent": "ghlink/0.4.2"})
33
+ with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
34
+ return resp.read().decode("utf-8", errors="replace")
35
+
36
+
37
+ def parse_hosts(text: str) -> Dict[str, List[str]]:
38
+ """解析 hosts 文本 → {domain: [ips]}。跳过注释/空行/坏行。"""
39
+ entries: Dict[str, List[str]] = {}
40
+ for line in text.splitlines():
41
+ line = line.strip()
42
+ if not line or line.startswith("#"):
43
+ continue
44
+ parts = line.split()
45
+ if len(parts) < 2:
46
+ continue
47
+ ip, domain = parts[0], parts[1].rstrip(".")
48
+ # 只收 GitHub 生态域名(防社区列表混入无关项)
49
+ if not domain.endswith(
50
+ ("github.com", "githubusercontent.com", "githubassets.com", "fastly.net")
51
+ ):
52
+ continue
53
+ entries.setdefault(domain, [])
54
+ if ip not in entries[domain]:
55
+ entries[domain].append(ip)
56
+ return entries
57
+
58
+
59
+ def _safe_cache_path(state_dir: str = "") -> str:
60
+ """校验并规范化缓存路径(SonarCloud S8707:防符号链接/路径逃逸)。
61
+
62
+ 要求:绝对路径 + realpath 解析符号链接 + 位于允许目录
63
+ (用户主目录或系统临时目录)内。
64
+ """
65
+ import tempfile
66
+
67
+ path = _cache_path(state_dir)
68
+ resolved = os.path.realpath(path)
69
+ if not os.path.isabs(resolved):
70
+ raise ValueError(f"cache path must be absolute: {path}")
71
+ allowed_roots = (os.path.expanduser("~"), tempfile.gettempdir())
72
+ for root in allowed_roots:
73
+ root = os.path.realpath(root)
74
+ if resolved == root or resolved.startswith(root + os.sep):
75
+ return resolved
76
+ raise ValueError(f"cache path outside allowed dirs: {resolved}")
77
+
78
+
79
+ def _ip_reachable(ip: str, timeout_sec: float = 2.0) -> bool:
80
+ """基础可达性抽检:TCP 443 连通即认为可用(防坏 IP 入场)。
81
+
82
+ v0.4.2(拂晓实测建议):超时 5s→2s 收敛——TCP 443 建连 <2s 即可判通断,
83
+ 40 行 × 5s 串行最坏 200s,2s + 并行后显著提速。
84
+ """
85
+ import socket
86
+
87
+ try:
88
+ sock = socket.create_connection((ip, 443), timeout=timeout_sec)
89
+ sock.close()
90
+ return True
91
+ except OSError:
92
+ return False
93
+
94
+
95
+ def _precheck_ips(
96
+ ips: List[str],
97
+ timeout_sec: float = 2.0,
98
+ max_check: int = 5,
99
+ cache: Dict[str, bool] | None = None,
100
+ ) -> List[str]:
101
+ """v0.4.2(拂晓实测建议落地):并行预检 + 短路 + 去重缓存。
102
+
103
+ - 并行:ThreadPoolExecutor 并发 TCP 443 预检(40 行最坏从串行 200s → 并行 ~2s)
104
+ - 短路:每域名最多预检前 max_check 条(first-match-wins 只吃首条)
105
+ - 去重:同 IP 本轮只预检一次(cache dict 跨域名共享结果)
106
+ - 语义:可达排前(ok_ips + rest),未预检的排后——保持现有排序语义
107
+ """
108
+ from concurrent.futures import ThreadPoolExecutor
109
+
110
+ cache = cache if cache is not None else {}
111
+ to_check = [ip for ip in ips[:max_check] if ip not in cache]
112
+ with ThreadPoolExecutor(max_workers=max(len(to_check), 1)) as pool:
113
+ futs = {pool.submit(_ip_reachable, ip, timeout_sec): ip for ip in to_check}
114
+ for f in futs:
115
+ cache[futs[f]] = f.result()
116
+ ok = [ip for ip in ips if cache.get(ip)]
117
+ rest = [ip for ip in ips if ip not in ok]
118
+ return ok + rest
119
+
120
+
121
+ def filter_reachable(entries: Dict[str, List[str]], max_ips: int = 2) -> Dict[str, List[str]]:
122
+ """抽检:每域名保留可达 IP(最多 max_ips 个),全不可达则剔除该域名。
123
+
124
+ v0.4.2(拂晓实测建议):改用 _precheck_ips 并行预检(超时 2s、短路前 5 条、去重)。
125
+ """
126
+ out: Dict[str, List[str]] = {}
127
+ cache: Dict[str, bool] = {}
128
+ for domain, ips in entries.items():
129
+ ranked = _precheck_ips(ips, timeout_sec=2.0, max_check=5, cache=cache)
130
+ ok_ips = [ip for ip in ranked if cache.get(ip)][:max_ips]
131
+ if ok_ips:
132
+ out[domain] = ok_ips
133
+ return out
134
+
135
+
136
+ def load_cached(state_dir: str = "") -> Dict[str, List[str]]:
137
+ """读本地缓存(拉取失败时兜底,防坏 IP 列表已抽检过)。"""
138
+ try:
139
+ path = _safe_cache_path(state_dir)
140
+ if os.path.exists(path):
141
+ with open(path, encoding="utf-8") as f:
142
+ data = json.load(f)
143
+ return dict(data.get("entries", {}))
144
+ except (OSError, ValueError):
145
+ pass
146
+ return {}
147
+
148
+
149
+ def cache_age(state_dir: str = "") -> float:
150
+ """v0.4.2:返回本地缓存年龄(秒);无缓存/损坏返回超大值(视为过期需重拉)。"""
151
+ try:
152
+ path = _safe_cache_path(state_dir)
153
+ if os.path.exists(path):
154
+ with open(path, encoding="utf-8") as f:
155
+ data = json.load(f)
156
+ ts = float(data.get("ts", 0))
157
+ if ts:
158
+ return time.time() - ts
159
+ except (OSError, ValueError):
160
+ pass
161
+ return float("inf")
162
+
163
+
164
+ def save_cache(entries: Dict[str, List[str]], state_dir: str = "") -> None:
165
+ """保存抽检后的缓存(供下次拉取失败兜底)。"""
166
+ try:
167
+ path = _safe_cache_path(state_dir)
168
+ os.makedirs(os.path.dirname(path), exist_ok=True)
169
+ with open(path, "w", encoding="utf-8") as f:
170
+ json.dump({"ts": time.time(), "entries": entries}, f)
171
+ except (OSError, ValueError):
172
+ pass
173
+
174
+
175
+ def sync_github520(cfg: Dict[str, Any], state_dir: str = "") -> Dict[str, List[str]]:
176
+ """日常轮次:拉取 + 解析 + 抽检 + 缓存;失败回退缓存/内置快照。
177
+
178
+ 返回非核心域名的社区 IP(核心域名由动态自愈优先,不写死静态 IP)。
179
+ """
180
+ return _sync(cfg, state_dir, include_core=False)
181
+
182
+
183
+ def initial_entries(cfg: Dict[str, Any], state_dir: str = "") -> Dict[str, List[str]]:
184
+ """首装全量兜底(v0.4.2 新增,李工 12:35 点 1):含全部域名(含核心),
185
+ 预检过的 IP 排前、未预检的排后——首装/动态失败时 hosts 必有可用条目。
186
+ """
187
+ return _sync(cfg, state_dir, include_core=True, full_write=True)
188
+
189
+
190
+ def _sync(
191
+ cfg: Dict[str, Any],
192
+ state_dir: str = "",
193
+ include_core: bool = False,
194
+ full_write: bool = False,
195
+ ) -> Dict[str, List[str]]:
196
+ """核心同步逻辑。include_core=保留核心域名;full_write=全量写(预检过排前)。"""
197
+ g = cfg.get("github520", {})
198
+ if not g.get("enabled", True):
199
+ return {}
200
+ url = g.get("url", "https://raw.hellogithub.com/hosts")
201
+ timeout_sec = float(g.get("timeout_sec", 30))
202
+ core = set(cfg.get("probe", {}).get("core_targets", ["github.com", "api.github.com"]))
203
+
204
+ try:
205
+ text = fetch_hosts(url, timeout_sec)
206
+ entries = parse_hosts(text)
207
+ if not include_core:
208
+ entries = {d: ips for d, ips in entries.items() if d not in core}
209
+ if full_write:
210
+ entries = _sort_prechecked_first(entries, min(timeout_sec, 5.0))
211
+ else:
212
+ entries = filter_reachable(entries)
213
+ if entries:
214
+ save_cache(entries, state_dir)
215
+ return entries
216
+ except Exception:
217
+ pass
218
+ # 拉取失败 → 缓存兜底(缓存已抽检过)
219
+ cached = load_cached(state_dir)
220
+ if cached:
221
+ if include_core:
222
+ # v0.4.3(李工 8 bug 点④ + 顾笙无缓存场景验证):首装全量语义下
223
+ # 缓存是 sync_github520(include_core=False) 存的,本就不含核心域名——
224
+ # 若直接剔除核心,动态从未成功 + 拉取失败时 hosts 段无 github.com
225
+ # 主条目。从内置快照补齐核心域名静态 IP(20.205.243.166 github.com 等)。
226
+ builtin = parse_hosts(BUILTIN_GITHUB520_HOSTS)
227
+ for _d in core:
228
+ if _d in builtin and _d not in cached:
229
+ cached[_d] = builtin[_d]
230
+ return cached
231
+ return {d: ips for d, ips in cached.items() if d not in core}
232
+ # 内置快照兜底(防首装断网尴尬)
233
+ builtin = parse_hosts(BUILTIN_GITHUB520_HOSTS)
234
+ if not include_core:
235
+ builtin = {d: ips for d, ips in builtin.items() if d not in core}
236
+ if full_write:
237
+ builtin = _sort_prechecked_first(builtin, min(timeout_sec, 5.0))
238
+ else:
239
+ builtin = filter_reachable(builtin)
240
+ if builtin:
241
+ save_cache(builtin, state_dir)
242
+ return builtin
243
+ return {}
244
+
245
+
246
+ def _sort_prechecked_first(
247
+ entries: Dict[str, List[str]], timeout_sec: float = 2.0
248
+ ) -> Dict[str, List[str]]:
249
+ """v0.4.2:全量写入时预检过的 IP 排前、未预检的排后(hosts 取首个命中)。
250
+
251
+ v0.4.2(拂晓实测建议):走 _precheck_ips 并行预检(超时 2s、短路前 5 条、去重缓存)。
252
+ """
253
+ out: Dict[str, List[str]] = {}
254
+ cache: Dict[str, bool] = {}
255
+ for domain, ips in entries.items():
256
+ out[domain] = _precheck_ips(ips, timeout_sec=timeout_sec, max_check=5, cache=cache)
257
+ return out
@@ -0,0 +1,259 @@
1
+ """hosts 段落式管理:写入/备份/回滚/自检。
2
+
3
+ 约定(借鉴 GitHub520 思路,全新实现):
4
+ - hosts 段落标记:# ghlink Start / # ghlink End,段落可重复安全更新
5
+ - GitHub520 静态兜底子段:# ghlink520 Start / # ghlink520 End(v0.2.19 起)
6
+ —— 初始化时合入一次,后续动态更新自动保留(不重复合入、不丢失)
7
+ - 写入前 backup_hosts(),写入后立即自检(probe 替换域名),失败 restore_hosts()
8
+ - 自检失败 → 回滚 + degraded 状态 + 告警,坏配置绝不留场
9
+ - v0.2.19(李工 8 条):正常态也保持 hosts 段存在(全局访问生效),
10
+ 写入前与现有段落比较,内容无变化不落盘(避免频繁写盘/flushdns)
11
+ """
12
+
13
+ from typing import Dict, List
14
+
15
+ from . import platform_adapter
16
+
17
+ START_MARK = "# ghlink Start"
18
+ END_MARK = "# ghlink End"
19
+ G520_START = "# ghlink520 Start"
20
+ G520_END = "# ghlink520 End"
21
+
22
+
23
+ def build_block(entries: Dict[str, List[str]]) -> str:
24
+ """由 {domain: [ips]} 生成 hosts 段落文本(含 Start/End 标记)。"""
25
+ lines = [START_MARK]
26
+ for domain, ips in entries.items():
27
+ for ip in ips:
28
+ lines.append(f"{ip} {domain}")
29
+ lines.append(END_MARK)
30
+ return "\n".join(lines) + "\n"
31
+
32
+
33
+ def build_combined_block(dynamic: Dict[str, List[str]], g520: Dict[str, List[str]]) -> str:
34
+ """生成「动态段 + GitHub520 静态子段」复合段落。
35
+
36
+ 结构:
37
+ # ghlink Start
38
+ <动态 IP:8 域名>
39
+ # ghlink520 Start
40
+ <GitHub520 社区 IP:非核心域名>
41
+ # ghlink520 End
42
+ # ghlink End
43
+
44
+ g520 为空时不输出子段标记,段落保持原样(向后兼容旧 hosts)。
45
+ """
46
+ lines = [START_MARK]
47
+ for domain, ips in dynamic.items():
48
+ for ip in ips:
49
+ lines.append(f"{ip} {domain}")
50
+ if g520:
51
+ lines.append(G520_START)
52
+ for domain, ips in g520.items():
53
+ for ip in ips:
54
+ lines.append(f"{ip} {domain}")
55
+ lines.append(G520_END)
56
+ lines.append(END_MARK)
57
+ return "\n".join(lines) + "\n"
58
+
59
+
60
+ def _read_hosts(path: str) -> str:
61
+ try:
62
+ with open(path, encoding="utf-8", errors="replace") as f:
63
+ return f.read()
64
+ except OSError:
65
+ return ""
66
+
67
+
68
+ def _write_hosts(path: str, content: str) -> bool:
69
+ try:
70
+ with open(path, "w", encoding="utf-8") as f:
71
+ f.write(content)
72
+ return True
73
+ except OSError:
74
+ return False
75
+
76
+
77
+ def _extract_section(content: str, start_mark: str, end_mark: str) -> str:
78
+ """提取段落内部文本(不含标记);段落缺失/标记不完整返回 ''。"""
79
+ start = content.find(start_mark)
80
+ end = content.find(end_mark)
81
+ if start == -1 or end == -1 or end <= start:
82
+ return ""
83
+ return content[start + len(start_mark) : end]
84
+
85
+
86
+ def current_ghlink_block(path: str = "") -> str:
87
+ """读取当前 hosts 中的 # ghlink Start/End 段全文(含标记);不存在返回 ''。"""
88
+ path = path or platform_adapter.get_hosts_path()
89
+ content = _read_hosts(path)
90
+ start = content.find(START_MARK)
91
+ end = content.find(END_MARK)
92
+ if start == -1 or end == -1 or end <= start:
93
+ return ""
94
+ return content[start : end + len(END_MARK)]
95
+
96
+
97
+ def current_g520_entries(path: str = "") -> Dict[str, List[str]]:
98
+ """从当前 hosts 提取 GitHub520 子段条目 {domain: [ips]};无子段返回 {}。
99
+
100
+ v0.2.19:初始化合入后,动态更新时从现有 hosts 保留该子段(不重复拉取/合入)。
101
+ """
102
+ path = path or platform_adapter.get_hosts_path()
103
+ content = _read_hosts(path)
104
+ section = _extract_section(content, G520_START, G520_END)
105
+ if not section:
106
+ return {}
107
+ entries: Dict[str, List[str]] = {}
108
+ for line in section.splitlines():
109
+ line = line.strip()
110
+ if not line or line.startswith("#"):
111
+ continue
112
+ parts = line.split()
113
+ if len(parts) < 2:
114
+ continue
115
+ ip, domain = parts[0], parts[1].rstrip(".")
116
+ entries.setdefault(domain, [])
117
+ if ip not in entries[domain]:
118
+ entries[domain].append(ip)
119
+ return entries
120
+
121
+
122
+ def apply_block(
123
+ block: str,
124
+ backup_dir: str = "backup",
125
+ preserve_g520: bool = True,
126
+ ) -> tuple:
127
+ """写入 hosts(替换旧段落);提权/写入失败返回 (False, "")。
128
+
129
+ 参数:
130
+ - block: 新段落全文(含 # ghlink Start/End 标记)
131
+ - preserve_g520: 若现有 hosts 含 GitHub520 子段而新 block 不含,
132
+ 则自动保留子段(v0.2.19 初始化合一次、动态更新不丢兜底段)
133
+
134
+ 返回 (ok, backup_path):ok=False 表示写入失败;ok=True 时 backup_path
135
+ 为本次写入前的备份文件路径(供自检失败回滚使用)。
136
+ """
137
+ if not platform_adapter.ensure_privilege():
138
+ return False, ""
139
+ path = platform_adapter.get_hosts_path()
140
+ content = _read_hosts(path)
141
+
142
+ # v0.2.19:动态段更新时保留现有 GitHub520 子段(初始化已合入,不重复拉取)
143
+ if preserve_g520 and G520_START not in block:
144
+ g520 = _extract_section(content, G520_START, G520_END)
145
+ if g520:
146
+ # 把子段插入 # ghlink End 之前
147
+ end_pos = block.rfind(END_MARK)
148
+ if end_pos != -1:
149
+ sub = f"\n{G520_START}{g520}{G520_END}"
150
+ block = block[:end_pos] + sub + block[end_pos:]
151
+
152
+ # v0.4.0(李工 12:35 点 3):段落插到文件最前优先命中(first-match-wins),
153
+ # 避免用户预存条目在段落前遮蔽 ghlink 写入;段落外内容零改动
154
+ start = content.find(START_MARK)
155
+ end = content.find(END_MARK)
156
+ if start != -1 and end != -1 and end > start:
157
+ before = content[:start]
158
+ after = content[end + len(END_MARK) :]
159
+ content = before + block + after
160
+ # 若段落不在文件最前(前面还有非空内容),把段落提前到最前
161
+ if before.strip():
162
+ content = block + "\n" + before + after
163
+ elif start == -1 and end == -1:
164
+ content = block + "\n" + content.rstrip("\n") + "\n"
165
+ else:
166
+ # 段落标记不完整,视为异常:整体重建安全内容
167
+ return False, ""
168
+
169
+ # v0.2.19:内容无变化不落盘(避免每轮写盘 + flushdns)
170
+ if content == _read_hosts(path):
171
+ return True, ""
172
+
173
+ backup = platform_adapter.backup_hosts(backup_dir)
174
+ if not backup:
175
+ return False, ""
176
+ if not _write_hosts(path, content):
177
+ platform_adapter.restore_hosts(backup)
178
+ return False, ""
179
+ platform_adapter.flush_dns()
180
+ return True, backup
181
+
182
+
183
+ def remove_block(path: str = "") -> bool:
184
+ """v0.4.1(拂晓 Linux 严格测试发现):移除 hosts 中的 ghlink 段落(含 ghlink520 子段),
185
+ 还原基线(disable/卸载时调用,李工"卸载也直接删"要求)。段落不存在返回 True(幂等)。"""
186
+ if not platform_adapter.ensure_privilege():
187
+ return False
188
+ path = path or platform_adapter.get_hosts_path()
189
+ content = _read_hosts(path)
190
+ start = content.find(START_MARK)
191
+ end = content.find(END_MARK)
192
+ if start == -1 or end == -1 or end <= start:
193
+ return True # 无段落,幂等成功
194
+ # 移除段落(含段落前后的多余空行清理)
195
+ before = content[:start]
196
+ after = content[end + len(END_MARK) :]
197
+ new_content = before + after
198
+ # 清理段落移除后残留的双空行
199
+ while "\n\n\n" in new_content:
200
+ new_content = new_content.replace("\n\n\n", "\n\n")
201
+ if new_content == content:
202
+ return True
203
+ if not _write_hosts(path, new_content):
204
+ return False
205
+ platform_adapter.flush_dns()
206
+ return True
207
+
208
+
209
+ def detect_external_dupes(path: str = "") -> Dict[str, str]:
210
+ """v0.4.0(李工 12:35 点 3):检测段落外预存的 GitHub 生态域名条目。
211
+
212
+ 返回 {domain: "ip"}——用户在 ghlink 块之外已配置的条目,
213
+ first-match-wins 下可能与 ghlink 写入冲突。enable 时调用,命中则告警+备份。
214
+ """
215
+ path = path or platform_adapter.get_hosts_path()
216
+ content = _read_hosts(path)
217
+ # 剔除 ghlink 段落(含 ghlink520 子段)
218
+ start = content.find(START_MARK)
219
+ end = content.find(END_MARK)
220
+ if start != -1 and end != -1 and end > start:
221
+ content = content[:start] + content[end + len(END_MARK) :]
222
+ out: Dict[str, str] = {}
223
+ for line in content.splitlines():
224
+ line = line.strip()
225
+ if not line or line.startswith("#"):
226
+ continue
227
+ parts = line.split()
228
+ if len(parts) < 2:
229
+ continue
230
+ ip, domain = parts[0], parts[1].rstrip(".")
231
+ if domain.endswith(
232
+ (".github.com", "github.com", "githubusercontent.com", "githubassets.com", "fastly.net")
233
+ ):
234
+ out.setdefault(domain, ip)
235
+ return out
236
+
237
+
238
+ def verify_after_apply(targets: List[str], timeout_sec: float) -> bool:
239
+ """写入后立即自检:新 IP 下全部目标连通才算成功。
240
+
241
+ v0.4.4(李工 03:27 终裁 B 方案,顾笙无缓存场景专项发现):分级宽容降级——
242
+ 先三层全检(TCP+TLS+HTTP HEAD),失败目标用 TCP-only 复检:
243
+ TCP 通判通过(与预检同口径,防 TLS 干扰误杀——TLS 握手被干扰但 IP 实际可达
244
+ 时不再回滚清空兜底写入);TCP 也不通才判失败(真坏 IP 仍回滚,坏配置绝不留场)。
245
+ """
246
+ from . import probe
247
+
248
+ results = probe.probe_all(targets, timeout_sec)
249
+ failed = [h for h, r in results.items() if not r.get("ok")]
250
+ if not failed:
251
+ return True
252
+ # 分级宽容:失败目标 TCP-only 复检(TCP 通即通过)
253
+ tcp_results = probe.probe_tcp_only_many(failed, timeout_sec)
254
+ return all(r.get("ok") for r in tcp_results.values())
255
+
256
+
257
+ def rollback(backup_path: str) -> bool:
258
+ """回滚 hosts 到备份版本。"""
259
+ return platform_adapter.restore_hosts(backup_path)