文章背景
上篇文章我使用Python 开发了一款 桌面工具网页转Markdown 桌面工具,有网友反馈建议开发一个 浏览器插件 来使用。咱主打就是一个听话照做,经过几天的开发,今天终于给他开发完成。
先看效果
这个插件解决了什么问题
HtmlToMD 当前支持:
• 将整个网页转换为 Markdown • 只转换用户选中的内容 • 使用 Readability 提取正文,减少广告和无关区域 • 使用 Turndown 将 HTML 转成 Markdown • 处理懒加载图片,例如 data-src、data-original• 自动提取 Markdown 中的图片链接 • 下载图片并和 Markdown 一起打包为 ZIP • 支持右键菜单和快捷键 • 支持 Chrome、Edge、Brave 等 Chromium 浏览器 • 提供 Firefox 临时加载版本 • 接入匿名使用统计,便于了解插件是否真的有人用
插件界面非常克制,只保留两个核心选项:
• 提取正文 • 下载图片并打包为 ZIP
对我来说,这类工具最重要的是稳定和快速,而不是做一个复杂后台。
技术选型
这是一个 Manifest V3 浏览器扩展,核心结构如下:
HtmlToMD├── manifest.json├── popup.html├── popup.css├── popup.js├── background.js├── content.js├── icons/└── lib/ ├── readability.js ├── turndown.js ├── jszip.min.js ├── download-utils.js └── analytics.js主要用到三个库:
• Readability:从网页里提取正文 • Turndown:把 HTML 转成 Markdown • JSZip:把 Markdown 和图片打包成 ZIP
整体链路是:
用户点击插件 ↓popup.js 向当前页面发送消息 ↓content.js 提取 HTML / 选中内容 ↓Readability 清理正文 ↓Turndown 转 Markdown ↓提取图片链接 ↓popup.js 展示结果 ↓download-utils.js 下载 Markdown 或 ZIPManifest V3 配置
插件的 manifest.json 里配置了 popup、后台脚本、内容脚本、权限和快捷键:
{ "manifest_version": 3, "name": "HtmlToMD", "version": "1.0.0", "description": "将网页 HTML 转换为 Markdown 格式,支持正文提取、图片下载、ZIP 打包", "permissions": [ "activeTab", "contextMenus", "downloads", "storage" ], "host_permissions": [ "http://*/*", "https://*/*" ], "action": { "default_popup": "popup.html", "default_title": "HtmlToMD - 转换为 Markdown" }, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": [ "lib/readability.js", "lib/turndown.js", "content.js" ], "run_at": "document_idle" } ]}这里有几个关键点。
activeTab 用来访问当前标签页。
contextMenus 用来支持右键转换。
downloads 用来下载 .md 或 .zip。
storage 用来保存用户选项,以及生成匿名统计 ID。
核心:把网页正文变成 Markdown
真正做转换的是 content.js。
第一步,先创建 Turndown 实例:
function getTurndownService() { if (!turndownService) { turndownService = new TurndownService({ headingStyle: 'atx', hr: '---', bulletListMarker: '-', codeBlockStyle: 'fenced', emDelimiter: '*', strongDelimiter: '**', linkStyle: 'inlined', }); } return turndownService;}这里我选择了 fenced 代码块,因为它更适合技术文章:
```jsconsole.log('hello');```为了避免公众号编辑器误识别,上面这一段在真实 Markdown 文件里需要注意转义。插件生成的 Markdown 会正常输出三反引号代码块。
处理懒加载图片
现在很多网站不会直接把图片地址放在 src 上,而是放在:
• data-src• data-original• data-original-src• data-actualsrc
所以我给 Turndown 增加了一个图片规则:
turndownService.addRule('lazyImages', { filter: ['img'], replacement: function (content, node) { var src = node.getAttribute('data-src') || node.getAttribute('data-original') || node.getAttribute('data-original-src') || node.getAttribute('data-actualsrc') || node.getAttribute('src') || ''; if (!src) return ''; if (src.indexOf('//') === 0) src = 'https:' + src; var alt = node.getAttribute('alt') || ''; var title = node.getAttribute('title'); return title ? '' : ''; }});这样即使网页使用懒加载,转换出来的 Markdown 里也尽量保留真实图片链接。
提取正文:Readability
如果直接转换整个 document.documentElement.outerHTML,结果通常会非常脏。
页面里会有:
• 顶部导航 • 侧边栏 • 推荐文章 • 评论区 • 登录弹窗 • 广告位
所以我使用 Readability 做正文提取:
function convertWithReadability(html, url) { var doc = createDocFromHTML(html, url); var reader = new Readability(doc); var article = reader.parse(); if (!article) return null; return { title: article.title || extractTitle(), content: article.content, byline: article.byline || '', };}这一步的作用是把“网页”变成“文章主体”。
如果用户关闭「提取正文」,插件也可以直接转换整个页面 HTML。
为什么要单独处理代码块
技术文章最怕代码块格式乱。
默认 HTML 转 Markdown 有时能处理代码块,但语言标识可能丢失。于是我加了一条规则:
turndownService.addRule('fencedCodeBlock', { filter: function (node, options) { return ( options.codeBlockStyle === 'fenced' && node.nodeName === 'PRE' && node.firstChild && node.firstChild.nodeName === 'CODE' ); }, replacement: function (content, node) { var code = node.firstChild; var className = code.getAttribute('class') || ''; var language = (className.match(/language-(\S+)/) || [null, ''])[1] || (className.match(/lang-(\S+)/) || [null, ''])[1] || (className.match(/brush:\s*(\S+)/) || [null, ''])[1]; return '\n\n```' + language + '\n' + code.textContent.replace(/\n$/, '') + '\n```\n\n'; }});这样遇到类似:
<pre><code class="language-js">console.log('hello')</code></pre>就能输出:
```jsconsole.log('hello')```对技术文章归档来说,这个细节很重要。
图片打包:把 Markdown 和图片放进 ZIP
如果只下载 Markdown,图片依然依赖网络链接。
这就会遇到一个问题:过一段时间,图片链接可能失效。
所以插件支持下载图片并打包成 ZIP。
核心逻辑在 download-utils.js:
async function downloadAsZip(result, onProgress) { var zip = new JSZip(); var markdown = result.markdown; var imageMap = {}; for (var i = 0; i < result.images.length; i++) { imageMap[result.images[i].url] = result.images[i].filename; } markdown = markdown.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (match, alt, url) { return imageMap[url] ? '' : match; }); zip.file(result.filename.replace(/\.md$/, '') + '.md', markdown); var zipBlob = await zip.generateAsync({ type: 'blob' }); var zipUrl = URL.createObjectURL(zipBlob); chrome.downloads.download({ url: zipUrl, filename: result.filename.replace(/\.md$/, '') + '.zip', saveAs: false, });}这里做了两件事:
第一,把 Markdown 里的远程图片链接替换成本地相对路径:
第二,把 .md 文件和 images/ 目录一起放进 ZIP。
最终下载出来的结构大概是:
article.zip├── article.md└── images/ ├── cover.png ├── image_1.jpg └── image_2.webp这对长期归档很友好。
右键菜单和快捷键
除了 popup,插件也支持右键菜单和快捷键。
后台脚本里会创建两个菜单:
chrome.contextMenus.create({ id: 'convert-page', title: '将当前页面转换为 Markdown', contexts: ['page', 'action'],});chrome.contextMenus.create({ id: 'convert-selection', title: '将选中内容转换为 Markdown', contexts: ['selection'],});快捷键配置在 manifest.json:
{ "commands": { "convert-page": { "suggested_key": { "default": "Alt+M", "mac": "Alt+M" }, "description": "转换当前页面为 Markdown" } }}我个人很喜欢 Alt + M 这个交互。
它的含义很直接:Markdown。
打包时踩过的坑
这个插件打包时我踩了两个典型坑。
第一个是 service worker 注册失败:
Service worker registration failed. Status code: 2解决方式是让后台脚本启动时保持轻量,把依赖改成懒加载:
function ensureDownloadUtils() { if (typeof JSZip === 'undefined' && typeof importScripts === 'function') { importScripts('lib/jszip.min.js'); } if (typeof DownloadUtils === 'undefined' && typeof importScripts === 'function') { importScripts('lib/download-utils.js'); }}这样后台 service worker 注册时不会立刻加载所有依赖。
第二个是读取配置时,chrome.storage.sync.get 可能失败或返回空值。
如果直接写:
document.getElementById('extract-article').checked = items.extractArticle;一旦 items 是 undefined,popup 就会直接报错。
所以我加了默认值兜底:
const DEFAULT_OPTIONS = { extractArticle: true, preserveImages: true,};chrome.storage.sync.get(DEFAULT_OPTIONS, (items) => { const options = chrome.runtime.lastError || !items ? DEFAULT_OPTIONS : items; document.getElementById('extract-article').checked = options.extractArticle; document.getElementById('preserve-images').checked = options.preserveImages;});浏览器扩展开发里,很多问题不是核心逻辑错了,而是浏览器运行环境比普通网页更严格。
多浏览器支持
目前我打了两个包:
HtmlToMD-v1.0.0-chromium.zipHtmlToMD-v1.0.0-firefox.zipChromium 系浏览器使用:
{ "background": { "service_worker": "background.js" }}Firefox 临时加载版本使用:
{ "background": { "scripts": [ "lib/analytics.js", "lib/jszip.min.js", "lib/download-utils.js", "background.js" ] }}这是因为不同浏览器对 Manifest V3 后台脚本的实现细节并不完全一致。
如果要做长期分发,Chrome Web Store 和 Mozilla Add-ons 仍然是更合适的方式。
网盘安装更适合内测。
如何安装
Chrome / Edge / Brave
1. 下载 HtmlToMD-v1.0.0-chromium.zip2. 解压 3. 打开扩展管理页 • Chrome: chrome://extensions• Edge: edge://extensions• Brave: brave://extensions4. 开启「开发者模式」 5. 点击「加载已解压的扩展程序」 6. 选择解压后的 HtmlToMD 文件夹
Firefox
1. 下载 HtmlToMD-v1.0.0-firefox.zip2. 解压 3. 打开 about:debugging#/runtime/this-firefox4. 点击「临时载入附加组件」 5. 选择解压目录里的 manifest.json
Firefox 这种方式是临时安装,重启后可能需要重新加载。
最后
HtmlToMD 不是一个很复杂的项目,但它很实用。
它把几个平时经常遇到的小痛点串起来:
• 网页正文提取 • HTML 转 Markdown • 图片链接修复 • 图片批量下载 • ZIP 打包 • 浏览器扩展交互 • 匿名事件统计 • 多浏览器打包
这些点单独看都不大,但组合起来就是一个能每天节省时间的小工具。
如果你也经常整理网页资料、写技术博客、维护知识库,HtmlToMD 应该会很顺手。
后面我还计划继续优化:
• 支持更多网站的定制提取规则 • 优化微信公众号文章转换 • 增加批量页面转换 • 提供更友好的设置页 • 发布到 Chrome Web Store
欢迎试用,也欢迎反馈。
体验软件 私信回复 MD插件GitHub :https://github.com/zhugyGit/HtmlToMD-dis
上期文章:
夜雨聆风