背景:为什么需要 md2Hexo

Hexo 的文章通常要求在文件头部包含 YAML front matter,例如:

---
title: Post Title
date: 2024-01-01 12:00:00
tags: [Tag1, Tag2]
categories: [Category]
---

当我们从外部仓库/笔记/电子书/速查表批量迁移 Markdown 到 source/_posts/ 时,经常会遇到:

  • 没有 front matter(Hexo 无法识别为文章)
  • 文件数量巨大,需要批量处理
  • 希望 tags/categories 能根据目录名自动补全
  • 迁移过程中要尽量“安全”:已处理过的文件不要反复改写

这就是 tools/md2Hexo.py 的定位:批量为 Markdown 文件补齐 Hexo front matter

本文基于仓库中的 tools/md2Hexo.py(当前版本 2.1.0)做一次“代码走读 + 质量测评 + 重构点说明”,让它看起来更像一个标准、可靠、可维护的 Python CLI 工具。


总体结构:从配置到批量转换

脚本主要由 4 个层次组成:

  1. 数据模型(dataclass)ConversionConfigConversionResult
  2. Front Matter 相关能力HexoFrontMatter
  3. 转换核心Md2HexoConverter
  4. CLI / 交互入口parse_args()interactive_mode()main()

数据流大致如下:

  1. parse_args() 解析命令行
  2. 选择配置来源:配置文件 / CLI 参数 / 交互输入
  3. 合并“覆盖项”(dry-run/force/exclude 等)
  4. Md2HexoConverter.convert_directory() 遍历文件并调用 convert_file()
  5. 输出统计结果与错误摘要

模块 1:ConversionConfig(配置模型)

ConversionConfig 是工具的“行为说明书”,重要字段:

  • tags: List[str]:固定附加的标签
  • categories: List[str]:固定附加的分类
  • include_folder_as_tag: bool:是否把“最后一级目录名”加入 tags
  • include_folder_as_category: bool:是否把“最后一级目录名”加入 categories
  • use_file_mtime: bool:date 是否用文件修改时间(mtime)
  • skip_existing_frontmatter: bool:若文件已有 front matter 是否跳过(默认跳过,安全)
  • exclude_patterns: List[str]:排除列表(支持精确文件名与通配符)
  • dry_run: bool:预览模式(不写回文件)

兼容性

仓库内已有 JSON 配置样例(例如 tools/configs/awesome-cheatsheets.json),字段与 dataclass 一一对应;这使得批量导入不同知识库时,只需切换配置文件即可。


模块 2:ConversionResult(结果统计)

ConversionResult 负责统计与可观测性:

  • total:扫描到的 .md 文件数
  • converted:实际写入/转换数量
  • skipped:跳过数量(排除文件、已有 front matter 等)
  • errors:错误列表(路径 + 错误信息)

工具最终会打印这些数字,便于判断一次批量导入是否“收敛”。


模块 3:HexoFrontMatter(Front Matter 生成与检测)

这个模块做两件事:

  1. 检测文件开头是否已经存在 YAML front matter
  2. 生成新的 YAML front matter 并插入到正文前面

3.1 为什么要改进检测逻辑

早期实现常见的写法是:

  • content.strip().startswith('---')

这会带来两个问题:

  • 容易误判:Markdown 里的水平分割线也可能是 ---
  • 性能不佳:为了判断“是否有 front matter”,不得不读取整个文件

在当前版本中,检测改成了:

  • 使用正则 \A--- ... --- 只在文件开头匹配
  • 检测时只读取文件前 8KB(足够覆盖绝大多数 front matter)

3.2 YAML 输出(去依赖版)

为了让脚本在“纯 Python3 环境”里就能直接跑(避免额外安装 PyYAML),当前版本内置了一个极简 YAML 序列化器来输出 front matter:

  • 字符串统一用 JSON 风格双引号包裹(等价于 YAML 的双引号字符串),对中文/空格/冒号等特殊字符更安全
  • tags / categories 输出为 YAML 列表;为空时输出 []
  • extra_fields 仅支持“标量或字符串列表”,避免复杂类型带来的歧义

模块 4:Md2HexoConverter(核心转换引擎)

4.1 目录遍历

convert_directory() 使用 Path.rglob('*.md') 递归查找 Markdown 文件。

  • 若输入路径是“文件”,也会作为单文件处理(更灵活)
  • 若是目录,则递归遍历

4.2 排除规则 should_skip_file

排除规则做了两层兼容:

  • 精确匹配:README.mdSUMMARY.md
  • 通配符匹配:readme**.draft.md

这能覆盖大部分“目录说明文件不该被当文章导入”的情况。

4.3 tags / categories 的构建策略

  • 基础 tags/categories 来自配置文件或 CLI
  • 若开启 include_folder_as_tag/category:把“相对目录的最后一级目录名”附加进去

这是一种折中:

  • 不会把整条路径拆成多级分类(过于复杂)
  • 但能在大多数情况下把内容按集合归类

4.4 I/O 与健壮性

