Win配置:Md文件索引
概述
一套用于「快速索引分散在多处目录的 Markdown 文档、并一键用系统默认程序打开」的自建工具。它由两部分互补组成:
- 静态索引页:扫描配置的目录/文件,生成带实时搜索(支持中文 / 全拼 / 首字母)、按目录分组且顺序以
dirs.txt为准的index.html; - 自定义 URI 协议
mdopen://:点击文件名时,唤起本机.md关联的默认程序(Typora / VS Code / Obsidian 等)打开对应文件。
边界:纯前端静态页 + 一个用户级注册表协议,不依赖常驻进程、不依赖第三方服务;只解决「一览 + 点击打开」的本地检索诉求,不做全文搜索/云端同步。
背景与诉求
用户的 Markdown 文档散布在多个目录(如 AISkills/、Docs/、SyncBlock/...),日常想快速浏览或编辑时缺乏统一入口。两个核心诉求:
- 一览:一个可搜索的清单,按目录分组、顺序按配置可控;
- 快速打开:点一下即用习惯的 Markdown 编辑器打开,而非在浏览器看源码。
候选方案对比见后文「对比与选型」,最终落地为「静态 HTML 索引 + 自定义协议打开」。
方案总览
工具根目录 E:\SyncBlock\Code\LOC\MdIndex,由以下文件协作:
| 文件 | 角色 |
|---|---|
md_index.py | 生成器:读 dirs.txt,产出 index.html(零第三方依赖,仅标准库) |
dirs.txt | 配置:每行一个绝对路径,可为目录(递归扫描)或单个 .md 文件,# 开头为注释 |
pinyin_map.txt | 拼音字表(汉字→无调拼音,6763 字 / 22.9 KB),供生成器写入拼音检索串;缺失时自动退化为仅按文件名搜索 |
run.bat | 双击入口:定位 Python → 生成 index.html → 自动用浏览器打开 |
mdopen_launcher.py | mdopen:// 协议处理器:解码链接 → os.startfile 用默认程序打开 |
register_mdopen.bat | 一次性注册协议到 HKCU(当前用户,无需管理员) |
unregister_mdopen.bat | 一键卸载协议 |
核心概念
| 概念 | 说明 |
|---|---|
file:// 的局限 | 浏览器出于安全,点击 file:// 只会自己显示或下载,不会转交系统默认程序 |
| 自定义 URI 协议 | 注册 mdopen:// 后,浏览器点击 mdopen://xxx 会把完整 URL 作为 %1 传给注册的处理器(本机脚本) |
os.startfile(path) | Windows 下用「文件关联默认程序」打开文件,等效于双击文件 |
| base32 编码链接 | 把整条路径编码成只含 A-Z2-7 的串,放进 URL 路径位置,规避浏览器规范化破坏 |
| 拼音检索串 | 生成阶段把文件名转成「全拼 + 首字母」(如 AISkill一览 → aiskillyilan aiskillyl)写入页面数据,实现「输入字母匹配中文」,静态页也可用 |
HKCU\Software\Classes | 当前用户的协议注册表根;写这里无需管理员权限 |
原理与机制
为什么不能直接用 file://
浏览器安全模型接管 file://:点击 .md 链接时,要么在浏览器内显示原文,要么下载,不会唤起外部默认程序。要让「点击 → 默认程序打开」,需要一个本机代理来调用 os.startfile。
自定义协议如何工作
- 注册表写入
HKCU\Software\Classes\mdopen,声明URL Protocol; - 其
shell\open\command指向pythonw "mdopen_launcher.py" "%1"; - 浏览器点击
mdopen://...时弹出一次确认框,确认后系统把完整 URL 作为%1传给启动器; - 启动器解码出真实路径并
os.startfile打开。
拼音搜索为什么能在静态页实现
「输入字母匹配中文文件名」不需要服务端,也不需要页面内嵌几十 KB 的字表,关键是把转换放在生成阶段:
- 生成器读取
pinyin_map.txt(汉字 → 无调拼音),对每个文件名逐字符转换:汉字取拼音、英文字母/数字原样保留、标点忽略; - 同时产出两种形态——全拼(
yilan)与首字母(yl),拼成一个检索串全拼 空格 首字母写入index.html的数据(如aitishiciyilan aitscyl); - 页面搜索时把「文件名 + 检索串」作为匹配目标,因此中文、全拼、首字母三种输入都能命中;
- 纯英文文件名不产生检索串(无汉字时不写入),避免冗余。
代价只有两点:页面增大约 0.3 KB(仅收录实际文件名的拼音),以及随工具多带一个 22.9 KB 的数据文件。
已知限制
- 多音字只取常用读音:如
长记为zhang,搜chang不命中(收录全部读音会让字表与匹配逻辑显著复杂化)。 - 生僻字(GB2312 之外)无拼音:6763 字已覆盖日常文件名,极少数古字/异体字只能按字面搜索。
列表排序规则:以 dirs.txt 为准
索引页的显示顺序不是字母序,而是跟着你的配置走,排序键为三层:
- 条目顺序:按路径在
dirs.txt中出现的先后(生成时给每条记录打上order = 条目序号)。想让某类文档排在前面,把它在dirs.txt里的行往上移即可。 - 目录层级:同一条目内,用
path_key()把路径拆成逐级小写片段元组比较,实现「父目录组在前、子目录组在后」(而非整串字符串的字母序,否则a\b与a-c的顺序会反直觉)。 - 文件名:同一目录内按文件名排序。
对应两处实现:
- 生成端(
collect()):records.sort(key=lambda r: (r["order"], path_key(r["parent"]), r["name"].lower())) - 渲染端:JS 用
Map累积分组并直接[...groups.keys()]遍历,不再调用.sort()——否则会按字母序重排、把生成端的顺序全抹掉。
容易被忽略的一点
早期版本用 Object.keys(groups).sort() 逐组渲染,导致无论 dirs.txt 怎么写,页面永远是字母序。排序要「以配置为准」,就必须同时改生成端与渲染端;只改一端不生效。
链接编码的三层演进(关键)
为让链接「安全穿过浏览器而不被篡改」,编码经历三轮迭代:
| 阶段 | 链接形态 | 失败原因 |
|---|---|---|
| 明文 + 百分号编码 | mdopen://E:/SyncBlock/... | 浏览器把 E: 的冒号当作 authority:port 分隔符吃掉,路径变成 E/SyncBlock/... |
| base64url | mdopen://RTov... | 浏览器把 payload 当主机,末尾补 /;且曾错误地对 base64 做 .lower()+.upper(),而 base64 大小写敏感,整串损坏 |
| base32 放路径位置 ✅ | mdopen://md/MZXW6YTBOI... | base32 A-Z2-7 大小写不敏感,解码端统一 upper 即可免疫大小写改写;置于 /md/ 路径位置,浏览器不再做 authority 规范化 |
最终编码函数:
# md_index.py —— 生成链接
def to_mdopen(path: str) -> str:
p = path.replace("\\", "/")
b = base64.b32encode(p.encode("utf-8")).decode("ascii").rstrip("=")
return PROTOCOL + "md/" + b # 形如 mdopen://md/MZXW6YTBOI...# mdopen_launcher.py —— 解码链接
s = url[len("mdopen://"):]
s = s.rstrip("/").split("/")[-1].rstrip("=").upper() # 取最后一段、去 / 与 =
if re.fullmatch(r"[A-Z2-7]+", s):
pad = "=" * (-len(s) % 8)
raw = base64.b32decode(s + pad)
s = raw.decode("utf-8").replace("/", "\\") # 还原 Windows 路径踩坑与调优
坑1:双击 bat 时找不到 pythonw
双击启动的 cmd.exe 的 PATH 与 IDE 沙箱不同,where pythonw 失败。修复:脚本主动扫描 C:\Users\atodo\.workbuddy\binaries\python\versions\*\pythonw.exe,把完整绝对路径写入注册表与调用链,彻底摆脱对 PATH 的依赖。
坑2:盘符冒号被浏览器吞掉
明文 mdopen://E:/... 中 E: 的冒号被 URL 解析当 host:port 分隔符吃掉 → 路径失效。这是「点击无反应」的真实根因(并非默认程序未设)。
坑3 + 坑4:authority 规范化与 base64 大小写自毁
浏览器对 mdopen://<payload> 把 payload 当主机,会补 / 并可能改写大小写。补 / 可用「取最后一段」兼容;但曾为「防大小写」对 base64 做 .lower()+.upper(),而 base64 区分大小写,等于自毁数据。改用 base32(大小写不敏感)彻底解决。
坑5:GBK / cp936 与 chcp 65001 编码混乱
.bat 文件用 GBK 编码,若再加 chcp 65001 切 UTF-8,echo 的中文会乱码。修复:去掉 chcp 65001,统一 set PYTHONIOENCODING=cp936,让 bat 提示 / Python 输出 / 日志全部走 GBK,与本机控制台代码页一致。
坑6:本地服务方案被安全策略拦截
曾考虑「本地 HTTP 服务 + /open 接口」唤起默认程序,但该方案需常驻进程,且自动写注册表被宿主安全策略拦截。最终选用「自定义 URI 协议 + 由用户手动双击注册脚本」的方案,既无常驻进程,又合规。
执行环境说明
| 项目 | 说明 |
|---|---|
| 操作系统 | Windows 10 / 11 |
| Python | 3.13.x(本机为 WorkBuddy 自带,位于 C:\Users\atodo\.workbuddy\binaries\python\versions\3.13.12\);仅用标准库,无第三方依赖 |
| 拼音字表 | pinyin_map.txt(UTF-8,6763 个 GB2312 常用汉字 / 400 音节,22.9 KB);纯文本数据文件,不需要安装 pypinyin 等第三方包。缺失时拼音搜索自动禁用,其余功能不受影响 |
pythonw | 用于无控制台唤起启动器,避免黑框;路径在注册时由 register_mdopen.bat 自动扫描并写入完整绝对路径 |
.bat 编码 | 必须 GBK 编码 + CRLF 行尾;若存成 UTF-8,中文 echo 在 cmd 中会乱码(见坑5)。本工具三个 bat 均已按此要求保存 |
| 注册表 | HKCU\Software\Classes\mdopen(当前用户,无需管理员);由 register_mdopen.bat 写入、unregister_mdopen.bat 删除 |
| 前置依赖 | Windows「设置 → 默认应用」需把 .md 关联到目标程序(Typora / VS Code / Obsidian 等),否则 os.startfile 抛 1155 错误 |
| 工具根目录 | E:\SyncBlock\Code\LOC\MdIndex\(以下源码中的绝对路径均为本机实际路径,迁移时需相应调整) |
bat 文件迁移/重建须知
若在他机重建,.bat 里两处绝对路径需改:① LAUNCHER=...mdopen_launcher.py;② PYBASE=...python\versions。或改为运行时用 where pythonw 自动探测。.bat 务必以 GBK + CRLF 保存。
执行文件源码
以下为工具根目录 E:\SyncBlock\Code\LOC\MdIndex\ 下全部执行文件的完整源码,另附拼音字表数据文件的结构说明。
md_index.py(索引生成器,UTF-8)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
md_index.py —— Markdown 文档快速索引生成器
功能:
1. 读取 dirs.txt 中配置的一个或多个条目(目录或单个 .md 文件,每行一个绝对路径,# 开头为注释)
2. 目录递归扫描 *.md;单文件条目直接收录该文件
3. 生成 index.html:按「所在目录」分组,每项仅显示文件名;搜索按「文件名 + 其拼音」过滤
4. 链接使用 mdopen:// 协议(需先注册,见 register_mdopen.bat / 手动注册),
点击文件即用【系统默认程序】打开;组头可打开所在目录
用法:
python md_index.py # 生成 index.html
依赖:仅 Python 标准库,零第三方依赖。
拼音搜索依赖同目录下的 pinyin_map.txt(汉字->无调拼音字表);缺失时自动退化为仅按文件名搜索。
"""
import base64
import html
from functools import lru_cache
from pathlib import Path
HERE = Path(__file__).resolve().parent
DIRS_FILE = HERE / "dirs.txt"
OUTPUT = HERE / "index.html"
PINYIN_FILE = HERE / "pinyin_map.txt"
# 自定义协议前缀(需在本机注册后,浏览器点击才会唤起默认程序)
PROTOCOL = "mdopen://"
def to_mdopen(path: str) -> str:
"""把本地绝对路径转换为 mdopen:// 链接。
采用 base32 编码整条路径(大小写不敏感,解码端统一 upper 即可免疫
浏览器对路径的任何大小写改写),并放入「路径」位置(mdopen://md/<payload>),
避免被当成主机而遭规范化(补 /、去冒号等)。
"""
p = path.replace("\\", "/")
b = base64.b32encode(p.encode("utf-8")).decode("ascii").rstrip("=")
return PROTOCOL + "md/" + b
def load_entries() -> list[Path]:
"""读取 dirs.txt,返回有效条目(目录或单个 .md 文件,绝对路径)。"""
if not DIRS_FILE.exists():
raise FileNotFoundError(f"未找到配置文件:{DIRS_FILE}")
entries: list[Path] = []
for raw in DIRS_FILE.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
# 支持行尾 # 注释
line = line.split("#", 1)[0].strip()
p = Path(line)
if not p.is_absolute():
p = (HERE / p).resolve()
if p.exists():
entries.append(p)
else:
print(f"[跳过] 路径不存在:{p}")
return entries
@lru_cache(maxsize=1)
def load_pinyin_map() -> dict[str, str]:
"""读取 pinyin_map.txt,返回 {汉字: 无调拼音};文件缺失则返回空表。"""
if not PINYIN_FILE.exists():
print(f"[提示] 未找到拼音字表 {PINYIN_FILE.name},拼音搜索将被禁用")
return {}
table: dict[str, str] = {}
for raw in PINYIN_FILE.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "\t" not in line:
continue
py, chars = line.split("\t", 1)
py = py.strip().lower()
for ch in chars.strip():
table[ch] = py
return table
def to_pinyin(text: str) -> str:
"""把文本转成用于搜索的拼音串:全拼 + 空格 + 首字母。
例:`AISkill一览.md` -> `aiskillyilan aiskillyl`
非汉字部分(英文字母/数字)原样保留,标点等符号忽略。
字表未收录的字符(极少见字)直接跳过,不影响按文件名搜索。
"""
table = load_pinyin_map()
full: list[str] = []
initials: list[str] = []
has_cjk = False
for ch in text:
if ch.isascii():
if ch.isalnum():
low = ch.lower()
full.append(low)
initials.append(low)
continue
py = table.get(ch)
if py:
has_cjk = True
full.append(py)
initials.append(py[0])
if not has_cjk:
return ""
return "".join(full) + " " + "".join(initials)
def path_key(p: str) -> tuple[str, ...]:
"""把路径拆成逐级小写片段,用于「按目录层级」排序(父目录在前、同级再比)。"""
return tuple(part.lower() for part in Path(p).parts)
def collect(entries: list[Path]) -> list[dict]:
"""扫描条目(目录递归 / 单文件),返回文档记录列表(按所在目录分组用)。
排序规则(与 dirs.txt 一致):
1. 先按条目在 dirs.txt 中的先后顺序;
2. 同一条目内按所在目录的层级(逐级路径)排序;
3. 同一目录内按文件名排序。
"""
records: list[dict] = []
seen: set[str] = set()
for idx, entry in enumerate(entries):
if entry.is_dir():
files = [f for f in entry.rglob("*.md") if f.is_file()]
else:
# 单文件条目
files = [entry]
for md in files:
if not md.is_file():
continue
try:
key = str(md.resolve())
except Exception:
key = str(md)
if key in seen:
continue
seen.add(key)
parent = str(Path(key).parent)
records.append(
{
"order": idx, # 条目在 dirs.txt 中的序号(主排序键)
"parent": parent, # 所在目录(分组键)
"name": md.name, # 实际文件名(显示与搜索用)
"py": to_pinyin(md.stem), # 文件名拼音(全拼 + 首字母,搜索用)
"abspath": key,
"mdopen": to_mdopen(key), # 打开该文件
"dir": to_mdopen(parent), # 打开所在目录
"size": md.stat().st_size,
}
)
records.sort(key=lambda r: (r["order"], path_key(r["parent"]), r["name"].lower()))
return records
def fmt_size(n: int) -> str:
if n < 1024:
return f"{n} B"
if n < 1024 * 1024:
return f"{n / 1024:.1f} KB"
return f"{n / 1024 / 1024:.1f} MB"
def build_html(records: list[dict], entries: list[str], tool_dir_mdopen: str) -> str:
import json
rows_js = [
{
"dir": r["dir"], # 打开所在目录
"dirPath": r["parent"], # 所在目录路径(分组与显示)
"name": r["name"], # 实际文件名(显示与搜索)
"py": r["py"], # 文件名拼音(搜索用)
"mdopen": r["mdopen"], # 打开该文件
"size": fmt_size(r["size"]),
}
for r in records
]
data_json = json.dumps(rows_js, ensure_ascii=False)
dir_list_html = "\n".join(
f'<li><code>{html.escape(d)}</code></li>' for d in entries
)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Markdown 文档索引</title>
<style>
:root {{
--bg:#f7f8fa; --panel:#ffffff; --border:#e3e6ea; --text:#1f2329;
--muted:#8a9099; --accent:#2f6fed; --accent-soft:#eaf1ff; --hover:#f0f4ff;
}}
* {{ box-sizing:border-box; }}
body {{ margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
"PingFang SC","Microsoft YaHei",sans-serif; background:var(--bg);
color:var(--text); font-size:14px; }}
/* 头部与列表统一宽度:两者都受同一 max-width + 内边距约束,保证搜索栏与列表左右对齐 */
header {{ position:sticky; top:0; background:var(--panel);
border-bottom:1px solid var(--border); z-index:10; }}
.wrap {{ max-width:1100px; margin:0 auto; padding:14px 20px; }}
.topbar {{ display:flex; align-items:center; gap:14px; flex-wrap:wrap;
margin-bottom:10px; }}
h1 {{ font-size:17px; margin:0; flex-shrink:0; }}
.meta {{ margin-left:auto; display:flex; align-items:center; gap:10px;
color:var(--muted); font-size:12px; }}
.meta .metatext {{ text-align:right; }}
.search {{ width:100%; padding:9px 12px; border:1px solid var(--border);
border-radius:8px; font-size:14px; outline:none; }}
.search:focus {{ border-color:var(--accent);
box-shadow:0 0 0 3px var(--accent-soft); }}
.opendir {{ font-size:11px; color:var(--muted); text-decoration:none;
border:1px solid var(--border); border-radius:6px; padding:2px 9px;
display:inline-block; transition:all .12s; white-space:nowrap; }}
.opendir:hover {{ color:var(--accent); border-color:#c9d6f5;
background:var(--accent-soft); }}
main {{ max-width:1100px; margin:0 auto; padding:16px 20px 60px; }}
.group {{ margin-top:20px; }}
.ghead {{ display:flex; align-items:center; gap:10px; margin-bottom:7px; }}
.gpath {{ flex:1; min-width:0; font-size:12px; color:var(--muted);
word-break:break-all; }}
.gcount {{ font-size:11px; color:var(--muted); white-space:nowrap; }}
.list {{ background:var(--panel); border:1px solid var(--border);
border-radius:10px; overflow:hidden; }}
.item {{ display:flex; align-items:center; gap:12px; padding:9px 14px;
border-top:1px solid var(--border); }}
.item:first-child {{ border-top:none; }}
.item:hover {{ background:var(--hover); }}
.item .name {{ flex:1; min-width:0; color:var(--text); text-decoration:none;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }}
.item .name:hover {{ color:var(--accent); }}
.item .size {{ font-size:11px; color:var(--muted); white-space:nowrap;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }}
.empty {{ color:var(--muted); text-align:center; padding:40px;
display:none; }}
footer {{ color:var(--muted); font-size:11px; text-align:center;
padding:20px; }}
ul.dirs {{ font-size:12px; color:var(--muted); margin:6px 0 0;
padding-left:18px; }}
</style>
</head>
<body>
<header>
<div class="wrap">
<div class="topbar">
<h1>Markdown 文档索引</h1>
<div class="meta">
<span class="metatext"><span id="count">0</span> 个文件 · 点击文件用【系统默认程序】打开 · 输入关键字过滤</span>
<a class="opendir" href="{tool_dir_mdopen}" title="打开本索引页所在目录">打开本目录</a>
</div>
</div>
<input class="search" id="q" type="text" placeholder="搜索文件名 / 拼音(全拼或首字母)…" autofocus>
</div>
</header>
<main>
<div id="list"></div>
<div class="empty" id="empty">没有匹配的文档</div>
<details style="margin-top:24px;">
<summary style="color:var(--muted);font-size:12px;cursor:pointer;">已索引目录 / 文件</summary>
<ul class="dirs">{dir_list_html}</ul>
</details>
</main>
<footer>由 md_index.py 生成 · 链接使用 mdopen:// 协议(需先在本机注册)</footer>
<script>
const DATA = {data_json};
const listEl = document.getElementById('list');
const qEl = document.getElementById('q');
const emptyEl = document.getElementById('empty');
document.getElementById('count').textContent = DATA.length;
function esc(s){{ return String(s).replace(/[&<>"]/g, c => ({{'&':'&','<':'<','>':'>','"':'"'}})[c]); }}
function render(q) {{
const needle = q.trim().toLowerCase();
const groups = new Map();
for (const r of DATA) {{
if (needle) {{
const hay = r.name.toLowerCase() + ' ' + (r.py || '');
if (!hay.includes(needle)) continue;
}}
if (!groups.has(r.dirPath)) groups.set(r.dirPath, []);
groups.get(r.dirPath).push(r);
}}
const keys = [...groups.keys()]; // 保持 DATA 顺序(= dirs.txt 顺序 → 目录层级 → 文件名)
if (keys.length === 0) {{ listEl.innerHTML=''; emptyEl.style.display='block'; return; }}
emptyEl.style.display='none';
let html = '';
for (const k of keys) {{
const items = groups.get(k);
html += '<div class="group"><div class="ghead">'
+ '<span class="gpath">' + esc(k) + '</span>'
+ '<span class="gcount">' + items.length + ' 项</span>'
+ '<a class="opendir" href="' + items[0].dir + '" title="打开所在目录">打开目录</a>'
+ '</div><div class="list">';
for (const r of items) {{
const tip = esc(r.name);
html += '<div class="item">'
+ '<a class="name" href="' + r.mdopen + '" title="' + tip + '">' + esc(r.name) + '</a>'
+ '<span class="size">' + esc(r.size) + '</span>'
+ '</div>';
}}
html += '</div></div>';
}}
listEl.innerHTML = html;
}}
qEl.addEventListener('input', e => render(e.target.value));
render('');
</script>
</body>
</html>
"""
def main() -> None:
entries = load_entries()
if not entries:
print("[错误] dirs.txt 中没有有效条目,请先配置(目录或 .md 文件)。")
return
print(f"[扫描] 条目数:{len(entries)}")
for e in entries:
print(f" - {e}")
records = collect(entries)
out = build_html(records, [str(e) for e in entries], to_mdopen(str(HERE)))
OUTPUT.write_text(out, encoding="utf-8")
print(f"[完成] 共索引 {len(records)} 个 md 文件 -> {OUTPUT}")
if __name__ == "__main__":
main()mdopen_launcher.py(协议启动器,UTF-8)
#!/usr/bin/env pythonw
# -*- coding: utf-8 -*-
"""
mdopen_launcher.py —— mdopen:// 协议启动器
当浏览器/系统以 mdopen:// 协议打开链接时,Windows 会把完整 URL
作为 %1 传给本脚本。本脚本:
1. 去掉 mdopen:// 前缀
2. 解码百分号编码(中文等)
3. 用系统默认关联程序打开该 .md 文件(如 Typora / VS Code / Obsidian)
为方便排查,本脚本会把每次调用的原始参数、解码结果、是否成功写入
同目录的 mdopen_launcher.log;任何异常都会用弹窗提示(pythonw 无控制台)。
"""
import base64
import ctypes
import os
import re
import sys
import traceback
from datetime import datetime
from urllib.parse import unquote
PREFIX = "mdopen://"
HERE = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(HERE, "mdopen_launcher.log")
def log(msg: str) -> None:
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {msg}\n")
except Exception:
pass
def show_error(text: str) -> None:
"""用系统消息框弹出错误(pythonw 无控制台,靠它反馈)。"""
try:
ctypes.windll.user32.MessageBoxW(
0,
text,
"mdopen 协议启动器",
0x10 | 0x4000, # MB_ICONERROR | MB_TOPMOST
)
except Exception:
pass
def decode_url(url: str) -> str:
"""把 mdopen:// URL 还原为 Windows 本地路径。
链接形如 mdopen://md/<base32>,其中 <base32> 为整条路径的 base32 编码。
浏览器可能对 URL 做规范化(补 /、大小写转换等),这里做鲁棒处理:
- 去掉协议前缀;
- 取最后一个 '/' 分段(兼容把编码串当作主机、或末尾被补 '/' 的情况);
- 去掉尾部 '/' 与多余 '=',统一 upper 后做 base32 解码(base32 大小写不敏感);
- 仍失败则回退到旧明文格式(百分号解码)。
"""
url = url.strip()
low = url.lower()
if low.startswith("mdopen://"):
s = url[len("mdopen://"):]
elif low.startswith("mdopen:"):
s = url[len("mdopen:"):]
else:
s = url
# 取最后一个分段,并去掉可能被浏览器追加的尾部 '/' 与多余 '='
s = s.rstrip("/").split("/")[-1].rstrip("=").upper()
# 新格式:base32(字母 A-Z 与数字 2-7,大小写不敏感,统一 upper 还原)
if re.fullmatch(r"[A-Z2-7]+", s):
pad = "=" * (-len(s) % 8)
try:
raw = base64.b32decode(s + pad)
s = raw.decode("utf-8")
except Exception:
pass # 解码失败则保留原样,后续存在性检查会提示
else:
# 旧格式:百分号解码
s = unquote(s)
# 统一为 Windows 反斜杠路径
return s.replace("/", "\\")
def main() -> None:
try:
if len(sys.argv) < 2:
log("收到调用但缺少参数 sys.argv=" + repr(sys.argv))
show_error("未收到链接参数(sys.argv 为空)。\n请确认注册表中的命令末尾包含 \"%1\"。")
return
url = sys.argv[1]
log("raw url: " + url)
path = decode_url(url)
log("decoded path: " + path)
if not path:
show_error("未能从链接中解析出文件路径:\n" + url)
return
if not os.path.exists(path):
show_error("文件不存在,无法打开:\n" + path + "\n\n原始链接:\n" + url)
return
try:
os.startfile(path) # type: ignore[attr-defined]
log("opened OK: " + path)
except OSError as e:
# 1155 = 没有应用程序与此操作的指定文件关联
if getattr(e, "winerror", None) == 1155:
show_error(
"系统没有为 .md 文件关联默认程序。\n\n请到 Windows「设置 → 默认应用」"
"把 .md 关联到 Typora / VS Code / Obsidian 等,\n"
"然后再点击索引卡片。\n\n路径:" + path
)
else:
show_error("打开文件失败:\n" + repr(e) + "\n\n路径:" + path)
log("os.startfile error: " + repr(e))
except Exception:
err = traceback.format_exc()
log("UNEXPECTED EXCEPTION:\n" + err)
show_error("启动器内部异常:\n" + err)
if __name__ == "__main__":
main()run.bat(双击生成并打开索引,GBK + CRLF)
@echo off
cd /d "%~dp0"
setlocal
REM 强制 Python 以 GBK(cp936) 输出,与本机控制台代码页一致,避免中文乱码
set "PYTHONIOENCODING=cp936"
REM 定位 Python:优先 WorkBuddy 自带(已知可用),其次 PATH 中的 python/python3/py
set "PY="
for /d %%G in ("C:\Users\atodo\.workbuddy\binaries\python\versions\*") do (
if exist "%%G\python.exe" set "PY=%%G\python.exe"
)
if not defined PY (where python >nul 2>nul && set "PY=python")
if not defined PY (where python3 >nul 2>nul && set "PY=python3")
if not defined PY (where py >nul 2>nul && set "PY=py")
if not defined PY (
echo [错误] 未找到 Python。请确认 WorkBuddy 已安装,或先安装 Python 并加入 PATH。
pause
exit /b 1
)
echo 使用解释器: %PY%
echo.
REM 运行生成器,全部输出与错误写入 md_index.log 并同时显示
%PY% md_index.py > md_index.log 2>&1
if errorlevel 1 (
echo.
echo [失败] 生成过程出错,完整错误见下方及 md_index.log:
echo ========================================
type md_index.log
echo ========================================
pause
exit /b 1
)
echo 生成成功,过程摘要:
type md_index.log
REM 若带 noopen 参数则不自动打开浏览器
if /i not "%~1"=="noopen" (
echo.
echo 正在打开索引页...
start "" "%~dp0index.html"
)
echo.
echo 完成。索引文件:%~dp0index.html
pauseregister_mdopen.bat(注册协议,GBK + CRLF)
@echo off
setlocal
REM ============================================================
REM 注册 mdopen:// 协议(仅当前用户 HKCU,无需管理员)
REM 双击本文件即可执行;注册后点击索引页 md 链接会调用
REM 系统默认 Markdown 程序(Typora / VS Code / Obsidian 等)打开。
REM 说明:pythonw 不在双击环境的 PATH 中,这里自动扫描
REM WorkBuddy 自带的 Python 安装目录并写入完整绝对路径。
REM ============================================================
set "PROTO=mdopen"
set "LAUNCHER=E:\SyncBlock\Code\LOC\MdIndex\mdopen_launcher.py"
set "PYBASE=C:\Users\atodo\.workbuddy\binaries\python\versions"
echo 正在注册 %PROTO%:// 协议(仅当前用户 HKCU)...
echo.
REM 定位 pythonw:先查 PATH,再扫描 WorkBuddy 安装目录
set "PYW="
where pythonw >nul 2>nul && set "PYW=pythonw"
if not defined PYW (
for /d %%G in ("%PYBASE%\*") do (
if exist "%%G\pythonw.exe" set "PYW=%%G\pythonw.exe"
)
)
if not defined PYW (
echo [错误] 未找到 pythonw,请确认 WorkBuddy 已安装,或先安装 Python 并加入 PATH。
goto :end
)
echo 使用 pythonw:%PYW%
REM 确认启动器存在
if not exist "%LAUNCHER%" (
echo [错误] 未找到启动器:%LAUNCHER%
goto :end
)
REM 1) 协议根节点(URL Protocol 标识)
reg add "HKCU\Software\Classes\%PROTO%" /ve /t REG_SZ /d "URL:Markdown Open Protocol" /f >nul
reg add "HKCU\Software\Classes\%PROTO%" /v "URL Protocol" /t REG_SZ /d "" /f >nul
REM 2) open 命令:用完整绝对路径的 pythonw 唤起启动器,%1 为 mdopen:// 完整 URL
reg add "HKCU\Software\Classes\%PROTO%\shell\open\command" /ve /t REG_SZ /d "\"%PYW%\" \"%LAUNCHER%\" \"%%1\"" /f >nul
echo [完成] %PROTO%:// 协议已注册。
echo 启动器:%LAUNCHER%
echo.
echo 使用:双击 MdIndex\run.bat 打开 index.html,点击卡片即用默认 Markdown 程序打开。
echo 卸载:双击 unregister_mdopen.bat
:end
pauseunregister_mdopen.bat(卸载协议,GBK + CRLF)
@echo off
setlocal
REM ============================================================
REM 卸载 mdopen:// 协议(仅当前用户 HKCU,无需管理员)
REM 双击本文件即可删除此前由 register_mdopen.bat 注册的协议。
REM ============================================================
set "PROTO=mdopen"
set "KEY=HKCU\Software\Classes\%PROTO%"
echo 正在卸载 %PROTO%:// 协议(仅当前用户 HKCU)...
echo.
REM 确认键是否存在,避免误报
reg query "%KEY%" >nul 2>nul
if errorlevel 1 (
echo [提示] 未找到 %PROTO%:// 协议注册项,可能尚未注册或已卸载。
goto :end
)
REM 删除整个协议键
reg delete "%KEY%" /f >nul
if errorlevel 1 (
echo [错误] 卸载失败,请检查权限或手动运行:
echo reg delete "%KEY%" /f
goto :end
)
echo [完成] %PROTO%:// 协议已卸载。
echo 此后点击索引页 md 链接将不再唤起默认 Markdown 程序。
:end
pausedirs.txt(索引源配置,UTF-8)
# MdIndex 目录配置
# 每行一个【绝对路径】(支持 Windows 反斜杠或正斜杠)
# 目录会递归扫描其下所有 *.md;也可直接写单个 .md 文件
# 以 # 开头的行是注释,会被忽略
# 把下面需要索引的条目前的 # 去掉,或新增你自己的条目行
E:\SyncBlock\Code\LOC\AISkills\AISkill一览.md
E:\SyncBlock\Code\LOC\AISkills\aiskills-prompt-archive\AI提示词一览.md
E:\SyncBlock\Code\LOC\AISkills\aiskills-memory-organizer\memory_snapshotpinyin_map.txt(拼音字表,UTF-8,数据文件)
拼音搜索所需的「汉字 → 无调拼音」字表,共 6763 个 GB2312 常用汉字 / 400 个音节,约 22.9 KB。
格式:每行 <无调拼音>\t<该读音的汉字>(# 开头为注释);生成器解析后得到 {汉字: 拼音} 映射。数据来源于 pypinyin 的字典,但已固化为纯文本,运行时不需要安装任何第三方包。
# 汉字->拼音(无调)字表:每行 `<拼音>\t<该读音的汉字>`
# 由 pypinyin 数据筛选 GB2312 常用汉字生成,仅供 MdIndex 文件名拼音搜索使用
a 啊嗄锕阿
ai 哀哎唉嗌嗳埃嫒挨捱暧爱瑷癌皑矮砹碍艾蔼锿隘霭
an 俺埯安岸庵按揞暗案桉氨犴胺谙铵鞍鹌黯
ang 昂盎肮
ao 傲凹嗷坳奥媪岙廒懊拗敖澳熬獒翱聱螯袄遨鏊鏖骜鳌
ba 八叭吧坝岜巴扒把拔捌灞爸疤笆粑罢耙芭茇菝跋钯霸靶魃鲅
bai 佰拜捭掰摆擘柏白百稗败
…(其余 390 行,按拼音字母序排列)缺失时的行为
若该文件不存在,md_index.py 会打印提示并自动退化为「仅按文件名搜索」,不会报错。
使用步骤
- 配置索引源:编辑
MdIndex/dirs.txt,每行写绝对路径(目录或.md文件)。 - 注册协议(一次性):双击
MdIndex/register_mdopen.bat,提示mdopen:// 协议已注册。 - 生成并打开索引:双击
MdIndex/run.bat→ 自动生成index.html并用浏览器打开。 - 点击打开:在索引页点文件名 → 浏览器确认一次 → 用
.md关联的默认程序打开该文件。 - 搜索:输入中文、全拼或首字母均可命中(如
一览/yilan/yl);搜索只作用于文件名及其拼音,不匹配目录路径。 - 调整排序:页面顺序 =
dirs.txt行序(条目 → 目录层级 → 文件名);想让某类文档靠前,把该行上移后重跑run.bat。 - 新增文档:编辑
dirs.txt增行 → 再双击run.bat刷新。 - 卸载协议:双击
unregister_mdopen.bat。
索引页的「打开目录」按钮
索引页顶部「打开本目录」按钮可打开 index.html 所在目录;每个分组标题右侧的「打开目录」按钮可打开该组文档所在文件夹。两者同样走 mdopen:// 协议——对目录 os.startfile 会直接唤起资源管理器,无需额外注册或设置 .md 默认程序。
前置条件
先在 Windows「设置 → 默认应用」把 .md 关联到 Typora / VS Code / Obsidian。否则 os.startfile 会抛 1155 错误,启动器会弹窗提示。
对比与选型
| 方案 | 常驻进程 | 改系统 | 点击行为 |
|---|---|---|---|
静态 file://(最初版) | 否 | 否 | 浏览器内看原文(无法直接编辑) |
本地 HTTP 服务 /open | 是 | 否 | 默认程序打开(被安全策略否决) |
自定义 mdopen:// 协议 ✅ | 否 | 是(一次性 HKCU) | 默认程序打开 |
| VS Code 多根工作区 / Everything | 否 | 否 | 编辑器内搜索打开(无统一清单页) |
常见问题
点击后浏览器弹出确认框但没打开文件?
协议已注册成功;未打开通常是启动器静默失败。本版启动器会弹窗显示原因,并写入 MdIndex\mdopen_launcher.log。最可能原因:.md 未关联默认程序(1155 错误),按前述「前置条件」设置即可。
换机器 / 重装后协议没了?
mdopen:// 写进本机 HKCU,重装或换机器需重新双击 register_mdopen.bat。脚本与启动器都在 MdIndex/ 目录,可随目录一起迁移。
想加更多文档?
编辑 dirs.txt 增行,双击 run.bat 重生成索引,无需重新注册协议。
列表顺序可以自定义吗?
可以,顺序完全由 dirs.txt 的行序决定:条目顺序优先,其次目录层级,最后文件名。把希望靠前的条目行上移即可。若改了生成器却仍看到字母序,检查渲染端是否还残留 Object.keys(groups).sort()。
输入字母为什么能匹配到中文文件名?
生成阶段已把每个文件名转成「全拼 + 首字母」写入页面数据,因此 yilan、yl 都能命中「一览」。字表见 pinyin_map.txt。多音字只取常用读音(长→zhang),GB2312 之外的生僻字无拼音,只能按字面搜索。
拼音搜索失灵了?
先确认 MdIndex\pinyin_map.txt 是否存在:缺失时生成器会打印提示并自动退化为仅按文件名搜索。该文件可随目录一起迁移,切勿单独删除。
小结
本工具用「静态索引 + 自定义协议」两条轻量机制,解决了分散 Markdown 的快速一览与一键打开。真正的技术难点不在生成器,而在「链接如何安全穿过浏览器」——浏览器对 URL 的 authority 规范化会吞掉盘符冒号、补斜杠、改写大小写。最终用 base32(大小写不敏感)+ 路径位置(mdopen://md/<payload>)彻底规避。配合「扫描 WorkBuddy 自带 Python 写绝对路径」「全链路 GBK 编码」两个工程化处理,使整套方案在双击即可用的前提下稳定可靠。检索侧同样走「生成期预处理」思路:把文件名转成全拼与首字母写入页面数据,让纯静态页也能用输入法习惯(yilan / yl)定位中文文档,而无需任何运行时依赖。排序侧则把「顺序」也交给配置:dirs.txt 的行序即页面顺序(条目 → 目录层级 → 文件名三级键),生成端与渲染端同时不排序才能保住这一语义。本文档已附全部执行文件源码与执行环境说明,便于迁移与复刻。
参考资料
致谢
- 作者原创整理
