我做了一个能一键复制算法题为 Markdown 的浏览器扩展,欢迎 Star

4 阅读10分钟

我做了一个把 LeetCode 和 Codeforces 题面一键转成 Markdown 的浏览器扩展

刷题时我一直有一个固定动作:

  1. 看题
  2. 把题目复制到本地笔记
  3. 再开始写思路、代码和复盘

真正麻烦的其实不是第 3 步,而是第 2 步。

因为题面复制这件事看起来简单,实际很碎:

  • 直接复制网页,粘到 Markdown 编辑器里格式经常乱
  • 标题、链接、难度、标签要自己补
  • 示例和代码块很容易丢结构
  • LeetCode 还是单页应用,很多“临时脚本”刷新一次就失效

所以我做了一个浏览器扩展,目标很明确:

在题目页面里注入一个“复制题目”按钮,一键把题面整理成 Markdown,并写入系统剪贴板。

当前仓库已经打通的站点有:

  • LeetCode
  • Codeforces

NowCoder、AcWing、洛谷、AtCoder 目前还是占位适配器,仓库里已经预留了 handler,但还没有完成页面提取和 UI 注入。

这篇文章不做“项目宣传帖”,主要聊实现过程里几个比较关键的问题:平台抽象、DOM 转 Markdown、SPA 页面兼容、默认代码提取和剪贴板兜底。


先说一下项目边界

这个扩展不是爬虫,也不是自动做题工具。

它只做三件事:

  1. 判断当前页面是不是支持的题目页
  2. 在页面上注入一个复制按钮
  3. 把当前可见题面整理成 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 渲染、按钮 UI
  • platform/ 放平台适配逻辑,不同站点各管各的页面结构

这个拆法的目的很简单:平台页面结构差异很大,但复制、渲染、提示这些能力其实是通用的。


一、先把“多平台支持”这件事抽象出来

一开始最容易写成的样子,是在 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 不是这种模型。

它会在不刷新页面的情况下切换题目、切换语言、切换布局,所以你会遇到两个问题:

  1. URL 变了,但页面没真正 reload
  2. DOM 结构会反复重建,刚插进去的按钮可能很快就没了

所以这里不能只靠一次性注入。

我在 LeetCode handler 里做了三层兜底:

1. patch history.pushStatereplaceState

这样路由切换时可以主动派发一个自定义事件。

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 这边,我没有把正文提取绑定在单一选择器上,而是做了“候选节点打分”。

思路大概是:

  1. 先尝试几个明显的正文容器选择器
  2. 如果没拿到稳定结果,就在标题附近和 main 区域内搜索候选节点
  3. 按文本长度、结构化块数量、是否包含 Example / Constraints 等关键词来打分
  4. 选出最像题面正文的节点

类似这样:

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 或渲染层里拿文本

所以我的处理方式不是“假设只有一种编辑器”,而是:

  1. 先找代码区域的根容器
  2. 分别尝试 Monaco、CodeMirror、通用编辑器提取逻辑
  3. 收集多个候选代码片段
  4. 用简单的规则给候选片段打分
  5. 取最像“默认代码”的那一份

比如:

  • 行数多的分更高
  • classfunctionreturnListNodeTreeNode 的分更高
  • InputOutputExample 这类题面词的分更低

这个思路本质上还是“候选 + 打分”,因为页面里能拿到的文本并不一定就是你真正想要的代码。


六、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 路由变化
  • 编辑器兼容
  • 剪贴板降级

也正因为这些问题都比较具体,所以这个项目挺适合持续迭代。每补一个站点、每修一个选择器,实际体验都会立刻变好。

项目已经开源,仓库在这里:

github.com/galaxywk223…

如果你也做刷题笔记,或者正好在写浏览器扩展,这个题目还挺适合拿来练手。