作者:Htryone
日期:2026-06-13
适用场景:静态 HTML 站点需要统一注入深色模式切换脚本,且希望新增/修改的文件能自动完成注入,无需手动执行命令。
本文档完整记录从需求分析、方案选型、代码实现到踩坑解决的完整过程,适合分享给有类似需求的开发者参考。
有一个纯静态 HTML 站点(Typora 导出 + 手写的 HTML),希望所有页面都支持深色模式切换。
解决方案是写一个 dark-mode-toggle.js,在每个 HTML 的 </body> 前插入一句:
<script src="相对路径/dark-mode-toggle.js"></script>| 痛点 | 描述 |
|---|---|
| 文件多 | 手动改每个文件不现实 |
| Typora 覆盖 | Typora 重新导出 HTML 会覆盖掉已注入的 <script> 标签 |
| 容易忘记 | 新增文件容易忘记跑注入脚本 |
"一个监控脚本,启动监控脚本默认启动一次注入脚本,然后监控文件改动,把没有注入的自动注入,导出的文件可能会覆盖这个也需要重新导入"
拆解后就是三步:
xxxxxxxxxx启动 → 扫描所有文件,补注入↓持续监控 → 新建文件自动注入↓→ 修改/覆盖文件也自动注入
本脚本在 Python 3.13 下开发和测试。建议使用 Python 3.10+。
xxxxxxxxxx# 检查版本python --version# 或python3 --version只需要一个第三方库:
xxxxxxxxxxpip install watchdog| 依赖 | 版本 | 用途 |
|---|---|---|
watchdog | ≥ 2.0 | 跨平台文件系统监控 |
推荐使用虚拟环境隔离依赖,避免污染全局 Python:
x# 创建 venvpython -m venv .venv
# 激活(Windows).venv\Scripts\activate
# 激活(macOS / Linux)source .venv/bin/activate
# 安装依赖pip install watchdog激活后命令行前面会出现 (.venv) 提示符,表示已在虚拟环境中。
脚本通过 __file__ 自动定位工作区,不需要手动配置路径:
xxxxxxxxxx你的站点根目录/├── scripts/│ ├── dark-mode-toggle.js # ← 必须存在,否则注入后 JS 加载失败│ ├── watch-dark-mode.py # ← 本脚本│ └── start-watch.bat # ← Windows 双击启动入口(可选)├── index.html├── subdir/│ └── page.html└── ...
⚠️ 重要:
dark-mode-toggle.js必须放在scripts/目录下,否则注入的<script src>路径会 404。
xxxxxxxxxx# 1. 确认 watchdog 已安装python -c "import watchdog; print(watchdog.__version__)"
# 2. 确认 scripts/dark-mode-toggle.js 存在ls scripts/dark-mode-toggle.js
# 3. 试运行(应该看到"启动扫描完成")python scripts/watch-dark-mode.py# 按 Ctrl+C 停止| 需求 | 选型 | 理由 |
|---|---|---|
| 文件监控 | watchdog | Python 生态最成熟的跨平台文件监控库 |
| 注入标记 | HTML 注释(自定义唯一标记) | 唯一标记,不误判代码示例 |
| 相对路径计算 | os.path.relpath | 标准库,处理各层级目录无压力 |
| 防抖 | 时间戳字典 | 避免编辑器保存时触发多次 on_modified |
这是本方案最核心的设计决策,直接决定了是否会"死循环"。
错误做法(会导致死循环):
xxxxxxxxxx# 用文件名字符串判断MARKER = "dark-mode-toggle.js"def has_script(content): return MARKER in content # ❌ 正文里出现文件名就会误判技术文档(本文件)的代码示例里本身就出现了 dark-mode-toggle.js 这个字符串,MARKER in content 会返回 True,导致脚本误判"这个文件已经注入过了",实际上它没有。
正确做法(本方案采用):
xxxxxxxxxx# 用唯一 HTML 注释标记判断(标记内容自行定义,保证唯一性即可)INJECT_TAG = "<!-- 唯一注入标记(自行定义) -->"def is_injected(content): return INJECT_TAG in content # ✅ 只有注入脚本自己会写这行这个注释只有注入脚本自己会写,正文里的代码示例不可能包含它,彻底避免误判。
xxxxxxxxxxscripts/├── dark-mode-toggle.js # 深色模式核心 JS(用户已有)├── watch-dark-mode.py # 监控 + 自动注入脚本(本文主角)└── start-watch.bat # Windows 双击启动入口(可选)
scan_all)目标:脚本启动时,把所有"还没有注入脚本"的 HTML 文件补注入一次。
xxxxxxxxxxdef scan_all(): count = 0 for root, dirs, files in os.walk(WORKSPACE): # 排除不需要扫描的目录 dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] for fname in files: if fname.lower().endswith((".html", ".htm")): fp = os.path.join(root, fname) if inject_file(fp): # inject_file 内部会判断是否已注入 count += 1 print(f"启动扫描完成:注入 {count} 个文件")关键点:inject_file() 函数内部判断"是否已注入",已注入的文件直接跳过,不会重复写入。
inject_file)这是最核心的函数,逻辑必须清晰:
xxxxxxxxxxdef inject_file(filepath): # 1. 读取文件 with open(filepath, "r", encoding="utf-8") as f: content = f.read()
# 2. 判断是否已注入(只认注入标记注释,不误判) if is_injected(content): return False # 已有,跳过,不碰文件!
# 3. 没有才注入 script_abs = os.path.join(WORKSPACE, "scripts", "dark-mode-toggle.js") src = rel_path(script_abs, filepath) insert = f"\n{INJECT_TAG}\n<script src=\"{src}\"></script>\n"
# 4. 在 </body> 前插入,没有 </body> 就追加末尾 pos = content.rfind("</body>") if pos != -1: new_content = content[:pos] + insert + content[pos:] else: new_content = content.rstrip() + insert
# 5. 只有内容真的变了才写文件 if new_content == content: return False
with open(filepath, "w", encoding="utf-8") as f: f.write(new_content) return True设计原则:先判断,再动手。判断是只读操作,不动文件;确认没有才写。
INJECT_TAG)xxxxxxxxxxINJECT_TAG = "<!-- 自定义唯一标记 -->"注入后 HTML 文件末尾(`` 前)会多出这两行:
xxxxxxxxxx<!-- 自定义唯一标记 --><script src="scripts/dark-mode-toggle.js"></script></body></html>标记命名建议:使用足够特殊的字符串(如包含随机符号或长字符串),确保不会出现在正文内容中。
不同层级的 HTML 文件,<script src> 的路径不一样:
| HTML 文件位置 | script src 值 |
|---|---|
index.html(根目录) | scripts/dark-mode-toggle.js |
personal/foo.html(一级子目录) | ../scripts/dark-mode-toggle.js |
a/b/c.html(三级子目录) | ../../scripts/dark-mode-toggle.js |
实现:
xxxxxxxxxxdef rel_path(script_abs, html_file): """计算 html_file 到 script_abs 的相对路径""" d = os.path.dirname(html_file) return os.path.relpath(script_abs, d).replace("\\", "/") # Windows 路径统一转斜杠<script>规则:</body> 标签之前插入,这样 JS 能操作完整的 DOM。
xxxxxxxxxxdef inject_file(filepath): ... pos = content.rfind("</body>") if pos != -1: new_content = content[:pos] + insert + content[pos:] else: new_content = content.rstrip() + insert用 rfind(从右向左找)而不是 find,防止 HTML 里有多个 `` 注释或字符串时的误判。
watchdog)watchdog 而不是轮询?watchdog 走操作系统原生 API(Windows 用 ReadDirectoryChangesW,Linux 用 inotify),零延迟、零 CPU 空转
轮询(每隔 N 秒扫一次目录)有延迟,且大目录很慢
watchdog 的 FileSystemEventHandler 提供这些事件:
| 事件 | 触发时机 | 本需求是否需要 |
|---|---|---|
on_created | 文件新建 | ✓ 需要 |
on_modified | 文件内容修改 | ✓ 需要 |
on_moved | 文件移动/重命名 | ✓ 需要(Typora 覆盖可能触发) |
on_deleted | 文件删除 | 不需要 |
本实现用 on_any_event 统一处理(简化代码),内部再判断文件类型:
xxxxxxxxxxclass Handler(FileSystemEventHandler): def on_any_event(self, event): if event.is_directory: return # 兼容 moved 事件(src_path + dest_path) fp = getattr(event, "dest_path", None) or event.src_path fp = os.path.normpath(fp) if not self._is_target(fp) or not self._debounce(fp): return # 等文件写完再读 time.sleep(0.8) if os.path.exists(fp): inject_file(fp)on_any_event 里 sleep(0.8)?很多编辑器(包括 Typora)保存文件的方式是:
写入临时文件
删除原文件
把临时文件重命名为原文件名
这个过程中 on_modified 可能触发时文件还没写完,open() 读到的内容是残缺的。加 sleep(0.8) 等文件稳定后再读,是最简单的规避方式。
问题:某些编辑器保存一个文件会触发多次 on_modified 事件。
如果不防抖,inject_file() 会被调用多次,虽然幂等(第二次会跳过),但浪费 IO。
解法:用字典记录每个文件最后处理时间,5 秒内只处理一次。
xxxxxxxxxxdef _debounce(self, fp): now = time.time() if now - self._last.get(fp, 0) < 5: return False self._last[fp] = now return Truewatch-dark-mode.pyxxxxxxxxxx#!/usr/bin/env python3# -*- coding: utf-8 -*-"""深色模式自动监控(watchdog 事件驱动版)用法:python watch-dark-mode.py"""
import osimport timeimport refrom watchdog.observers import Observerfrom watchdog.events import FileSystemEventHandler
# ── 配置 ────────────────────────────────────────────────SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))WORKSPACE = os.path.dirname(SCRIPTS_DIR)INJECT_TAG = "<!-- 自定义唯一标记 -->" # 唯一注入标记EXCLUDE_FILES = {"dark-mode-demo.html", "smart-dark-mode-demo.html"}# ──────────────────────────────────────────────────────────
def is_injected(content): """判断是否已注入,只认唯一标记注释""" return INJECT_TAG in content
def rel_path(script_abs, html_file): """计算 html_file 到 script_abs 的相对路径""" d = os.path.dirname(html_file) return os.path.relpath(script_abs, d).replace("\\", "/")
def inject_file(filepath): """ 对单个 HTML 文件执行注入。 返回 True 表示文件被修改了,False 表示跳过(已注入或出错)。 """ try: with open(filepath, "r", encoding="utf-8") as f: content = f.read() except Exception as e: print(f" [读取失败] {filepath}: {e}") return False
if is_injected(content): return False # 已有标记,跳过
script_abs = os.path.join(WORKSPACE, "scripts", "dark-mode-toggle.js") src = rel_path(script_abs, filepath)
# 构造要插入的片段:标记 + script 标签 insert = f"\n{INJECT_TAG}\n<script src=\"{src}\"></script>\n"
# 在 </body> 前插入,没有 </body> 就追加末尾 pos = content.rfind("</body>") if pos != -1: new_content = content[:pos] + insert + content[pos:] else: new_content = content.rstrip() + insert
# 只有内容真的变了才写文件 if new_content == content: return False
with open(filepath, "w", encoding="utf-8") as f: f.write(new_content)
rel = os.path.relpath(filepath, WORKSPACE) print(f" [注入] {rel} → {src}") return True
def scan_all(): """启动时全量扫描一次""" count = 0 for root, dirs, files in os.walk(WORKSPACE): dirs[:] = [d for d in dirs if d not in {".git", ".workbuddy", "node_modules"}] for fname in files: if fname.lower().endswith((".html", ".htm")) and fname not in EXCLUDE_FILES: fp = os.path.join(root, fname) if inject_file(fp): count += 1 print(f" 启动扫描完成:注入 {count} 个文件\n")
# ── watchdog 事件处理 ──────────────────────────────────
class Handler(FileSystemEventHandler): def __init__(self): super().__init__() self._last = {} # 防抖:filepath -> 最后处理时间戳
def _debounce(self, fp): now = time.time() if now - self._last.get(fp, 0) < 5: return False self._last[fp] = now return True
def _is_target(self, fp): f = os.path.basename(fp).lower() return f.endswith((".html", ".htm")) and f not in EXCLUDE_FILES
def on_any_event(self, event): if event.is_directory: return # 兼容 moved 事件(src_path + dest_path) fp = getattr(event, "dest_path", None) or event.src_path fp = os.path.normpath(fp) if not self._is_target(fp) or not self._debounce(fp): return # 等文件写完再读 time.sleep(0.8) if os.path.exists(fp): inject_file(fp)
def main(): print("=" * 60) print(" 深色模式脚本自动监控(watchdog 事件驱动)") print("=" * 60) print(f"\n工作目录 : {WORKSPACE}") print(f"注入标记 : {INJECT_TAG}") print(f"防抖间隔 : 5 秒\n") print("-" * 60)
scan_all()
observer = Observer() observer.schedule(Handler(), WORKSPACE, recursive=True) observer.start() print("监控已启动,按 Ctrl+C 停止...\n") print("-" * 60)
try: while observer.is_alive(): observer.join(1) except KeyboardInterrupt: print("\n正在停止...") finally: observer.stop() observer.join() print("监控已停止。")
if __name__ == "__main__": main()start-watch.bat(Windows 双击启动)xxxxxxxxxx@echo offchcp 65001 >nulcd /d "%~dp0.."python scripts\watch-dark-mode.pypause方式一:终端启动
xxxxxxxxxxcd /path/to/your/sitepython scripts/watch-dark-mode.py方式二:Windows 双击启动
双击 scripts/start-watch.bat,会弹出终端窗口,监控日志直接显示在里面。
终端启动:按 Ctrl + C
批处理启动:关闭终端窗口
启动监控
打开一个未注入的 HTML 文件,手动删掉注入标记和 <script> 标签
保存文件
观察监控窗口,应该看到类似输出:
xxxxxxxxxx[注入] index.html → scripts/dark-mode-toggle.js
再次保存同一文件,应该不再看到 [注入] 输出(防抖 + 标记判断生效)
MARKER in content 误判代码示例现象:apply-dark-mode.py 运行时,部分已注入的文件被判定为"未注入",导致重复注入;或者反过来,未注入的文件被判定为"已注入",跳过不处理。
原因:has_script() 最初的实现是简单字符串匹配:
xxxxxxxxxx# 错误写法def has_script(content): return MARKER in content # MARKER = "dark-mode-toggle.js"技术文档(本文件)的代码示例里本身就出现了 dark-mode-toggle.js 这个字符串,MARKER in content 会返回 True,导致脚本误判"这个文件已经注入过了",实际上它没有。
正确做法:用唯一 HTML 注释标记判断,不解析 <script> 标签:
xxxxxxxxxx# 正确写法INJECT_TAG = "<!-- 自定义唯一标记 -->"def is_injected(content): return INJECT_TAG in content # 只有注入脚本自己会写这行inject_file() 里"判断"和"清理"耦合错误写法:
xxxxxxxxxxdef inject_file(filepath): content = read(fp) new_content = remove_old_script(content) # 先清理 if has_script(new_content): # 再判断 ...如果 remove_old_script 有 bug,这里就会误判。
正确做法:先判断,再动手,两个步骤完全独立:
xxxxxxxxxxdef inject_file(filepath): content = read(fp) if is_injected(content): # 第一步:判断(只读,不修改) return False # 第二步:确认没有才动手 new_content = insert_script_tag(content, src) write(fp, new_content)watchdog 的 on_moved 事件Typora 导出 HTML 时,实际行为是「写临时文件 → 替换原文件」,watchdog 在 Windows 上可能触发 on_moved 而不是 on_modified。
所以 on_any_event 里要同时处理 src_path 和 dest_path:
xxxxxxxxxxfp = getattr(event, "dest_path", None) or event.src_path现象:启动监控后,脚本注入了文件,文件修改触发 on_modified,脚本又尝试注入,死循环。
原因:inject_file() 写入文件后,watchdog 捕获到 on_modified 事件,又调用 inject_file()。
正确做法:
注入标记写在文件里,inject_file() 第一行就是检查标记,已有标记直接返回 False
防抖机制:5 秒内同一文件只处理一次
两个机制双重保险,彻底避免死循环
现象:git reset --hard 后,git status 仍然显示文件被修改。
原因:watch-dark-mode.py 还在运行,它监控到文件没有注入标记,又注入了一次。
正确做法:重置仓库前,先关掉所有 Python 进程:
xxxxxxxxxxtaskkill /F /IM python.exegit reset --hard <hash>git clean -fd| 需求 | 实现思路 |
|---|---|
| 开机自启,不需要每次手动运行 | 用 taskschd(Windows 任务计划)或写成 Windows 服务 |
支持更多文件类型(如 .php) | 修改 _is_target() 里的后缀判断 |
注入位置可配置(<head> 或 </body> 前) | 给 inject_file() 加参数 |
| 多 JS 脚本注入 | 把 SCRIPT_FILE 改成列表,循环注入 |
| 推送前自动注入(不常驻监控) | 用 Git Hook(pre-commit 或 pre-push) |
| 目标 | 方案 |
|---|---|
| 所有 HTML 支持深色模式 | 注入 dark-mode-toggle.js |
| 新增文件自动注入 | watchdog 监控 on_created |
| 覆盖导出自动重新注入 | watchdog 监控 on_modified / on_moved |
| 避免死循环 | 唯一注入标记 + 防抖 |
| 避免误判代码示例 | 用 HTML 注释标记,不解析 <script> 标签 |
核心经验:判断"是否已注入"的逻辑必须 100% 可靠,这是整个方案的基础。用唯一标记(HTML 注释)比解析 <script> 标签简单且可靠得多。
文档整理自真实开发过程,包含需求分析、实现细节和踩坑记录。欢迎分享给有类似需求的开发者参考。