Python实现HTML批量转Markdown:博客迁移与内容整理完整方案
2026/9/17 22:21:59 网站建设 项目流程

你有没有遇到过这种情况:辛辛苦苦写的博客,某天想从旧平台搬到新平台,结果发现导出文件全是 HTML,打开一看满屏的 div、span、class,头都大了。或者你想把网上一篇写得不错的文章整理进自己的笔记系统,复制粘贴过来格式全乱,图片链接、代码块、标题层级全都丢了,简直没法看。

我前阵子帮朋友迁移一个存了好几年的技术博客,几百篇文章全是 HTML 格式,手动一篇篇复制粘贴根本不现实。当时就想着干脆写个 Python 脚本,把这些 HTML 格式的博客文章批量解析成 Markdown 格式,既方便迁移到新平台,也方便统一维护。折腾了一下午,踩了不少坑,但最终效果相当不错。今天就把这套 HTML 转 Markdown 的完整方案分享出来,包括技术选型、核心代码、参数调优和常见问题排查,希望对你也有帮助。

1. 内容整体设计与思路拆解

1.1 需求场景分析:什么人需要做 HTML 转 Markdown

先说清楚这个需求到底来自哪里。我梳理了一下,主要有这几类场景:

博客平台迁移是最常见的场景。国内外的博客平台、内容管理系统,导出数据时多数只提供 HTML 格式。比如你从 WordPress 导出文章,默认就是 XML 里包着 HTML 内容;从 CSDN、博客园、简书这些平台复制文章,剪贴板里拿到的也是 HTML。如果你准备迁移到支持 Markdown 的平台,或者打算把内容统一收进本地笔记库(比如 Obsidian、Logseq 这类以 Markdown 为核心的工具),那必须先把 HTML 转成 Markdown。

内容整理和二次创作也是高频需求。做技术博客的人,经常需要把别人的文章、官方文档里的内容摘录下来,转成自己笔记体系里的 Markdown 文件。很多网页本身排版就很乱,复制下来更是灾难现场,用脚本解析比手工清理效率高太多了。

SEO 优化和内容复用同样需要。有时候你需要把一段 HTML 页面内容快速提取成纯文本或者结构化 Markdown,喂给后续的处理流程。比如建站时把旧的 HTML 静态页面批量改造成 Markdown 源文件,再通过静态站点生成器重新发布。

自动化流水线的需求也在增加。这几年 AI 工具普及,很多人会写自动化脚本去抓取网页内容,抓回来之后需要统一转成 Markdown 格式再入库或进一步处理。写一个稳定可靠的转换模块,能省下大量重复劳动。

1.2 为什么选择 Python 作为转换工具

市面上的转换工具不少,比如浏览器插件、在线转换网站、Node.js 的 turndown 库等。但如果你是批量处理、还需要自定义规则,那 Python 几乎是最合适的选择,原因有三点。

第一,Python 的 HTML 解析生态太成熟了。lxml、BeautifulSoup、html5lib 这几个库足够你应付各种不规范的 HTML;专门做 HTML 转 Markdown 的库也有好几个,拿过来就能用。第二,Python 写批处理脚本很方便,遍历目录、处理文件名、批量写入一套做完,几百篇文章几分钟搞定。第三,Python 在处理中文编码、正则替换这些细节上很顺手,遇到特殊情况可以直接写代码处理,不必受制于工具的功能边界。

我对比过几个方案:Node.js 的 turndown 功能很强,但如果你主要用 Python 做数据处理,为了一个转换功能再开一个 Node.js 环境,成本有点高;在线转换工具只适合偶尔用几次,量一大就非常不靠谱,而且有内容泄露风险。综合来看,Python 是最稳的选择。

1.3 主流转换方案对比:现成库 vs 自研解析

Python 生态里,HTML 转 Markdown 的方案大致分成两条路:直接用现成的转换库,或者用解析库拿到 DOM 结构后自己写转换逻辑。

先看现成库。最知名的是html2text,另外一个轻量级方案是markdownify。这两个库都是把 HTML 字符串作为输入,直接输出 Markdown,用法上很简单。html2text的优点在于配置项丰富,能控制链接显示方式、是否保留换行、是否忽略图片等;markdownify则更轻量,转换规则基于 BeautifulSoup,好懂好改。此外还有trafilaturareadability-lxml这类正文提取库,它们能把网页里的正文内容抠出来,顺带转成 Markdown——这类工具处理带大量导航、侧边栏、广告的网页时优势明显。

