你有没有遇到过这种情况:辛辛苦苦写的博客,某天想从旧平台搬到新平台,结果发现导出文件全是 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,好懂好改。此外还有trafilatura、readability-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 的形式。
强调方面的对应关系是:strong或b对应**加粗文字**,em或i对应*斜体文字*,行内代码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,但实际编码和声明不一致。读文件时不处理好编码,后面所有操作都会出问题。
我推荐用requests或chardet配合的方式:
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 系统常见article或entry-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'' return f"" # 自定义链接转换:保留 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_img和convert_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'' return f"" 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_pre或convert_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包着ol,markdownify转换后第二层列表挨着第一层的文本,没有做缩进。我的修复策略是给嵌套列表统一增加两个空格前缀:
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""
另外还有一类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 流程图,源码里长这样: 这种处理方式在技术笔记场景下非常实用。 6.3 脚注和引用信息有些博客系统的引用是 Markdown 标准脚注语法是: 提取脚注时,把 6.4 视频和 iframe 嵌入内容博客文章里偶尔会有 YouTube、Bilibili 等视频嵌入,HTML 源码是 有些笔记系统支持直接嵌入 iframe,有些则不支持。根据你的目标平台决定是否保留。 6.5 HTML 实体字符的处理中文博客里经常出现 后处理时用正则统一清理即可: 这步要在代码块处理之后做,否则代码块里的 7. 效果验证与质量控制做完转换后,不要急着删除原始 HTML 文件,先用几种方式验证转换质量。 第一轮是自动化验证。写一个简单的检查脚本,统计 Markdown 文件里的标题数量、代码块数量、图片数量、链接数量,与原始 HTML 对比,看偏差是否在可接受范围。比如你的 HTML 里明明有 15 张图片,转换后只剩 3 张,那肯定是图片提取出了问题,需要检查懒加载处理逻辑。 第二轮是抽样人工检查。随机抽取 5 到 10 篇文章,在编辑器中打开 Markdown 源文件,对照原始文章检查排版、链接是否正确,代码块是否完整,图片是否正常显示。尤其是标题层级和列表缩进,这些细节人工看起来最直观。 第三轮是渲染检查。用你常用的 Markdown 渲染工具,比如 Typora、VS Code 预览或 Obsidian,打开转换后的文件,看渲染效果是否符合预期。这一步会暴露一些源码里看不出的问题,比如表格宽度异常、图片过大等。 实际测试中,我这套流程处理 300 篇博客的转换准确率大约在 95% 左右,剩余 5% 需要手工微调,主要是个别页面的特殊布局导致正文容器定位不准。对于批量迁移场景,这个准确率已经足够。 8. 一些我认为值得分享的经验最后分享几个实操中总结出来的个人经验,不做总结,只是希望帮你少走弯路。 第一,不要用单一的正文定位规则走天下。博客系统的 HTML 结构五花八门,有的用 第二,先做小批量试验,再全量跑。无论你的脚本写得多完备,先拿 5 到 10 篇有代表性的文章做测试,覆盖不同类型的排版,确认没问题后再批量跑。批量跑之前记得备份原始 HTML,万一转换不理想还能回滚。 第三,关注最终渲染效果,而不是只关注源码。有时候 Markdown 源码看起来很规整,但渲染出来效果不对,比如列表层级错误、引用块内容顺序颠倒。用你日常使用的 Markdown 编辑器打开预览,对照检查,这样才能保证转换后的内容真正可用。 第四,把脚本保存为你自己的工具库。这次写的 第五,markdownify 不是万能的,但它确实是目前 Python 生态里最好的 HTML 转 Markdown 库之一。如果你遇到它处理不了的复杂场景,可以结合 BeautifulSoup 做预处理和后处理。如果还不行,考虑用 这套方案我在多个项目里用过,包括迁移个人博客、整理技术文档、清洗抓取数据,都稳定跑通了。你可以直接拿代码去用,遇到具体问题再微调规则,应该能省下不少时间。 |