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 后台准备
导入接口
- Discuz 管理中心 → 站长 → RESTful 接口
- 导入官方 XML(
discuz_restful_official_trial.xml) - 在线安装"Discuz! Team"的完整接口包(含发帖、上传等)
创建应用
关键:RESTful 的 appid 不是用户 UID,必须在后台单独创建。
- 管理中心 → 站长 → RESTful 接口 → 应用管理
- 添加应用,记录 AppID 和 Secret
- 确认应用状态为"启用"
- 确认应用有访问目标接口的权限
关键接口清单
| 功能 | Path | 参数 |
|---|---|---|
| 获取 Token | /token | 无 |
| 版块列表 | /index/forumlist | 无 |
| 登录 | /member/login | username, password |
| 发帖前置信息 | /post/postInfo | fid |
| 发表主题 | /post/newthread | subject, message, fid |
| 回复主题 | /post/newreply | subject, message, fid, tid |
| 上传附件 | /upload/post | fid, 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
方式一:手动复制
- 找到 Vault 的插件目录:
你的Vault/.obsidian/plugins/ - 新建文件夹
discuz-publisher/ - 复制
main.js和manifest.json进去 - 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。
使用
- 左侧 Ribbon 栏点纸飞机图标(
send) - 或者
Ctrl/Cmd + P搜 "发布当前笔记到 Discuz" - 弹窗里:选站点(多站点时)、选笔记、勾版块、改标题
- 点"发布"
- 成功后自动打开帖子链接,并回写
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
暂无评论
还没有评论,来说点什么吧