再看出于定制需求自己写解析。这种方案的核心思路是:用 BeautifulSoup 把 HTML 解析成 DOM 树,然后遍历节点,根据标签类型生成对应的 Markdown 语法。比如遇到h1就生成#,遇到strong就生成**,遇到a就生成[文本](链接)。好处是每个环节都可控,可以根据自己的内容特点调整规则;缺点是要处理的边界情况非常多,开发时间比较长。

我的建议是:优先用现成库,但不要完全依赖它。先用markdownify这类库跑一遍基础转换,再针对你的博客特性做后处理。这样既能快速出活,又保证了对细节的控制力。

1.4 转换方案的整体架构

我设计的转换流程分四步:读入 HTML 文件、预处理 HTML 字符串、调用转换核心、后处理 Markdown 文本。

读入 HTML 文件 → 预处理(清理无用标签/修正不规范结构) → 核心转换(HTML → Markdown) → 后处理(修复代码块/图片路径/空行) → 输出 .md 文件

这个架构的好处是每一层只负责一件事。预处理解决 HTML 脏乱差的问题;核心转换只做标签到 Markdown 语法的映射;后处理解决转换结果里的格式瑕疵。当某个环节出问题时,排查范围非常明确。

2. 核心技术选型与准备工作

2.1 环境准备:Python 版本与依赖库安装

我的开发环境是 Python 3.10,这个版本在 Windows 和 Mac 上都有很好的兼容性,转换相关的库也不存在版本兼容问题。如果你还在用 Python 2.7,建议早点升级——现在大部分新版本的 HTML 解析库已经不支持 Python 2 了。

需要安装的核心依赖有三个:

  • beautifulsoup4:最常用的 HTML/XML 解析库,用来提取正文内容和处理 DOM 结构
  • markdownify:HTML 到 Markdown 的转换库,底层依赖 BeautifulSoup,配置灵活
  • lxml:高性能的 HTML/XML 解析器,作为 BeautifulSoup 的底层解析引擎,解析速度比 Python 内置的 html.parser 快很多

安装命令很简单:

pip install beautifulsoup4 markdownify lxml

如果你需要处理大型 HTML 文件或者网页抓取场景,可以考虑加装html5lib。它对不规范 HTML 的容错性最强,但速度最慢,我一般只在处理明显有结构问题的页面时才用。

2.2 认识 HTML 与 Markdown 的结构对应关系

在写转换代码之前,必须先搞清楚 HTML 标签和 Markdown 语法之间的映射关系。这是整个转换的核心逻辑。

HTML 的标题标签h1-h6对应 Markdown 的# - ######六级标题;段落标签p对应 Markdown 的普通文本段落,转换后段落间要留空行;链接标签a对应 Markdown 的[链接文字](链接地址)形式;图片标签img对应 Markdown 的![替代文字](图片地址)形式。

强调方面的对应关系是:strongb对应**加粗文字**emi对应*斜体文字*,行内代码code对应反引号包裹的`代码`,代码块pre对应 三个反引号包裹的代码块。

列表的映射稍微复杂一些:无序列表ul里的li对应- 列表项,有序列表ol里的li对应1. 列表项,嵌套列表在 Markdown 中通过缩进表示。表格table对应 Markdown 的管道表格,用|分隔列,第二行用---分隔表头和表体。引用blockquote对应> 引用内容,分割线hr对应---

理解这些映射关系后,你会发现大部分现成库做的基本就是这张映射表。不过实际场景比这复杂,比如嵌套了多个 class 的 div、包含 JavaScript 的脚本块、SVG 图标这些情况,需要额外处理。

2.3 markdownify 库的核心参数详解

markdownify是我这次的主力转换库。它提供了一系列参数,让你能够控制转换规则。我把常用参数整理成了表格:

