三
三股水
三
三股水

Obsidian 发布插件开发教程:对接 Discuz X5 RESTful API

本文从 Obsidian 撰写发布

Obsidian 发布插件开发教程:对接 Discuz X5 RESTful API

从零开发一个 Obsidian 插件,把笔记一键发布到 Discuz X5 论坛。

一、前置知识

  • 技术栈:Obsidian 插件(TypeScript + esbuild)、Discuz X5 RESTful API(基于 oAuth2,XML 定义接口)
  • 签名算法:base64(sha256(nonce + t + secret))
  • 请求方式:所有接口 POST,参数平铺在 body
  • 官方仓库:https://gitee.com/Discuz/discuz-restful-api

全局参数(放在 Header,首字母大写)

Header说明
Appid应用 ID
Nonce随机串,保证唯一
T时间戳(秒)
Sign签名
Token可选,登录后拿到

URL 格式

https://域名/api/restful/?/接口path

返回结构

Token 接口返回扁平结构:

{"ret": 0, "token": "xxx", "expires_in": 1789550526}

业务接口返回:

{"ret": 0, "data": {...}}

ret > 0 表示出错,看 msg 字段。

二、Discuz 后台准备

导入接口

  1. Discuz 管理中心 → 站长 → RESTful 接口
  2. 导入官方 XML(discuz_restful_official_trial.xml)
  3. 在线安装"Discuz! Team"的完整接口包(含发帖、上传等)

创建应用

关键:RESTful 的 appid 不是用户 UID,必须在后台单独创建。

  • 管理中心 → 站长 → RESTful 接口 → 应用管理
  • 添加应用,记录 AppID 和 Secret
  • 确认应用状态为"启用"
  • 确认应用有访问目标接口的权限

关键接口清单

功能Path参数
获取 Token/token无
版块列表/index/forumlist无
登录/member/loginusername, password
发帖前置信息/post/postInfofid
发表主题/post/newthreadsubject, message, fid
回复主题/post/newreplysubject, message, fid, tid
上传附件/upload/postfid, uid + 文件

版块列表返回结构

{
  "ret": 0,
  "data": {
    "forumlist": [
      {
        "fid": "30",
        "type": "group",
        "name": "版块分组名",
        "subforums": [
          {"fid": "31", "type": "forum", "name": "具体版块名", "subforums": []}
        ]
      }
    ]
  }
}
  • type: "group" 是分组,不可发帖
  • type: "forum" 是真实版块,可发帖
  • 层级通过 subforums 递归

三、Obsidian 插件工程

文件结构

discuz-publisher/
├── manifest.json
├── package.json
├── tsconfig.json
├── esbuild.config.mjs
└── src/
    ├── main.ts
    ├── setting/
    │   ├── plugin_settings.ts
    │   └── setting_tab.ts
    ├── utils/
    │   ├── request.ts
    │   └── converter.ts
    └── view/
        └── push.ts

manifest.json

{
  "id": "discuz-publisher",
  "name": "Discuz Publisher",
  "version": "1.0.0",
  "minAppVersion": "1.0.0",
  "description": "将笔记发布到 Discuz X5 论坛",
  "author": "you",
  "isDesktopOnly": false
}

package.json

{
  "name": "discuz-publisher",
  "version": "1.0.0",
  "description": "将笔记发布到 Discuz X5 论坛",
  "main": "main.js",
  "scripts": {
    "dev": "node esbuild.config.mjs",
    "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
  },
  "devDependencies": {
    "@types/node": "^20.11.0",
    "builtin-modules": "3.3.0",
    "esbuild": "0.19.11",
    "obsidian": "latest",
    "tslib": "2.6.2",
    "typescript": "5.3.3"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "inlineSourceMap": true,
    "inlineSources": true,
    "module": "ESNext",
    "target": "ES2018",
    "allowJs": true,
    "noImplicitAny": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "isolatedModules": true,
    "strictNullChecks": true,
    "lib": ["DOM", "ES5", "ES6", "ES7", "ES2018"]
  },
  "include": ["**/*.ts"]
}