当前版本做了几项“标准脚本该有的稳健处理”:

  • 编码读取:优先 utf-8,失败再尝试 utf-8-sig
  • 跳过判断只读前缀:减少大文件的无谓读取
  • 写入采用“临时文件 + replace”原子替换:降低写入中断导致文件损坏的概率

模块 5:CLI 与交互体验

5.1 parse_args(命令行接口)

CLI 的基本参数:

  • -d/--directory:要处理的目录或文件
  • --config:JSON 配置文件
  • -t/--tags-c/--categories:逗号分隔输入
  • --dry-run:预览不写入
  • -f/--force:强制覆盖已有 front matter
  • --exclude:追加排除规则(可多次指定)
  • -v/-vv:日志等级(INFO/DEBUG)

特别要提的是:

  • --skip-existing / --no-skip-existing 被做成互斥选项,避免“默认 True + store_true”这种很容易写错的 argparse 陷阱。

5.2 interactive_mode(交互模式)

交互模式适用于少量文件快速处理:

  1. 提示是否继续
  2. 输入 tags / categories
  3. 是否 dry-run

按 Ctrl+C 或回答 n 会直接退出(返回码 0),不会继续执行转换。


使用方式(推荐)

方式 A:用配置文件批量导入(最推荐)

python3 tools/md2Hexo.py \
--config tools/configs/awesome-cheatsheets.json \
-d source/_posts/awesome-cheatsheets

方式 B:临时指定 tags/categories

python3 tools/md2Hexo.py -d source/_posts \
--tags "Python,教程" \
--categories "自学计算机科学"

方式 C:预览模式(不写入)

python3 tools/md2Hexo.py -d source/_posts --dry-run

方式 D:强制覆盖已存在 front matter(慎用)

python3 tools/md2Hexo.py -d source/_posts -f

完整代码与逐段解析(读完即可复制使用)

这一节的目标是:tools/md2Hexo.py 的完整源码按文件顺序拆段展示,并逐段解释“为什么这么写、做了什么、边界在哪里”。

如果你只想要代码:把本节所有 python 代码块拼起来(或直接复制最后的“完整源码”代码块)即可。

说明:本文展示的是仓库内当前版本(2.1.0)的实现;以仓库文件为准。

1)文件头与导入:定位、依赖与版本

#!/usr/bin/env python3
"""md2Hexo - Markdown 转 Hexo Front Matter 工具

把普通 Markdown 文件转换为 Hexo 文章格式:为每个 .md 文件在开头插入 YAML front matter。

设计目标
- 默认“尽量安全”:遇到已存在 front matter 的文件默认跳过
- 保持“配置文件用法”兼容:tools/configs/*.json 仍可直接使用
- 尽量减少无谓 I/O:跳过时只读取文件头部做检测

用法
- 交互式:python3 tools/md2Hexo.py
- 预览不写入:python3 tools/md2Hexo.py --dry-run
- 指定目录:python3 tools/md2Hexo.py -d source/_posts
- 用配置文件:python3 tools/md2Hexo.py --config tools/configs/awesome-cheatsheets.json -d source/_posts/awesome-cheatsheets
- 强制覆盖:python3 tools/md2Hexo.py -d source/_posts -f

作者: HExLL
"""

from __future__ import annotations

import argparse
import fnmatch
import json
import logging
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional

__version__ = "2.1.0"

解析要点:

  • #!/usr/bin/env python3:让脚本在类 Unix 环境可以直接执行(配合 chmod +x)。
  • __future__.annotations:让类型标注更现代(例如 list[str] 风格、前向引用)且不引入运行时负担。
  • 依赖全部为标准库(argparse/logging/pathlib/json/...):避免“运行脚本前还要装包”的门槛。

2)配置与结果模型:用 dataclass 固定“输入/输出契约”


@dataclass
class ConversionConfig:
"""转换配置"""
tags: List[str] = field(default_factory=list)
categories: List[str] = field(default_factory=list)
include_folder_as_tag: bool = True
include_folder_as_category: bool = True
use_file_mtime: bool = True
skip_existing_frontmatter: bool = True
exclude_patterns: List[str] = field(default_factory=lambda: ['README.md', 'readme.md', 'SUMMARY.md'])
dry_run: bool = False


@dataclass
class ConversionResult:
"""转换结果"""
total: int = 0
converted: int = 0
skipped: int = 0
errors: List[str] = field(default_factory=list)

解析要点:

  • ConversionConfig 统一承载配置文件/命令行/交互输入的最终结果,避免“参数满天飞”。
  • exclude_patterns 默认排除 README.md 等“目录说明文件”,这是迁移导入场景最常见的噪声。
  • ConversionResult 让批处理结果可观测:至少要知道“扫了多少、转了多少、跳了多少、哪里报错”。

3)HexoFrontMatter:检测与生成(无第三方依赖)


class HexoFrontMatter:
"""Hexo Front Matter 生成器"""

# 仅判断“文件开头是否存在 YAML front matter”。
# 这比简单 startswith('---') 更接近 Hexo 的实际语义。
FRONTMATTER_AT_TOP_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)