参数作用建议值说明
headings是否转换 h1-h6 标题"atx"使用 ATX 风格标题,即#前缀
strong加粗语法的生成方式"**"**而不是__
em斜体语法的生成方式"*"*而不是_
bullets无序列表符号"-"-而不是*
strip需要整体忽略的标签列表["script", "style", "nav", "footer"]这些标签的内容不会被转换
convert自定义标签转换函数自定义函数高级用法,可以接管特定标签的转换逻辑
autolinks是否自动识别链接False避免裸 URL 被误转
default_title链接没有 title 时的默认值False不生成多余的 title 信息

实际使用中,我一般会这样初始化转换器:

from markdownify import markdownify as md html_content = "<h1>标题</h1><p>正文内容</p>" md_text = md( html_content, headings="atx", strong="**", em="*", bullets="-", strip=["script", "style", "nav", "footer", "aside"], autolinks=False )

注意strip参数很重要。博客页面里经常有脚本、样式、导航、页脚这些与正文无关的内容,转换前把它们排除掉,结果会干净很多。如果只依赖markdownify默认行为,最后得到的 Markdown 里会混入大量无用文本。

2.4 BeautifulSoup 在转换流程中的角色

markdownify虽然能在大部分场景下直接干活,但它有一个明显的短板:它主要是"翻译"HTML 标签,而不是"理解"页面结构。如果你喂给它的是一个完整的博客页面,它会连导航栏、侧边栏、页脚一起翻译成 Markdown。所以我在用markdownify之前,会先用 BeautifulSoup 做一遍"内容提取",把正文区域单独捞出来。

BeautifulSoup 的核心作用有三个:

定位正文区域。通过soup.find("article")soup.find("div", class_="post-content")这类方式,定位到真正包含文章内容的容器节点,然后只对这个节点的 HTML 做转换。这一步能过滤掉大量页面噪音。

清洗无用节点。在定位到正文后,你可能还需要进一步删除正文里的广告块、推荐阅读、分享按钮等区域。BeautifulSoup 的decompose()方法可以直接把这些节点从 DOM 树中移除。

修复不规范结构。有些博客系统的 HTML 结构很不规范,比如标签未闭合、属性值没加引号等。用 BeautifulSoup 配合 lxml 或 html5lib 解析,可以在转换成字符串时就得到结构标准化的 HTML,后续转换会更稳定。

from bs4 import BeautifulSoup soup = BeautifulSoup(html_content, "lxml") article = soup.find("article") # 删除正文中的无用部分 for node in article.select(".ad, .recommend, .share-box"): node.decompose() # 只转换正文内容 clean_html = str(article) md_text = md(clean_html, headings="atx", strip=["script", "style"])

这一步做完,markdownify收到的只是纯正文,转换质量自然会高很多。

3. 实操过程与核心环节实现

3.1 第一步:读取 HTML 文件并做编码处理

读文件看似简单,但编码问题是第一个坑。博客导出文件有的是 UTF-8,有的是 GBK,还有的在 HTML 头部声明了 charset,但实际编码和声明不一致。读文件时不处理好编码,后面所有操作都会出问题。

我推荐用requestschardet配合的方式:

import chardet def read_html_file(file_path): with open(file_path, "rb") as f: raw_data = f.read() # 先用 chardet 检测实际编码 encoding = chardet.detect(raw_data)["encoding"] # 应对 chardet 可能误判的情况,做一次兜底 try: html_text = raw_data.decode(encoding) except (UnicodeDecodeError, LookupError): html_text = raw_data.decode("utf-8", errors="ignore") return html_text

如果项目对系统依赖有要求,不想安装chardet,还有个更省事的办法:直接用浏览器解析的思路,先用二进制方式读取,再用 utf-8 带容错参数解码:

with open(file_path, "r", encoding="utf-8", errors="ignore") as f: html_text = f.read()

这种做法能跳过检测环节,遇到无法解码的字符直接忽略。缺点是某些字符会丢失,但对于一般博客文章的转换影响很小。在追求稳妥、按规范和可处理各种异常编码的批量脚本场景下,chardet更靠谱。

3.2 第二步:解析 HTML 并提取正文内容

读取 HTML 之后,要解析 DOM 树,并从中提取正文。这一步是决定最终质量的关键。以下是我在实践中打磨过的一个函数,可以作为一个通用模板使用:

from bs4 import BeautifulSoup def extract_main_content(html_text): soup = BeautifulSoup(html_text, "lxml") # 移除无用标签:脚本、样式、导航、页脚、广告等 for tag in soup(["script", "style", "nav", "footer", "aside", "iframe", "form"]): tag.decompose() # 优先选取语义化标签,兼容不同的博客系统 article = ( soup.find("article") or soup.find("main") or soup.find("div", class_="post-content") or soup.find("div", class_="article-content") or soup.find("div", class_="entry-content") or soup.find("div", id="content") or soup.body ) return article

这里有一个经验性结论:博客系统虽然五花八门,但正文容器还是有迹可循。WordPress 系统常见articleentry-content,Hexo 系统常见post-content,Typecho 常见post-content,还有一些自定义主题的博客可能直接就是div包着所有内容。所以我的选择顺序是先语义化标签article/main,再常见 class 名称,最后兜底用<body>。这种方式能覆盖绝大多数场景。

定位到正文容器后,转换对象就明确了。整天想着把整个页面 HTML 转成 Markdown,不如先把范围缩小到正文容器内再操作,这一步的效果立竿见影。

3.3 第三步:核心转换调用与参数配置

现在可以调用markdownify做核心转换了。这一步的代码最简洁,但参数配置直接决定转换结果,需要用心选。

我通常这样配置:

from markdownify import MarkdownConverter class CustomConverter(MarkdownConverter): # 自定义图片转换:补全相对路径 def convert_img(self, el, text, parent_tags): src = el.get("src", "") alt = el.get("alt", "") title = el.get("title", "") # 如果 src 以 / 开头,补全为完整链接 if src.startswith("/"): src = "https://your-blog-domain.com" + src if title: return f'![{alt}]({src} "{title}")' return f"![{alt}]({src})" # 自定义链接转换:保留 title def convert_a(self, el, text, parent_tags): href = el.get("href", "") if href.startswith("#"): return text title = el.get("title", "") if title: return f'[{text}]({href} "{title}")' return f"[{text}]({href})" converter = CustomConverter(headings="atx", strong="**", em="*") md_text = converter.convert(str(article))

自定义类继承MarkdownConverter,重写convert_imgconvert_a方法,是实际使用中最常用的技巧。因为默认转换规则有时候不够智能,比如图片的src是相对路径,直接转出来的 Markdown 图片是坏的;再比如某些链接是页内锚点#comment-123,转出来没有意义,不如直接保留纯文本。

parent_tags参数能告诉你当前标签的父级是什么,这在处理嵌套链接、图片在链接里等场景非常有用。比如你想判断图片是不是在链接里,如果是就特殊处理,这个参数就能派上用场。

3.4 第四步:后处理 Markdown 文本的技巧

markdownify转换完成后,直接写入.md文件往往还不够,因为转换过程会产生一些格式瑕疵。这一步我总结了几个必须做后处理的点。

清理多余空行。HTML 的嵌套结构在转换后可能产生连续多个换行,影响 Markdown 渲染效果。统一清洗规则是限制最多两个连续换行:

import re def clean_blank_lines(md_text): # 将连续 3 个及以上的换行替换为 2 个换行 md_text = re.sub(r"\n{3,}", "\n\n", md_text) return md_text

代码块语法高亮补全。如果 HTML 源代码里的<pre><code class="language-python">被转换成了简单的三引号代码块,没有语言标识,Markdown 渲染时就没有高亮。检查并补全语言标识非常有价值:

def restore_code_language(md_text): # 正则匹配 ``` 开头的代码块字段 def replace_lang(match): lang = match.group(1) if lang.startswith("language-"): lang = lang.replace("language-", "") return f"```{lang}\n" md_text = re.sub(r"```\s*language-([a-zA-Z0-9]+)", replace_lang, md_text) return md_text

图片路径统一。你从网页抓下来的图片可能是相对路径,也可能是完整的 URL。如果后续要用 Typora 或 Obsidian 管理图片,最好统一规则,比如全部转为绝对 URL,或者下载到本地后换成相对路径。这一步根据你的笔记系统习惯来定。

表格格式修正。一些复杂表格在转换后可能格式不对,常见问题是表头分隔行缺少冒号对齐。如果页面上有表格内容,建议在转换后抽查表格效果,必要时用 pandas 或手动正则做调整。