esbuild.config.mjs

import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";

const banner = `/* Generated by esbuild */`;
const prod = process.argv[2] === "production";

const context = await esbuild.context({
  banner: { js: banner },
  entryPoints: ["src/main.ts"],
  bundle: true,
  external: [
    "obsidian", "electron",
    "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands",
    "@codemirror/language", "@codemirror/lint", "@codemirror/search",
    "@codemirror/state", "@codemirror/view",
    "@lezer/common", "@lezer/highlight", "@lezer/lr",
    ...builtins,
  ],
  format: "cjs",
  target: "es2018",
  logLevel: "info",
  sourcemap: prod ? false : "inline",
  treeShaking: true,
  outfile: "main.js",
});

if (prod) {
  await context.rebuild();
  process.exit(0);
} else {
  await context.watch();
}

安装依赖

npm install
npm run dev     # 开发模式,watch 自动构建
# 或
npm run build   # 生产构建,带类型检查

新版 npm 会拦截 esbuild 的 postinstall 脚本,需要执行:

npm approve-scripts esbuild
# 或
npm rebuild esbuild

四、核心代码

src/setting/plugin_settings.ts

export interface SiteConfig {
  id: string;
  name: string;
  apiBase: string;      // https://xxx/api/restful/
  siteUrl: string;      // https://xxx
  appid: string;
  secret: string;
  username: string;
  password: string;
  defaultFid: number;
  token?: string | null;
  tokenExpiry?: number;
  uid?: number;
}

export interface DiscuzSettings {
  sites: SiteConfig[];
  activeSiteId: string;
}

export const DEFAULT_SETTINGS: DiscuzSettings = {
  sites: [],
  activeSiteId: "",
};