@staticmethod
def has_frontmatter(content_prefix: str) -> bool:
"""检查文件是否已有 front matter(只需传入文件前缀内容)"""
return bool(HexoFrontMatter.FRONTMATTER_AT_TOP_RE.match(content_prefix))

@staticmethod
def generate(
title: str,
date: str,
tags: List[str],
categories: List[str],
**extra_fields
) -> str:
"""生成 YAML front matter"""
lines: List[str] = ["---"]

def dump_scalar(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
# 使用 JSON 风格引号保证特殊字符安全(同时保留中文)
return json.dumps(value, ensure_ascii=False)
return json.dumps(str(value), ensure_ascii=False)

def dump_list(key: str, items: List[str]) -> None:
if not items:
lines.append(f"{key}: []")
return
lines.append(f"{key}:")
for item in items:
lines.append(f" - {dump_scalar(item)}")

lines.append(f"title: {dump_scalar(title)}")
lines.append(f"date: {dump_scalar(date)}")
dump_list("tags", tags)
dump_list("categories", categories)

# 额外字段:只支持标量或 List[str]
for key, value in extra_fields.items():
if isinstance(value, list) and all(isinstance(x, str) for x in value):
dump_list(str(key), value)
else:
lines.append(f"{key}: {dump_scalar(value)}")

lines.append("---")
return "\n".join(lines) + "\n\n"

解析要点:

  • has_frontmatter() 只匹配文件开头:避免把正文里的 --- 误当成 front matter。
  • 生成 YAML 的实现刻意“极简且可控”:
    • 只输出我们需要的字段:title/date/tags/categories
    • 字符串统一用 JSON 双引号(对中文、空格、冒号等更稳)。
    • 不支持复杂嵌套结构:避免 YAML 语义分歧。

4)Md2HexoConverter:转换引擎(I/O、跳过、写入策略)


class Md2HexoConverter:
"""Markdown 到 Hexo 格式转换器"""

def __init__(self, config: ConversionConfig, logger: logging.Logger):
self.config = config
self.logger = logger
self.result = ConversionResult()

def _read_prefix(self, filepath: Path, max_bytes: int = 8192) -> str:
"""读取文件头部用于检测(UTF-8 / UTF-8-SIG)。"""
for encoding in ("utf-8", "utf-8-sig"):
try:
with filepath.open("r", encoding=encoding, errors="strict") as f:
return f.read(max_bytes)
except UnicodeDecodeError:
continue
# 兜底:避免把二进制/未知编码内容当成可转换文本
raise UnicodeDecodeError("utf-8", b"", 0, 1, "unable to decode file prefix")

def _read_all_text(self, filepath: Path) -> str:
for encoding in ("utf-8", "utf-8-sig"):
try:
return filepath.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
raise UnicodeDecodeError("utf-8", b"", 0, 1, "unable to decode file")

def _write_text_atomic(self, filepath: Path, content: str) -> None:
tmp_path = filepath.with_suffix(filepath.suffix + ".tmp")
tmp_path.write_text(content, encoding="utf-8")
tmp_path.replace(filepath)

解析要点:

  • _read_prefix():只读前缀用于“是否跳过”的判断,这是批量场景里最关键的优化之一。
  • 编码优先 utf-8,其次 utf-8-sig:后者可兼容带 BOM 的文件。
  • _write_text_atomic():先写 .tmp 再替换,能降低写入中途中断导致文件被截断的风险。

接下来是“规则层”方法:日期、标题、排除、tag/category 构建。


def get_file_date(self, filepath: Path) -> str:
"""获取文件日期"""
if self.config.use_file_mtime:
mtime = filepath.stat().st_mtime
return datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')

def get_title_from_file(self, filepath: Path) -> str:
"""从文件名获取标题"""
return filepath.stem

def should_skip_file(self, filepath: Path) -> bool:
"""判断是否应该跳过文件"""
filename = filepath.name
for pattern in self.config.exclude_patterns:
pat = pattern.strip()
if not pat:
continue
# 支持精确匹配和简单通配符(*.md / readme* 等)
if any(ch in pat for ch in "*?[]"):
if fnmatch.fnmatch(filename.lower(), pat.lower()):
return True
else:
if pat.lower() == filename.lower():
return True
return False

def build_tags(self, folder_name: str) -> List[str]:
"""构建标签列表"""
tags = self.config.tags.copy()
if self.config.include_folder_as_tag and folder_name:
if folder_name not in tags:
tags.append(folder_name)
return tags

def build_categories(self, folder_name: str) -> List[str]:
"""构建分类列表"""
categories = self.config.categories.copy()
if self.config.include_folder_as_category and folder_name:
if folder_name not in categories:
categories.append(folder_name)
return categories