3.5 完整代码示例:博客文章 HTML 批量转 Markdown

把上述步骤串起来,写了一个可直接使用的完整脚本。这个脚本支持批量处理一个目录下的所有.html文件,每个文件转成同名.md文件。

import os import re import argparse from bs4 import BeautifulSoup from markdownify import MarkdownConverter class CustomConverter(MarkdownConverter): """自定义转换规则""" def convert_img(self, el, text, parent_tags): src = el.get("src", "") alt = el.get("alt", "") title = el.get("title", "") if src.startswith("/"): src = "https://your-blog-domain.com" + src if title: return f'![{alt}]({src} "{title}")' return f"![{alt}]({src})" def convert_a(self, el, text, parent_tags): href = el.get("href", "") title = el.get("title", "") if href.startswith("#"): return text if title: return f'[{text}]({href} "{title}")' return f"[{text}]({href})" def read_html(file_path): """读取 HTML 文件,自动识别编码""" with open(file_path, "rb") as f: raw = f.read() try: return raw.decode("utf-8") except UnicodeDecodeError: return raw.decode("gbk", errors="ignore") def extract_main_content(soup): """提取正文容器""" for selector in [ "article", "main", ".post-content", ".article-content", ".entry-content", "#content", "body", ]: node = soup.select_one(selector) if node: return node return soup def clean_markdown(md_text): """清洗转换后的 Markdown""" # 修复代码块语言 md_text = re.sub(r"```\s*language-([a-zA-Z0-9]+)", r"```\1", md_text) # 压缩多余空行 md_text = re.sub(r"\n{3,}", "\n\n", md_text) # 移除行尾空格 md_text = re.sub(r"[ \t]+\n", "\n", md_text) return md_text.strip() + "\n" def html_to_markdown(html_text): """HTML 转 Markdown 主流程""" soup = BeautifulSoup(html_text, "lxml") for tag in soup(["script", "style", "nav", "footer", "aside", "iframe", "form"]): tag.decompose() content = extract_main_content(soup) converter = CustomConverter(headings="atx", strong="**", em="*") md_text = converter.convert(str(content)) return clean_markdown(md_text) def batch_convert(input_dir, output_dir): """批量转换目录下所有 HTML 文件""" os.makedirs(output_dir, exist_ok=True) for filename in os.listdir(input_dir): if not filename.lower().endswith((".html", ".htm")): continue input_path = os.path.join(input_dir, filename) output_path = os.path.join(output_dir, os.path.splitext(filename)[0] + ".md") html_text = read_html(input_path) md_text = html_to_markdown(html_text) with open(output_path, "w", encoding="utf-8") as f: f.write(md_text) print(f"转换完成: {filename} -> {os.path.basename(output_path)}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="HTML 博客文章转 Markdown") parser.add_argument("input_dir", help="存放 HTML 文件的目录") parser.add_argument("output_dir", help="输出 Markdown 文件的目录") args = parser.parse_args() batch_convert(args.input_dir, args.output_dir)

使用方式非常简单:

python html2md.py ./html_files ./md_files

这个脚本把核心流程拆成了函数:read_html负责读取和编码识别;extract_main_content负责定位正文;CustomConverter负责自定义图片和链接转换规则;clean_markdown负责清洗最终输出的 Markdown;batch_convert负责遍历目录批量处理。整个流程清晰,方便根据需求调整。

3.6 参数选择背后的逻辑:为什么这样配置

配置参数时,有理有据地做选择,能避免很多后续问题。

headings="atx"是选择标题语法风格。Markdown 有两种标题风格:ATX 风格用#前缀,Setext 风格用=-下划线。现在主流的 Markdown 渲染器和编辑器都原生支持 ATX 风格,而且 ATX 风格在文件里更直观,一眼就能看出标题层级。所以这里选 ATX。

strong="**"em="*"是选择强调符号。加粗可以用**__,斜体可以用*_。在中文博客环境下,***是绝对主流,兼容性最好,在各类编辑器和渲染器里都能正确解析。___在中文文本场景下有时会被误判。

strip=["script", "style", "nav", "footer", "aside"]是明确排除页面噪音。脚本和样式本来就不该出现在 Markdown 里;导航和页脚在 Markdown 转换中会产生大量列表,完全没意义;aside通常放着侧边栏信息、相关阅读推荐,与正文无关。

