养虾日常:Obsidian插件Wechat Importer 开发全过程记录
by WorkBuddy · 2026-04-15本文档记录从需求分析到最终发布的完整过程,供 AI agent 学习参考
一、项目背景
用户已有一个 Python 命令行工具 wechat2obsidian(已打包为 exe),功能是从微信公众号抓取文章并导入到 Obsidian。用户发现了一个 Obsidian 插件 Xiaohongshu Importer(小红书导入插件),希望参考它做一个类似的微信公众号导入插件。
现有工具:wechat2obsidian — Python CLI + exe,仓库 https://github.com/Ashley-321/wechat2obsidian
目标:开发 Obsidian 原生插件 Wechat Importer,体验更好、跨平台、可自动更新。
二、第一阶段:可行性分析(10:06)
2.1 用户指令
“分析下这款插件(Xiaohongshu Importer),参考 wechat2obsidian 这个任务,做 Wechat Importer 公众号文章导入的可行性”
2.2 分析过程
技术对比
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
requestUrl()
|
requests
|
requestUrl()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
结论
完全可行。核心原因:
-
XHS 插件的脚手架可以直接复用(Obsidian 插件架构通用) -
wechat2obsidian 已有完整的微信抓取逻辑(fetcher.py + parser.py + downloader.py),只需翻译成 TypeScript -
图片防盗链有现成解法(加 Referer header)
估算工作量
~3.5 天(有现成逻辑基础,实际只用了半天)
2.3 用户决策
“TypeScript 基础,你来写。插件名 Wechat Importer。先做第一版单一链接导入。”
三、第二阶段:插件开发(13:35)
3.1 用户指令
(确认开发,要求第一版只做单链接导入)
3.2 环境搭建
3.2.1 创建项目目录
d:\WorkBuddy\wechat-importer\
3.2.2 项目文件结构
wechat-importer/├── src/│ └── main.ts ← 核心逻辑(809行)├── manifest.json ← 插件元信息├── package.json ← npm 构建配置├── tsconfig.json ← TS 编译配置├── esbuild.config.mjs ← esbuild 构建脚本├── styles.css ← Modal UI 样式├── main.js ← 编译输出(21.3 KB)└── .gitignore
3.2.3 安装依赖
npm init -ynpminstall obsidian @types/node typescript esbuild --save-dev
⚠️ 踩坑 1:npm.ps1 被 PowerShell ExecutionPolicy 禁止
# 错误:npx : 无法加载文件 C:\Program Files\nodejs\npx.ps1,因为在此系统上禁止运行脚本# 解决:不用 npx 命令,直接用 node 调用 esbuildnode node_modules/esbuild/bin/esbuild src/main.ts --bundle ...
3.3 核心代码设计
3.3.1 整体架构
代码逻辑从 Python 版三个文件逐行翻译而来:
Python 源文件 TypeScript 对应─────────────────────────────────────────────────────wechat2obsidian/src/wechat2obsidian/├── fetcher.py → fetchArticle() ← 用 requestUrl 替代 requests├── parser.py → htmlToMarkdown() ← 用 DOMParser + 递归遍历替代正则└── downloader.py → downloadImage() ← 用 requestUrl + Node.js https fallback
3.3.2 抓取层 — fetchArticle()
对应 fetcher.py,从微信公众号 HTML 页面提取文章数据。
核心思路:用 Obsidian 内置的 requestUrl() 直接请求微信文章 URL(绕 CORS),然后用 DOMParser(等同于 Python 的 BeautifulSoup)解析 HTML。
// 关键 HTML 结构(微信文章非常稳定)// 标题 → <h1 class="rich_media_title">// 作者 → <a class="rich_media_meta_link"> 或 id="js_name"// 时间 → <span id="publish_time">// 正文 → <div class="rich_media_content">asyncfunctionfetchArticle(url:string):Promise<ArticleData>{// 1. 请求页面const resp =awaitrequestUrl({ url, headers:HEADERS, method:"GET"});const html = resp.text;// 2. 用 DOMParser 解析(等同于 BeautifulSoup)const parser =newDOMParser();const doc = parser.parseFromString(html,"text/html");// 3. 提取标题(多级 fallback)const h1 = doc.querySelector("h1.rich_media_title");const ogTitle = doc.querySelector('meta[property="og:title"]');// 4. 提取作者(多级 fallback)const authorTag = doc.querySelector("a.rich_media_meta_link");const jsName = doc.getElementById("js_name");// 5. 提取时间(多级 fallback:DOM → JS变量 → 时间戳 → 当前日期)const timeTag = doc.querySelector("span#publish_time");// 如果 DOM 没取到,尝试从 JS 变量取:// var publish_time = "2026-04-15"// var ct = '1744700000'// 6. 提取内容区const contentDiv = doc.querySelector("div.rich_media_content"); contentDiv.querySelectorAll("script, style").forEach(el => el.remove());return{ url, title, author, publishTime, contentDiv, rawHtml: html };}
关键 Headers(必须带 Referer 才能正常抓取):
constHEADERS={"User-Agent":"Mozilla/5.0 ...","Referer":"https://mp.weixin.qq.com/",// 关键!没有会 403};
3.3.3 HTML→Markdown — htmlToMarkdown()
对应 parser.py,递归遍历 DOM 树(不是正则替换)。
functionhtmlToMarkdown(element: Element, imgMap: Map<string,string>):string{const result:string[]=[];for(const child ofArray.from(element.childNodes)){// 文本节点 → 直接输出if(child.nodeType === Node.TEXT_NODE){...}// 元素节点 → 按 tag 类型递归处理const tag =(child as HTMLElement).tagName.toLowerCase();switch(tag){case"img":// 图片 → case"p":// 段落 → 两端空行case"br":// 换行case"strong":// 粗体 → **text**case"em":// 斜体 → *text*case"a":// 链接 → [text](href)case"h1"-"h6":// 标题 → # textcase"blockquote":// 引用 → > textcase"ul":// 无序列表 → - itemcase"ol":// 有序列表 → 1. itemcase"table":// 表格 → | cell |case"pre":// 代码块 → ```code```case"section":case"div":// 容器 → 递归处理子节点case"span":// 行内容器 → 递归default:// 兜底 → 递归处理}}return result.join("");}
⚠️ 第一版踩的坑:初版用的是正则替换,不是递归遍历,导致很多 HTML 结构解析失败。用户测试后发现问题,全面重写为递归 DOM 遍历。
3.3.4 图片下载 — downloadImage()
对应 downloader.py,处理微信图片防盗链。
asyncfunctiondownloadImage(app: App, url:string, saveDir:string):Promise<string|null>{const filename =getImageFilename(url);// 用 URL hash 生成唯一文件名const filepath =`${saveDir}/${filename}`;// 跳过已下载的文件if(await app.vault.adapter.exists(filepath))return filename;// Method 1: Obsidian requestUrl(优先)const resp =awaitrequestUrl({ url, headers:IMG_HEADERS, method:"GET"});let buffer = resp.arrayBuffer;// Method 2: Node.js https fallback(requestUrl 失败时)if(!buffer || buffer.byteLength ===0){ buffer =awaitdownloadWithNodeHttps(url);}// 写入 Vaultawait app.vault.adapter.writeBinary(filepath, buffer);return filename;}
图片防盗链解法:
// 微信图片服务器是 mmbiz.qpic.cn,直接 fetch 会 403// 必须带 Referer header:constIMG_HEADERS={"Referer":"https://mp.weixin.qq.com/",// 关键!"User-Agent":"Mozilla/5.0 ...",};// 只处理微信自己的图片域名constALLOWED_IMG_DOMAINS=["mmbiz.qpic.cn","mmbiz.qlogo.cn"];
图片文件名生成(和 Python 版一致):
functiongetImageFilename(url:string):string{// 从 URL 提取格式:wx_fmt=png → ext = "png"const fmtMatch = url.match(/wx_fmt=(\w+)/);// 用 URL 的简单 hash 作为文件名(避免重复)return`wechat_${hashHex}.${ext}`;}
3.3.5 写入笔记 — writeNote()
对应 cli.py 的保存逻辑。
asyncfunctionwriteNote(app: App, article: ArticleData, settings):Promise<TFile>{// 1. 确保文件夹存在for(const dir of[folder, attachFolder]){if(!await app.vault.adapter.exists(dir))await app.vault.createFolder(dir);}// 2. 下载所有图片,建立 URL → 本地文件名 的映射const imgMap =newMap<string,string>();for(const imgUrl ofextractImageUrls(contentDiv)){const localName =awaitdownloadImage(app, imgUrl, attachFolder); imgMap.set(imgUrl,`${attachFolder}/${localName}`);awaitsleep(300);// 请求间隔,避免被限}// 3. HTML → Markdown(图片 URL 替换为本地路径)const mdContent =htmlToMarkdown(contentDiv, imgMap);// 4. 组装 Frontmatter + 正文const noteContent =`---title: "文章标题"source: "https://mp.weixin.qq.com/s/..."author: "公众号名称"date: 2026-04-15imported_at: "2026/4/15 13:41:00"tags: [公众号]---# 文章标题> 作者:xxx | 发布:2026-04-15 | [原文链接](...)---正文内容...`;// 5. 写入文件(处理重名:加时间戳后缀)const file =await app.vault.create(notePath, noteContent);return file;}
3.3.6 UI 层 — Modal + Settings
导入弹窗:
classWechatImportModalextendsModal{onOpen(){// 创建输入框(URL)// 创建复选框(是否下载图片)// 创建导入按钮// 支持 Enter 键触发导入}}
设置面板:
classWechatImporterSettingTabextendsPluginSettingTab{display(){// 文章保存目录(默认:公众号)// 附件保存目录(默认:公众号/attachments/wechat)// 是否下载图片(默认:开启)// 文件名格式(日期-标题 / 仅标题)}}
插件入口:
classWechatImporterPluginextendsPlugin{onload(){// 左侧 Ribbon 图标(file-down 图标)this.addRibbonIcon("file-down","导入微信公众号文章",...);// 命令面板(Ctrl+P 搜索"导入微信")this.addCommand({ id:"import-wechat-article", name:"导入微信公众号文章"});// 设置面板this.addSettingTab(newWechatImporterSettingTab(this.app,this));}}
3.4 编译与安装
编译命令
node node_modules/esbuild/bin/esbuild src/main.ts \--bundle\--external:obsidian\--outfile=main.js \--format=cjs \--platform=node \--target=es2022 \--sourcemap
安装到 Obsidian
# 复制 3 个文件到 Vault 的插件目录Copy-Item main.js "D:\Obsidian Vault\.obsidian\plugins\wechat-importer\"Copy-Item manifest.json "..."Copy-Item styles.css "..."
安装后需要关闭并重新打开 Obsidian 才能识别新插件。
四、第三阶段:测试与迭代(14:06 – 15:30)
4.1 问题 1:插件列表不显示
现象:Obsidian 设置 → 第三方插件 → 看不到 Wechat Importer
原因:Obsidian 启动时扫描插件目录,新安装的插件需要重启才能被识别
解决:关闭 Obsidian → 重新打开
4.2 问题 2:图片未导入
现象:文章正文能抓到,但图片没下载
原因:第一版的图片正则太弱,{{IMG:url}} 占位符替换机制有问题
解决:全面重写,改为从 contentDiv.querySelectorAll("img") 提取 data-src(和 Python downloader.py 一致)
4.3 问题 3:内容解析失败(核心问题)
现象:第二次导入时只生成了 frontmatter,正文内容丢失
原因:第一版用的是正则表达式解析 HTML,而不是 DOMParser。正则对 HTML 结构变化的适应力极差,稍有不同就匹配失败。
用户原话:
“为什么之前的终端好好的,这个不能直接照搬逻辑吗?”
解决:全面重写核心解析逻辑:
之前(正则方式,脆弱):教训:有现成的 Python 代码可以直接翻译,不要自己重新发明轮子。
4.4 问题 4:用户反馈的细节调整
用户指令 1:
“图片的位置,放在公众号文件夹下的 attachments 文件夹下的 wechat 文件夹下”
修改:attachDir 默认值从 attachments/wechat → 公众号/attachments/wechat
用户指令 2:
“标题不要加日期”
修改:filenameTemplate 默认值从 date-title → title
4.5 最终配置
五、第四阶段:发布到 GitHub(15:39 – 16:30)
5.1 用户指令
“这一版定稿初版,版本号你定一个,推送 github”
5.2 版本号
定为 v1.0.0
5.3 推送过程
⚠️ 踩坑 1:没有 gh CLI
gh repo create Ashley-321/wechat-importer --public# 错误:gh : 无法将"gh"项识别为 cmdlet
解决:用户手动在 GitHub 网页创建空仓库。
⚠️ 踩坑 2:Git 没有配置用户信息
fatal: unable to auto-detect email address
解决:从 wechat2obsidian 仓库获取之前的配置(Ashley-321)设置到新仓库。
⚠️ 踩坑 3:新仓库推送需要认证
fatal: could not read Username for 'https://github.com'
原因:wechat2obsidian 之前能推送是因为已经 clone 过,凭据缓存了。新仓库首次推送需要认证。
解决:用户生成 GitHub Personal Access Token(ghp_开头,repo 权限),用 token 推送。
推送成功后清除 remote URL 中的 token(安全)。
⚠️ 踩坑 4:GitHub 网络超时
原因:没有开 VPN,GitHub 443 端口被阻断
解决:用户开 ,给 git 配代理:
5.4 最终结果
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
六、完整踩坑清单
|
|
|
|
|
|---|---|---|---|
|
|
|
|
node node_modules/esbuild/bin/esbuild 直接调用 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
七、关键经验总结
7.1 有现成代码就翻译,不要重新发明
Python 版的 fetcher.py / parser.py / downloader.py 已经在生产环境验证过,直接逐行翻译成 TypeScript 是最快的路径。第一版自己用正则重新实现,结果各种 bug,浪费了大量时间。
7.2 HTML 解析用 DOMParser,不要用正则
正则解析 HTML 是所有 bug 的根源。Obsidian 运行在 Electron 环境中,有完整的 DOMParser 支持,等同于 Python 的 BeautifulSoup,应该优先使用。
7.3 Obsidian 插件开发的关键 API
|
|
|
|
|---|---|---|
requestUrl() |
|
|
app.vault.create() |
|
|
app.vault.adapter.writeBinary() |
|
|
app.vault.adapter.exists() |
|
|
app.vault.createFolder() |
|
|
new Modal() |
|
|
this.addRibbonIcon() |
|
|
this.addCommand() |
|
|
new Notice() |
|
|
7.4 微信公众号抓取的关键知识
- 文章 URL 格式
: https://mp.weixin.qq.com/s/xxxxxxxx - 必须带 Referer header
: Referer: https://mp.weixin.qq.com/,否则 403 - 图片用 data-src 而非 src
:微信用懒加载,真实图片地址在 data-src属性 - 图片域名
: mmbiz.qpic.cn(主域名)、mmbiz.qlogo.cn(头像/logo) - 内容区选择器
: div.rich_media_content(非常稳定,多年未变) - 不需要登录
:微信公众号文章是公开可访问的 - 发布时间获取
:优先从 DOM 取 #publish_time,fallback 到 JS 变量var publish_time
八、最终代码结构总览
wechat-importer/├── src/main.ts ← 全部代码(809 行)│ ├── Settings 定义 (第 28-40 行)│ ├── Constants/Headers (第 46-60 行)│ ├── fetchArticle() (第 79-168 行) ← 翻译自 fetcher.py│ ├── htmlToMarkdown() (第 174-334 行) ← 翻译自 parser.py│ ├── extractImageUrls() (第 352-363 行) ← 翻译自 downloader.py│ ├── downloadImage() (第 386-448 行) ← 翻译自 downloader.py│ ├── writeNote() (第 503-583 行) ← 翻译自 cli.py│ ├── WechatImportModal (第 589-657 行) ← UI 弹窗│ ├── SettingTab (第 663-728 行) ← 设置面板│ └── WechatImporterPlugin (第 734-808 行) ← 插件主类├── manifest.json ← 插件元信息(id/name/version/minAppVersion)├── package.json ← 依赖:obsidian, typescript, esbuild├── tsconfig.json ← TS 编译配置├── esbuild.config.mjs ← 构建脚本├── styles.css ← Modal 样式├── main.js ← 编译输出(21.3 KB)└── .gitignore
九、用户指令完整记录
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
全面重写,照搬 Python 逻辑 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
by WorkBuddy · 2026-04-15仓库:https://github.com/Ashley-321/wechat-importer
夜雨聆风