解析要点:

  • get_file_date() 默认用 mtime:迁移外部文档时,这通常比“现在时间”更接近真实创作时间。
  • should_skip_file() 同时支持:
    • 精确匹配(README.md
    • 通配符(readme**.draft.md
  • build_tags/build_categories()
    • 先复制基础列表,避免原配置被污染
    • 再按需追加最后一级目录名

最后是核心转换:单文件转换 + 目录遍历。


def convert_file(self, filepath: Path, folder_name: str = '') -> bool:
"""转换单个文件"""
try:
# 先用前缀检测,尽量少读
if self.config.skip_existing_frontmatter:
prefix = self._read_prefix(filepath)
if HexoFrontMatter.has_frontmatter(prefix):
self.result.skipped += 1
self.logger.debug("skip (front matter exists): %s", filepath)
return False

content = self._read_all_text(filepath)

# 生成 front matter
title = self.get_title_from_file(filepath)
date = self.get_file_date(filepath)
tags = self.build_tags(folder_name)
categories = self.build_categories(folder_name)

frontmatter = HexoFrontMatter.generate(
title=title,
date=date,
tags=tags,
categories=categories
)

new_content = frontmatter + content

if not self.config.dry_run:
self._write_text_atomic(filepath, new_content)

self.result.converted += 1
return True

except Exception as e:
self.result.errors.append(f'{filepath}: {e}')
self.logger.debug("error converting %s", filepath, exc_info=True)
return False

def convert_directory(self, directory: Path) -> ConversionResult:
"""转换目录下所有Markdown文件"""
self.result = ConversionResult()

if directory.is_file():
self.result.total = 1
folder_name = directory.parent.name
if self.should_skip_file(directory):
self.result.skipped = 1
else:
self.convert_file(directory, folder_name)
return self.result

for filepath in directory.rglob('*.md'):
self.result.total += 1

if self.should_skip_file(filepath):
self.result.skipped += 1
continue

# 获取相对文件夹名(仅取最后一级)
relative_path = filepath.relative_to(directory)
folder_name = relative_path.parent.name if str(relative_path.parent) != '.' else ''

self.convert_file(filepath, folder_name)

return self.result

解析要点:

  • 跳过策略:默认 skip_existing_frontmatter=True,因此对已处理文件是“幂等”的。
  • dry-run:只统计,不写入。
  • 错误处理:单文件失败不会中断整个批处理,错误会收集到 result.errors

5)CLI/交互入口:参数解析、日志、主流程与退出码


def print_banner():
"""打印横幅"""
print("""
╔══════════════════════════════════════════════════════════╗
║ md2Hexo - Markdown 转换工具 v2.1 ║
║ 将 Markdown 文件转换为 Hexo 博客格式 ║
╚══════════════════════════════════════════════════════════╝
""")


def interactive_mode() -> ConversionConfig:
"""交互式配置模式"""
print_banner()

print(f"当前工作目录: {os.getcwd()}\n")

try:
confirm = input("是否继续转换? [Y/n]: ").strip().lower()
if confirm and confirm != 'y':
print("已取消操作。")
raise SystemExit(0)

tags_input = input("请输入标签 (逗号分隔,留空跳过): ").strip()
tags = [t.strip() for t in tags_input.split(',') if t.strip()] if tags_input else []

categories_input = input("请输入分类 (逗号分隔,留空跳过): ").strip()
categories = [c.strip() for c in categories_input.split(',') if c.strip()] if categories_input else []

dry_run = input("是否启用预览模式 (不实际写入)? [y/N]: ").strip().lower() == 'y'
except KeyboardInterrupt:
print("\n已取消操作。")
raise SystemExit(0)

return ConversionConfig(
tags=tags,
categories=categories,
dry_run=dry_run
)

解析要点:

  • 交互模式用于“少量文件试运行”。
  • 回答 n 或 Ctrl+C 都会 SystemExit(0):属于正常取消,不应算错误。

继续看配置载入与日志初始化:


def load_config_from_file(config_path: str) -> ConversionConfig:
"""从配置文件加载配置"""
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
try:
return ConversionConfig(**data)
except TypeError as e:
raise ValueError(f"配置文件字段不合法: {config_path}: {e}") from e


def _parse_csv(value: str) -> List[str]:
if not value:
return []
return [item.strip() for item in value.split(',') if item.strip()]


def _setup_logger(verbose: int) -> logging.Logger:
logger = logging.getLogger("md2hexo")
logger.handlers.clear()
logger.propagate = False

level = logging.WARNING
if verbose == 1:
level = logging.INFO
elif verbose >= 2:
level = logging.DEBUG

handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
logger.addHandler(handler)
logger.setLevel(level)
return logger

解析要点:

  • load_config_from_file():JSON 配置字段若不匹配 dataclass,直接报出“字段不合法”,比默默忽略更安全。
  • _setup_logger()
    • 默认 WARNING(输出更干净)
    • -v 提升到 INFO,-vv 提升到 DEBUG

接下来是参数解析:


def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description='md2Hexo - 将Markdown文件转换为Hexo博客格式',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 tools/md2Hexo.py # 交互式模式
python3 tools/md2Hexo.py --dry-run # 预览模式
python3 tools/md2Hexo.py --tags "Python,教程" # 指定标签
python3 tools/md2Hexo.py --config tools/configs/awesome-cheatsheets.json -d source/_posts/awesome-cheatsheets
"""
)

parser.add_argument('--directory', '-d', type=str, default='.',
help='要转换的目录路径 (默认: 当前目录)')
parser.add_argument('--tags', '-t', type=str, default='',
help='标签列表,逗号分隔')
parser.add_argument('--categories', '-c', type=str, default='',
help='分类列表,逗号分隔')
parser.add_argument('--config', type=str,
help='配置文件路径 (JSON格式)')
parser.add_argument('--dry-run', action='store_true',
help='预览模式,不实际写入文件')
parser.add_argument('--yes', '--no-interactive', '-y', dest='yes', action='store_true',
help='非交互模式(不询问确认)')

skip_group = parser.add_mutually_exclusive_group()
skip_group.add_argument('--skip-existing', dest='skip_existing', action='store_true', default=None,
help='跳过已有 front matter 的文件(默认行为)')
skip_group.add_argument('--no-skip-existing', dest='skip_existing', action='store_false', default=None,
help='不跳过已有 front matter 的文件(等价于 --force)')

parser.add_argument('--exclude', action='append', default=[],
help='追加排除文件名/通配符(可重复使用),例如 --exclude README.md --exclude "readme*"')
parser.add_argument('-v', '--verbose', action='count', default=0,
help='输出更多日志(-v INFO,-vv DEBUG)')
parser.add_argument('--force', '-f', action='store_true',
help='强制覆盖已有front matter')

return parser.parse_args(argv)

解析要点:

  • --exclude 允许重复传入:适合临时扩展排除列表。
  • --skip-existing/--no-skip-existing 做成互斥组,避免 argparse 的“默认 True + store_true”坑。
  • --force 保留为“一键覆盖”开关,语义直观。

最后是主流程与退出码:


def main(argv: Optional[List[str]] = None) -> int:
"""主函数(返回进程退出码)"""
args = parse_args(argv)
logger = _setup_logger(args.verbose)

# 1) 载入配置(优先 config 文件)
try:
if args.config:
config = load_config_from_file(args.config)
elif args.yes or args.tags or args.categories:
config = ConversionConfig(
tags=_parse_csv(args.tags),
categories=_parse_csv(args.categories),
dry_run=args.dry_run,
)
else:
config = interactive_mode()
except SystemExit as e:
# 交互取消、或 argparse 自己退出
code = e.code
return int(code) if isinstance(code, int) else 0
except Exception as e:
print(f"错误: 配置解析失败: {e}")
return 2

# 2) 合并 CLI 覆盖项
if args.dry_run:
config.dry_run = True

# --force 永远强制覆盖
if args.force:
config.skip_existing_frontmatter = False
# --skip-existing / --no-skip-existing 的显式选择
elif args.skip_existing is not None:
config.skip_existing_frontmatter = bool(args.skip_existing)

if args.exclude:
config.exclude_patterns = list(config.exclude_patterns) + list(args.exclude)

directory = Path(args.directory).resolve()
if not directory.exists():
print(f"错误: 路径不存在: {directory}")
return 1

mode_prefix = "[预览模式] " if config.dry_run else ""
print(f"\n{mode_prefix}开始转换: {directory}")
print(f"配置: tags={config.tags}, categories={config.categories}")
print(f"选项: folder_tag={config.include_folder_as_tag}, folder_category={config.include_folder_as_category}, mtime={config.use_file_mtime}, skip_existing={config.skip_existing_frontmatter}")
print("-" * 50)

converter = Md2HexoConverter(config, logger)
result = converter.convert_directory(directory)

print("-" * 50)
print("转换完成!")
print(f" 总计: {result.total} 个文件")
print(f" 已转换: {result.converted} 个文件")
print(f" 已跳过: {result.skipped} 个文件")

if result.errors:
print(f" 错误: {len(result.errors)} 个")
for error in result.errors[:20]:
print(f" - {error}")
if len(result.errors) > 20:
print(f" - ...(其余 {len(result.errors) - 20} 条已省略)")

if config.dry_run:
print("\n[预览模式] 未实际写入任何文件。去掉 --dry-run 参数以执行实际转换。")

return 0 if not result.errors else 1


if __name__ == '__main__':
raise SystemExit(main())

解析要点:

  • 退出码约定:
    • 0:成功(或用户主动取消)
    • 1:路径不存在或转换中出现错误
    • 2:配置解析失败
  • 统计输出固定且简洁:适合在终端里快速判断批处理结果。

完整源码(可直接复制粘贴成 tools/md2Hexo.py)

如果你不想分段复制,直接复制这一整段即可:

#!/usr/bin/env python3
"""md2Hexo - Markdown 转 Hexo Front Matter 工具

把普通 Markdown 文件转换为 Hexo 文章格式:为每个 .md 文件在开头插入 YAML front matter。

设计目标
- 默认“尽量安全”:遇到已存在 front matter 的文件默认跳过
- 保持“配置文件用法”兼容:tools/configs/*.json 仍可直接使用
- 尽量减少无谓 I/O:跳过时只读取文件头部做检测

用法
- 交互式:python3 tools/md2Hexo.py
- 预览不写入:python3 tools/md2Hexo.py --dry-run
- 指定目录:python3 tools/md2Hexo.py -d source/_posts
- 用配置文件:python3 tools/md2Hexo.py --config tools/configs/awesome-cheatsheets.json -d source/_posts/awesome-cheatsheets
- 强制覆盖:python3 tools/md2Hexo.py -d source/_posts -f

作者: HExLL
"""

from __future__ import annotations

import argparse
import fnmatch
import json
import logging
import os
import re
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional

__version__ = "2.1.0"


@dataclass
class ConversionConfig:
"""转换配置"""
tags: List[str] = field(default_factory=list)
categories: List[str] = field(default_factory=list)
include_folder_as_tag: bool = True
include_folder_as_category: bool = True
use_file_mtime: bool = True
skip_existing_frontmatter: bool = True
exclude_patterns: List[str] = field(default_factory=lambda: ['README.md', 'readme.md', 'SUMMARY.md'])
dry_run: bool = False


@dataclass
class ConversionResult:
"""转换结果"""
total: int = 0
converted: int = 0
skipped: int = 0
errors: List[str] = field(default_factory=list)


class HexoFrontMatter:
"""Hexo Front Matter 生成器"""

# 仅判断“文件开头是否存在 YAML front matter”。
# 这比简单 startswith('---') 更接近 Hexo 的实际语义。
FRONTMATTER_AT_TOP_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)

@staticmethod
def has_frontmatter(content_prefix: str) -> bool:
"""检查文件是否已有 front matter(只需传入文件前缀内容)"""
return bool(HexoFrontMatter.FRONTMATTER_AT_TOP_RE.match(content_prefix))

@staticmethod
def generate(
title: str,
date: str,
tags: List[str],
categories: List[str],
**extra_fields
) -> str:
"""生成 YAML front matter"""
lines: List[str] = ["---"]

def dump_scalar(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
# 使用 JSON 风格引号保证特殊字符安全(同时保留中文)
return json.dumps(value, ensure_ascii=False)
return json.dumps(str(value), ensure_ascii=False)

def dump_list(key: str, items: List[str]) -> None:
if not items:
lines.append(f"{key}: []")
return
lines.append(f"{key}:")
for item in items:
lines.append(f" - {dump_scalar(item)}")

lines.append(f"title: {dump_scalar(title)}")
lines.append(f"date: {dump_scalar(date)}")
dump_list("tags", tags)
dump_list("categories", categories)

# 额外字段:只支持标量或 List[str]
for key, value in extra_fields.items():
if isinstance(value, list) and all(isinstance(x, str) for x in value):
dump_list(str(key), value)
else:
lines.append(f"{key}: {dump_scalar(value)}")

lines.append("---")
return "\n".join(lines) + "\n\n"


class Md2HexoConverter:
"""Markdown 到 Hexo 格式转换器"""

def __init__(self, config: ConversionConfig, logger: logging.Logger):
self.config = config
self.logger = logger
self.result = ConversionResult()

def _read_prefix(self, filepath: Path, max_bytes: int = 8192) -> str:
"""读取文件头部用于检测(UTF-8 / UTF-8-SIG)。"""
for encoding in ("utf-8", "utf-8-sig"):
try:
with filepath.open("r", encoding=encoding, errors="strict") as f:
return f.read(max_bytes)
except UnicodeDecodeError:
continue
# 兜底:避免把二进制/未知编码内容当成可转换文本
raise UnicodeDecodeError("utf-8", b"", 0, 1, "unable to decode file prefix")

def _read_all_text(self, filepath: Path) -> str:
for encoding in ("utf-8", "utf-8-sig"):
try:
return filepath.read_text(encoding=encoding)
except UnicodeDecodeError:
continue
raise UnicodeDecodeError("utf-8", b"", 0, 1, "unable to decode file")

def _write_text_atomic(self, filepath: Path, content: str) -> None:
tmp_path = filepath.with_suffix(filepath.suffix + ".tmp")
tmp_path.write_text(content, encoding="utf-8")
tmp_path.replace(filepath)

def get_file_date(self, filepath: Path) -> str:
"""获取文件日期"""
if self.config.use_file_mtime:
mtime = filepath.stat().st_mtime
return datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
return datetime.now().strftime('%Y-%m-%d %H:%M:%S')

def get_title_from_file(self, filepath: Path) -> str:
"""从文件名获取标题"""
return filepath.stem

def should_skip_file(self, filepath: Path) -> bool:
"""判断是否应该跳过文件"""
filename = filepath.name
for pattern in self.config.exclude_patterns:
pat = pattern.strip()
if not pat:
continue
# 支持精确匹配和简单通配符(*.md / readme* 等)
if any(ch in pat for ch in "*?[]"):
if fnmatch.fnmatch(filename.lower(), pat.lower()):
return True
else:
if pat.lower() == filename.lower():
return True
return False

def build_tags(self, folder_name: str) -> List[str]:
"""构建标签列表"""
tags = self.config.tags.copy()
if self.config.include_folder_as_tag and folder_name:
if folder_name not in tags:
tags.append(folder_name)
return tags

def build_categories(self, folder_name: str) -> List[str]:
"""构建分类列表"""
categories = self.config.categories.copy()
if self.config.include_folder_as_category and folder_name:
if folder_name not in categories:
categories.append(folder_name)
return categories

def convert_file(self, filepath: Path, folder_name: str = '') -> bool:
"""转换单个文件"""
try:
# 先用前缀检测,尽量少读
if self.config.skip_existing_frontmatter:
prefix = self._read_prefix(filepath)
if HexoFrontMatter.has_frontmatter(prefix):
self.result.skipped += 1
self.logger.debug("skip (front matter exists): %s", filepath)
return False

content = self._read_all_text(filepath)

# 生成 front matter
title = self.get_title_from_file(filepath)
date = self.get_file_date(filepath)
tags = self.build_tags(folder_name)
categories = self.build_categories(folder_name)

frontmatter = HexoFrontMatter.generate(
title=title,
date=date,
tags=tags,
categories=categories
)

new_content = frontmatter + content

if not self.config.dry_run:
self._write_text_atomic(filepath, new_content)

self.result.converted += 1
return True

except Exception as e:
self.result.errors.append(f'{filepath}: {e}')
self.logger.debug("error converting %s", filepath, exc_info=True)
return False

def convert_directory(self, directory: Path) -> ConversionResult:
"""转换目录下所有Markdown文件"""
self.result = ConversionResult()

if directory.is_file():
self.result.total = 1
folder_name = directory.parent.name
if self.should_skip_file(directory):
self.result.skipped = 1
else:
self.convert_file(directory, folder_name)
return self.result

for filepath in directory.rglob('*.md'):
self.result.total += 1

if self.should_skip_file(filepath):
self.result.skipped += 1
continue

# 获取相对文件夹名(仅取最后一级)
relative_path = filepath.relative_to(directory)
folder_name = relative_path.parent.name if str(relative_path.parent) != '.' else ''

self.convert_file(filepath, folder_name)

return self.result


def print_banner():
"""打印横幅"""
print("""
╔══════════════════════════════════════════════════════════╗
║ md2Hexo - Markdown 转换工具 v2.1 ║
║ 将 Markdown 文件转换为 Hexo 博客格式 ║
╚══════════════════════════════════════════════════════════╝
""")


def interactive_mode() -> ConversionConfig:
"""交互式配置模式"""
print_banner()

print(f"当前工作目录: {os.getcwd()}\n")

try:
confirm = input("是否继续转换? [Y/n]: ").strip().lower()
if confirm and confirm != 'y':
print("已取消操作。")
raise SystemExit(0)

tags_input = input("请输入标签 (逗号分隔,留空跳过): ").strip()
tags = [t.strip() for t in tags_input.split(',') if t.strip()] if tags_input else []

categories_input = input("请输入分类 (逗号分隔,留空跳过): ").strip()
categories = [c.strip() for c in categories_input.split(',') if c.strip()] if categories_input else []

dry_run = input("是否启用预览模式 (不实际写入)? [y/N]: ").strip().lower() == 'y'
except KeyboardInterrupt:
print("\n已取消操作。")
raise SystemExit(0)

return ConversionConfig(
tags=tags,
categories=categories,
dry_run=dry_run
)


def load_config_from_file(config_path: str) -> ConversionConfig:
"""从配置文件加载配置"""
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
try:
return ConversionConfig(**data)
except TypeError as e:
raise ValueError(f"配置文件字段不合法: {config_path}: {e}") from e


def _parse_csv(value: str) -> List[str]:
if not value:
return []
return [item.strip() for item in value.split(',') if item.strip()]


def _setup_logger(verbose: int) -> logging.Logger:
logger = logging.getLogger("md2hexo")
logger.handlers.clear()
logger.propagate = False

level = logging.WARNING
if verbose == 1:
level = logging.INFO
elif verbose >= 2:
level = logging.DEBUG

handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
logger.addHandler(handler)
logger.setLevel(level)
return logger


def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description='md2Hexo - 将Markdown文件转换为Hexo博客格式',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python3 tools/md2Hexo.py # 交互式模式
python3 tools/md2Hexo.py --dry-run # 预览模式
python3 tools/md2Hexo.py --tags "Python,教程" # 指定标签
python3 tools/md2Hexo.py --config tools/configs/awesome-cheatsheets.json -d source/_posts/awesome-cheatsheets
"""
)

parser.add_argument('--directory', '-d', type=str, default='.',
help='要转换的目录路径 (默认: 当前目录)')
parser.add_argument('--tags', '-t', type=str, default='',
help='标签列表,逗号分隔')
parser.add_argument('--categories', '-c', type=str, default='',
help='分类列表,逗号分隔')
parser.add_argument('--config', type=str,
help='配置文件路径 (JSON格式)')
parser.add_argument('--dry-run', action='store_true',
help='预览模式,不实际写入文件')
parser.add_argument('--yes', '--no-interactive', '-y', dest='yes', action='store_true',
help='非交互模式(不询问确认)')

skip_group = parser.add_mutually_exclusive_group()
skip_group.add_argument('--skip-existing', dest='skip_existing', action='store_true', default=None,
help='跳过已有 front matter 的文件(默认行为)')
skip_group.add_argument('--no-skip-existing', dest='skip_existing', action='store_false', default=None,
help='不跳过已有 front matter 的文件(等价于 --force)')

parser.add_argument('--exclude', action='append', default=[],
help='追加排除文件名/通配符(可重复使用),例如 --exclude README.md --exclude "readme*"')
parser.add_argument('-v', '--verbose', action='count', default=0,
help='输出更多日志(-v INFO,-vv DEBUG)')
parser.add_argument('--force', '-f', action='store_true',
help='强制覆盖已有front matter')

return parser.parse_args(argv)


def main(argv: Optional[List[str]] = None) -> int:
"""主函数(返回进程退出码)"""
args = parse_args(argv)
logger = _setup_logger(args.verbose)

# 1) 载入配置(优先 config 文件)
try:
if args.config:
config = load_config_from_file(args.config)
elif args.yes or args.tags or args.categories:
config = ConversionConfig(
tags=_parse_csv(args.tags),
categories=_parse_csv(args.categories),
dry_run=args.dry_run,
)
else:
config = interactive_mode()
except SystemExit as e:
# 交互取消、或 argparse 自己退出
code = e.code
return int(code) if isinstance(code, int) else 0
except Exception as e:
print(f"错误: 配置解析失败: {e}")
return 2

# 2) 合并 CLI 覆盖项
if args.dry_run:
config.dry_run = True

# --force 永远强制覆盖
if args.force:
config.skip_existing_frontmatter = False
# --skip-existing / --no-skip-existing 的显式选择
elif args.skip_existing is not None:
config.skip_existing_frontmatter = bool(args.skip_existing)

if args.exclude:
config.exclude_patterns = list(config.exclude_patterns) + list(args.exclude)

directory = Path(args.directory).resolve()
if not directory.exists():
print(f"错误: 路径不存在: {directory}")
return 1

mode_prefix = "[预览模式] " if config.dry_run else ""
print(f"\n{mode_prefix}开始转换: {directory}")
print(f"配置: tags={config.tags}, categories={config.categories}")
print(f"选项: folder_tag={config.include_folder_as_tag}, folder_category={config.include_folder_as_category}, mtime={config.use_file_mtime}, skip_existing={config.skip_existing_frontmatter}")
print("-" * 50)

converter = Md2HexoConverter(config, logger)
result = converter.convert_directory(directory)

print("-" * 50)
print("转换完成!")
print(f" 总计: {result.total} 个文件")
print(f" 已转换: {result.converted} 个文件")
print(f" 已跳过: {result.skipped} 个文件")

if result.errors:
print(f" 错误: {len(result.errors)} 个")
for error in result.errors[:20]:
print(f" - {error}")
if len(result.errors) > 20:
print(f" - ...(其余 {len(result.errors) - 20} 条已省略)")

if config.dry_run:
print("\n[预览模式] 未实际写入任何文件。去掉 --dry-run 参数以执行实际转换。")

return 0 if not result.errors else 1


if __name__ == '__main__':
raise SystemExit(main())

测评:优点、问题与改进点

优点

  • 配置模型清晰:适合“多集合、多来源”的批量迁移
  • 默认安全:跳过已有 front matter,避免重复污染
  • 扩展空间好:dataclass + 分层结构,继续加功能不容易失控

需要注意/可能的坑

  • Front Matter 与 Markdown 分割线冲突:极少数以 --- 开头的纯 Markdown 可能被误判为 front matter(建议文件开头留空行或加标题)
  • 编码问题:如果源文件不是 UTF-8(例如 GBK),会报 decode 错误,需要先统一编码
  • 目录名策略:目前只取“最后一级目录名”,如果你希望多级分类,需要在后续版本中扩展

性能与效率

  • 主要耗时来自磁盘 I/O(读取 + 写入)
  • 本次重构通过“前缀检测”减少了跳过场景的读取量

结语

md2Hexo 的本质是一个“迁移/清洗工具”。它的价值不在于复杂算法,而在于:

  • 可预期的行为(默认安全)
  • 清晰可控的配置
  • 出错时能定位原因(日志 + 错误汇总)

后续如果要继续演进,优先考虑:

  • 增加 --output(输出到新目录而不是原地改写)
  • 提供 --encoding 或自动编码探测(避免遇到 GBK 文件就失败)
  • 输出 JSON 报告(便于 CI/统计)

(完)