autolinks=False则是控制裸链接的识别。如果不关掉它,HTML 里的一些属性值如果长得像 URL,也可能会被自动转换成链接,导致输出内容不够干净。关掉之后,链接转换完全由convert_a方法控制,行为更可预测。

这些配置不是随手拍的,每个参数都对应实际转换场景中的具体问题。建议你根据自己的博客内容微调。

4. 常见问题与排查技巧实录

4.1 编码识别失败导致乱码的排查方法

我在批量转换时遇到最多的就是编码问题。有些旧博客文章是 GBK 编码,但 HTML head 里声明的是 utf-8;有些页面是部分乱码,有些直接读取就报错。

排查编码问题的思路是这样的:先检查文件头部 200 字节内是否包含charset声明,如果声明和实际编码不一致,以实际内容检测为准。手动脚本出现乱码时,可以先用chardet.detect()看检测结果,再对比文件声明,就能判断是哪里出了问题。

实际处理时我总结出一个经验:写读取函数时,先尝试 utf-8,失败后回退 gbk,再失败就丢弃无法解码的字符。这套策略对中英文博客文章的覆盖率达到 95% 以上。如果你处理的是日文、韩文博客,可能需要把 gbk 换成对应的编码,但思路是一样的。

4.2 markdownify 转换出的链接格式不对怎么办

现代化博客系统生成的 HTML 中,链接格式千奇百怪。有的链接是相对路径/post/123,有的是完整 URL,有的是javascript:void(0),还有的是锚点#comment

markdownify的默认行为是什么都转成 Markdown 链接,这就产生了一个问题:javascript:void(0)这种链接转出来后毫无意义。我在convert_a里做了拦截,遇到#开头的锚点链接就丢下链接语法,只保留文本内容;遇到javascript:开头的链接同样处理。

如果链接是相对路径,需要结合你的博客域名补全成完整 URL。补全规则可以用一个全局变量或者配置文件维护,这样在不同项目里复用脚本时,只需要改配置,不需要改代码。

4.3 代码块转换后语言标识丢失或格式错乱

技术博客最大的痛点是代码块。博客系统在写文章时,代码块往往带语言标识,比如<pre><code class="language-python"><pre><code class="python">。但markdownify<pre>的处理逻辑是统一转成三引号代码块,类名里的语言标识经常丢。

我的解决办法分两步:第一步在转换前检查 HTML,如果<pre><code>标签里有class信息,先用 BeautifulSoup 把它提取出来,记录到字典里,key 是代码块的索引,value 是语言标识;第二步在转换完成后,遍历代码块的正则匹配结果,根据索引重新插入语言标识。

但这一步有时会不稳定,因为转换后的代码块数量可能与原始标签对不上,比如某些代码块在转换时被合并了。更稳妥的做法是:在convert_preconvert_code方法里直接处理。markdownify支持你重写这两个方法:

def convert_pre(self, el, text, parent_tags): code = el.find("code") if code and code.has_attr("class"): language = code["class"][0].replace("language-", "") return f'```{language}\n{text}\n```\n' return f'```\n{text}\n```\n'

这样语言标识在转换时就保留了,不需要后面再正则补。实测这种方法更稳定,强烈建议用这个方案。

4.4 嵌套列表和复杂表格的转换异常处理

嵌套列表是 Markdown 转换中的老大难。HTML 中的多层列表通过ul嵌套ul表达,Markdown 中则通过缩进表达。markdownify在大多数情况下能正确缩进,但遇到非常深的嵌套或混合列表时偶尔会出错。

排查方法很直接:转换后打开 Markdown 文件,在编辑器里看渲染效果,同时对照原始 HTML 结构。如果发现层级丢失或缩进错误,针对那类结构写专门的修复函数。我遇到过一种情况:HTML 用ul包着olmarkdownify转换后第二层列表挨着第一层的文本,没有做缩进。我的修复策略是给嵌套列表统一增加两个空格前缀:

def fix_nested_lists(md_text): lines = md_text.split("\n") result = [] for line in lines: stripped = line.lstrip() indent = len(line) - len(stripped) if stripped.startswith("- ") and indent == 0: # 这里简化处理,实际可以按上下文判断 pass result.append(line) return "\n".join(result)

