三
三股水
三
三股水

Obsidian 小红书同步插件(Xiaohongshu Sync)今日修复与 AI 模块开发记录

本文从 Obsidian 撰写发布

Obsidian 小红书同步插件(Xiaohongshu Sync)今日修复与 AI 模块开发记录

本文档仅整理今日(2026-09-18)修复的问题和新增的 AI 模块,不包含此前已有的功能改造。

目录

  1. 今日修复的问题
  2. AI 模块设计与集成
  3. 完整操作流程
  4. 当前 frontmatter 结构
  5. 常见问题

一、今日修复的问题

问题 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(可选)

  1. 进入 设置 - Xiaohongshu Sync
  2. 打开 开启 AI 功能
  3. 填入 DeepSeek API Key
  4. 其他字段保持默认

步骤 6:测试导入

  1. 点击左侧边栏书本图标,或按 Ctrl/Cmd + P 搜索“导入小红书笔记”
  2. 粘贴小红书分享文本
  3. 选择分类
  4. 勾选是否下载媒体
  5. 点击“导入”

四、当前 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 功能没生效

排查

  1. 设置页里 开启 AI 功能 是否打开
  2. AI API Key 是否填写
  3. 控制台是否有 AI 生成标题失败 / AI 生成标签失败 / AI 生成摘要失败 的报错
  4. 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 格式,就能直接用。


总结

今日的改动聚焦在两件事:

  1. 修复顽固 bug

    • 电脑版链接失败(小红书机制,非 bug)
    • 文件夹创建 ENOENT(控制字符过滤)
    • 标题被截取正文(extractTitle 不再兜底)
    • 设置页标题左边距
  2. 集成 AI 模块

    • 标题:仅无标题时调用
    • 标签:始终调用,JSON 数组返回
    • 摘要:全文发送,类型判断,标题扩写,60 字以内
    • 设置页:开关 + 关闭时隐藏的 3 个字段

至此,插件从“能导入”升级到了“导入得好、可检索、AI 辅助”的阶段。

上一篇 Obsidian 插件开发实战:给 Discuz 发布插件加上多站点、登录态与马甲功能 下一篇 Typecho Restful 插件 + Obsidian 对接排错全记录

4条评论

Ctrl + Enter 发送
    1. obaby Lv2

      弄了好多ob的插件啊

      回复 Mac · Chrome
      1. XIGE 站长 Lv5

        是的,现在我很多工作甚至博客都转到OB集中管理了

        回复 Windows · Chrome
    2. Tom Lv1

      这周我也在给 Obsidian 插件接 AI 做摘要,踩的坑跟你反着来:我是先存全文再压缩,结果长文一超过模型上下文就被截断,最后改成先按标题层级切块再摘要才稳定。你把 AI 摘要写进 frontmatter 这个做法很实用,检索时不用再解析正文。另外 extractTitle 兜底去掉之后,遇到开头是代码块的笔记有没有再出现标题取错的情况?

      回复 Mac · Chrome
      1. XIGE 站长 Lv5

        没有呢

        回复 Windows · Chrome
© 2026 三股水