我做了一个把 LeetCode 和 Codeforces 题面一键转成 Markdown 的浏览器扩展
刷题时我一直有一个固定动作:
- 看题
- 把题目复制到本地笔记
- 再开始写思路、代码和复盘
真正麻烦的其实不是第 3 步,而是第 2 步。
因为题面复制这件事看起来简单,实际很碎:
- 直接复制网页,粘到 Markdown 编辑器里格式经常乱
- 标题、链接、难度、标签要自己补
- 示例和代码块很容易丢结构
- LeetCode 还是单页应用,很多“临时脚本”刷新一次就失效
所以我做了一个浏览器扩展,目标很明确:
在题目页面里注入一个“复制题目”按钮,一键把题面整理成 Markdown,并写入系统剪贴板。
当前仓库已经打通的站点有:
- LeetCode
- Codeforces
NowCoder、AcWing、洛谷、AtCoder 目前还是占位适配器,仓库里已经预留了 handler,但还没有完成页面提取和 UI 注入。
这篇文章不做“项目宣传帖”,主要聊实现过程里几个比较关键的问题:平台抽象、DOM 转 Markdown、SPA 页面兼容、默认代码提取和剪贴板兜底。
先说一下项目边界
这个扩展不是爬虫,也不是自动做题工具。
它只做三件事:
- 判断当前页面是不是支持的题目页
- 在页面上注入一个复制按钮
- 把当前可见题面整理成 Markdown 后复制到剪贴板
也就是说,它解决的是“题目沉淀”问题,不解决“题目来源”问题。
项目结构
整体结构比较简单:
copy-algo-problems/
├─ src/
│ ├─ core/
│ │ ├─ clipboard.ts
│ │ ├─ dom.ts
│ │ ├─ markdown.ts
│ │ └─ ui.ts
│ ├─ platform/
│ │ ├─ leetcode.ts
│ │ ├─ codeforces.ts
│ │ ├─ nowcoder.ts
│ │ ├─ acwing.ts
│ │ ├─ luogu.ts
│ │ └─ atcoder.ts
│ └─ content.ts
├─ manifest.json
└─ esbuild.config.mjs
我把代码拆成了两层:
core/放公共能力,比如剪贴板、DOM 清洗、Markdown 渲染、按钮 UIplatform/放平台适配逻辑,不同站点各管各的页面结构
这个拆法的目的很简单:平台页面结构差异很大,但复制、渲染、提示这些能力其实是通用的。
一、先把“多平台支持”这件事抽象出来
一开始最容易写成的样子,是在 content.ts 里塞满各种 if (location.host.includes(...))。
这种写法前期很快,但一旦平台多起来,后面会越来越难维护。
所以我做了一个很轻的 handler 抽象,每个平台都实现同一套接口:
type PlatformHandler = {
matches(loc: Location): boolean;
ensureUI(): void;
buildMarkdown(): string;
};
入口文件只做两件事:
- 选择当前命中的平台 handler
- 调用对应 handler 的 UI 注入逻辑
类似这样:
const handlers: PlatformHandler[] = [
leetcodeHandler,
codeforcesHandler,
nowcoderHandler,
acwingHandler,
luoguHandler,
atcoderHandler,
];
function pickHandler(): PlatformHandler | null {
return handlers.find((h) => h.matches(window.location)) || null;
}
这么做之后,后面补新平台时基本不需要动入口逻辑,只要新加一个 handler 即可。
这个点看起来普通,但对浏览器扩展很重要,因为不同站点的 DOM 结构往往没有任何复用价值,尽早做隔离比后面重构便宜很多。
二、难点不在“复制”,而在 DOM 转 Markdown
如果只是把某个节点的 innerText 复制出去,这个项目几乎没什么价值。
真正的问题是:网页题面本质上是富文本结构,而目标输出是 Markdown。
所以核心逻辑其实是:
把页面 DOM 结构尽量稳定地还原成可读的 Markdown。
我在 src/core/markdown.ts 里做了一层自己的渲染逻辑,大体思路是递归处理节点类型:
- 文本节点直接清洗空白字符
h1 ~ h6转标题p转段落ul/ol转列表pre/code转代码块和行内代码blockquote转引用table转 Markdown 表格a/img转链接和图片
像这样:
export function renderNode(node: Node, depth = 0, inlineOnly = false): string {
if (node.nodeType === Node.TEXT_NODE) {
return collapseInlineWhitespace(node.textContent || "");
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return "";
}
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (tag === "pre") {
const code = (el.innerText || el.textContent || "").replace(/\n+$/, "");
return code ? `\n```\n${code}\n```\n\n` : "";
}
if (/^h[1-6]$/.test(tag)) {
const level = Number(tag[1]);
const text = renderChildrenInline(node).trim();
return text ? `\n${"#".repeat(level)} ${text}\n\n` : "";
}
return renderChildren(node, depth);
}
这套逻辑的关键不是“支持所有 HTML 标签”,而是优先覆盖题面里最常见、最重要的结构。
题目页面里最影响可读性的,一般就是这几类:
- 标题和分段
- 示例代码块
- 有序/无序列表
- 表格
- 链接
只要这些结构还原得比较稳定,复制结果就已经能直接用于笔记和题解草稿。
三、LeetCode 最大的坑是它是单页应用
如果只是传统多页网站,通常在 window.load 时注入一次按钮就够了。
但 LeetCode 不是这种模型。
它会在不刷新页面的情况下切换题目、切换语言、切换布局,所以你会遇到两个问题:
- URL 变了,但页面没真正 reload
- DOM 结构会反复重建,刚插进去的按钮可能很快就没了
所以这里不能只靠一次性注入。
我在 LeetCode handler 里做了三层兜底:
1. patch history.pushState 和 replaceState
这样路由切换时可以主动派发一个自定义事件。
function patchHistory() {
const rawPushState = history.pushState;
const rawReplaceState = history.replaceState;
history.pushState = function (...args: any[]) {
const result = rawPushState.apply(this, args as any);
window.dispatchEvent(new Event("cap-copy-helper:urlchange"));
return result;
} as any;
history.replaceState = function (...args: any[]) {
const result = rawReplaceState.apply(this, args as any);
window.dispatchEvent(new Event("cap-copy-helper:urlchange"));
return result;
} as any;
}
2. 监听 popstate 和自定义 URL 变更事件
路由变化后,重新尝试注入按钮。
3. 用 MutationObserver 监听 DOM 变化
即使页面没有明显的路由切换,只要正文区域被重新渲染,也会重新检查按钮是否存在。
这部分逻辑做完之后,扩展在 LeetCode 上的稳定性才明显提升。
很多内容脚本 demo 的问题就在这里:第一次能用,第二题就失效。
四、题面提取不能只靠单个选择器
做这种扩展有个很现实的问题:目标站点的 DOM 结构不稳定。
如果你把提取逻辑写成:
document.querySelector(".xxx .yyy .zzz")
那站点只要改一次类名,你的功能基本就废了。
所以在 LeetCode 这边,我没有把正文提取绑定在单一选择器上,而是做了“候选节点打分”。
思路大概是:
- 先尝试几个明显的正文容器选择器
- 如果没拿到稳定结果,就在标题附近和
main区域内搜索候选节点 - 按文本长度、结构化块数量、是否包含
Example/Constraints等关键词来打分 - 选出最像题面正文的节点
类似这样:
function scoreDescriptionNode(el: HTMLElement): number {
const text = normalizeWhitespace(el.innerText || "").trim();
if (text.length < 80) return -Infinity;
let score = text.length;
if (text.includes("Example 1")) score += 800;
if (text.includes("Constraints")) score += 800;
if (text.includes("Submissions")) score -= 1500;
if (text.includes("Solutions")) score -= 1500;
const blockCount = el.querySelectorAll("p, pre, code, ul, ol, li, table, h1, h2, h3, h4").length;
score += blockCount * 20;
return score;
}
这个策略当然不是“永远正确”,但比“完全依赖一个类名”稳很多。
对这种面向第三方页面的工具来说,容错比完美更重要。
五、默认代码提取比题面正文还麻烦
LeetCode 上还有一个额外需求:复制题目时,最好把默认代码模板也带上。
这块比正文更难,因为编辑器实现本身就不统一:
- 有些页面是 Monaco
- 有些区域可能是 CodeMirror
- 有些情况下只能从
textarea或渲染层里拿文本
所以我的处理方式不是“假设只有一种编辑器”,而是:
- 先找代码区域的根容器
- 分别尝试 Monaco、CodeMirror、通用编辑器提取逻辑
- 收集多个候选代码片段
- 用简单的规则给候选片段打分
- 取最像“默认代码”的那一份
比如:
- 行数多的分更高
- 含
class、function、return、ListNode、TreeNode的分更高 - 含
Input、Output、Example这类题面词的分更低
这个思路本质上还是“候选 + 打分”,因为页面里能拿到的文本并不一定就是你真正想要的代码。
六、Codeforces 是另一套完全不同的适配逻辑
如果说 LeetCode 的麻烦主要在 SPA 和编辑器,那 Codeforces 的麻烦点更多在题面结构本身。
它的页面有自己固定的语义结构,比如:
.problem-statement.header .title.section-title.sample-test
所以我给 Codeforces 单独做了一套清洗逻辑,主要做了几件事:
1. 识别不同题目路径
支持两类 URL:
/contest/{id}/problem/{index}/problemset/problem/{id}/{index}
2. 结构化提取题目信息
包括:
- 标题
- 比赛名
- 标签
- 题面正文
3. 把页面里的语义块提前转换一下
比如把:
.header .title转成标题节点.section-title转成二级/三级标题- time limit / memory limit 转成普通段落
pre里的预格式文本重新清洗
这样到了公共的 Markdown 渲染层,就不用再做太多站点特判。
这部分让我比较确定一件事:多平台支持不能靠一个“大而全解析器”,一定要保留平台层的定制空间。
七、复制到剪贴板也要考虑失败回退
现代浏览器里最顺手的方案当然是:
await navigator.clipboard.writeText(text);
但现实里你不能假设它永远可用。
有些页面环境、权限状态或者浏览器策略下,这个 API 可能失败,所以我又补了一个降级方案:
function fallbackCopyText(text: string) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "readonly");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
}
虽然 execCommand("copy") 是老办法,但在这类扩展场景里,作为失败回退还是很有价值。
从结果上说,用户在意的不是你用了哪个 API,而是按钮点下去之后,到底能不能复制成功。
八、Manifest V3 下,这类扩展其实很适合做成轻量内容脚本
这个项目没有背景服务里的一堆复杂逻辑,主要就是内容脚本在页面中运行。
Manifest 里只声明了当前真正需要的能力:
clipboardWrite- 对 LeetCode / Codeforces 的
host_permissions
这点我刻意收得比较窄,因为这个工具本身不需要更高权限。
对一个只做页面提取和复制的扩展来说,权限越小越容易理解,也越符合“功能边界清楚”的设计。
九、发布层面顺手把打包流程也补了
为了让这个项目不是“只有源码能跑”,我顺手把构建和发布链路也整理了:
npm install
npm run build
npm run dev
npm run package
另外仓库里还配了 GitHub Actions,在打 tag 后自动打包 zip 并挂到 Release。
这件事虽然不复杂,但对开源项目很重要,因为它决定了别人到底能不能低成本试用。
十、现在还有哪些问题没解决
虽然核心链路已经通了,但这个项目现在仍然有几个很现实的边界:
- 当前真正可用的平台只有 LeetCode 和 Codeforces
- 页面提取依赖站点 DOM 结构,目标站点改版后仍然可能失效
- 目前没有自动化测试,主要靠真实页面手动验证
- 占位平台虽然有文件,但还没有真正实现
如果后面继续做,我比较想补这几件事:
- 把 NowCoder、AcWing、洛谷、AtCoder 补齐
- 给平台提取逻辑加更多兜底选择器
- 做最小化回归测试
- 给 README 和文章补演示截图
总结
这个项目做到现在,我最大的感受其实不是“浏览器扩展难写”,而是:
只要你开始和第三方网页结构打交道,真正难的都不是功能本身,而是兼容性和容错。
“把题面复制成 Markdown” 这句话很短,但拆开之后其实包含了这些问题:
- 页面识别
- DOM 抽取
- 富文本结构转换
- SPA 路由变化
- 编辑器兼容
- 剪贴板降级
也正因为这些问题都比较具体,所以这个项目挺适合持续迭代。每补一个站点、每修一个选择器,实际体验都会立刻变好。
项目已经开源,仓库在这里:
如果你也做刷题笔记,或者正好在写浏览器扩展,这个题目还挺适合拿来练手。