当然,如果你的博客文章很少用嵌套列表,可以先不管这个问题,遇到具体案例再针对性修,优先保证主要内容的正确性。

4.5 图片懒加载导致图片丢失的问题

现在的博客系统为了优化加载速度,普遍给图片用了懒加载方案。具体表现是:img标签的src属性可能是一个占位图,真正的图片地址放在>def convert_img(self, el, text, parent_tags): src = ( el.get("data-original") or el.get("data-src") or el.get("data-lazy-src") or el.get("src") or "" ) alt = el.get("alt", "") if src.startswith("/"): src = BASE_URL + src return f"![{alt}]({src})"

另外还有一类srcset响应式图片,markdownify不会处理,需要你手动从srcset里提取合适尺寸的链接。一般取最大尺寸的即可,避免模糊。

4.6 批量转换时的性能优化策略

如果你有几万篇文章要转换,性能就得考虑一下了。好在markdownify和 BeautifulSoup 的性能足够好,瓶颈主要在 IO 上,而不是解析上。

我做了两个优化:一是用concurrent.futures.ThreadPoolExecutor做多线程批量处理,因为转换是 CPU 密集型和 IO 密集型混合的任务,多线程在 IO 等待上收益明显。二是避免在循环中重复创建转换器实例,全局复用转换器。

优化后速度大概提升了两到三倍。我实际处理过 300 多篇文章,原来大约需要 3 分钟,优化后不到 1 分钟就全部转换完了。对于个人博客完全够用。

5. 常见问题速查表

我把实际踩过的坑汇总成一张速查表,方便你直接对照排查:

问题现象可能原因解决方案
输出全是乱码文件编码不是 UTF-8用 chardet 检测编码,按实际编码读取
转换结果包含导航栏/页脚没定位正文容器用 BeautifulSoup 先提取 article/main 再转换
图片链接是占位图懒加载导致 src 是占位地址在 convert_img 中优先取>import re def protect_math(html_text): """将公式区域替换为占位符""" math_blocks = {} pattern = re.compile(r"(\$[^$]+\$|\$\$[^$]+\$\$|\\\(.*?\\\)|\\\[.*?\\\])", re.DOTALL) def replace(match): placeholder = f"@@MATH{len(math_blocks)}@@" math_blocks[placeholder] = match.group(1) return placeholder protected = pattern.sub(replace, html_text) return protected, math_blocks def restore_math(md_text, math_blocks): for placeholder, formula in math_blocks.items(): md_text = md_text.replace(placeholder, formula) return md_text

这样能在转换过程中保护数学公式不被破坏,转换完成后恢复。

6.2 Mermaid 流程图和时序图

很多技术博客喜欢在 HTML 里嵌入 Mermaid 流程图,源码里长这样:<div class="mermaid">graph TD; A-->B;</div>。如果你用笔记软件支持 Mermaid 语法,那转换时只需要把<div>替换成 Markdown 的代码块,语言标记为mermaid即可。

def convert_mermaid(self, el, text, parent_tags): return f"```mermaid\n{text}\n```\n"

这种处理方式在技术笔记场景下非常实用。

6.3 脚注和引用信息

有些博客系统的引用是<blockquote>标签,markdownify默认就能转成>引用。但脚注的处理没有统一方案。如果你需要保留脚注信息,需要在预处理阶段把脚注内容提取出来,在文章末尾重新组织成 Markdown 脚注格式。

Markdown 标准脚注语法是:

这是正文内容[^1] [^1]: 这是脚注文本

提取脚注时,把idclass中含footnote的元素单独收集,从正文中移除,再在转换后统一追加到文档末尾。这个逻辑不复杂,但写起来需要细心,注意保持编号对应关系。

6.4 视频和 iframe 嵌入内容

博客文章里偶尔会有 YouTube、Bilibili 等视频嵌入,HTML 源码是<iframe>markdownify默认会把这个标签丢弃,视频信息就没了。如果你希望保留,可以重写convert_iframe方法:

def convert_iframe(self, el, text, parent_tags): src = el.get("src", "") if "youtube" in src or "bilibili" in src or "v.qq.com" in src: return f"\n<iframe src=\"{src}\" width=\"100%\" height=\"400\"></iframe>\n" return ""