export function generateSiteId(): string {
  return "site_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

export function createDefaultSite(name = "新站点"): SiteConfig {
  return {
    id: generateSiteId(),
    name,
    apiBase: "",
    siteUrl: "",
    appid: "",
    secret: "",
    username: "",
    password: "",
    defaultFid: 2,
  };
}

src/utils/request.ts

import { requestUrl } from "obsidian";
import { SiteConfig } from "../setting/plugin_settings";

export class HttpUtils {
  private static site: SiteConfig | null = null;
  private static token: string | null = null;
  private static tokenExpiry = 0;
  private static loggedIn = false;
  private static uid = 0;

  static setSite(site: SiteConfig) {
    this.site = site;
    this.token = site.token || null;
    this.tokenExpiry = site.tokenExpiry || 0;
    this.uid = site.uid || 0;
    this.loggedIn = false;
  }

  static getSite(): SiteConfig | null {
    return this.site;
  }

  static getUid(): number {
    return this.uid;
  }

  private static async sign(nonce: string, t: number): Promise<string> {
    if (!this.site) throw new Error("未设置站点");
    const raw = nonce + t + this.site.secret;
    const buf = new TextEncoder().encode(raw);
    const hashBuf = await crypto.subtle.digest("SHA-256", buf);
    const hex = Array.from(new Uint8Array(hashBuf))
      .map((b) => b.toString(16).padStart(2, "0"))
      .join("");
    return btoa(hex);
  }

  private static capitalize(name: string): string {
    return name
      .split("-")
      .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
      .join("-");
  }

  private static async buildHeaders(): Promise<Record<string, string>> {
    if (!this.site) throw new Error("未设置站点");
    const t = Math.floor(Date.now() / 1000);
    const nonce = Math.random().toString(36).substring(2, 10);
    const sign = await this.sign(nonce, t);

    const raw: Record<string, string> = {
      "Content-Type": "application/json",
      appid: this.site.appid,
      nonce,
      t: String(t),
      sign,
    };
    if (this.token) raw.token = this.token;

    const headers: Record<string, string> = {};
    for (const [k, v] of Object.entries(raw)) {
      headers[this.capitalize(k)] = v;
    }
    return headers;
  }

  private static async ensureToken(): Promise<void> {
    if (!this.site) throw new Error("未设置站点");
    if (this.token && Date.now() < this.tokenExpiry) return;

    const headers = await this.buildHeaders();
    const res = await requestUrl({
      url: `${this.site.apiBase}?/token`,
      method: "POST",
      headers,
      body: "{}",
    });

    const json = res.json;
    if (json.ret > 0 || !json.token) {
      throw new Error("Token 获取失败: " + JSON.stringify(json));
    }
    this.token = json.token;
    this.tokenExpiry = Date.now() + 50 * 60 * 1000;
    this.site.token = this.token;
    this.site.tokenExpiry = this.tokenExpiry;
  }

  static async post(path: string, body: Record<string, any>): Promise<any> {
    if (!this.site) throw new Error("未设置站点");
    await this.ensureToken();
    const headers = await this.buildHeaders();

    const res = await requestUrl({
      url: `${this.site.apiBase}?${path}`,
      method: "POST",
      headers,
      body: JSON.stringify(body),
    });

    return res.json;
  }

  static async login(): Promise<any> {
    if (this.loggedIn) return { ret: 0 };
    if (!this.site) throw new Error("未设置站点");

    const res = await this.post("/member/login", {
      username: this.site.username,
      password: this.site.password,
    });

    if (res.ret > 0) {
      throw new Error(
        "登录失败: " + (res.msg?.message || res.msg || JSON.stringify(res))
      );
    }
    this.loggedIn = true;
    if (res.uid) {
      this.uid = res.uid;
      this.site.uid = res.uid;
    } else if (res.user?.uid) {
      this.uid = res.user.uid;
      this.site.uid = res.user.uid;
    }
    return res;
  }

  static async testToken(): Promise<any> {
    await this.ensureToken();
    return { token: this.token };
  }
}

src/utils/converter.ts

export function markdownToBBCode(md: string): string {
  let r = md;

  r = r.replace(/```(\w*)\n([\s\S]*?)```/g, (_, _lang, code) =>
    `[code]${code.trim()}[/code]`
  );
  r = r.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "[img]$2[/img]");
  r = r.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "[url=$2]$1[/url]");
  r = r.replace(/^### (.+)$/gm, "[size=3][b]$1[/b][/size]");
  r = r.replace(/^## (.+)$/gm, "[size=4][b]$1[/b][/size]");
  r = r.replace(/^# (.+)$/gm, "[size=5][b]$1[/b][/size]");
  r = r.replace(/\*\*(.+?)\*\*/g, "[b]$1[/b]");
  r = r.replace(/\*(.+?)\*/g, "[i]$1[/i]");
  r = r.replace(/`([^`]+)`/g, "[code]$1[/code]");
  r = r.replace(/^> (.+)$/gm, "[quote]$1[/quote]");
  r = r.replace(/^- (.+)$/gm, "[*]$1");

  return r;
}

export function stripFrontmatter(content: string): string {
  return content.replace(/^---\s*[\s\S]*?---\s*/, "");
}

export function extractTitle(content: string, fileName: string): string {
  const fm = content.match(/^---\n([\s\S]*?)\n---/);
  if (fm) {
    const m = fm[1].match(/^title:\s*(.+)$/m);
    if (m) return m[1].trim().replace(/^["']|["']$/g, "");
  }
  const h1 = content.match(/^#\s+(.+)$/m);
  if (h1) return h1[1].trim();
  return fileName.replace(/\.md$/, "");
}

export function extractFid(content: string, defaultFid: number): number {
  const fm = content.match(/^---\n([\s\S]*?)\n---/);
  if (fm) {
    const m = fm[1].match(/^discuz_fid:\s*(\d+)$/m);
    if (m) return parseInt(m[1]);
  }
  return defaultFid;
}

src/view/push.ts

import { App, Modal, Notice } from "obsidian";
import { HttpUtils } from "../utils/request";
import { DiscuzSettings, SiteConfig } from "../setting/plugin_settings";
import {
  markdownToBBCode,
  stripFrontmatter,
  extractTitle,
  extractFid,
} from "../utils/converter";

export class PushModal extends Modal {
  settings: DiscuzSettings;
  notice: Notice;
  title = "";
  content = "";
  activeFilePath = "";
  selectedFid: number;
  forums: any[] = [];
  forumsDom: HTMLElement;
  currentSite: SiteConfig;

  constructor(app: App, settings: DiscuzSettings) {
    super(app);
    this.settings = settings;
    const active =
      settings.sites.find((s) => s.id === settings.activeSiteId) ||
      settings.sites[0];
    this.currentSite = active;
    this.selectedFid = active?.defaultFid || 2;
  }

  async onOpen() {
    const { contentEl } = this;
    contentEl.empty();
    contentEl.createEl("h2", { text: "发布到 Discuz" });

    if (this.settings.sites.length > 1) {
      const siteDiv = contentEl.createDiv({ attr: { style: "margin-bottom: 15px;" } });
      siteDiv.createEl("label", { text: "选择站点:", attr: { style: "margin-right: 10px;" } });
      const siteSelect = siteDiv.createEl("select");
      for (const s of this.settings.sites) {
        const opt = siteSelect.createEl("option", { text: s.name, value: s.id });
        if (s.id === this.currentSite?.id) opt.selected = true;
      }
      siteSelect.addEventListener("change", async () => {
        const selected = this.settings.sites.find((s) => s.id === siteSelect.value);
        if (selected) {
          this.currentSite = selected;
          this.selectedFid = selected.defaultFid || 2;
          HttpUtils.setSite(selected);
          await this.getForums(contentEl);
          new Notice("已切换到:" + selected.name);
        }
      });
    }

    if (this.currentSite) HttpUtils.setSite(this.currentSite);

    await this.getForums(contentEl);

    const files = this.app.vault.getFiles();
    const activeFile = this.app.workspace.getActiveFile();
    this.activeFilePath = activeFile?.path || "";

    const selectEl = contentEl.createEl("select", {
      attr: {
        style:
          "width: 100%; margin-bottom: 15px; border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal);",
      },
    });
    for (const file of files) {
      if (!file.path.endsWith(".md")) continue;
      const opt = selectEl.createEl("option", { text: file.path, value: file.path });
      if (file.path === this.activeFilePath) opt.selected = true;
    }
    if (this.activeFilePath) {
      await this.readFileAndSetContent(this.activeFilePath);
      this.setTitleFromFile(this.activeFilePath);
    }
    selectEl.addEventListener("change", async (e) => {
      const p = (e.target as HTMLSelectElement).value;
      await this.readFileAndSetContent(p);
      this.setTitleFromFile(p);
    });

    const titleInput = contentEl.createEl("input", {
      attr: {
        type: "text",
        placeholder: "标题",
        style:
          "width: 100%; margin-bottom: 15px; border: 1px solid var(--background-modifier-border); border-radius: 4px; color: var(--text-normal);",
      },
    });
    titleInput.value = this.title;
    titleInput.addEventListener("input", () => {
      this.title = titleInput.value;
    });

    const btn = contentEl.createEl("button", {
      text: "发布",
      attr: {
        style:
          "margin-top: 15px; padding: 8px 16px; border: none; border-radius: 4px; color: var(--text-normal); cursor: pointer;",
      },
    });
    btn.addEventListener("click", () => this.publish());
  }

  async getForums(contentEl: HTMLElement) {
    if (this.forumsDom) this.forumsDom.empty();
    else this.forumsDom = contentEl.createDiv({ attr: { style: "margin-bottom: 15px;" } });

    this.notice = new Notice("获取版块列表...");
    try {
      await HttpUtils.login();
      const res = await HttpUtils.post("/index/forumlist", {});
      const raw = res.data?.forumlist || res.forumlist || [];
      this.forums = Array.isArray(raw) ? raw : Object.values(raw);
    } catch (e) {
      console.error("[Discuz] 获取版块失败:", e);
      this.forums = [];
    }
    this.notice.hide();
    this.createForumCheckboxes();
  }

  private createForumCheckboxes() {
    this.forumsDom.empty();
    const group = this.forumsDom.createDiv({
      attr: {
        style:
          "border: 1px solid var(--background-modifier-border); padding: 10px; border-radius: 5px; max-height: 280px; overflow-y: auto;",
      },
    });
    group.createEl("h4", {
      text: "选择版块",
      attr: { style: "margin: 0 0 8px 0; font-size: 14px; font-weight: bold;" },
    });

    if (this.forums.length === 0) {
      group.createDiv({
        text: "暂无版块",
        attr: { style: "color: var(--text-muted); font-size: 13px; text-align: center;" },
      });
      return;
    }

    const render = (forums: any[], container: HTMLElement, depth = 0) => {
      for (const f of forums) {
        const isGroup = f.type === "group";
        const hasSub = f.subforums && f.subforums.length > 0;

        if (isGroup) {
          container.createDiv({
            text: f.name,
            attr: {
              style: `font-weight: bold; color: var(--text-normal); font-size: 13px; margin: ${
                depth > 0 ? "8px" : "4px"
              } 0 4px 0; padding-left: ${depth * 12}px;`,
            },
          });
          if (hasSub) render(f.subforums, container, depth + 1);
        } else {
          const row = container.createDiv({
            attr: {
              style: `display: flex; align-items: center; padding: 2px 4px; padding-left: ${
                depth * 12 + 8
              }px;`,
            },
          });
          const cb = row.createEl("input", {
            type: "checkbox",
            attr: {
              id: `forum-${f.fid}`,
              style: "margin-right: 6px; accent-color: var(--interactive-accent);",
            },
          });
          cb.checked = String(f.fid) === String(this.selectedFid);
          cb.addEventListener("change", () => {
            if (cb.checked) this.selectedFid = parseInt(f.fid);
          });
          const label = row.createEl("label", {
            text: f.name,
            attr: {
              for: `forum-${f.fid}`,
              style: "cursor: pointer; font-size: 13px; color: var(--text-normal);",
            },
          });
          label.addEventListener("click", () => cb.click());
          if (hasSub) render(f.subforums, container, depth + 1);
        }
      }
    };

    render(this.forums, group);
  }

  async readFileAndSetContent(path: string) {
    const file = this.app.vault.getFileByPath(path);
    if (!file) return;
    this.content = await this.app.vault.cachedRead(file);
  }

  setTitleFromFile(path: string) {
    this.activeFilePath = path;
    const file = this.app.vault.getFileByPath(path);
    if (!file) return;
    this.title = extractTitle(this.content, file.basename);
    this.selectedFid = extractFid(this.content, this.currentSite.defaultFid);
  }

  async publish() {
    if (!this.title || !this.content) {
      new Notice("标题或内容不能为空");
      return;
    }
    this.notice = new Notice("发布中...");
    try {
      await HttpUtils.login();

      let body = stripFrontmatter(this.content);
      body = markdownToBBCode(body);

      const res = await HttpUtils.post("/post/newthread", {
        fid: this.selectedFid,
        subject: this.title,
        message: body,
      });

      if (res.ret === 0) {
        const tid = res.tid || res.data?.tid;
        const url = tid && this.currentSite.siteUrl
          ? `${this.currentSite.siteUrl}/forum.php?mod=viewthread&tid=${tid}`
          : "";

        if (tid && this.activeFilePath) {
          await this.writeBackFrontmatter(this.activeFilePath, tid, url);
        }

        new Notice("✅ 发布成功" + (tid ? ` (tid: ${tid})` : ""));
        if (url) window.open(url, "_blank");
        this.close();
      } else {
        new Notice("❌ 发布失败: " + (res.msg?.message || res.msg || "未知错误"));
      }
    } catch (e) {
      console.error(e);
      new Notice("❌ " + (e instanceof Error ? e.message : String(e)));
    } finally {
      this.notice.hide();
    }
  }

  private async writeBackFrontmatter(path: string, tid: number, url: string) {
    const file = this.app.vault.getFileByPath(path);
    if (!file) return;
    let content = await this.app.vault.read(file);

    const now = new Date().toISOString();
    const discuzYaml = [
      `discuz:`,
      `  tid: ${tid}`,
      `  fid: ${this.selectedFid}`,
      `  site: ${this.currentSite.name}`,
      `  url: ${url}`,
      `  published: ${now}`,
    ].join("\n");

    const fmRegex = /^---\n([\s\S]*?)\n---\n?/;
    const match = content.match(fmRegex);

    if (match) {
      let fm = match[1];
      fm = fm.replace(/\ndiscuz:\n(?:[ \t]+.*\n?)*/g, "\n");
      fm = fm.replace(/^discuz:\n(?:[ \t]+.*\n?)*/g, "");
      const newFm = fm.trimEnd() + "\n" + discuzYaml;
      content = content.replace(fmRegex, `---\n${newFm}\n---\n`);
    } else {
      content = `---\n${discuzYaml}\n---\n\n${content}`;
    }

    await this.app.vault.modify(file, content);
  }

  onClose() {
    this.contentEl.empty();
  }
}

src/setting/setting_tab.ts

import { App, PluginSettingTab, Setting, Notice } from "obsidian";
import DiscuzPublisher from "../main";
import {
  DiscuzSettings,
  SiteConfig,
  createDefaultSite,
} from "./plugin_settings";
import { HttpUtils } from "../utils/request";

export class DiscuzSettingTab extends PluginSettingTab {
  plugin: DiscuzPublisher;

  constructor(app: App, plugin: DiscuzPublisher) {
    super(app, plugin);
    this.plugin = plugin;
  }

  display(): void {
    const { containerEl } = this;
    containerEl.empty();
    containerEl.createEl("h2", { text: "Discuz 发布设置" });

    const settings = this.plugin.settings;

    containerEl.createEl("h3", { text: "站点列表" });

    settings.sites.forEach((site, index) => {
      const siteBox = containerEl.createDiv({
        attr: {
          style:
            "border: 1px solid var(--background-modifier-border); padding: 12px; border-radius: 6px; margin-bottom: 12px;",
        },
      });

      const header = siteBox.createDiv({
        attr: {
          style:
            "display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;",
        },
      });
      header.createEl("strong", {
        text: site.name || `站点 ${index + 1}`,
        attr: { style: "font-size: 14px;" },
      });

      const btnGroup = header.createDiv({ attr: { style: "display: flex; gap: 6px;" } });

      const isActive = settings.activeSiteId === site.id;
      const activeBtn = btnGroup.createEl("button", { text: isActive ? "✓ 当前" : "设为当前" });
      if (isActive) activeBtn.disabled = true;
      activeBtn.addEventListener("click", async () => {
        settings.activeSiteId = site.id;
        await this.plugin.saveSettings();
        this.display();
      });

      const delBtn = btnGroup.createEl("button", { text: "删除" });
      delBtn.addEventListener("click", async () => {
        if (!confirm(`确定删除站点 "${site.name}"?`)) return;
        settings.sites.splice(index, 1);
        if (settings.activeSiteId === site.id) {
          settings.activeSiteId = settings.sites[0]?.id || "";
        }
        await this.plugin.saveSettings();
        this.display();
      });

      const fields: [keyof SiteConfig, string, boolean][] = [
        ["name", "站点名称", false],
        ["apiBase", "API 地址(末尾带 /)", false],
        ["siteUrl", "站点地址", false],
        ["appid", "AppID", false],
        ["secret", "Secret", true],
        ["username", "用户名", false],
        ["password", "密码", true],
      ];

      for (const [key, label, isPwd] of fields) {
        new Setting(siteBox).setName(label).addText((t) => {
          if (isPwd) t.inputEl.type = "password";
          t.setValue(String(site[key] ?? "")).onChange(async (v) => {
            (site as any)[key] = v;
            await this.plugin.saveSettings();
          });
        });
      }

      new Setting(siteBox)
        .setName("默认版块 ID")
        .addText((t) =>
          t.setValue(String(site.defaultFid)).onChange(async (v) => {
            site.defaultFid = parseInt(v) || 2;
            await this.plugin.saveSettings();
          })
        );

      new Setting(siteBox)
        .setName("测试连接")
        .addButton((b) =>
          b.setButtonText("测试").onClick(async () => {
            try {
              HttpUtils.setSite(site);
              const r = await HttpUtils.testToken();
              new Notice("✅ Token 获取成功: " + r.token);
            } catch (e) {
              new Notice("❌ " + (e instanceof Error ? e.message : String(e)));
            }
          })
        );
    });

    new Setting(containerEl).addButton((b) =>
      b.setButtonText("+ 添加站点").onClick(async () => {
        const newSite = createDefaultSite(`站点 ${settings.sites.length + 1}`);
        settings.sites.push(newSite);
        if (!settings.activeSiteId) settings.activeSiteId = newSite.id;
        await this.plugin.saveSettings();
        this.display();
      })
    );
  }
}

src/main.ts

import { Notice, Plugin } from "obsidian";
import { PushModal } from "./view/push";
import { DiscuzSettingTab } from "./setting/setting_tab";
import { DiscuzSettings, DEFAULT_SETTINGS } from "./setting/plugin_settings";
import { HttpUtils } from "./utils/request";

export default class DiscuzPublisher extends Plugin {
  settings: DiscuzSettings;

  async onload() {
    await this.loadSettings();

    const active =
      this.settings.sites.find((s) => s.id === this.settings.activeSiteId) ||
      this.settings.sites[0];
    if (active) HttpUtils.setSite(active);

    this.addSettingTab(new DiscuzSettingTab(this.app, this));

    this.addRibbonIcon("send", "发布到 Discuz", () => {
      if (this.settings.sites.length === 0) {
        new Notice("请先在设置中添加站点");
        return;
      }
      new PushModal(this.app, this.settings).open();
    });

    this.addCommand({
      id: "publish-to-discuz",
      name: "发布当前笔记到 Discuz",
      callback: () => {
        new PushModal(this.app, this.settings).open();
      },
    });
  }

  async loadSettings() {
    const loaded = await this.loadData();
    this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);

    if (loaded && (loaded as any).apiBase && !this.settings.sites?.length) {
      const old = loaded as any;
      this.settings.sites = [
        {
          id: "site_migrated",
          name: "默认站点",
          apiBase: old.apiBase || "",
          siteUrl: old.siteUrl || "",
          appid: old.appid || "",
          secret: old.secret || "",
          username: old.username || "",
          password: old.password || "",
          defaultFid: old.defaultFid || 2,
        },
      ];
      this.settings.activeSiteId = this.settings.sites[0].id;
      await this.saveSettings();
    }
  }

  async saveSettings() {
    await this.saveData(this.settings);
  }
}

五、踩坑记录

5.1 Obsidian 的 fetch 受 CORS 限制

错误:Failed to fetch

原因:Obsidian 插件的 fetch() 走浏览器策略,跨域请求被拦截。

解决:用 Obsidian 官方提供的 requestUrl(),走 Electron 网络层,无 CORS 限制。

import { requestUrl } from "obsidian";

const res = await requestUrl({
  url: "...",
  method: "POST",
  headers: {...},
  body: "...",
});
console.log(res.json);  // 直接是解析好的 JSON

5.2 Header 名首字母大写

错误:{"ret":-101,"msg":"checkSign: param is missing"}

原因:Discuz PHP 端用 getallheaders() 取 Header,键名是 Appid 这种首字母大写形式,不是 appid。

解决:构造 Header 时把每个 key 首字母大写。

private static capitalize(name: string): string {
  return name
    .split("-")
    .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
    .join("-");
}

5.3 AppID 抄错

错误:{"ret":-110,"msg":"getAppidPerm: appid is invalid"}

原因:AppID 少抄了一位数字。

解决:仔细核对后台"应用管理"里的 AppID。

5.4 版块列表解析错误

错误:弹窗显示"暂无版块"

原因:以为返回是 res.forumlist,实际是 res.data.forumlist。且分组 type: "group" 和版块 type: "forum" 混在一起。

解决:

  • 解析路径:res.data?.forumlist || res.forumlist
  • 递归渲染时:type === "group" 作为分组标题,type === "forum" 作为可勾选版块

5.5 游客身份问题

现象:直接发帖"成功",但用的是游客身份,很多版块发不了。

原因:未登录时 user.groupid: 7, grouptitle: "游客", allowpost: 0。

解决:拉版块列表和发帖前都先调 HttpUtils.login()。

5.6 esbuild 的 postinstall 被拦截

错误:npm run dev 时找不到 esbuild 二进制。

原因:新版 npm 默认拦截第三方包的 install 脚本。

解决:

npm approve-scripts esbuild
# 或
npm rebuild esbuild

5.7 TypeScript 类型不匹配

错误:Type 'string | null' is not assignable to type 'string | undefined'

解决:把 SiteConfig.token 类型改成 string | null。

六、安装插件

构建

npm install
npm run dev

构建产物 main.js 在项目根目录。

加载到 Obsidian

方式一:手动复制

  1. 找到 Vault 的插件目录:你的Vault/.obsidian/plugins/
  2. 新建文件夹 discuz-publisher/
  3. 复制 main.js 和 manifest.json 进去
  4. Obsidian 设置 → 第三方插件 → 关闭受限模式 → 启用 Discuz Publisher

方式二:软链接(推荐开发时用)

mklink /J "你的Vault\.obsidian\plugins\discuz-publisher" "C:\path\to\GI-discuz-publisher"

Windows 需要在管理员权限的 cmd 里执行。

配置

启用后,设置 → Discuz Publisher:

字段填写
站点名称任意,比如"我的论坛"
API 地址https://xxx/api/restful/(末尾带 /)
站点地址https://xxx
AppID后台应用管理里生成的
Secret同上
用户名论坛账号
密码论坛密码
默认版块 ID比如 31

点"测试连接",看到"✅ Token 获取成功"就 OK。

使用

  1. 左侧 Ribbon 栏点纸飞机图标(send)
  2. 或者 Ctrl/Cmd + P 搜 "发布当前笔记到 Discuz"
  3. 弹窗里:选站点(多站点时)、选笔记、勾版块、改标题
  4. 点"发布"
  5. 成功后自动打开帖子链接,并回写 tid 到笔记 frontmatter

七、已实现功能

  • ✅ 左侧 Ribbon 按钮 + 命令面板入口
  • ✅ 多站点配置,可切换
  • ✅ 拉取版块列表,支持分组层级
  • ✅ 自动登录 + Token 管理
  • ✅ Markdown → BBCode 转换
  • ✅ 发帖成功后回写 tid 到 frontmatter
  • ✅ 发帖后自动打开帖子链接
  • ✅ 设置页"测试连接"

八、可选扩展方向

  • 更新已有帖子:frontmatter 里有 tid 时走编辑接口而非新建
  • 图片上传:/upload/post 上传文件,返回 aid 后用 [attachimg]aid[/attachimg] 插入
  • 发布前 BBCode 预览:弹窗里加折叠区
  • 删除帖子:/upload/delete 或对应删除接口
  • 分类/标签:Discuz 主题分类相关接口
  • 同步评论:拉取帖子回复到笔记

九、参考

  • Discuz RESTful API 仓库:https://gitee.com/Discuz/discuz-restful-api
  • Obsidian 插件示例:https://github.com/obsidianmd/obsidian-sample-plugin
  • Obsidian API 文档:https://docs.obsidian.md/
  • UCenter RESTful 规范(Discuz 一致):https://gitee.com/Discuz/UCenter/tree/master/developer
上一篇 Obsidian 小红书同步插件(Xiaohongshu Sync)二次开发完整教程 下一篇 Obsidian 插件开发实战:给 Discuz 发布插件加上多站点、登录态与马甲功能

暂无评论

Ctrl + Enter 发送

还没有评论,来说点什么吧

© 2026 三股水