Obsidian 小红书同步插件(Xiaohongshu Sync)今日修复与 AI 模块开发记录
本文档仅整理今日(2026-09-18)修复的问题和新增的 AI 模块,不包含此前已有的功能改造。
目录
- 今日修复的问题
- AI 模块设计与集成
- 完整操作流程
- 当前 frontmatter 结构
- 常见问题
一、今日修复的问题
问题 1:电脑版链接无法下载
现象
某些电脑版链接(https://www.xiaohongshu.com/discovery/item/xxx)导入失败,提示无有效链接或解析失败。
原因
电脑版链接的 xsec_token 与请求环境绑定,且会过期。插件在 Obsidian 里发起请求时,用的是一套全新的网络环境,服务器会认为 token 和请求来源不匹配,直接拒绝返回数据。
结论
这不是插件 bug,是小红的机制决定的。解决方案是用手机 App 重新分享,短链跳转时会自动生成匹配当前环境的 token,成功率最高。
问题 2:文件夹创建失败(ENOENT)
现象
控制台报错:
ENOENT: no such file or directory, mkdir 'C:\...\xhs\育儿\2026-09-18_首先,你要么可以选择一个线上学校要么可以选择一个体系。 线上学校- 最好找有Cognia或者WA'
部分博主的笔记全部导入失败,其他博主正常。
原因
该博主的标题里带有制表符(\t)或换行符(\n),而原版 safeTitle 只替换了文件系统明确禁止的字符:
let safeTitle = title.replace(/[/\\?%*:|"<>]/g, "-").trim();
没有过滤控制字符,Windows 的 mkdir 遇到控制字符直接报 ENOENT。
修复
加强 safeTitle 的清理逻辑:
let safeTitle = title
.replace(/[/\\?%*:|"<>]/g, "-") // 文件系统非法字符
.replace(/[\x00-\x1f\x7f]/g, "") // 控制字符(\t \n \r 等)
.replace(/\s+/g, " ") // 连续空白压成一个空格
.trim();
safeTitle = safeTitle.length > 0 ? safeTitle : "无标题";
safeTitle = safeTitle.substring(0, 20);
效果
- 所有控制字符被清掉
- 连续空白(含全角空格)被压成一个普通空格
- 标题长度限制从 50 改为 20,避免文件夹名过长
问题 3:标题被截取正文内容
现象
小红书笔记本身没有标题时,插件直接用正文第一句话当标题,文件夹名变成正文的开头一段,阅读体验差。
原因
原版 extractTitle 在拿不到 note.title 时,会用正文第一句兜底:
const desc = note.desc || "";
const firstLine = desc.split("\n")[0].trim();
if (firstLine.length > 0) {
return firstLine.substring(0, 20); // ← 这里
}
导致 extractTitle 几乎永远返回非空值,AI 标题功能根本没机会触发。
修复
删掉正文第一句兜底,拿不到真实标题就返回空字符串:
extractTitle(html: string): string {
const stateMatch = html.match(/window\.__INITIAL_STATE__=(.*?)<\/script>/s);
if (stateMatch) {
try {
const jsonStr = stateMatch[1].trim();
const cleanedJson = jsonStr.replace(/undefined/g, "null");
const state = JSON.parse(cleanedJson);
const noteId = Object.keys(state.note.noteDetailMap)[0];
const note = state.note.noteDetailMap[noteId].note;
const titleFromState = note.title || note.displayTitle || "";
if (titleFromState && titleFromState.trim().length > 0) {
return titleFromState.trim();
}
} catch (e) {
console.log(`从 JSON 解析标题失败:${e.message}`);
}
}
return "";
}
兜底逻辑放到上层,由 importXHSNote 根据 AI 是否开启决定:
// 无标题且未开 AI(或 AI 失败):用正文第一句兜底
if (!title || title.trim().length === 0) {
const firstLine = content.split("\n")[0].trim();
title = firstLine.substring(0, 20) || "无标题";
}
问题 4:设置页“分类管理”标题左边距
现象
设置页里 setHeading() 生成的标题默认带左边距,和其他设置项不对齐。
修复
在 styles.css 末尾加:
.xhs-setting-heading {
margin-left: 0;
padding-left: 0;
}
同时在 main.ts 里给标题加 class:
const headingSetting = new Setting(containerEl)
.setName("分类管理")
.setHeading();
headingSetting.settingEl.addClass("xhs-setting-heading");
AI 功能的标题也用同样的 class。
二、AI 模块设计与集成
2.1 设计目标
| 功能 | 触发条件 | 说明 |
|---|---|---|
| AI 生成标题 | 只在原始标题无效时调用 | 有标题就不浪费 token |
| AI 生成标签 | 始终调用,替换正则提取 | 写入 frontmatter tags |
| AI 生成摘要 | 始终调用,放在正文开头 | 独立 callout,不污染正文 |
| AI 设置开关 | 设置页新增开关 | 关闭时所有 AI 功能跳过 |
2.2 设置结构
interface XHSSyncSettings {
defaultFolder: string;
categories: string[];
lastCategory: string;
downloadMedia: boolean;
aiEnabled: boolean;
aiApiKey: string;
aiApiUrl: string;
aiModel: string;
}
const DEFAULT_SETTINGS: XHSSyncSettings = {
defaultFolder: "xhs",
categories: ["美食", "旅行", "娱乐", "知识", "工作", "情感", "个人成长", "优惠", "搞笑", "育儿"],
lastCategory: "",
downloadMedia: false,
aiEnabled: false,
aiApiKey: "",
aiApiUrl: "https://api.deepseek.com/chat/completions",
aiModel: "deepseek-chat",
};
2.3 AI 调用底层
统一走 DeepSeek 的 OpenAI 兼容接口,用 Obsidian 自带的 requestUrl 发请求,不引入新依赖。
async callAI(systemPrompt: string, userContent: string): Promise<string> {
const response = await requestUrl({
url: this.settings.aiApiUrl,
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${this.settings.aiApiKey}`,
},
body: JSON.stringify({
model: this.settings.aiModel,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent },
],
temperature: 0.7,
}),
});
const json = response.json;
const content = json?.choices?.[0]?.message?.content;
if (!content) throw new Error("AI 返回内容为空");
return content.trim();
}
2.4 AI 生成标题
只在原始标题无效时调用。
async generateTitleWithAI(content: string): Promise<string> {
try {
const result = await this.callAI(
"你是小红书笔记标题助手。请根据用户提供的笔记正文,生成一个简洁的中文标题。要求:不超过 20 个字,不要标点符号,不要引号,不要表情符号,只返回标题本身。",
content.substring(0, 1500)
);
let title = result.replace(/["""'']/g, "").replace(/[。!?,、;:]/g, "").trim();
title = title.substring(0, 20);
return title || "";
} catch (e) {
console.log(`AI 生成标题失败:${e.message}`);
return "";
}
}
2.5 AI 生成标签
始终调用,返回 JSON 数组,解析失败回退到正则。
async generateTagsWithAI(content: string): Promise<string[]> {
try {
const result = await this.callAI(
"你是小红书笔记标签助手。请根据用户提供的笔记正文,生成 3-6 个中文标签。要求:每个标签不超过 8 个字,不要带 # 号,不要标点符号。只返回一个 JSON 数组,例如 [\"自然教育\",\"户外活动\"],不要有其他文字。",
content.substring(0, 2000)
);
const jsonMatch = result.match(/\[[\s\S]*\]/);
if (jsonMatch) {
const tags = JSON.parse(jsonMatch[0]);
if (Array.isArray(tags)) {
return tags
.map((t: any) => String(t).replace(/[#\s]/g, "").trim())
.filter((t: string) => t.length > 0)
.slice(0, 6);
}
}
} catch (e) {
console.log(`AI 生成标签失败:${e.message}`);
}
return [];
}
2.6 AI 生成摘要(今日重点优化)
问题
早期摘要用 content.substring(0, 2000) 只发正文前 2000 字,且系统提示写的是“生成摘要”,AI 会试图覆盖所有要点,导致摘要“什么都提一点,什么都不深”。
修复方向
- 全文发送,让 AI 看到完整信息
- 任务从“生成摘要”改为“标题扩写”,先判断笔记类型再按对应方式写
- 明确 60 字以内,不再硬截断
最终实现
async generateSummaryWithAI(content: string): Promise<string> {
try {
const fullContent = content.length > 8000 ? content.substring(0, 8000) : content;
const result = await this.callAI(
`你是笔记摘要助手。请先判断这篇笔记的类型,再按对应方式写摘要:
- 观点论述型:提炼最核心的观点或结论
- 清单/数据型:概括内容主题 + 列出最重要的 1-2 个关键数据或条目
- 教程/步骤型:概括目标 + 核心步骤
摘要本质是标题的扩写,要让读者一眼知道这篇笔记主要讲了什么。严格控制在 60 字以内,只返回摘要本身,不要分点,不要表情符号,不要引号。`,
fullContent
);
return result.replace(/\n+/g, " ").trim();
} catch (e) {
console.log(`AI 生成摘要失败:${e.message}`);
return "";
}
}
摘要逻辑一览
| 项目 | 说明 |
|---|---|
| 输入 | 全文,超过 8000 字符才截断 |
| 任务 | 判断类型 + 按类型写摘要 |
| 本质 | 标题的扩写,说清“讲了什么” |
| 长度 | 60 字以内,无硬截断 |
| 输出 | 一段话,无分点、无表情、无引号 |
2.7 AI 处理逻辑接入
let aiSummary = "";
let tags: string[] = [];
if (this.settings.aiEnabled && this.settings.aiApiKey) {
// 标题:只有原始标题无效时才调用
if (!title || title.trim().length === 0) {
new Notice("正在用 AI 生成标题...");
const aiTitle = await this.generateTitleWithAI(content);
if (aiTitle) title = aiTitle;
}
// 标签:始终调用
new Notice("正在用 AI 生成标签...");
tags = await this.generateTagsWithAI(content);
// 摘要:始终调用
new Notice("正在用 AI 生成摘要...");
aiSummary = await this.generateSummaryWithAI(content);
}
// 无标题且未开 AI(或 AI 失败):用正文第一句兜底
if (!title || title.trim().length === 0) {
const firstLine = content.split("\n")[0].trim();
title = firstLine.substring(0, 20) || "无标题";
}
// 没有 AI 或 AI 失败,回退到正则标签
if (tags.length === 0) {
tags = this.extractTags(content);
}
2.8 摘要写入位置
放在 H1 下方,用 callout 形式,阅读视图里有独立样式,不干扰正文:
if (aiSummary) {
markdown += `> [!abstract] AI 摘要\n> ${aiSummary}\n\n`;
}
2.9 设置页 AI 区块
const aiHeading = new Setting(containerEl)
.setName("AI 功能")
.setHeading();
aiHeading.settingEl.addClass("xhs-setting-heading");
containerEl.createEl("p", {
text: "开启后,导入笔记时会调用 AI 自动生成标题(仅无标题时)、标签和摘要。需要填写 DeepSeek API Key。",
});
new Setting(containerEl)
.setName("开启 AI 功能")
.setDesc("关闭时,标题和标签走原有逻辑,不生成摘要。")
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.aiEnabled)
.onChange(async (value) => {
this.plugin.settings.aiEnabled = value;
await this.plugin.saveSettings();
this.display();
})
);
if (this.plugin.settings.aiEnabled) {
new Setting(containerEl)
.setName("AI API Key")
.setDesc("DeepSeek 的 API Key,形如 sk-xxxxxx。")
.addText((text) =>
text
.setPlaceholder("sk-...")
.setValue(this.plugin.settings.aiApiKey)
.onChange(async (value) => {
this.plugin.settings.aiApiKey = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("AI API 地址")
.setDesc("默认使用 DeepSeek 官方接口。如果使用其他兼容 OpenAI 格式的服务,可以改这里。")
.addText((text) =>
text
.setPlaceholder("https://api.deepseek.com/chat/completions")
.setValue(this.plugin.settings.aiApiUrl)
.onChange(async (value) => {
this.plugin.settings.aiApiUrl = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("AI 模型名")
.setDesc("默认 deepseek-chat。")
.addText((text) =>
text
.setPlaceholder("deepseek-chat")
.setValue(this.plugin.settings.aiModel)
.onChange(async (value) => {
this.plugin.settings.aiModel = value.trim();
await this.plugin.saveSettings();
})
);
}
三、完整操作流程
步骤 1:修改 main.ts
用今日最终版覆盖 main.ts。
步骤 2:构建
npm run build
步骤 3:复制到插件目录
main.js
复制到:
<你的库>/.obsidian/plugins/xiaohongshu-sync/main.js
覆盖旧文件。
步骤 4:重启 Obsidian
步骤 5:配置 AI(可选)
- 进入 设置 - Xiaohongshu Sync
- 打开 开启 AI 功能
- 填入 DeepSeek API Key
- 其他字段保持默认
步骤 6:测试导入
- 点击左侧边栏书本图标,或按 Ctrl/Cmd + P 搜索“导入小红书笔记”
- 粘贴小红书分享文本
- 选择分类
- 勾选是否下载媒体
- 点击“导入”
四、当前 frontmatter 结构
---
title: 美国网校学费课本费对比
作者: 某某
作者主页: https://www.xiaohongshu.com/user/profile/xxxxx
发布时间: 2026-09-15
source: https://xhslink.cn/o/xxxxx
导入时间: 2026-09-18 14:30:00
分类: 知识
tags:
- 美国网校
- 学费对比
- 课本费
- 在线教育
---
AI 摘要位置
在 H1 下方:
# 美国网校学费课本费对比
> [!abstract] AI 摘要
> 对比了 14 所美国网校的学费和课本费,从 K21 的 750 美元到 School of Humanity 的 8000 美元不等。
正文...
五、常见问题
Q1:AI 功能没生效
排查
- 设置页里 开启 AI 功能 是否打开
- AI API Key 是否填写
- 控制台是否有
AI 生成标题失败/AI 生成标签失败/AI 生成摘要失败的报错 - DeepSeek 账户是否有余额
Q2:摘要还是不对
可能原因
- AI 类型判断错误(把清单型判成观点型)
- 系统提示词需要针对你的笔记风格微调
调整方式
修改 generateSummaryWithAI 里的系统提示,比如把 60 字改成 80 字,或在类型说明里补充你常看的笔记类型。
Q3:标签生成得不准
调整方式
修改 generateTagsWithAI 的系统提示,比如:
- 把“3-6 个”改成“5-8 个”
- 把“每个标签不超过 8 个字”改成“不超过 6 个字”
- 增加“优先使用笔记中出现过的关键词”
Q4:AI 调用慢
DeepSeek 的响应时间通常在 2-5 秒。一次导入会调用 3 次(标题、标签、摘要),总计 10 秒左右。这是正常的,控制台会依次提示“正在用 AI 生成标题...”、“正在用 AI 生成标签...”、“正在用 AI 生成摘要...”。
Q5:API Key 安全吗
目前是明文存在插件的 data.json 里,和设置一起保存。Obsidian 插件普遍如此。如果你担心泄露,可以给 data.json 设置文件权限,或不用时清空 Key。
Q6:不想用 AI 怎么办
设置页里关闭 开启 AI 功能 即可。关闭后:
- 标题:无标题时用正文第一句截取 20 字
- 标签:走正则
#[\w\u4e00-\u9fa5-]+ - 摘要:不生成
Q7:能否用其他 AI 服务
可以。设置页里的 AI API 地址 和 AI 模型名 都可以改。只要对方兼容 OpenAI 的 /chat/completions 格式,就能直接用。
总结
今日的改动聚焦在两件事:
修复顽固 bug
- 电脑版链接失败(小红书机制,非 bug)
- 文件夹创建 ENOENT(控制字符过滤)
- 标题被截取正文(extractTitle 不再兜底)
- 设置页标题左边距
集成 AI 模块
- 标题:仅无标题时调用
- 标签:始终调用,JSON 数组返回
- 摘要:全文发送,类型判断,标题扩写,60 字以内
- 设置页:开关 + 关闭时隐藏的 3 个字段
至此,插件从“能导入”升级到了“导入得好、可检索、AI 辅助”的阶段。
弄了好多ob的插件啊
是的,现在我很多工作甚至博客都转到OB集中管理了
这周我也在给 Obsidian 插件接 AI 做摘要,踩的坑跟你反着来:我是先存全文再压缩,结果长文一超过模型上下文就被截断,最后改成先按标题层级切块再摘要才稳定。你把 AI 摘要写进 frontmatter 这个做法很实用,检索时不用再解析正文。另外 extractTitle 兜底去掉之后,遇到开头是代码块的笔记有没有再出现标题取错的情况?
没有呢