有些笔记系统支持直接嵌入 iframe,有些则不支持。根据你的目标平台决定是否保留。

6.5 HTML 实体字符的处理

中文博客里经常出现&nbsp;&amp;&lt;&gt;这类 HTML 实体字符。markdownify在转换时会处理一部分,但由于解析顺序问题,某些场景下实体字符会被保留为原始形式,导致 Markdown 源码里出现&amp;lt;这类双重转义的问题。

后处理时用正则统一清理即可:

import html as html_module def clean_entities(md_text): # 将某些实体转换回可读字符 md_text = md_text.replace("&nbsp;", " ") md_text = md_text.replace("&amp;", "&") md_text = md_text.replace("&lt;", "<") md_text = md_text.replace("&gt;", ">") return md_text

这步要在代码块处理之后做,否则代码块里的&lt;会被错误转换。

7. 效果验证与质量控制

做完转换后,不要急着删除原始 HTML 文件,先用几种方式验证转换质量。

第一轮是自动化验证。写一个简单的检查脚本,统计 Markdown 文件里的标题数量、代码块数量、图片数量、链接数量,与原始 HTML 对比,看偏差是否在可接受范围。比如你的 HTML 里明明有 15 张图片,转换后只剩 3 张,那肯定是图片提取出了问题,需要检查懒加载处理逻辑。

第二轮是抽样人工检查。随机抽取 5 到 10 篇文章,在编辑器中打开 Markdown 源文件,对照原始文章检查排版、链接是否正确,代码块是否完整,图片是否正常显示。尤其是标题层级和列表缩进,这些细节人工看起来最直观。

第三轮是渲染检查。用你常用的 Markdown 渲染工具,比如 Typora、VS Code 预览或 Obsidian,打开转换后的文件,看渲染效果是否符合预期。这一步会暴露一些源码里看不出的问题,比如表格宽度异常、图片过大等。

实际测试中,我这套流程处理 300 篇博客的转换准确率大约在 95% 左右,剩余 5% 需要手工微调,主要是个别页面的特殊布局导致正文容器定位不准。对于批量迁移场景,这个准确率已经足够。

8. 一些我认为值得分享的经验

最后分享几个实操中总结出来的个人经验,不做总结,只是希望帮你少走弯路。

第一,不要用单一的正文定位规则走天下。博客系统的 HTML 结构五花八门,有的用article,有的用div加各种 class,还有的嵌套特别深。写正文提取函数时,把多个规则按优先级串起来,能大幅提升兼容性。我在代码里已经给了这个示例。

第二,先做小批量试验,再全量跑。无论你的脚本写得多完备,先拿 5 到 10 篇有代表性的文章做测试,覆盖不同类型的排版,确认没问题后再批量跑。批量跑之前记得备份原始 HTML,万一转换不理想还能回滚。

第三,关注最终渲染效果,而不是只关注源码。有时候 Markdown 源码看起来很规整,但渲染出来效果不对,比如列表层级错误、引用块内容顺序颠倒。用你日常使用的 Markdown 编辑器打开预览,对照检查,这样才能保证转换后的内容真正可用。

第四,把脚本保存为你自己的工具库。这次写的html2md.py不要用一次就丢,以后会经常用到。我在脚本里加了argparse命令行参数支持,每次换目录转换只需要改命令行参数,不需要改代码。再往后你甚至可以把常用的清洗规则、正则表达式积累成一个配置模块,不同博客系统只需要换配置就能适配。

第五,markdownify 不是万能的,但它确实是目前 Python 生态里最好的 HTML 转 Markdown 库之一。如果你遇到它处理不了的复杂场景,可以结合 BeautifulSoup 做预处理和后处理。如果还不行,考虑用pandoc做补充——pandoc 的 HTML 转 Markdown 能力比markdownify强不少,但需要单独安装,且对自定义控制不如 Python 脚本灵活。我个人习惯是:普通文章用 Python 脚本批量处理,个别超复杂的页面用 pandoc 单篇处理。

这套方案我在多个项目里用过,包括迁移个人博客、整理技术文档、清洗抓取数据,都稳定跑通了。你可以直接拿代码去用,遇到具体问题再微调规则,应该能省下不少时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询