minecraft-wiki-mdifier 0.1.0__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,26 @@
1
+ """
2
+ Minecraft Wiki MDifier
3
+
4
+ 将Minecraft Wiki页面转换为AI助手易读的Markdown格式。
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from minecraft_wiki_mdifier.lib import (
10
+ BatchConvertResult,
11
+ ConvertResult,
12
+ convert,
13
+ convert_detailed,
14
+ convert_many,
15
+ search,
16
+ )
17
+
18
+ __all__ = [
19
+ "BatchConvertResult",
20
+ "ConvertResult",
21
+ "convert",
22
+ "convert_detailed",
23
+ "convert_many",
24
+ "search",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,37 @@
1
+ """
2
+ 共享 HTTP Session 工厂函数
3
+
4
+ 提供统一的 HTTP Session 配置(重试机制、User-Agent),避免在多个模块中重复定义。
5
+ """
6
+
7
+ import requests
8
+ from requests.adapters import HTTPAdapter
9
+ from urllib3.util.retry import Retry
10
+
11
+ from . import __version__
12
+
13
+ USER_AGENT = f"Minecraft-Wiki-MDifier/{__version__} (Python Wiki Converter)"
14
+
15
+
16
+ def create_session() -> requests.Session:
17
+ """
18
+ 创建配置好的 HTTP Session
19
+
20
+ 配置项:
21
+ - User-Agent 头
22
+ - 重试机制:3 次重试,指数退避(0.5s/1s/2s),对 429/500/502/503/504 生效
23
+
24
+ Returns:
25
+ 配置好的 requests.Session 实例
26
+ """
27
+ session = requests.Session()
28
+ session.headers.update({"User-Agent": USER_AGENT})
29
+ retry = Retry(
30
+ total=3,
31
+ backoff_factor=0.5,
32
+ status_forcelist={429, 500, 502, 503, 504},
33
+ raise_on_status=False,
34
+ )
35
+ session.mount("https://", HTTPAdapter(max_retries=retry))
36
+ session.mount("http://", HTTPAdapter(max_retries=retry))
37
+ return session
@@ -0,0 +1,26 @@
1
+ """
2
+ 共享验证函数
3
+
4
+ 提供语言验证等通用验证逻辑,避免多处重复定义。
5
+ """
6
+
7
+ from minecraft_wiki_mdifier.exceptions import InvalidInputError
8
+
9
+
10
+ def validate_lang(lang: str | None) -> None:
11
+ """
12
+ 验证语言代码是否支持
13
+
14
+ Args:
15
+ lang: 语言代码
16
+
17
+ Raises:
18
+ InvalidInputError: 不支持的语言代码
19
+ """
20
+ # 延迟导入避免循环依赖
21
+ from minecraft_wiki_mdifier.wiki import LANG_CONFIG
22
+
23
+ if lang is not None and lang not in LANG_CONFIG:
24
+ raise InvalidInputError(
25
+ f"Unsupported language: {lang}. Available: {list(LANG_CONFIG.keys())}"
26
+ )
@@ -0,0 +1,135 @@
1
+ """
2
+ 模板展开结果缓存(持久化)
3
+
4
+ 将跨页模板展开结果保存到磁盘,避免重复 API 请求。
5
+
6
+ 存储位置:~/.cache/mdifier/templates.json
7
+ 有效期:7 天(wiki 内容会更新)
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from datetime import UTC
13
+ from pathlib import Path
14
+
15
+ from minecraft_wiki_mdifier.exceptions import CacheError
16
+
17
+ CACHE_DIR = Path.home() / ".cache" / "mdifier"
18
+ CACHE_FILE = CACHE_DIR / "templates.json"
19
+
20
+ # 缓存有效期(7 天,wiki 内容会更新)
21
+ CACHE_TTL = 7 * 24 * 3600
22
+
23
+ # 模块级单例:跨 lang 复用同一份持久化缓存,只在首次使用时懒加载
24
+ _SHARED_PERSISTENT_CACHE: dict | None = None
25
+
26
+
27
+ def get_or_load_persistent_cache() -> dict:
28
+ """懒加载持久化缓存,全局只读一次磁盘。"""
29
+ global _SHARED_PERSISTENT_CACHE
30
+ if _SHARED_PERSISTENT_CACHE is None:
31
+ _SHARED_PERSISTENT_CACHE = load_cache()
32
+ return _SHARED_PERSISTENT_CACHE
33
+
34
+
35
+ def reset_persistent_cache() -> None:
36
+ """重置单例(测试用)。"""
37
+ global _SHARED_PERSISTENT_CACHE
38
+ _SHARED_PERSISTENT_CACHE = None
39
+
40
+
41
+ def load_cache() -> dict:
42
+ """从磁盘加载缓存;过期或不存在则返回空 dict
43
+
44
+ Returns:
45
+ {cache_key: {name, class, text, html, format, table, ts}}
46
+ """
47
+ if not CACHE_FILE.exists():
48
+ return {}
49
+ try:
50
+ data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
51
+ now = time.time()
52
+ # 过滤过期项
53
+ return {k: v for k, v in data.items() if now - v.get("ts", 0) < CACHE_TTL}
54
+ except (json.JSONDecodeError, OSError):
55
+ return {}
56
+
57
+
58
+ def save_cache(cache: dict) -> None:
59
+ """保存缓存到磁盘(添加时间戳用于 TTL)
60
+
61
+ Args:
62
+ cache: 模板缓存 dict
63
+ """
64
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
65
+ enriched = {k: {**v, "ts": time.time()} for k, v in cache.items()}
66
+ try:
67
+ CACHE_FILE.write_text(json.dumps(enriched, ensure_ascii=False), encoding="utf-8")
68
+ except OSError as e:
69
+ raise CacheError(f"缓存写入失败: {e}") from e
70
+
71
+
72
+ def clear_cache() -> bool:
73
+ """清空缓存(删除磁盘文件)
74
+
75
+ Returns:
76
+ True 如果文件存在并被删除;False 如果缓存不存在
77
+ """
78
+ if CACHE_FILE.exists():
79
+ CACHE_FILE.unlink()
80
+ return True
81
+ return False
82
+
83
+
84
+ def cache_info() -> dict:
85
+ """返回缓存统计信息
86
+
87
+ Returns:
88
+ {
89
+ "path": 缓存文件路径,
90
+ "exists": 是否存在,
91
+ "size_bytes": 文件大小(如果存在),
92
+ "size_mb": 文件大小 MB,
93
+ "entries": 总条目数,
94
+ "fresh_entries": 未过期条目数,
95
+ "expired_entries": 已过期条目数,
96
+ "oldest_ts": 最早时间戳(ISO 格式),
97
+ "newest_ts": 最新时间戳(ISO 格式),
98
+ }
99
+ """
100
+ from datetime import datetime
101
+
102
+ info = {
103
+ "path": str(CACHE_FILE),
104
+ "exists": CACHE_FILE.exists(),
105
+ "size_bytes": 0,
106
+ "size_mb": 0.0,
107
+ "entries": 0,
108
+ "fresh_entries": 0,
109
+ "expired_entries": 0,
110
+ "oldest_ts": None,
111
+ "newest_ts": None,
112
+ }
113
+ if not CACHE_FILE.exists():
114
+ return info
115
+
116
+ info["size_bytes"] = CACHE_FILE.stat().st_size
117
+ info["size_mb"] = round(info["size_bytes"] / 1024 / 1024, 2)
118
+
119
+ try:
120
+ data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
121
+ info["entries"] = len(data)
122
+ now = time.time()
123
+ ts_list = [v.get("ts", 0) for v in data.values() if "ts" in v]
124
+ for v in data.values():
125
+ ts = v.get("ts", 0)
126
+ if now - ts < CACHE_TTL:
127
+ info["fresh_entries"] += 1
128
+ else:
129
+ info["expired_entries"] += 1
130
+ if ts_list:
131
+ info["oldest_ts"] = datetime.fromtimestamp(min(ts_list), tz=UTC).isoformat()
132
+ info["newest_ts"] = datetime.fromtimestamp(max(ts_list), tz=UTC).isoformat()
133
+ except (json.JSONDecodeError, OSError):
134
+ pass
135
+ return info
@@ -0,0 +1,469 @@
1
+ """
2
+ 命令行接口
3
+
4
+ 用法:
5
+ mdifier convert "页面标题" # 转换页面
6
+ mdifier convert "页面标题" -o x.md # 输出到文件
7
+ mdifier convert "https://zh.minecraft.wiki/页面" # URL方式
8
+ mdifier search "关键词" # 搜索页面
9
+ """
10
+
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import click
16
+
17
+ from . import __version__
18
+ from minecraft_wiki_mdifier.converter import MarkdownConverter
19
+ from minecraft_wiki_mdifier.exceptions import FetchError, InvalidInputError, PageNotFoundError
20
+ from minecraft_wiki_mdifier.lib import convert, convert_detailed, convert_many, search
21
+ from minecraft_wiki_mdifier.wiki import LANG_CONFIG
22
+
23
+ # BSD sysexits.h 退出码(Python 3.13+ 统一支持)
24
+ EXIT_OK = 0
25
+ EXIT_USAGE = 64 # 命令行参数错
26
+ EXIT_DATAERR = 65 # 数据错
27
+ EXIT_SOFTWARE = 70 # 内部软件错
28
+ EXIT_IOERR = 74 # 本地 I/O 错
29
+ EXIT_TEMPFAIL = 75 # 网络临时失败
30
+ EXIT_NOPERM = 77 # 权限错
31
+ EXIT_CONFIG = 78 # 配置错
32
+
33
+ LANGUAGES = list(LANG_CONFIG.keys())
34
+
35
+
36
+ @click.group()
37
+ @click.version_option(version=__version__, prog_name="mdifier")
38
+ def main():
39
+ """
40
+ Minecraft Wiki MDifier
41
+
42
+ 将Minecraft Wiki页面转换为AI助手易读的Markdown格式
43
+
44
+ 子命令:convert / search / batch / cache
45
+ """
46
+ pass
47
+
48
+
49
+ @main.command()
50
+ @click.argument("title_or_url", type=str, metavar="TITLE_OR_URL")
51
+ @click.option(
52
+ "-o", "--output", type=click.Path(), default=None, help="输出文件路径,默认为标准输出"
53
+ )
54
+ @click.option(
55
+ "-l",
56
+ "--lang",
57
+ type=click.Choice(LANGUAGES, case_sensitive=False),
58
+ default="zh",
59
+ help="语言(默认 zh,支持自动 URL 识别)",
60
+ )
61
+ @click.option(
62
+ "--detail",
63
+ is_flag=True,
64
+ help="输出完整 JSON(包含 title、markdown、source、templates)",
65
+ )
66
+ def convert_cmd(
67
+ title_or_url: str,
68
+ output: str | None,
69
+ lang: str,
70
+ detail: bool,
71
+ ):
72
+ """
73
+ 转换Wiki页面为Markdown
74
+
75
+ 支持纯标题或自动识别 URL(zh.minecraft.wiki / minecraft.wiki / en.minecraft.wiki)
76
+
77
+ 示例:
78
+ mdifier convert "铁锭"
79
+ mdifier convert "铁锭" -o iron_ingot.md
80
+ mdifier convert "铁锭" --detail # 完整 JSON 输出
81
+ mdifier convert "https://zh.minecraft.wiki/铁锭"
82
+ """
83
+ try:
84
+ if detail:
85
+ import json
86
+
87
+ result = convert_detailed(title_or_url, lang=lang)
88
+ content = json.dumps(
89
+ {
90
+ "title": result.title,
91
+ "markdown": result.markdown,
92
+ "source": result.source,
93
+ "templates": result.templates,
94
+ },
95
+ ensure_ascii=False,
96
+ indent=2,
97
+ )
98
+ else:
99
+ content = convert(title_or_url, lang=lang)
100
+
101
+ if output:
102
+ try:
103
+ # 解析为绝对路径:避免 Git Bash 的 MSYS 路径翻译
104
+ # 相对路径基于 cwd;绝对路径不变
105
+ out_path = Path(output).resolve()
106
+ if out_path.parent and not out_path.parent.exists():
107
+ out_path.parent.mkdir(parents=True, exist_ok=True)
108
+ out_path.write_text(content, encoding="utf-8")
109
+ click.echo(f"已保存到: {out_path}")
110
+ except FileNotFoundError as e:
111
+ click.secho(f"错误: 路径无效 ({output}): {e}", fg="red", err=True)
112
+ sys.exit(EXIT_IOERR)
113
+ except PermissionError as e:
114
+ click.secho(f"错误: 无写权限 ({output}): {e}", fg="red", err=True)
115
+ sys.exit(EXIT_NOPERM)
116
+ except OSError as e:
117
+ click.secho(f"错误: 写入文件失败 ({output}): {e}", fg="red", err=True)
118
+ sys.exit(EXIT_IOERR)
119
+ else:
120
+ click.echo(content)
121
+
122
+ except InvalidInputError as e:
123
+ click.secho(f"错误: {e}", fg="red", err=True)
124
+ sys.exit(EXIT_USAGE)
125
+ except PageNotFoundError as e:
126
+ click.secho(f"页面未找到: {e}", fg="red", err=True)
127
+ sys.exit(EXIT_DATAERR)
128
+ except FetchError as e:
129
+ click.secho(f"网络错误: {e}", fg="red", err=True)
130
+ sys.exit(EXIT_TEMPFAIL)
131
+ except Exception as e:
132
+ click.secho(f"未知错误: {type(e).__name__}: {e}", fg="red", err=True)
133
+ sys.exit(EXIT_SOFTWARE)
134
+
135
+
136
+ @main.command()
137
+ @click.argument("query", type=str)
138
+ @click.option(
139
+ "-l",
140
+ "--lang",
141
+ type=click.Choice(LANGUAGES, case_sensitive=False),
142
+ default="zh",
143
+ help="语言(默认 zh)",
144
+ )
145
+ @click.option("-n", "--num", type=int, default=10, help="返回结果数量(默认 10)")
146
+ def search_cmd(query: str, lang: str, num: int):
147
+ """
148
+ 搜索Wiki页面
149
+
150
+ 示例:
151
+ mdifier search "钻石"
152
+ """
153
+ try:
154
+ results = search(query, lang=lang)[:num]
155
+
156
+ if not results:
157
+ click.echo("未找到结果")
158
+ return
159
+
160
+ for i, result in enumerate(results, 1):
161
+ title = result.get("title", "")
162
+ if not title:
163
+ continue
164
+ desc = result.get("description", "")
165
+ url = result.get("url", "")
166
+
167
+ click.echo(f"{i}. {title}")
168
+ if desc:
169
+ click.echo(f" {desc}")
170
+ if url:
171
+ click.echo(f" {url}")
172
+ if i < len(results):
173
+ click.echo()
174
+
175
+ except Exception as e:
176
+ click.secho(f"错误: {e}", fg="red", err=True)
177
+ sys.exit(EXIT_SOFTWARE)
178
+
179
+
180
+ @main.command(name="batch")
181
+ @click.option("titles", "-t", "--title", multiple=True, help="页面标题(可多次使用)")
182
+ @click.option(
183
+ "-i",
184
+ "--input-file",
185
+ type=click.Path(exists=True),
186
+ default=None,
187
+ help="标题列表文件(每行一个;# 开头为注释)",
188
+ )
189
+ @click.option("--from-search", default=None, help="通过搜索获取标题")
190
+ @click.option("--search-limit", type=int, default=20, help="--from-search 时返回的最大结果数")
191
+ @click.option(
192
+ "-l",
193
+ "--lang",
194
+ type=click.Choice(LANGUAGES, case_sensitive=False),
195
+ default="zh",
196
+ help="默认语言(默认 zh)",
197
+ )
198
+ @click.option(
199
+ "-o",
200
+ "--output-dir",
201
+ type=click.Path(file_okay=False),
202
+ default=None,
203
+ help="输出目录;为 None 则打印到 stdout",
204
+ )
205
+ @click.option("--workers", type=int, default=4, help="跨页并发抓取数")
206
+ @click.option("--no-progress", is_flag=True, default=False, help="禁用进度条")
207
+ @click.option(
208
+ "--marker-format", default=None, help="自定义模板标记,格式 'open/close',如 ':::{name}:::/:::'"
209
+ )
210
+ def batch_cmd(
211
+ titles,
212
+ input_file,
213
+ from_search,
214
+ search_limit,
215
+ lang,
216
+ output_dir,
217
+ workers,
218
+ no_progress,
219
+ marker_format,
220
+ ):
221
+ """
222
+ 批量转换 Wiki 页面
223
+
224
+ 示例:
225
+ mdifier batch -t 钻石 -t 铁锭 -o ./out
226
+ mdifier batch -i pages.txt -o ./out --workers 8
227
+ mdifier batch --from-search "红石" --search-limit 30 -o ./out
228
+ """
229
+ try:
230
+ items: list[str] = list(titles)
231
+ if input_file:
232
+ items.extend(_read_titles_file(input_file))
233
+ if from_search:
234
+ items.extend(r["title"] for r in search(from_search, lang=lang)[:search_limit])
235
+ if not items:
236
+ click.secho("错误: 没有提供任何标题(用 -t / -i / --from-search)", fg="red", err=True)
237
+ sys.exit(EXIT_USAGE)
238
+
239
+ # 去重保留顺序
240
+ seen, deduped = set(), []
241
+ for title in items:
242
+ if title not in seen:
243
+ seen.add(title)
244
+ deduped.append(title)
245
+
246
+ progress = _make_progress(len(deduped), enabled=not no_progress)
247
+ # 解析 --marker-format 为 converter_factory
248
+ converter_factory = None
249
+ if marker_format:
250
+ try:
251
+ open_, close_ = marker_format.split("/", 1)
252
+ except ValueError:
253
+ click.secho(
254
+ "错误: --marker-format 格式为 'open/close',必须包含 '/'", fg="red", err=True
255
+ )
256
+ sys.exit(EXIT_USAGE)
257
+
258
+ def _make_converter(item_lang: str, cache: dict | None):
259
+ c = MarkdownConverter(lang=item_lang, template_cache=cache)
260
+ c.template_marker_open = open_
261
+ c.template_marker_close = close_
262
+ return c
263
+
264
+ converter_factory = _make_converter
265
+ result = convert_many(
266
+ deduped,
267
+ lang=lang,
268
+ max_workers=workers,
269
+ on_progress=progress,
270
+ converter_factory=converter_factory,
271
+ )
272
+ _emit_results(result, output_dir)
273
+
274
+ # 报告未展开的模板
275
+ if result.unresolved:
276
+ click.secho(
277
+ f"\n⚠️ 警告:{len(result.unresolved)} 个模板未展开(驼峰映射缺失或模板不存在):",
278
+ fg="yellow",
279
+ err=True,
280
+ )
281
+ for name in result.unresolved:
282
+ click.secho(f" - {name}", fg="yellow", err=True)
283
+ click.secho("建议添加到 MarkdownConverter.CAMEL_CASE_TEMPLATES", fg="yellow", err=True)
284
+
285
+ if result.failed:
286
+ click.echo(
287
+ f"\n完成: {len(result.results)} 成功, {len(result.failed)} 失败",
288
+ err=True,
289
+ )
290
+ for t, err in result.failed:
291
+ click.echo(f" - {t}: {err}", err=True)
292
+ sys.exit(EXIT_DATAERR)
293
+ click.echo(f"完成: {len(result.results)} 成功", err=True)
294
+ except Exception as e:
295
+ click.secho(f"未知错误: {e}", fg="red", err=True)
296
+ sys.exit(EXIT_SOFTWARE)
297
+
298
+
299
+ @main.group()
300
+ def cache():
301
+ """管理模板展开缓存"""
302
+
303
+
304
+ @cache.command(name="info")
305
+ def cache_info_cmd():
306
+ """显示缓存统计信息(路径、大小、条目、时间戳)"""
307
+ from minecraft_wiki_mdifier.cache import cache_info
308
+
309
+ info = cache_info()
310
+ click.echo(f"路径: {info['path']}")
311
+ click.echo(f"存在: {info['exists']}")
312
+ if info["exists"]:
313
+ click.echo(f"大小: {info['size_bytes']:,} 字节 ({info['size_mb']} MB)")
314
+ click.echo(f"总条目: {info['entries']}")
315
+ click.echo(f" 未过期: {info['fresh_entries']}")
316
+ click.echo(f" 已过期: {info['expired_entries']}")
317
+ if info["oldest_ts"]:
318
+ click.echo(f"最早: {info['oldest_ts']}")
319
+ click.echo(f"最新: {info['newest_ts']}")
320
+
321
+
322
+ @cache.command(name="clear")
323
+ @click.option("-y", "--yes", is_flag=True, help="跳过确认提示")
324
+ def cache_clear_cmd(yes):
325
+ """清空整个缓存文件(强制下次重新请求)"""
326
+ from minecraft_wiki_mdifier.cache import cache_info, clear_cache
327
+
328
+ info = cache_info()
329
+ if not info["exists"]:
330
+ click.echo("缓存不存在,无需清理", err=True)
331
+ return
332
+
333
+ if not yes:
334
+ click.confirm(
335
+ f"确定删除 {info['size_mb']} MB、{info['entries']} 条目的缓存?",
336
+ abort=True,
337
+ )
338
+ if clear_cache():
339
+ click.secho(
340
+ f"✓ 已清空缓存:{info['size_mb']} MB、{info['entries']} 条目", fg="green", err=True
341
+ )
342
+ else:
343
+ click.echo("缓存不存在", err=True)
344
+
345
+
346
+ @cache.command(name="prune")
347
+ def cache_prune_cmd():
348
+ """清理已过期条目(保留 < 7 天的 fresh 条目)"""
349
+ from minecraft_wiki_mdifier.cache import CACHE_FILE, CACHE_TTL, cache_info
350
+
351
+ info = cache_info()
352
+ if not info["exists"]:
353
+ click.echo("缓存不存在", err=True)
354
+ return
355
+ if info["expired_entries"] == 0:
356
+ click.echo(f"无过期条目(共 {info['entries']} 条目,全部未过期)", err=True)
357
+ return
358
+ # 加载 → 过滤 → 写回
359
+ import json
360
+ import time
361
+
362
+ cache = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
363
+ now = time.time()
364
+ pruned = {k: v for k, v in cache.items() if now - v.get("ts", 0) < CACHE_TTL}
365
+ removed = len(cache) - len(pruned)
366
+ CACHE_FILE.write_text(
367
+ json.dumps(pruned, ensure_ascii=False),
368
+ encoding="utf-8",
369
+ )
370
+ click.secho(f"✓ 清理完成:移除 {removed} 条过期,保留 {len(pruned)} 条", fg="green", err=True)
371
+
372
+
373
+ def _read_titles_file(path: str) -> list[str]:
374
+ """从文件读取标题列表"""
375
+ titles: list[str] = []
376
+ with open(path, encoding="utf-8") as f:
377
+ for line in f:
378
+ line = line.strip()
379
+ if not line or line.startswith("#"):
380
+ continue
381
+ titles.append(line)
382
+ return titles
383
+
384
+
385
+ def _make_progress(total: int, enabled: bool):
386
+ """构造进度回调(tqdm 优先,缺失降级为 stderr 文本)"""
387
+ if not enabled:
388
+ return lambda done, total, title: None
389
+
390
+ try:
391
+ from tqdm import tqdm
392
+ except ImportError:
393
+ last_emitted = 0
394
+ threshold = max(1, total // 20)
395
+
396
+ def progress_callback(done, total, title):
397
+ nonlocal last_emitted
398
+ if done == total or done - last_emitted >= threshold:
399
+ click.echo(f"\r进度: {done}/{total}", nl=False, err=True)
400
+ last_emitted = done
401
+
402
+ return progress_callback
403
+
404
+ bar = tqdm(total=total, unit="page", dynamic_ncols=True)
405
+
406
+ def progress_callback(done, total, title):
407
+ bar.update(1)
408
+ bar.set_postfix_str(title[:30])
409
+
410
+ return progress_callback
411
+
412
+
413
+ def _emit_results(result, output_dir: str | None) -> None:
414
+ """输出结果到 stdout 或文件目录"""
415
+ if not output_dir:
416
+ for i, r in enumerate(result.results):
417
+ if i > 0:
418
+ click.echo("\n---\n")
419
+ click.echo(f"# {r.title}\n")
420
+ click.echo(r.markdown)
421
+ return
422
+
423
+ # 解析为绝对路径:避免 Git Bash 的 MSYS 路径翻译
424
+ out_dir = Path(output_dir).resolve()
425
+ try:
426
+ out_dir.mkdir(parents=True, exist_ok=True)
427
+ except PermissionError as e:
428
+ click.secho(f"错误: 无写权限创建目录 ({out_dir}): {e}", fg="red", err=True)
429
+ return
430
+ except OSError as e:
431
+ click.secho(f"错误: 创建目录失败 ({out_dir}): {e}", fg="red", err=True)
432
+ return
433
+ used_names: set[str] = set()
434
+ for r in result.results:
435
+ path = _unique_path(out_dir, _slug(r.title) + ".md", used_names)
436
+ try:
437
+ path.write_text(r.markdown, encoding="utf-8")
438
+ except (FileNotFoundError, PermissionError, OSError) as e:
439
+ click.secho(f"警告: 写入失败 ({path}): {e}", fg="yellow", err=True)
440
+ continue
441
+ used_names.add(path.name)
442
+
443
+
444
+ def _slug(title: str) -> str:
445
+ """标题转文件名安全字符串"""
446
+ s = re.sub(r'[\\/:*?"<>|]', "_", title)
447
+ s = re.sub(r"\s+", "_", s.strip())
448
+ # 移除非BMP字符(emoji等在U+10000以上平面,不含CJK汉字)
449
+ s = "".join(ch for ch in s if ord(ch) <= 0xFFFF)
450
+ return s or "untitled"
451
+
452
+
453
+ def _unique_path(out_dir, name: str, used: set[str]):
454
+ """生成唯一文件路径(冲突加 -2、-3 后缀)"""
455
+ p = out_dir / name
456
+ if p.name not in used and not p.exists():
457
+ return p
458
+ stem, suffix = p.stem, p.suffix
459
+ for i in range(2, 1000):
460
+ cand = out_dir / f"{stem}-{i}{suffix}"
461
+ if cand.name not in used and not cand.exists():
462
+ return cand
463
+ import uuid
464
+
465
+ return out_dir / f"{stem}-{uuid.uuid4().hex[:6]}{suffix}"
466
+
467
+
468
+ if __name__ == "__main__":
469
+ main()