1. 为什么我要自己写一个 Notion 转 Markdown 剪切板插件
我平时主力笔记软件就是 Notion,写完之后经常要发到 CSDN、知乎这类支持 Markdown 的平台。Notion 自带的导出功能会给你一个 zip,里面是.md文件加一堆本地图片,还得手动解压、上传图床、再复制正文,流程特别割裂。我真正想要的动作只有一个:在 Notion 页面里点一下,Markdown 全文(含图片外链)直接进剪切板,切到编辑器 Ctrl+V 就完事。
搜了一圈,排名靠前的方案基本是 GitHub Action 形态的 notion2markdown,它的定位是把 Notion 内容同步到静态站点,需要配 workflow、配 secret,跟"随手复制"完全不是一个场景。也有在线转换网站,但要把私有页面内容贴给第三方,我是不太放心的。所以结论很明确:市面缺一个浏览器插件形态、本地完成转换、结果直接进剪切板的工具。
这个需求特别适合拿来练 Claude Code。它足够小,两小时能出可用版本;又足够完整,涉及 manifest v3、content script、跨域请求、第三方 SDK 打包这些真实工程问题。下面我会把插件结构、manifest 配置、内容提取与转换逻辑、以及我踩过的报错全部摊开,你可以直接照着复刻一个。核心检索词先摆在这:Claude Code 开发浏览器插件、Notion 转 Markdown、复制到剪切板,这三件事本文都会给到可复制的配置。
技术栈上我选的是 Notion 官方 API 客户端@notionhq/client拉取块数据,notion-to-md做块到 Markdown 的转换,图片走腾讯云 COS 的cos-js-sdk-v5上传后替换链接,构建用 Webpack 5。这套组合是 Claude Code 在读完参考项目后自己给出的方案,我基本没改。
2. 用 Claude Code 搭插件的准备工作与 TaoToken 接入配置
在正式写代码前,得先让 Claude Code 能稳定跑起来。我这边是通过 TaoToken 接入的,它把模型调用统一成一个 OpenAI 兼容的入口,配置一次就能在 Claude Code、Cline 这类工具里复用。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api ,注意 API 地址后面不加任何查询参数。
先说清楚三件套,这是所有接入的通用公式:Base URL、API Key、Model ID。缺任何一个都会在请求阶段直接失败。Base URL 填https://taotoken.net/api,API Key 在控制台的 API Keys 页面生成,Model ID 按你实际要用的模型填。下面这段是 Claude Code 的 settings 配置,路径是~/.claude/settings.json,直接复制改 Key 即可:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的TaoToken密钥", "ANTHROPIC_MODEL": "claude-sonnet-4-5-20250929" } }如果你用的是 Codex 系工具,配置落在~/.codex/auth.json,结构不太一样,注意别混:
{ "OPENAI_API_KEY": "sk-你的TaoToken密钥", "OPENAI_BASE_URL": "https://taotoken.net/api" }Cline 这类 VS Code 插件则是在设置面板里填 Base URL 和 Key,Model ID 手动输入。三者的共同点就是上面那三件套,配完先别急着写业务代码,跑一个最小请求验证通路。我习惯用 curl 先探一下:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的TaoToken密钥" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-5-20250929", "messages": [{"role": "user", "content": "回复 ok"}] }'返回里能看到choices[0].message.content就说明链路通了。这一步很关键,因为后面插件调试时如果报错,你得能区分是模型接入的问题还是插件本身的问题。我建议把 Key 单独放一个环境变量文件,别硬编码进仓库,Claude Code 生成代码时也提醒它不要写死密钥。
准备工作还包括:Chrome 打开chrome://extensions开启开发者模式,Notion 那边创建一个 Integration 拿到 token,并把目标页面授权给这个 Integration。腾讯云 COS 建一个存储桶,拿到 SecretId、SecretKey、Bucket 和 Region。这些凭据后面会填进插件的配置页,先备齐。
3. 可复制的 manifest 与转换逻辑配置
插件目录结构我让 Claude Code 按下面这样组织,清晰且好维护:
notion-to-markdown-extension/ ├── manifest.json ├── src/ │ ├── background.js │ ├── content.js │ ├── popup.html │ ├── popup.js │ └── converter.js ├── package.json └── webpack.config.jsmanifest.json是 MV3 格式,权限要开activeTab、scripting、storage,host 权限要覆盖 Notion 和 COS 域名,否则 fetch 会被拦:
{ "manifest_version": 3, "name": "Notion to Markdown Clipboard", "version": "1.0.0", "description": "一键把 Notion 页面转成 Markdown 并写入剪切板", "permissions": ["activeTab", "scripting", "storage", "clipboardWrite"], "host_permissions": [ "https://api.notion.com/*", "https://*.myqcloud.com/*" ], "background": { "service_worker": "background.js" }, "action": { "default_popup": "popup.html" }, "content_scripts": [ { "matches": ["https://www.notion.so/*"], "js": ["content.js"] } ] }转换核心在converter.js,思路是先用 Notion API 拿到 pageId 对应的 block 树,再交给notion-to-md转字符串,最后把图片块替换成 COS 外链:
import { Client } from '@notionhq/client'; import { NotionToMarkdown } from 'notion-to-md'; import COS from 'cos-js-sdk-v5'; const notion = new Client({ auth: NOTION_TOKEN }); const n2m = new NotionToMarkdown({ notionClient: notion }); const cos = new COS({ SecretId: COS_SECRET_ID, SecretKey: COS_SECRET_KEY }); async function uploadImage(buffer, key) { return new Promise((resolve, reject) => { cos.putObject({ Bucket: COS_BUCKET, Region: COS_REGION, Key: key, Body: buffer }, (err, data) => { if (err) return reject(err); resolve(`https://${COS_BUCKET}.cos.${COS_REGION}.myqcloud.com/${key}`); }); }); } export async function pageToMarkdown(pageId) { const mdblocks = await n2m.pageToMarkdown(pageId); const mdString = n2m.toMarkdownString(mdblocks); return mdString.parent; }图片处理是最容易翻车的地方。Notion 的图片块返回的是带签名的临时 URL,一小时后失效,所以必须下载后转存到自己的 COS,再把 Markdown 里的链接替换掉。Claude Code 一开始没处理这个,我贴了报错它才补上uploadImage这段。background.js负责接收 popup 的指令、调用 converter、把结果写回剪切板:
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'CONVERT') { pageToMarkdown(msg.pageId) .then(md => sendResponse({ ok: true, md })) .catch(e => sendResponse({ ok: false, error: e.message })); return true; } });注意:MV3 的 service worker 不能直接用
navigator.clipboard,写剪切板的动作要放在 popup 或 content script 里执行,background 只做数据搬运。
4. 验证请求与成功结果:从 Notion 页面到粘贴出 Markdown
配置填完后,先做一次端到端验证。打开一个已授权给 Integration 的 Notion 页面,点插件图标,popup 里会显示当前 pageId 和一个"转换并复制"按钮。点下去之后,正常流程是:background 调 Notion API 拉块 → converter 转 Markdown → 图片上传 COS → 返回完整字符串 → popup 写入剪切板。
验证成功有几个可观察的信号。第一,popup 里出现"已复制 N 字符"的提示;第二,切到任意 Markdown 编辑器 Ctrl+V,标题层级、列表、代码块都保留;第三,图片链接是https://你的bucket.cos.区域.myqcloud.com/...这种你自己的域名,而不是 Notion 的临时签名地址。我实测下来,一篇带 5 张图、3 个代码块的笔记,从点击到粘贴完成大概 3 到 5 秒,主要耗时在图片上传。
如果你想更严谨地验证转换质量,可以拿一篇结构复杂的页面测:包含 to-do、toggle、callout、嵌套列表。notion-to-md对大部分块支持良好,但 callout 会转成引用块,toggle 会展开成普通内容,这些差异你要心里有数。下面是一个转换前后的对照,方便你判断结果是否符合预期:
| Notion 块类型 | 转换后 Markdown | 备注 |
|---|---|---|
| Heading 1/2/3 | #/##/### | 层级保留 |
| Bulleted list | - | 嵌套用缩进 |
| Code block | 三反引号 + 语言 | 语言标识可能丢失 |
| Image |  | 需自行转存 |
| Callout | >引用块 | 图标丢失 |
验证阶段还有一个动作值得做:把转换函数单独抽出来在 Node 里跑一遍,不依赖浏览器环境。这样出问题时能快速定位是 API 层、转换层还是插件通信层的问题。我当时的做法是写一个test.js,直接 import converter,传入 pageId,打印结果。这一步帮我省了大量在浏览器里反复点的时间。
5. 本篇常见报错排查:401、Illegal invocation 与图片失效
调试过程中我遇到的报错基本集中在下面几类,逐个说清楚原因和解法。
第一类是Error: Failed to execute 'fetch' on 'Window': Illegal invocation。这个报错我卡了最久,Claude Code 也迭代了两三次才修好。根因是fetch被解构或赋值后脱离了window上下文,比如写成const { fetch } = window再调用就会触发。解法是始终用window.fetch(...)或fetch.call(window, ...),别把方法单独拎出来。如果你在 content script 里调用,还要注意 MV3 的隔离环境,跨域请求建议统一走 background。
第二类是 401。这个几乎都是凭据问题,对照检查三件套:Notion token 是否以secret_或ntn_开头、是否把页面 share 给了对应 Integration、TaoToken 的 Key 是否过期。如果返回体里是unauthorized,先看 Notion 侧;如果是模型调用返回 401,检查ANTHROPIC_AUTH_TOKEN有没有多余空格。我踩过的坑是复制 Key 时带了个换行,排查了十分钟。
第三类是Cannot read properties of undefined (reading 'choices')。这通常出现在你直接解析模型返回时,说明请求根本没成功,返回体是错误对象而不是标准结构。正确做法是先判断response.ok,再取choices。这类报错在接入初期很常见,本质是错误处理没写全。
第四类是图片 403 或链接过期。Notion 的图片 URL 带签名,超过有效期就失效。如果你没做转存,粘贴出去的 Markdown 过一会儿图就挂了。解法就是前面说的,下载后传 COS 再替换。COS 这边如果报AccessDenied,检查存储桶权限是不是私有读写、SecretId 有没有对应权限。
第五类是 OAuth 相关报错。如果你走的是 Notion 的 OAuth 授权流程而不是内部 Integration token,回调地址必须和 Notion 后台配置的完全一致,包括协议和端口。本地调试时用http://localhost容易被拒,建议直接配一个固定的回调路径。
提示:排查顺序建议从外到内——先 curl 验证模型通路,再验证 Notion API,最后才怀疑插件代码。这样能避免在错误的方向上浪费时间。
6. 后续迭代与接入入口
基础版本跑通后,我又迭代了几个方向。一个是兼容 Cookie 方案,不依赖 Integration token,直接从浏览器已登录的 Notion 会话里取数据,好处是用户零配置,坏处是稳定性受 Notion 前端改动影响,目前还在调。另一个是给 popup 加了转换选项,比如是否上传图片、是否保留 callout 图标、代码块语言标识补全。这些都可以让 Claude Code 帮你加,描述清楚需求它就能改。
如果你也想从零做一遍,建议按这个顺序推进:先用 curl 把 TaoToken 通路跑通,再单独写一个 Node 脚本验证 Notion API 和转换逻辑,最后才包成插件。这样每一步都有独立验证点,出问题好定位。模型调用入口在 https://taotoken.net/api ,密钥在控制台的 API Keys 页面生成,接入文档在 doc 页面有完整说明。需要长期跑编码和 Agent 任务的,可以看下 Coding Plan,按用量规划更省心。
真正动手写的时候你会发现,卡住你的从来不是"不会写代码",而是某个具体的报错和某个没配对的环境变量。把这两样解决掉,一个小工具两小时就能出来。