weread-export 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +796 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +2164 -0
- package/lib/types/api.d.ts +238 -0
- package/lib/types/cache.d.ts +57 -0
- package/lib/types/client/WereadSettingsPanel.d.ts +2 -0
- package/lib/types/client/api.d.ts +69 -0
- package/lib/types/client/index.d.ts +8 -0
- package/lib/types/export.d.ts +45 -0
- package/lib/types/flomo.d.ts +33 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/llm.d.ts +31 -0
- package/lib/types/routes.d.ts +57 -0
- package/lib/types/store.d.ts +87 -0
- package/lib/types/tools.d.ts +63 -0
- package/package.json +89 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2164 @@
|
|
|
1
|
+
import { defineTool, defineTool as defineTool$1 } from "@deepseek-ai/dsh-tools";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
//#region src/store.ts
|
|
6
|
+
/**
|
|
7
|
+
* weread-export — credential/cache store.
|
|
8
|
+
*
|
|
9
|
+
* Persists the WeRead Skills API key (wrk-...) to ~/.dsh/weread-export.json
|
|
10
|
+
* (mode 0600) and the latest sync snapshot (bookshelf + notebook overview)
|
|
11
|
+
* to ~/.dsh/weread-export-cache.json. The config file holds the API key plus
|
|
12
|
+
* the default flomo tag used by weread_flomo. Reads are lazy and cached;
|
|
13
|
+
* the public view() never exposes secrets. Config paths can be overridden
|
|
14
|
+
* with DSH_WEREAD_CONFIG / DSH_WEREAD_CACHE (used by tests).
|
|
15
|
+
*/
|
|
16
|
+
/** Default machine-wide config location (mode 0600). */
|
|
17
|
+
const DEFAULT_CONFIG_FILE = path.join(homedir(), ".dsh", "weread-export.json");
|
|
18
|
+
/** Default sync cache location (mode 0600). */
|
|
19
|
+
const DEFAULT_CACHE_FILE = path.join(homedir(), ".dsh", "weread-export-cache.json");
|
|
20
|
+
/** Test override for the config location. */
|
|
21
|
+
function configPath() {
|
|
22
|
+
const override = process.env.DSH_WEREAD_CONFIG;
|
|
23
|
+
return override !== void 0 && override !== "" ? override : DEFAULT_CONFIG_FILE;
|
|
24
|
+
}
|
|
25
|
+
/** Test override for the cache location. */
|
|
26
|
+
function cachePath() {
|
|
27
|
+
const override = process.env.DSH_WEREAD_CACHE;
|
|
28
|
+
return override !== void 0 && override !== "" ? override : DEFAULT_CACHE_FILE;
|
|
29
|
+
}
|
|
30
|
+
/** Mask a credential for display, keeping only the head and tail. */
|
|
31
|
+
function mask(value) {
|
|
32
|
+
if (!value) return "";
|
|
33
|
+
if (value.length <= 8) return value.slice(0, 2) + "****";
|
|
34
|
+
return value.slice(0, 4) + "****" + value.slice(-4);
|
|
35
|
+
}
|
|
36
|
+
/** Default export prompt template. */
|
|
37
|
+
const DEFAULT_EXPORT_PROMPT = "你是读书笔记整理助手。请根据下面提供的微信读书划线内容,输出一份结构化读书笔记:\n## 核心观点\n## 金句摘录\n## 我的思考\n要求:保留划线原文要点,语言精炼,使用 Markdown 格式。\n\n书籍:{title}\n作者:{author}\n划线内容:\n{highlights}";
|
|
38
|
+
/** Empty credentials record. */
|
|
39
|
+
function empty() {
|
|
40
|
+
return {
|
|
41
|
+
apiKey: "",
|
|
42
|
+
defaultFlomoTag: "微信读书",
|
|
43
|
+
exportLimit: 20,
|
|
44
|
+
exportDest: "flomo",
|
|
45
|
+
localExportDir: "",
|
|
46
|
+
notionToken: "",
|
|
47
|
+
notionTargetPageId: "",
|
|
48
|
+
usePrompt: false,
|
|
49
|
+
exportPrompt: DEFAULT_EXPORT_PROMPT,
|
|
50
|
+
llmBaseUrl: "https://api.deepseek.com/v1",
|
|
51
|
+
llmApiKey: "",
|
|
52
|
+
llmModel: "deepseek-chat",
|
|
53
|
+
lastSyncAt: ""
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Parse an unknown JSON record into credentials (tolerates missing keys). */
|
|
57
|
+
function parse(raw) {
|
|
58
|
+
const record = typeof raw === "object" && raw !== null ? raw : {};
|
|
59
|
+
const str = (value) => typeof value === "string" ? value : "";
|
|
60
|
+
const limit = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 20;
|
|
61
|
+
const dest = (value) => value === "local" || value === "notion" ? value : value === "flomo" ? "flomo" : "flomo";
|
|
62
|
+
const bool = (value) => value === true;
|
|
63
|
+
const base = empty();
|
|
64
|
+
return {
|
|
65
|
+
apiKey: str(record.apiKey),
|
|
66
|
+
defaultFlomoTag: str(record.defaultFlomoTag) || base.defaultFlomoTag,
|
|
67
|
+
exportLimit: limit(record.exportLimit),
|
|
68
|
+
exportDest: dest(record.exportDest),
|
|
69
|
+
localExportDir: str(record.localExportDir),
|
|
70
|
+
notionToken: str(record.notionToken),
|
|
71
|
+
notionTargetPageId: str(record.notionTargetPageId),
|
|
72
|
+
usePrompt: bool(record.usePrompt),
|
|
73
|
+
exportPrompt: str(record.exportPrompt) || base.exportPrompt,
|
|
74
|
+
llmBaseUrl: str(record.llmBaseUrl) || base.llmBaseUrl,
|
|
75
|
+
llmApiKey: str(record.llmApiKey),
|
|
76
|
+
llmModel: str(record.llmModel) || base.llmModel,
|
|
77
|
+
lastSyncAt: str(record.lastSyncAt)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Small credential store backed by ~/.dsh/weread-export.json.
|
|
82
|
+
* Reads are lazy and cached; writes use mode 0600 so the API key never
|
|
83
|
+
* leaks to other local users.
|
|
84
|
+
*/
|
|
85
|
+
var WereadStore = class {
|
|
86
|
+
config = null;
|
|
87
|
+
async load() {
|
|
88
|
+
if (this.config !== null) return this.config;
|
|
89
|
+
try {
|
|
90
|
+
const raw = await readFile(configPath(), "utf8");
|
|
91
|
+
this.config = parse(JSON.parse(raw));
|
|
92
|
+
} catch {
|
|
93
|
+
this.config = empty();
|
|
94
|
+
}
|
|
95
|
+
return this.config;
|
|
96
|
+
}
|
|
97
|
+
async save(next) {
|
|
98
|
+
this.config = next;
|
|
99
|
+
await mkdir(path.dirname(configPath()), { recursive: true });
|
|
100
|
+
await writeFile(configPath(), JSON.stringify(next, null, 2), { mode: 384 });
|
|
101
|
+
}
|
|
102
|
+
/** Public, secret-free view. */
|
|
103
|
+
async view() {
|
|
104
|
+
const cfg = await this.load();
|
|
105
|
+
return {
|
|
106
|
+
configured: cfg.apiKey.trim() !== "",
|
|
107
|
+
apiKeyMasked: cfg.apiKey.trim() !== "" ? mask(cfg.apiKey) : "",
|
|
108
|
+
defaultFlomoTag: cfg.defaultFlomoTag,
|
|
109
|
+
exportLimit: cfg.exportLimit,
|
|
110
|
+
exportDest: cfg.exportDest,
|
|
111
|
+
localExportDir: cfg.localExportDir,
|
|
112
|
+
notionConfigured: cfg.notionToken.trim() !== "",
|
|
113
|
+
notionTargetPageId: cfg.notionTargetPageId,
|
|
114
|
+
usePrompt: cfg.usePrompt,
|
|
115
|
+
llmConfigured: cfg.llmApiKey.trim() !== "",
|
|
116
|
+
llmBaseUrl: cfg.llmBaseUrl,
|
|
117
|
+
llmModel: cfg.llmModel,
|
|
118
|
+
lastSyncAt: cfg.lastSyncAt,
|
|
119
|
+
configPath: configPath()
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Apply a config patch: any supported field replaces, reset clears.
|
|
124
|
+
* Returns the public view.
|
|
125
|
+
*/
|
|
126
|
+
async patch(args) {
|
|
127
|
+
const cfg = await this.load();
|
|
128
|
+
let next = { ...cfg };
|
|
129
|
+
if (args !== void 0 && args.reset === true) next = {
|
|
130
|
+
...empty(),
|
|
131
|
+
defaultFlomoTag: cfg.defaultFlomoTag
|
|
132
|
+
};
|
|
133
|
+
if (args !== void 0 && typeof args.apiKey === "string") next.apiKey = args.apiKey.trim();
|
|
134
|
+
if (args !== void 0 && typeof args.defaultFlomoTag === "string") next.defaultFlomoTag = args.defaultFlomoTag.trim().replace(/^#+/, "") || "微信读书";
|
|
135
|
+
if (args !== void 0 && typeof args.exportLimit === "number" && Number.isFinite(args.exportLimit)) next.exportLimit = Math.max(0, Math.floor(args.exportLimit));
|
|
136
|
+
if (args !== void 0 && (args.exportDest === "flomo" || args.exportDest === "local" || args.exportDest === "notion")) next.exportDest = args.exportDest;
|
|
137
|
+
if (args !== void 0 && typeof args.localExportDir === "string") next.localExportDir = args.localExportDir.trim();
|
|
138
|
+
if (args !== void 0 && typeof args.notionToken === "string") next.notionToken = args.notionToken.trim();
|
|
139
|
+
if (args !== void 0 && typeof args.notionTargetPageId === "string") next.notionTargetPageId = args.notionTargetPageId.trim();
|
|
140
|
+
if (args !== void 0 && typeof args.usePrompt === "boolean") next.usePrompt = args.usePrompt;
|
|
141
|
+
if (args !== void 0 && typeof args.exportPrompt === "string") next.exportPrompt = args.exportPrompt;
|
|
142
|
+
if (args !== void 0 && typeof args.llmBaseUrl === "string") next.llmBaseUrl = args.llmBaseUrl.trim();
|
|
143
|
+
if (args !== void 0 && typeof args.llmApiKey === "string") next.llmApiKey = args.llmApiKey.trim();
|
|
144
|
+
if (args !== void 0 && typeof args.llmModel === "string") next.llmModel = args.llmModel.trim();
|
|
145
|
+
if (args !== void 0 && typeof args.lastSyncAt === "string") next.lastSyncAt = args.lastSyncAt;
|
|
146
|
+
await this.save(next);
|
|
147
|
+
return this.view();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/api.ts
|
|
152
|
+
/**
|
|
153
|
+
* weread-export — WeRead Skills Agent Gateway client.
|
|
154
|
+
*
|
|
155
|
+
* Official interface: POST https://i.weread.qq.com/api/agent/gateway with
|
|
156
|
+
* `Authorization: Bearer <wrk-...>`; the body carries `api_name`,
|
|
157
|
+
* `skill_version` and business parameters flattened at the top level.
|
|
158
|
+
* Responses are field-trimmed by the service; `errcode !== 0` means an
|
|
159
|
+
* error with a Chinese message, and an `upgrade_info` field means the
|
|
160
|
+
* client's skill_version is stale and must be bumped.
|
|
161
|
+
*
|
|
162
|
+
* API key acquisition: open https://weread.qq.com/r/weread-skills, log in
|
|
163
|
+
* with your WeRead account, click 创建 Key, copy the wrk- key.
|
|
164
|
+
*/
|
|
165
|
+
/** Gateway endpoint. */
|
|
166
|
+
const WEREAD_GATEWAY = "https://i.weread.qq.com/api/agent/gateway";
|
|
167
|
+
/** Skill version reported on every request (mirrors the weread-skills pack). */
|
|
168
|
+
const SKILL_VERSION = "1.0.4";
|
|
169
|
+
/** Request timeout for a gateway call. */
|
|
170
|
+
const REQUEST_TIMEOUT_MS$2 = 2e4;
|
|
171
|
+
/** Error surfaced from the gateway (carries an optional errcode). */
|
|
172
|
+
var WereadApiError = class extends Error {
|
|
173
|
+
code;
|
|
174
|
+
constructor(message, code) {
|
|
175
|
+
super(message);
|
|
176
|
+
this.name = "WereadApiError";
|
|
177
|
+
this.code = code;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/** Parse an unknown gateway payload into a record. */
|
|
181
|
+
function asRecord(value) {
|
|
182
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* WeRead Skills gateway client. All methods resolve parsed payloads and
|
|
186
|
+
* throw WereadApiError for API-level failures.
|
|
187
|
+
*/
|
|
188
|
+
var WereadApi = class {
|
|
189
|
+
apiKey;
|
|
190
|
+
constructor(apiKey) {
|
|
191
|
+
this.apiKey = apiKey;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Call one gateway endpoint.
|
|
195
|
+
* @param apiName - interface name, e.g. '/store/search' or '/_list'.
|
|
196
|
+
* @param params - business parameters, flattened at the top level.
|
|
197
|
+
*/
|
|
198
|
+
async gateway(apiName, params = {}) {
|
|
199
|
+
if (this.apiKey.trim() === "") throw new WereadApiError("未配置微信读书 API Key:请先调用 weread_config 配置(在 https://weread.qq.com/r/weread-skills 登录后创建 Key)。");
|
|
200
|
+
let response;
|
|
201
|
+
try {
|
|
202
|
+
response = await fetch(WEREAD_GATEWAY, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: {
|
|
205
|
+
"Authorization": "Bearer " + this.apiKey.trim(),
|
|
206
|
+
"Content-Type": "application/json"
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
api_name: apiName,
|
|
210
|
+
skill_version: SKILL_VERSION,
|
|
211
|
+
...params
|
|
212
|
+
}),
|
|
213
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS$2)
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw new WereadApiError("请求微信读书失败(网络错误): " + String(error instanceof Error ? error.message : error));
|
|
217
|
+
}
|
|
218
|
+
let payload;
|
|
219
|
+
try {
|
|
220
|
+
payload = await response.json();
|
|
221
|
+
} catch {
|
|
222
|
+
throw new WereadApiError("微信读书返回了无法解析的响应(HTTP " + response.status + ")");
|
|
223
|
+
}
|
|
224
|
+
if (!response.ok) throw new WereadApiError("微信读书请求失败(HTTP " + response.status + ")");
|
|
225
|
+
const record = asRecord(payload);
|
|
226
|
+
const upgrade = record.upgrade_info;
|
|
227
|
+
if (upgrade !== void 0) {
|
|
228
|
+
const message = asRecord(upgrade).message;
|
|
229
|
+
throw new WereadApiError("微信读书 Skills 需要升级,请先完成升级再继续:" + (typeof message === "string" ? message : JSON.stringify(upgrade)));
|
|
230
|
+
}
|
|
231
|
+
const errcode = record.errcode;
|
|
232
|
+
if (typeof errcode === "number" && errcode !== 0) throw new WereadApiError(typeof record.errmsg === "string" ? record.errmsg : typeof record.message === "string" ? record.message : "未知错误", errcode);
|
|
233
|
+
return payload;
|
|
234
|
+
}
|
|
235
|
+
/** List every available endpoint and its parameter definition. */
|
|
236
|
+
list() {
|
|
237
|
+
return this.gateway("/_list");
|
|
238
|
+
}
|
|
239
|
+
/** Search the book store. */
|
|
240
|
+
search(keyword, count = 10, scope) {
|
|
241
|
+
return this.gateway("/store/search", {
|
|
242
|
+
keyword,
|
|
243
|
+
count,
|
|
244
|
+
...scope !== void 0 ? { scope } : {}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/** Book metadata. */
|
|
248
|
+
bookInfo(bookId) {
|
|
249
|
+
return this.gateway("/book/info", { bookId });
|
|
250
|
+
}
|
|
251
|
+
/** Official chapter catalog (metadata only). */
|
|
252
|
+
chapterInfo(bookId) {
|
|
253
|
+
return this.gateway("/book/chapterinfo", { bookId });
|
|
254
|
+
}
|
|
255
|
+
/** Reading progress for one book. */
|
|
256
|
+
getProgress(bookId) {
|
|
257
|
+
return this.gateway("/book/getprogress", { bookId });
|
|
258
|
+
}
|
|
259
|
+
/** The current bookshelf (books + audiobook albums + mp). */
|
|
260
|
+
shelf() {
|
|
261
|
+
return this.gateway("/shelf/sync");
|
|
262
|
+
}
|
|
263
|
+
/** Notebook overview: every book with note/review/bookmark counts. */
|
|
264
|
+
notebooks(count = 50, lastSort) {
|
|
265
|
+
return this.gateway("/user/notebooks", {
|
|
266
|
+
count,
|
|
267
|
+
...lastSort !== void 0 ? { lastSort } : {}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** Underlines (highlights) for one book. */
|
|
271
|
+
bookmarklist(bookId) {
|
|
272
|
+
return this.gateway("/book/bookmarklist", { bookId });
|
|
273
|
+
}
|
|
274
|
+
/** Personal thoughts/reviews for one book. */
|
|
275
|
+
reviewListMine(bookid, count = 50, synckey) {
|
|
276
|
+
return this.gateway("/review/list/mine", {
|
|
277
|
+
bookid,
|
|
278
|
+
count,
|
|
279
|
+
...synckey !== void 0 ? { synckey } : {}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/** Reading statistics. mode: weekly | monthly | annually | overall. */
|
|
283
|
+
readdata(mode, baseTime) {
|
|
284
|
+
return this.gateway("/readdata/detail", {
|
|
285
|
+
mode,
|
|
286
|
+
...baseTime !== void 0 ? { baseTime } : {}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/flomo.ts
|
|
292
|
+
/**
|
|
293
|
+
* weread-export — flomo export integration.
|
|
294
|
+
*
|
|
295
|
+
* weread_flomo sends a book's highlights/thoughts to flomo (浮墨笔记).
|
|
296
|
+
* It reuses the credentials already configured for the dsh-flomo plugin
|
|
297
|
+
* (~/.dsh/dsh-flomo.json, mode 0600): webhookUrl wins over apiKey. The
|
|
298
|
+
* flomo tag is fully customizable — the tool's `tag` parameter, or the
|
|
299
|
+
* store's defaultFlomoTag (defaults to 微信读书).
|
|
300
|
+
*/
|
|
301
|
+
/** Config file location shared with dsh-flomo (machine-wide, mode 0600). */
|
|
302
|
+
const FLOMO_CONFIG_FILE = path.join(homedir(), ".dsh", "dsh-flomo.json");
|
|
303
|
+
/** Request timeout for a flomo POST. */
|
|
304
|
+
const REQUEST_TIMEOUT_MS$1 = 2e4;
|
|
305
|
+
/** Whether flomo credentials exist on this machine. */
|
|
306
|
+
async function flomoConfigured() {
|
|
307
|
+
return (await loadFlomoCredentials()).resolved !== null;
|
|
308
|
+
}
|
|
309
|
+
/** Load and resolve the flomo send URL (null when not configured). */
|
|
310
|
+
async function resolveFlomoUrl() {
|
|
311
|
+
return (await loadFlomoCredentials()).resolved;
|
|
312
|
+
}
|
|
313
|
+
/** Read ~/.dsh/dsh-flomo.json and resolve the request URL. */
|
|
314
|
+
async function loadFlomoCredentials() {
|
|
315
|
+
let record = {};
|
|
316
|
+
try {
|
|
317
|
+
const raw = await readFile(FLOMO_CONFIG_FILE, "utf8");
|
|
318
|
+
const parsed = JSON.parse(raw);
|
|
319
|
+
if (typeof parsed === "object" && parsed !== null) record = parsed;
|
|
320
|
+
} catch {}
|
|
321
|
+
const webhook = typeof record.webhookUrl === "string" ? record.webhookUrl.trim() : "";
|
|
322
|
+
if (webhook) return { resolved: webhook };
|
|
323
|
+
const key = typeof record.apiKey === "string" ? record.apiKey.trim() : "";
|
|
324
|
+
if (key) return { resolved: "https://flomoapp.com/api/prod/apis/webhook/v1/?apiKey=" + encodeURIComponent(key) };
|
|
325
|
+
return { resolved: null };
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* POST one memo to the flomo logging API. Resolves { ok, message, code? } —
|
|
329
|
+
* rejects only for transport-level failures.
|
|
330
|
+
*/
|
|
331
|
+
async function postMemo(url, content) {
|
|
332
|
+
const res = await fetch(url, {
|
|
333
|
+
method: "POST",
|
|
334
|
+
headers: { "Content-Type": "application/json" },
|
|
335
|
+
body: JSON.stringify({ content }),
|
|
336
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS$1)
|
|
337
|
+
});
|
|
338
|
+
const body = await res.text();
|
|
339
|
+
let parsed = null;
|
|
340
|
+
try {
|
|
341
|
+
parsed = JSON.parse(body);
|
|
342
|
+
} catch {}
|
|
343
|
+
if (parsed && typeof parsed === "object" && typeof parsed.code === "number") {
|
|
344
|
+
const record = parsed;
|
|
345
|
+
if (record.code === 0) return {
|
|
346
|
+
ok: true,
|
|
347
|
+
message: "已写入 flomo",
|
|
348
|
+
code: 0
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
message: "flomo 返回错误: " + String(typeof record.message === "string" ? record.message : JSON.stringify(parsed)),
|
|
353
|
+
code: record.code
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (!res.ok) return {
|
|
357
|
+
ok: false,
|
|
358
|
+
message: "请求失败(HTTP " + res.status + "): " + body.slice(0, 300)
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
ok: true,
|
|
362
|
+
message: "flomo 已响应: " + body.slice(0, 300)
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** Append normalized #tags to a memo body. */
|
|
366
|
+
function buildTaggedContent(content, tags) {
|
|
367
|
+
const body = (content || "").trim();
|
|
368
|
+
const suffix = (tags || "").split(/[\s,,;;]+/).map((t) => t.trim().replace(/^#+/, "")).filter(Boolean).map((t) => "#" + t).join(" ");
|
|
369
|
+
return suffix ? body + " " + suffix : body;
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/cache.ts
|
|
373
|
+
/**
|
|
374
|
+
* weread-export — local sync snapshot & render helpers.
|
|
375
|
+
*
|
|
376
|
+
* weread_sync pulls the bookshelf and the notebook overview into
|
|
377
|
+
* ~/.dsh/weread-export-cache.json (mode 0600) so the settings panel and
|
|
378
|
+
* quick actions can render without hammering the gateway. Markdown
|
|
379
|
+
* builders here are shared by the tools and the panel routes.
|
|
380
|
+
*/
|
|
381
|
+
/** Empty snapshot. */
|
|
382
|
+
function emptyCache() {
|
|
383
|
+
return {
|
|
384
|
+
updatedAt: "",
|
|
385
|
+
shelfBooks: [],
|
|
386
|
+
albumsCount: 0,
|
|
387
|
+
mpCount: 0,
|
|
388
|
+
notebooks: []
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/** Read the snapshot (never throws). */
|
|
392
|
+
async function readCache() {
|
|
393
|
+
try {
|
|
394
|
+
const raw = await readFile(cachePath(), "utf8");
|
|
395
|
+
const parsed = JSON.parse(raw);
|
|
396
|
+
const record = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
397
|
+
const books = Array.isArray(record.shelfBooks) ? record.shelfBooks : [];
|
|
398
|
+
const notebooks = Array.isArray(record.notebooks) ? record.notebooks : [];
|
|
399
|
+
return {
|
|
400
|
+
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : "",
|
|
401
|
+
shelfBooks: books,
|
|
402
|
+
albumsCount: typeof record.albumsCount === "number" ? record.albumsCount : 0,
|
|
403
|
+
mpCount: typeof record.mpCount === "number" ? record.mpCount : 0,
|
|
404
|
+
notebooks
|
|
405
|
+
};
|
|
406
|
+
} catch {
|
|
407
|
+
return emptyCache();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Persist the snapshot (mode 0600). */
|
|
411
|
+
async function writeCache(next) {
|
|
412
|
+
await mkdir(path.dirname(cachePath()), { recursive: true });
|
|
413
|
+
await writeFile(cachePath(), JSON.stringify(next, null, 2), { mode: 384 });
|
|
414
|
+
}
|
|
415
|
+
/** Pull shelf + notebooks into the snapshot. */
|
|
416
|
+
async function doSync(api) {
|
|
417
|
+
const [shelf, notebooksFirst] = await Promise.all([api.shelf(), api.notebooks(200)]);
|
|
418
|
+
const books = Array.isArray(shelf.books) ? shelf.books : [];
|
|
419
|
+
const albums = Array.isArray(shelf.albums) ? shelf.albums : [];
|
|
420
|
+
const mpCount = shelf.mp !== void 0 && shelf.mp !== null ? 1 : 0;
|
|
421
|
+
const entries = [...Array.isArray(notebooksFirst.books) ? notebooksFirst.books : []];
|
|
422
|
+
let lastSort = entries.length > 0 ? entries[entries.length - 1]?.sort ?? void 0 : void 0;
|
|
423
|
+
let hasMore = notebooksFirst.hasMore === true || notebooksFirst.hasMore === 1;
|
|
424
|
+
let page = 1;
|
|
425
|
+
while (hasMore && page < 5 && lastSort !== void 0) {
|
|
426
|
+
const next = await api.notebooks(200, lastSort);
|
|
427
|
+
const nextBooks = Array.isArray(next.books) ? next.books : [];
|
|
428
|
+
if (nextBooks.length === 0) break;
|
|
429
|
+
entries.push(...nextBooks);
|
|
430
|
+
lastSort = nextBooks[nextBooks.length - 1]?.sort ?? lastSort;
|
|
431
|
+
hasMore = next.hasMore === true || next.hasMore === 1;
|
|
432
|
+
page += 1;
|
|
433
|
+
}
|
|
434
|
+
const cache = {
|
|
435
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
436
|
+
shelfBooks: books,
|
|
437
|
+
albumsCount: albums.length,
|
|
438
|
+
mpCount,
|
|
439
|
+
notebooks: entries
|
|
440
|
+
};
|
|
441
|
+
await writeCache(cache);
|
|
442
|
+
const noteBooks = entries.filter((b) => (b.reviewCount ?? 0) + (b.noteCount ?? 0) + (b.bookmarkCount ?? 0) > 0);
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
message: "同步完成:书架 " + books.length + " 本书" + (albums.length > 0 ? "、有声书 " + albums.length + " 部" : "") + (mpCount > 0 ? "、公众号 1 个" : "") + ";有笔记的书 " + noteBooks.length + " 本(笔记/想法/书签共 " + String(notebooksFirst.totalNoteCount ?? "?") + " 条)。",
|
|
446
|
+
cache
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
const WEEKDAYS = [
|
|
450
|
+
"日",
|
|
451
|
+
"一",
|
|
452
|
+
"二",
|
|
453
|
+
"三",
|
|
454
|
+
"四",
|
|
455
|
+
"五",
|
|
456
|
+
"六"
|
|
457
|
+
];
|
|
458
|
+
/** Unix seconds → 'YYYY-MM-DD' (local); '' for missing values. */
|
|
459
|
+
function formatDate(ts) {
|
|
460
|
+
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) return "";
|
|
461
|
+
const d = /* @__PURE__ */ new Date(ts * 1e3);
|
|
462
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
463
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
464
|
+
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate());
|
|
465
|
+
}
|
|
466
|
+
/** Local date label 'YYYY-MM-DD(周X)'. */
|
|
467
|
+
function dateLabel(date) {
|
|
468
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
469
|
+
return date.getFullYear() + "-" + pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + "(周" + WEEKDAYS[date.getDay()] + ")";
|
|
470
|
+
}
|
|
471
|
+
/** Seconds → 'X小时Y分钟' / 'N分钟' / 'N秒'. */
|
|
472
|
+
function formatDuration(seconds) {
|
|
473
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "0分钟";
|
|
474
|
+
const total = Math.round(seconds);
|
|
475
|
+
if (total < 60) return total + "秒";
|
|
476
|
+
if (total < 3600) return Math.floor(total / 60) + "分钟";
|
|
477
|
+
const h = Math.floor(total / 3600);
|
|
478
|
+
const m = Math.floor(total % 3600 / 60);
|
|
479
|
+
return m > 0 ? h + "小时" + m + "分钟" : h + "小时";
|
|
480
|
+
}
|
|
481
|
+
/** Rating display: the gateway returns a 0-100 score; show as 0-10. */
|
|
482
|
+
function formatRating(n) {
|
|
483
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0) return "";
|
|
484
|
+
return (n > 10 ? n / 10 : n).toFixed(1);
|
|
485
|
+
}
|
|
486
|
+
/** Book detail deep link, preferring the service-provided one. */
|
|
487
|
+
function deepLink(bookId, provided) {
|
|
488
|
+
if (typeof provided === "string" && provided !== "") return provided;
|
|
489
|
+
if (typeof bookId === "string" && bookId !== "") return "https://weread.qq.com/web/bookDetail/" + bookId;
|
|
490
|
+
return "";
|
|
491
|
+
}
|
|
492
|
+
/** ChapterUid → title map for note rendering. */
|
|
493
|
+
function chapterTitleMap(chapters) {
|
|
494
|
+
const map = /* @__PURE__ */ new Map();
|
|
495
|
+
if (Array.isArray(chapters)) {
|
|
496
|
+
for (const chapter of chapters) if (typeof chapter.chapterUid === "number" && typeof chapter.title === "string") map.set(chapter.chapterUid, chapter.title);
|
|
497
|
+
}
|
|
498
|
+
return map;
|
|
499
|
+
}
|
|
500
|
+
/** One-line shelf entry. */
|
|
501
|
+
function shelfLine(book, progressByBookId) {
|
|
502
|
+
const progress = progressByBookId.get(book.bookId ?? "");
|
|
503
|
+
const finished = book.finishReading === true || book.finishReading === 1;
|
|
504
|
+
const progressText = typeof progress === "number" && progress > 0 && progress < 100 ? " 在读 " + progress + "%" : progress === 100 || finished ? " 已读完" : "";
|
|
505
|
+
const time = formatDate(book.readUpdateTime);
|
|
506
|
+
const timeText = time !== "" ? "(更新于 " + time + ")" : "";
|
|
507
|
+
return "- 《" + (book.title ?? "未知书名") + "》· " + (book.author ?? "未知作者") + progressText + timeText;
|
|
508
|
+
}
|
|
509
|
+
/** Notebook overview lines (笔记数 = 划线 + 想法 + 书签). */
|
|
510
|
+
function notebookLines(entries) {
|
|
511
|
+
const lines = [];
|
|
512
|
+
for (const entry of entries) {
|
|
513
|
+
const review = entry.reviewCount ?? 0;
|
|
514
|
+
const note = entry.noteCount ?? 0;
|
|
515
|
+
const bookmark = entry.bookmarkCount ?? 0;
|
|
516
|
+
const total = review + note + bookmark;
|
|
517
|
+
if (total <= 0) continue;
|
|
518
|
+
lines.push("- 《" + (entry.book?.title ?? entry.bookId ?? "未知书名") + "》:共 " + total + " 条(划线 " + note + " · 想法 " + review + " · 书签 " + bookmark + ")" + (typeof entry.readingProgress === "number" && entry.readingProgress > 0 ? " · 进度 " + entry.readingProgress + "%" : ""));
|
|
519
|
+
}
|
|
520
|
+
return lines;
|
|
521
|
+
}
|
|
522
|
+
/** Per-book notes markdown: highlights + thoughts. */
|
|
523
|
+
function buildNotesMarkdown(title, author, highlights, thoughts, chapters) {
|
|
524
|
+
const chapterMap = chapterTitleMap(chapters);
|
|
525
|
+
const lines = ["📖 《" + title + "》" + (author ? " · " + author : "")];
|
|
526
|
+
if (highlights.length > 0) {
|
|
527
|
+
lines.push("", "## 划线 " + highlights.length + " 条");
|
|
528
|
+
for (const h of highlights) {
|
|
529
|
+
const chapter = typeof h.chapterUid === "number" ? chapterMap.get(h.chapterUid) : void 0;
|
|
530
|
+
const time = formatDate(h.createTime);
|
|
531
|
+
lines.push("- “" + (h.markText ?? "").trim() + "”" + (chapter ? "(" + chapter + ")" : "") + (time !== "" ? " · " + time : ""));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (thoughts.length > 0) {
|
|
535
|
+
lines.push("", "## 想法 " + thoughts.length + " 条");
|
|
536
|
+
for (const entry of thoughts) {
|
|
537
|
+
const review = entry.review;
|
|
538
|
+
if (!review) continue;
|
|
539
|
+
const chapter = review.chapterName ?? "";
|
|
540
|
+
const time = formatDate(review.createTime);
|
|
541
|
+
const abstract = (review.abstract ?? "").trim();
|
|
542
|
+
const abstractText = abstract !== "" && abstract !== review.content ? "\n > 原文:" + abstract : "";
|
|
543
|
+
lines.push("- " + (review.content ?? "").trim() + abstractText + (chapter ? "(" + chapter + ")" : "") + (time !== "" ? " · " + time : ""));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (highlights.length === 0 && thoughts.length === 0) lines.push("(这本书暂无划线与想法)");
|
|
547
|
+
return lines.join("\n");
|
|
548
|
+
}
|
|
549
|
+
/** One flomo memo body for a book's highlights (truncated at `limit`). */
|
|
550
|
+
function buildFlomoMemo(title, highlights, chapters, total, limit) {
|
|
551
|
+
const chapterMap = chapterTitleMap(chapters);
|
|
552
|
+
const lines = ["📖《" + title + "》划线摘录 · 共 " + total + " 条"];
|
|
553
|
+
for (const h of highlights.slice(0, limit)) {
|
|
554
|
+
const chapter = typeof h.chapterUid === "number" ? chapterMap.get(h.chapterUid) : void 0;
|
|
555
|
+
lines.push("- “" + (h.markText ?? "").trim() + "”" + (chapter ? "(" + chapter + ")" : ""));
|
|
556
|
+
}
|
|
557
|
+
if (total > limit) lines.push("…(共 " + total + " 条,仅导出前 " + limit + " 条)");
|
|
558
|
+
return lines.join("\n");
|
|
559
|
+
}
|
|
560
|
+
/** Safe per-memo size cap (flomo does not document a hard limit; stay conservative). */
|
|
561
|
+
const FLOMO_MAX_CHARS = 1800;
|
|
562
|
+
/**
|
|
563
|
+
* Split a book's highlights into one or more flomo memo bodies so that
|
|
564
|
+
* ALL highlights are exported — long lists are chunked by character count,
|
|
565
|
+
* never truncated. A single over-long highlight becomes its own memo.
|
|
566
|
+
*/
|
|
567
|
+
function buildFlomoMemos(title, highlights, chapters, maxChars = FLOMO_MAX_CHARS) {
|
|
568
|
+
const chapterMap = chapterTitleMap(chapters);
|
|
569
|
+
const header = "📖《" + title + "》划线摘录 · 共 " + highlights.length + " 条";
|
|
570
|
+
const memos = [];
|
|
571
|
+
let current = header;
|
|
572
|
+
for (const h of highlights) {
|
|
573
|
+
const chapter = typeof h.chapterUid === "number" ? chapterMap.get(h.chapterUid) : void 0;
|
|
574
|
+
const line = "- “" + (h.markText ?? "").trim() + "”" + (chapter ? "(" + chapter + ")" : "");
|
|
575
|
+
if (current.length + 1 + line.length > maxChars && current !== header) {
|
|
576
|
+
memos.push(current);
|
|
577
|
+
current = header + "(续)";
|
|
578
|
+
}
|
|
579
|
+
current += "\n" + line;
|
|
580
|
+
}
|
|
581
|
+
memos.push(current);
|
|
582
|
+
return memos;
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/llm.ts
|
|
586
|
+
/** Request timeout for one chat completion. */
|
|
587
|
+
const REQUEST_TIMEOUT_MS = 6e4;
|
|
588
|
+
/** Is the LLM configured (key + base url + model present)? */
|
|
589
|
+
function llmConfigured(config) {
|
|
590
|
+
return config.apiKey.trim() !== "" && config.baseUrl.trim() !== "" && config.model.trim() !== "";
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* One chat completion. Resolves the assistant text; rejects with a readable
|
|
594
|
+
* error on transport or API failures.
|
|
595
|
+
*/
|
|
596
|
+
async function chatComplete(config, system, user) {
|
|
597
|
+
if (!llmConfigured(config)) throw new Error("LLM 未配置:请在设置面板「AI」区填写 API Key / Base URL / 模型。");
|
|
598
|
+
const base = config.baseUrl.trim().replace(/\/+$/, "");
|
|
599
|
+
const url = base.endsWith("/chat/completions") ? base : base + "/chat/completions";
|
|
600
|
+
let response;
|
|
601
|
+
try {
|
|
602
|
+
response = await fetch(url, {
|
|
603
|
+
method: "POST",
|
|
604
|
+
headers: {
|
|
605
|
+
"Authorization": "Bearer " + config.apiKey.trim(),
|
|
606
|
+
"Content-Type": "application/json"
|
|
607
|
+
},
|
|
608
|
+
body: JSON.stringify({
|
|
609
|
+
model: config.model.trim(),
|
|
610
|
+
messages: [{
|
|
611
|
+
role: "system",
|
|
612
|
+
content: system
|
|
613
|
+
}, {
|
|
614
|
+
role: "user",
|
|
615
|
+
content: user
|
|
616
|
+
}],
|
|
617
|
+
temperature: .4,
|
|
618
|
+
max_tokens: 4e3,
|
|
619
|
+
stream: false
|
|
620
|
+
}),
|
|
621
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
622
|
+
});
|
|
623
|
+
} catch (error) {
|
|
624
|
+
throw new Error("LLM 请求失败(网络错误): " + String(error instanceof Error ? error.message : error));
|
|
625
|
+
}
|
|
626
|
+
let payload;
|
|
627
|
+
try {
|
|
628
|
+
payload = await response.json();
|
|
629
|
+
} catch {
|
|
630
|
+
throw new Error("LLM 返回了无法解析的响应(HTTP " + response.status + ")");
|
|
631
|
+
}
|
|
632
|
+
if (!response.ok) {
|
|
633
|
+
const record = typeof payload === "object" && payload !== null ? payload : {};
|
|
634
|
+
const message = typeof record.message === "string" ? record.message : typeof record.error === "object" && record.error !== null ? String(record.error.message ?? JSON.stringify(record.error)) : "HTTP " + response.status;
|
|
635
|
+
throw new Error("LLM 请求失败: " + message);
|
|
636
|
+
}
|
|
637
|
+
const choices = (typeof payload === "object" && payload !== null ? payload : {}).choices;
|
|
638
|
+
if (!Array.isArray(choices) || choices.length === 0) throw new Error("LLM 响应缺少 choices");
|
|
639
|
+
const first = choices[0];
|
|
640
|
+
const message = typeof first.message === "object" && first.message !== null ? first.message : {};
|
|
641
|
+
return typeof message.content === "string" ? message.content : "";
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Fill {title} / {author} / {highlights} / {thoughts} placeholders in a
|
|
645
|
+
* prompt template.
|
|
646
|
+
*/
|
|
647
|
+
function renderPrompt(template, vars) {
|
|
648
|
+
return template.replaceAll("{title}", vars.title).replaceAll("{author}", vars.author).replaceAll("{highlights}", vars.highlights).replaceAll("{thoughts}", vars.thoughts);
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region src/export.ts
|
|
652
|
+
/**
|
|
653
|
+
* weread-export — unified export targets.
|
|
654
|
+
*
|
|
655
|
+
* One pipeline, three destinations: flomo, local file, Notion page.
|
|
656
|
+
* Highlights (+ thoughts) are rendered to markdown, optionally processed by
|
|
657
|
+
* the configured LLM prompt, then delivered to the selected target. The
|
|
658
|
+
* flomo path reuses the dsh-flomo credentials file; the Notion path uses
|
|
659
|
+
* this plugin's own token + parent page; the local path writes a .md file
|
|
660
|
+
* to a user-supplied directory (no default — the caller must provide it).
|
|
661
|
+
*/
|
|
662
|
+
/** Render full export markdown: highlights + thoughts with chapter/time. */
|
|
663
|
+
function buildExportMarkdown(title, author, highlights, thoughts, chapters) {
|
|
664
|
+
const chapterMap = chapterTitleMap(chapters);
|
|
665
|
+
const lines = ["# 《" + title + "》" + (author ? " · " + author : "")];
|
|
666
|
+
if (highlights.length > 0) {
|
|
667
|
+
lines.push("", "## 划线 " + highlights.length + " 条");
|
|
668
|
+
for (const h of highlights) {
|
|
669
|
+
const chapter = typeof h.chapterUid === "number" ? chapterMap.get(h.chapterUid) : void 0;
|
|
670
|
+
const time = formatDate(h.createTime);
|
|
671
|
+
lines.push("- “" + (h.markText ?? "").trim() + "”" + (chapter ? "(" + chapter + ")" : "") + (time !== "" ? " · " + time : ""));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (thoughts.length > 0) {
|
|
675
|
+
lines.push("", "## 想法 " + thoughts.length + " 条");
|
|
676
|
+
for (const entry of thoughts) {
|
|
677
|
+
const review = entry.review;
|
|
678
|
+
if (!review) continue;
|
|
679
|
+
const chapter = review.chapterName ?? "";
|
|
680
|
+
const time = formatDate(review.createTime);
|
|
681
|
+
const abstract = (review.abstract ?? "").trim();
|
|
682
|
+
const abstractText = abstract !== "" && abstract !== review.content ? "\n > 原文:" + abstract : "";
|
|
683
|
+
lines.push("- " + (review.content ?? "").trim() + abstractText + (chapter ? "(" + chapter + ")" : "") + (time !== "" ? " · " + time : ""));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (highlights.length === 0 && thoughts.length === 0) lines.push("(这本书暂无划线与想法)");
|
|
687
|
+
return lines.join("\n") + "\n";
|
|
688
|
+
}
|
|
689
|
+
/** Run export text through the configured LLM prompt. */
|
|
690
|
+
async function processWithPrompt(llm, promptTemplate, vars) {
|
|
691
|
+
return chatComplete(llm, "你是读书笔记整理助手,请严格按用户的 prompt 要求输出。", renderPrompt(promptTemplate, vars));
|
|
692
|
+
}
|
|
693
|
+
/** Write content to <dir>/<title>.md; creates the directory. */
|
|
694
|
+
async function exportToLocal(dir, title, content) {
|
|
695
|
+
const targetDir = dir.trim();
|
|
696
|
+
if (targetDir === "") throw new Error("本地导出需要填写目标目录(不设默认值)。");
|
|
697
|
+
const safe = title.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, " ").trim() || "未命名";
|
|
698
|
+
await mkdir(targetDir, { recursive: true });
|
|
699
|
+
const file = path.join(targetDir, safe + ".md");
|
|
700
|
+
await writeFile(file, content, "utf8");
|
|
701
|
+
return file;
|
|
702
|
+
}
|
|
703
|
+
/** Notion REST base URL. */
|
|
704
|
+
const NOTION_API = "https://api.notion.com";
|
|
705
|
+
/** API version header (covers every endpoint used here). */
|
|
706
|
+
const NOTION_VERSION = "2022-06-28";
|
|
707
|
+
/** Notion allows at most 100 blocks per create/append call. */
|
|
708
|
+
const NOTION_BLOCKS_PER_CALL = 100;
|
|
709
|
+
/** Normalize a Notion page URL / id to the 32-char page id. */
|
|
710
|
+
function normalizeNotionPageId(input) {
|
|
711
|
+
const value = input.trim();
|
|
712
|
+
if (value === "") throw new Error("请填写 Notion 目标页面 URL 或 ID。");
|
|
713
|
+
const hex = value.match(/[0-9a-f]{32}/i);
|
|
714
|
+
if (hex) return hex[0].toLowerCase();
|
|
715
|
+
const compact = value.replace(/-/g, "");
|
|
716
|
+
if (/^[0-9a-f]{32}$/i.test(compact)) return compact.toLowerCase();
|
|
717
|
+
throw new Error("无法识别 Notion 页面 ID:请粘贴页面链接或 32 位页面 ID。");
|
|
718
|
+
}
|
|
719
|
+
/** Split markdown text into Notion paragraph blocks. */
|
|
720
|
+
function toNotionBlocks(content) {
|
|
721
|
+
const lines = content.split("\n").map((l) => l.trimEnd());
|
|
722
|
+
const blocks = [];
|
|
723
|
+
for (const line of lines) {
|
|
724
|
+
if (line === "") continue;
|
|
725
|
+
blocks.push({
|
|
726
|
+
object: "block",
|
|
727
|
+
type: "paragraph",
|
|
728
|
+
paragraph: { rich_text: [{
|
|
729
|
+
type: "text",
|
|
730
|
+
text: { content: line.slice(0, 2e3) }
|
|
731
|
+
}] }
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return blocks;
|
|
735
|
+
}
|
|
736
|
+
/** One Notion API call with normalized errors. */
|
|
737
|
+
async function notionCall(token, method, apiPath, body) {
|
|
738
|
+
let response;
|
|
739
|
+
try {
|
|
740
|
+
response = await fetch(NOTION_API + apiPath, {
|
|
741
|
+
method,
|
|
742
|
+
headers: {
|
|
743
|
+
"Authorization": "Bearer " + token.trim(),
|
|
744
|
+
"Notion-Version": NOTION_VERSION,
|
|
745
|
+
"Content-Type": "application/json"
|
|
746
|
+
},
|
|
747
|
+
body: JSON.stringify(body)
|
|
748
|
+
});
|
|
749
|
+
} catch (error) {
|
|
750
|
+
throw new Error("Notion 请求失败(网络错误): " + String(error instanceof Error ? error.message : error));
|
|
751
|
+
}
|
|
752
|
+
let payload;
|
|
753
|
+
try {
|
|
754
|
+
payload = await response.json();
|
|
755
|
+
} catch {
|
|
756
|
+
throw new Error("Notion 返回了无法解析的响应(HTTP " + response.status + ")");
|
|
757
|
+
}
|
|
758
|
+
if (!response.ok) {
|
|
759
|
+
const record = typeof payload === "object" && payload !== null ? payload : {};
|
|
760
|
+
const message = typeof record.message === "string" ? record.message : "HTTP " + response.status;
|
|
761
|
+
throw new Error("Notion API 错误: " + message);
|
|
762
|
+
}
|
|
763
|
+
return typeof payload === "object" && payload !== null ? payload : {};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Create a child page under the target parent page with the export content,
|
|
767
|
+
* appending extra blocks in batches if needed.
|
|
768
|
+
*/
|
|
769
|
+
async function exportToNotion(token, parentId, title, content) {
|
|
770
|
+
if (token.trim() === "") throw new Error("Notion 未配置:请先在设置面板「Notion」区填写 Integration Token。");
|
|
771
|
+
const pageId = normalizeNotionPageId(parentId);
|
|
772
|
+
const blocks = toNotionBlocks(content);
|
|
773
|
+
const children = blocks.slice(0, NOTION_BLOCKS_PER_CALL);
|
|
774
|
+
const created = await notionCall(token, "POST", "/v1/pages", {
|
|
775
|
+
parent: { page_id: pageId },
|
|
776
|
+
properties: { title: { title: [{ text: { content: title.slice(0, 200) } }] } },
|
|
777
|
+
children
|
|
778
|
+
});
|
|
779
|
+
const newPageId = typeof created.id === "string" ? created.id : "";
|
|
780
|
+
for (let offset = NOTION_BLOCKS_PER_CALL; offset < blocks.length; offset += NOTION_BLOCKS_PER_CALL) {
|
|
781
|
+
const batch = blocks.slice(offset, offset + NOTION_BLOCKS_PER_CALL);
|
|
782
|
+
await notionCall(token, "PATCH", "/v1/blocks/" + newPageId + "/children", { children: batch });
|
|
783
|
+
}
|
|
784
|
+
return newPageId;
|
|
785
|
+
}
|
|
786
|
+
/** Send text to flomo, chunking by size (never truncates). */
|
|
787
|
+
async function exportToFlomo(flomoUrl, title, content, tag) {
|
|
788
|
+
const memos = chunkText(content, FLOMO_MAX_CHARS, title);
|
|
789
|
+
let sent = 0;
|
|
790
|
+
let failed = 0;
|
|
791
|
+
for (const memo of memos) if ((await postMemo(flomoUrl, buildTaggedContent(memo, tag))).ok) sent += 1;
|
|
792
|
+
else failed += 1;
|
|
793
|
+
const message = sent > 0 ? "已导出到 flomo(#" + tag + "):" + sent + " 条 MEMO 发送成功" + (failed > 0 ? "," + failed + " 条失败" : "") + "。" : "flomo 发送失败:全部 " + memos.length + " 条 MEMO 发送失败";
|
|
794
|
+
return {
|
|
795
|
+
sent,
|
|
796
|
+
memoCount: memos.length,
|
|
797
|
+
failed,
|
|
798
|
+
message
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
/** Split arbitrary text into size-capped chunks with a small header. */
|
|
802
|
+
function chunkText(text, maxChars, title) {
|
|
803
|
+
const header = "📖《" + title + "》";
|
|
804
|
+
const memos = [];
|
|
805
|
+
let current = header;
|
|
806
|
+
const lines = text.split("\n");
|
|
807
|
+
for (const line of lines) {
|
|
808
|
+
if (current.length + 1 + line.length > maxChars && current !== header) {
|
|
809
|
+
memos.push(current);
|
|
810
|
+
current = header + "(续)";
|
|
811
|
+
}
|
|
812
|
+
current += "\n" + line;
|
|
813
|
+
}
|
|
814
|
+
memos.push(current);
|
|
815
|
+
return memos;
|
|
816
|
+
}
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region src/tools.ts
|
|
819
|
+
/** One text content block (the only render shape these tools emit). */
|
|
820
|
+
function text(value) {
|
|
821
|
+
return [{
|
|
822
|
+
type: "text",
|
|
823
|
+
text: value
|
|
824
|
+
}];
|
|
825
|
+
}
|
|
826
|
+
/** Readable error for API failures. */
|
|
827
|
+
function apiError(err) {
|
|
828
|
+
if (err instanceof WereadApiError) return err.message;
|
|
829
|
+
return String(err instanceof Error ? err.message : err);
|
|
830
|
+
}
|
|
831
|
+
/** Build the api client from the store's current key (throws when unconfigured). */
|
|
832
|
+
async function requireApi(ctx) {
|
|
833
|
+
return new WereadApi((await ctx.store.load()).apiKey);
|
|
834
|
+
}
|
|
835
|
+
/** Masked-ish summary for search book entries. */
|
|
836
|
+
function formatSearchBook(entry) {
|
|
837
|
+
const info = entry.bookInfo ?? {};
|
|
838
|
+
const rating = formatRating(entry.newRating);
|
|
839
|
+
const reading = typeof entry.readingCount === "number" && entry.readingCount > 0 ? "在读 " + formatCount(entry.readingCount) : "";
|
|
840
|
+
const link = deepLink(info.bookId, info.deepLink);
|
|
841
|
+
return "- 《" + (info.title ?? "未知书名") + "》· " + (info.author ?? "未知作者") + (rating !== "" ? " · 评分 " + rating : "") + (reading !== "" ? " · " + reading : "") + " · bookId=" + (info.bookId ?? "?") + (link !== "" ? "\n " + link : "");
|
|
842
|
+
}
|
|
843
|
+
/** 12000 → 1.2万, 1234567 → 123万. */
|
|
844
|
+
function formatCount(n) {
|
|
845
|
+
if (n >= 1e8) return (n / 1e8).toFixed(1) + "亿";
|
|
846
|
+
if (n >= 1e4) return (n / 1e4).toFixed(1) + "万";
|
|
847
|
+
return String(n);
|
|
848
|
+
}
|
|
849
|
+
/** Status tool: configuration + cache + export targets. */
|
|
850
|
+
function wereadStatusTool(ctx) {
|
|
851
|
+
return defineTool$1({
|
|
852
|
+
name: "weread_status",
|
|
853
|
+
description: "查看 weread-export 插件状态:微信读书 API Key 配置、默认 flomo 标签、导出条数(0=全部)、默认导出目标(flomo/本地/Notion)、Notion 与 LLM(prompt 处理)配置状态、最近同步、缓存规模。不会泄露任何 Key。",
|
|
854
|
+
parameters: {},
|
|
855
|
+
output: {
|
|
856
|
+
schema: {
|
|
857
|
+
type: "object",
|
|
858
|
+
additionalProperties: false,
|
|
859
|
+
properties: {
|
|
860
|
+
ok: {
|
|
861
|
+
type: "boolean",
|
|
862
|
+
required: true
|
|
863
|
+
},
|
|
864
|
+
message: {
|
|
865
|
+
type: "string",
|
|
866
|
+
required: true
|
|
867
|
+
},
|
|
868
|
+
configured: { type: "boolean" },
|
|
869
|
+
apiKeyMasked: { type: "string" },
|
|
870
|
+
defaultFlomoTag: { type: "string" },
|
|
871
|
+
exportLimit: { type: "number" },
|
|
872
|
+
exportDest: { type: "string" },
|
|
873
|
+
localExportDir: { type: "string" },
|
|
874
|
+
notionConfigured: { type: "boolean" },
|
|
875
|
+
notionTargetPageId: { type: "string" },
|
|
876
|
+
usePrompt: { type: "boolean" },
|
|
877
|
+
llmConfigured: { type: "boolean" },
|
|
878
|
+
llmBaseUrl: { type: "string" },
|
|
879
|
+
llmModel: { type: "string" },
|
|
880
|
+
lastSyncAt: { type: "string" },
|
|
881
|
+
flomoConfigured: { type: "boolean" },
|
|
882
|
+
configPath: { type: "string" }
|
|
883
|
+
}
|
|
884
|
+
},
|
|
885
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
886
|
+
},
|
|
887
|
+
async execute() {
|
|
888
|
+
const view = await ctx.store.view();
|
|
889
|
+
const cache = await readCache();
|
|
890
|
+
const flomoOk = await flomoConfigured();
|
|
891
|
+
const destLabel = view.exportDest === "local" ? "本地文件" : view.exportDest === "notion" ? "Notion" : "flomo";
|
|
892
|
+
return {
|
|
893
|
+
ok: true,
|
|
894
|
+
message: [
|
|
895
|
+
view.configured ? "已配置:API Key " + view.apiKeyMasked + "(在 https://weread.qq.com/r/weread-skills 创建)" : "未配置:请先调用 weread_config 填入 API Key(https://weread.qq.com/r/weread-skills 登录后「创建 Key」)。",
|
|
896
|
+
"默认导出目标:" + destLabel + (view.exportDest === "local" ? view.localExportDir !== "" ? "(" + view.localExportDir + ")" : "(未设路径,导出时需指定)" : "") + (view.exportDest === "notion" ? view.notionConfigured && view.notionTargetPageId !== "" ? "(目标页 " + view.notionTargetPageId + ")" : "(Notion 未配置完整)" : ""),
|
|
897
|
+
"默认 flomo 标签:#" + view.defaultFlomoTag + (flomoOk ? "(flomo 已配置)" : "(flomo 未配置,flomo 导出不可用)"),
|
|
898
|
+
"导出条数:" + (view.exportLimit === 0 ? "全部导出(超长自动拆多条 MEMO)" : "最多 " + view.exportLimit + " 条"),
|
|
899
|
+
"prompt 处理:" + (view.usePrompt ? "已开启(LLM " + (view.llmConfigured ? view.llmModel + " @ " + view.llmBaseUrl : "未配置") + ")" : "关闭"),
|
|
900
|
+
"Notion:" + (view.notionConfigured ? "已配置 token" + (view.notionTargetPageId !== "" ? " + 目标页" : "(未填目标页)") : "未配置"),
|
|
901
|
+
"最近同步:" + (view.lastSyncAt !== "" ? view.lastSyncAt : "从未同步(可 weread_sync)"),
|
|
902
|
+
"缓存:书架 " + cache.shelfBooks.length + " 本 · 有笔记的书 " + cache.notebooks.filter((b) => (b.reviewCount ?? 0) + (b.noteCount ?? 0) + (b.bookmarkCount ?? 0) > 0).length + " 本",
|
|
903
|
+
"配置路径:" + view.configPath
|
|
904
|
+
].join("\n"),
|
|
905
|
+
configured: view.configured,
|
|
906
|
+
apiKeyMasked: view.apiKeyMasked,
|
|
907
|
+
defaultFlomoTag: view.defaultFlomoTag,
|
|
908
|
+
exportLimit: view.exportLimit,
|
|
909
|
+
exportDest: view.exportDest,
|
|
910
|
+
localExportDir: view.localExportDir,
|
|
911
|
+
notionConfigured: view.notionConfigured,
|
|
912
|
+
notionTargetPageId: view.notionTargetPageId,
|
|
913
|
+
usePrompt: view.usePrompt,
|
|
914
|
+
llmConfigured: view.llmConfigured,
|
|
915
|
+
llmBaseUrl: view.llmBaseUrl,
|
|
916
|
+
llmModel: view.llmModel,
|
|
917
|
+
lastSyncAt: view.lastSyncAt,
|
|
918
|
+
flomoConfigured: flomoOk,
|
|
919
|
+
configPath: view.configPath
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
/** Config tool: set/clear the API key, default flomo tag, and export limit. */
|
|
925
|
+
function wereadConfigTool(ctx) {
|
|
926
|
+
return defineTool$1({
|
|
927
|
+
name: "weread_config",
|
|
928
|
+
description: "配置或清除 weread-export 的凭据与导出偏好:apiKey 为微信读书 Skills API Key(wrk- 开头,https://weread.qq.com/r/weread-skills 创建);defaultFlomoTag 为 flomo 导出默认标签;exportLimit 为导出条数(0=全部);exportDest 为默认导出目标(flomo/local/notion);localExportDir 为本地导出目录;notionToken/notionTargetPageId 为 Notion 导出凭据与目标页;usePrompt/exportPrompt 为 LLM prompt 处理开关与模板;llmBaseUrl/llmApiKey/llmModel 为 LLM 配置(OpenAI 兼容)。test: true 保存后测试微信读书连接。reset: true 清除全部。凭据存 ~/.dsh/weread-export.json(0600)。",
|
|
929
|
+
parameters: {
|
|
930
|
+
apiKey: {
|
|
931
|
+
type: "string",
|
|
932
|
+
description: "微信读书 Skills API Key(wrk- 开头)"
|
|
933
|
+
},
|
|
934
|
+
defaultFlomoTag: {
|
|
935
|
+
type: "string",
|
|
936
|
+
description: "flomo 导出默认标签(不带 #,如 读书笔记)"
|
|
937
|
+
},
|
|
938
|
+
exportLimit: {
|
|
939
|
+
type: "number",
|
|
940
|
+
description: "每次导出的划线条数:0=全部导出,N>0=最多 N 条(默认 20)"
|
|
941
|
+
},
|
|
942
|
+
exportDest: {
|
|
943
|
+
type: "string",
|
|
944
|
+
enum: [
|
|
945
|
+
"flomo",
|
|
946
|
+
"local",
|
|
947
|
+
"notion"
|
|
948
|
+
],
|
|
949
|
+
description: "默认导出目标(weread_export 不传 dest 时使用)"
|
|
950
|
+
},
|
|
951
|
+
localExportDir: {
|
|
952
|
+
type: "string",
|
|
953
|
+
description: "本地导出目录(dest=local 时用,导出时也可临时传 localDir)"
|
|
954
|
+
},
|
|
955
|
+
notionToken: {
|
|
956
|
+
type: "string",
|
|
957
|
+
description: "Notion Integration Token(本插件独立配置,页面需分享给该 Integration)"
|
|
958
|
+
},
|
|
959
|
+
notionTargetPageId: {
|
|
960
|
+
type: "string",
|
|
961
|
+
description: "Notion 目标父页面 URL 或 32 位页面 ID"
|
|
962
|
+
},
|
|
963
|
+
usePrompt: {
|
|
964
|
+
type: "boolean",
|
|
965
|
+
description: "是否用 LLM 按 prompt 整理后再导出"
|
|
966
|
+
},
|
|
967
|
+
exportPrompt: {
|
|
968
|
+
type: "string",
|
|
969
|
+
description: "导出 prompt 模板,占位符 {title}/{author}/{highlights}/{thoughts}"
|
|
970
|
+
},
|
|
971
|
+
llmBaseUrl: {
|
|
972
|
+
type: "string",
|
|
973
|
+
description: "LLM Base URL(OpenAI 兼容,默认 https://api.deepseek.com/v1)"
|
|
974
|
+
},
|
|
975
|
+
llmApiKey: {
|
|
976
|
+
type: "string",
|
|
977
|
+
description: "LLM API Key"
|
|
978
|
+
},
|
|
979
|
+
llmModel: {
|
|
980
|
+
type: "string",
|
|
981
|
+
description: "LLM 模型名(默认 deepseek-chat)"
|
|
982
|
+
},
|
|
983
|
+
test: {
|
|
984
|
+
type: "boolean",
|
|
985
|
+
description: "true 时保存后立即测试微信读书连接"
|
|
986
|
+
},
|
|
987
|
+
reset: {
|
|
988
|
+
type: "boolean",
|
|
989
|
+
description: "设为 true 清除全部凭据"
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
output: {
|
|
993
|
+
schema: {
|
|
994
|
+
type: "object",
|
|
995
|
+
additionalProperties: false,
|
|
996
|
+
properties: {
|
|
997
|
+
ok: {
|
|
998
|
+
type: "boolean",
|
|
999
|
+
required: true
|
|
1000
|
+
},
|
|
1001
|
+
message: {
|
|
1002
|
+
type: "string",
|
|
1003
|
+
required: true
|
|
1004
|
+
},
|
|
1005
|
+
configured: { type: "boolean" },
|
|
1006
|
+
apiKeyMasked: { type: "string" },
|
|
1007
|
+
defaultFlomoTag: { type: "string" },
|
|
1008
|
+
exportLimit: { type: "number" },
|
|
1009
|
+
exportDest: { type: "string" },
|
|
1010
|
+
localExportDir: { type: "string" },
|
|
1011
|
+
notionConfigured: { type: "boolean" },
|
|
1012
|
+
notionTargetPageId: { type: "string" },
|
|
1013
|
+
usePrompt: { type: "boolean" },
|
|
1014
|
+
llmConfigured: { type: "boolean" },
|
|
1015
|
+
llmBaseUrl: { type: "string" },
|
|
1016
|
+
llmModel: { type: "string" },
|
|
1017
|
+
lastSyncAt: { type: "string" },
|
|
1018
|
+
configPath: { type: "string" }
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1022
|
+
},
|
|
1023
|
+
async execute(args) {
|
|
1024
|
+
const view = await ctx.store.patch(args);
|
|
1025
|
+
if (!view.configured) return {
|
|
1026
|
+
ok: false,
|
|
1027
|
+
message: "配置未生效:缺少 API Key。请在 https://weread.qq.com/r/weread-skills 登录后创建 Key 并填入。",
|
|
1028
|
+
configured: view.configured,
|
|
1029
|
+
apiKeyMasked: view.apiKeyMasked,
|
|
1030
|
+
defaultFlomoTag: view.defaultFlomoTag,
|
|
1031
|
+
exportLimit: view.exportLimit,
|
|
1032
|
+
exportDest: view.exportDest,
|
|
1033
|
+
localExportDir: view.localExportDir,
|
|
1034
|
+
notionConfigured: view.notionConfigured,
|
|
1035
|
+
notionTargetPageId: view.notionTargetPageId,
|
|
1036
|
+
usePrompt: view.usePrompt,
|
|
1037
|
+
llmConfigured: view.llmConfigured,
|
|
1038
|
+
llmBaseUrl: view.llmBaseUrl,
|
|
1039
|
+
llmModel: view.llmModel,
|
|
1040
|
+
lastSyncAt: view.lastSyncAt,
|
|
1041
|
+
configPath: view.configPath
|
|
1042
|
+
};
|
|
1043
|
+
const parts = [
|
|
1044
|
+
"已保存配置:API Key " + view.apiKeyMasked,
|
|
1045
|
+
"默认 flomo 标签 #" + view.defaultFlomoTag,
|
|
1046
|
+
"导出策略 " + (view.exportLimit === 0 ? "全部导出" : "最多 " + view.exportLimit + " 条"),
|
|
1047
|
+
"默认目标 " + view.exportDest
|
|
1048
|
+
];
|
|
1049
|
+
if (view.usePrompt) parts.push("prompt 处理已开启(" + (view.llmConfigured ? view.llmModel + " @ " + view.llmBaseUrl : "LLM 未配置") + ")");
|
|
1050
|
+
if (view.notionConfigured) parts.push("Notion 已配置" + (view.notionTargetPageId !== "" ? "(目标页 " + view.notionTargetPageId + ")" : "(未填目标页)"));
|
|
1051
|
+
if (args?.test === true) try {
|
|
1052
|
+
await (await requireApi(ctx)).list();
|
|
1053
|
+
parts.push("连接测试:成功(网关可用)");
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
parts.push("连接测试:失败(" + apiError(error) + ")");
|
|
1056
|
+
}
|
|
1057
|
+
return {
|
|
1058
|
+
ok: true,
|
|
1059
|
+
message: parts.join(";") + "。",
|
|
1060
|
+
configured: view.configured,
|
|
1061
|
+
apiKeyMasked: view.apiKeyMasked,
|
|
1062
|
+
defaultFlomoTag: view.defaultFlomoTag,
|
|
1063
|
+
exportLimit: view.exportLimit,
|
|
1064
|
+
exportDest: view.exportDest,
|
|
1065
|
+
localExportDir: view.localExportDir,
|
|
1066
|
+
notionConfigured: view.notionConfigured,
|
|
1067
|
+
notionTargetPageId: view.notionTargetPageId,
|
|
1068
|
+
usePrompt: view.usePrompt,
|
|
1069
|
+
llmConfigured: view.llmConfigured,
|
|
1070
|
+
llmBaseUrl: view.llmBaseUrl,
|
|
1071
|
+
llmModel: view.llmModel,
|
|
1072
|
+
lastSyncAt: view.lastSyncAt,
|
|
1073
|
+
configPath: view.configPath
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
/** Search tool: book store search. */
|
|
1079
|
+
function wereadSearchTool(ctx) {
|
|
1080
|
+
return defineTool$1({
|
|
1081
|
+
name: "weread_search",
|
|
1082
|
+
description: "在微信读书书城搜索书籍:按关键词返回书名、作者、评分(0-10)、在读人数、bookId 与跳转链接。scope 搜索类型:10=电子书(默认)、0=全部、16=网文小说、14=有声书/专辑、6=作者、12=全文、13=书单、2=公众号、4=文章。拿到 bookId 后可继续用 weread_book(详情/进度/章节)、weread_notes(划线/想法)。",
|
|
1083
|
+
parameters: {
|
|
1084
|
+
keyword: {
|
|
1085
|
+
type: "string",
|
|
1086
|
+
required: true,
|
|
1087
|
+
description: "搜索关键词(书名/作者)"
|
|
1088
|
+
},
|
|
1089
|
+
scope: {
|
|
1090
|
+
type: "number",
|
|
1091
|
+
description: "搜索类型(默认 10=电子书;0=全部;16=网文;14=听书;6=作者;12=全文)"
|
|
1092
|
+
},
|
|
1093
|
+
count: {
|
|
1094
|
+
type: "number",
|
|
1095
|
+
description: "返回条数上限(默认 10)"
|
|
1096
|
+
}
|
|
1097
|
+
},
|
|
1098
|
+
output: {
|
|
1099
|
+
schema: {
|
|
1100
|
+
type: "object",
|
|
1101
|
+
additionalProperties: false,
|
|
1102
|
+
properties: {
|
|
1103
|
+
ok: {
|
|
1104
|
+
type: "boolean",
|
|
1105
|
+
required: true
|
|
1106
|
+
},
|
|
1107
|
+
message: {
|
|
1108
|
+
type: "string",
|
|
1109
|
+
required: true
|
|
1110
|
+
},
|
|
1111
|
+
count: { type: "number" },
|
|
1112
|
+
keyword: { type: "string" }
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1116
|
+
},
|
|
1117
|
+
async execute(args) {
|
|
1118
|
+
const keyword = (args?.keyword ?? "").trim();
|
|
1119
|
+
if (keyword === "") return {
|
|
1120
|
+
ok: false,
|
|
1121
|
+
message: "请提供搜索关键词 keyword。"
|
|
1122
|
+
};
|
|
1123
|
+
const count = typeof args?.count === "number" && args.count > 0 ? Math.min(Math.floor(args.count), 50) : 10;
|
|
1124
|
+
const scope = typeof args?.scope === "number" ? args.scope : 10;
|
|
1125
|
+
let data;
|
|
1126
|
+
try {
|
|
1127
|
+
data = await (await requireApi(ctx)).search(keyword, count, scope);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
return {
|
|
1130
|
+
ok: false,
|
|
1131
|
+
message: "搜索失败:" + apiError(error)
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
const entries = (data.results ?? []).flatMap((r) => Array.isArray(r.books) ? r.books : []);
|
|
1135
|
+
const lines = entries.length === 0 ? ["(没有找到相关书籍)"] : entries.slice(0, count).map(formatSearchBook);
|
|
1136
|
+
return {
|
|
1137
|
+
ok: true,
|
|
1138
|
+
message: "「" + keyword + "」搜索结果(" + entries.length + " 条):\n" + lines.join("\n"),
|
|
1139
|
+
count: entries.length,
|
|
1140
|
+
keyword
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
/** Book tool: metadata + progress + chapter catalog summary. */
|
|
1146
|
+
function wereadBookTool(ctx) {
|
|
1147
|
+
return defineTool$1({
|
|
1148
|
+
name: "weread_book",
|
|
1149
|
+
description: "查看微信读书单本书详情:作者/出版社/分类/字数/评分/简介 + 阅读进度 + 章节目录概览(章节数、各级章节数)。bookId 来自 weread_search 或 weread_shelf。",
|
|
1150
|
+
parameters: { bookId: {
|
|
1151
|
+
type: "string",
|
|
1152
|
+
required: true,
|
|
1153
|
+
description: "书籍 bookId"
|
|
1154
|
+
} },
|
|
1155
|
+
output: {
|
|
1156
|
+
schema: {
|
|
1157
|
+
type: "object",
|
|
1158
|
+
additionalProperties: false,
|
|
1159
|
+
properties: {
|
|
1160
|
+
ok: {
|
|
1161
|
+
type: "boolean",
|
|
1162
|
+
required: true
|
|
1163
|
+
},
|
|
1164
|
+
message: {
|
|
1165
|
+
type: "string",
|
|
1166
|
+
required: true
|
|
1167
|
+
},
|
|
1168
|
+
bookId: { type: "string" }
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1172
|
+
},
|
|
1173
|
+
async execute(args) {
|
|
1174
|
+
const bookId = (args?.bookId ?? "").trim();
|
|
1175
|
+
if (bookId === "") return {
|
|
1176
|
+
ok: false,
|
|
1177
|
+
message: "请提供 bookId。"
|
|
1178
|
+
};
|
|
1179
|
+
const api = await requireApi(ctx);
|
|
1180
|
+
try {
|
|
1181
|
+
const [info, progress, chapters] = await Promise.all([
|
|
1182
|
+
api.bookInfo(bookId),
|
|
1183
|
+
api.getProgress(bookId),
|
|
1184
|
+
api.chapterInfo(bookId).catch(() => void 0)
|
|
1185
|
+
]);
|
|
1186
|
+
const lines = ["📖 《" + (info.title ?? "未知书名") + "》· " + (info.author ?? "未知作者")];
|
|
1187
|
+
const meta = [];
|
|
1188
|
+
const rating = formatRating(info.newRating);
|
|
1189
|
+
if (rating !== "") meta.push("评分 " + rating + (typeof info.newRatingCount === "number" && info.newRatingCount > 0 ? "(" + formatCount(info.newRatingCount) + " 人)" : ""));
|
|
1190
|
+
if (info.category) meta.push(info.category);
|
|
1191
|
+
if (info.publisher) meta.push(info.publisher);
|
|
1192
|
+
if (typeof info.wordCount === "number" && info.wordCount > 0) meta.push(formatCount(info.wordCount) + " 字");
|
|
1193
|
+
if (meta.length > 0) lines.push("信息:" + meta.join(" · "));
|
|
1194
|
+
if (info.intro) lines.push("简介:" + info.intro.slice(0, 200) + (info.intro.length > 200 ? "…" : ""));
|
|
1195
|
+
const progressValue = progress.book?.progress;
|
|
1196
|
+
if (typeof progressValue === "number") {
|
|
1197
|
+
const time = formatDate(progress.book?.updateTime);
|
|
1198
|
+
lines.push("阅读进度:" + progressValue + "%" + (progressValue >= 100 ? "(已读完)" : "") + (time !== "" ? "(更新于 " + time + ")" : ""));
|
|
1199
|
+
}
|
|
1200
|
+
const recordTime = progress.book?.recordReadingTime;
|
|
1201
|
+
if (typeof recordTime === "number" && recordTime > 0) lines.push("累计阅读:" + formatDuration(recordTime));
|
|
1202
|
+
const chapterList = chapters?.chapters;
|
|
1203
|
+
if (Array.isArray(chapterList) && chapterList.length > 0) {
|
|
1204
|
+
const topLevel = chapterList.filter((c) => (c.level ?? 1) === 1).length;
|
|
1205
|
+
lines.push("章节:共 " + chapterList.length + " 章" + (topLevel > 0 && topLevel < chapterList.length ? "(一级章节 " + topLevel + ")" : ""));
|
|
1206
|
+
const first = chapterList.slice(0, 5).map((c) => c.title ?? "").filter(Boolean);
|
|
1207
|
+
if (first.length > 0) lines.push(" 前几章:" + first.join(" / "));
|
|
1208
|
+
}
|
|
1209
|
+
const link = deepLink(bookId, info.deepLink);
|
|
1210
|
+
if (link !== "") lines.push("链接:" + link);
|
|
1211
|
+
return {
|
|
1212
|
+
ok: true,
|
|
1213
|
+
message: lines.join("\n"),
|
|
1214
|
+
bookId
|
|
1215
|
+
};
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
return {
|
|
1218
|
+
ok: false,
|
|
1219
|
+
message: "查询书籍失败:" + apiError(error),
|
|
1220
|
+
bookId
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
/** Shelf tool: live bookshelf (optionally from cache). */
|
|
1227
|
+
function wereadShelfTool(ctx) {
|
|
1228
|
+
return defineTool$1({
|
|
1229
|
+
name: "weread_shelf",
|
|
1230
|
+
description: "查看微信读书书架:返回书籍列表(书名/作者/进度/最近阅读时间/是否读完)与有声书、公众号条目数。默认实时拉取;useCache: true 时读本地缓存(先 weread_sync)。",
|
|
1231
|
+
parameters: {
|
|
1232
|
+
useCache: {
|
|
1233
|
+
type: "boolean",
|
|
1234
|
+
description: "true 时读本地缓存而非实时拉取"
|
|
1235
|
+
},
|
|
1236
|
+
limit: {
|
|
1237
|
+
type: "number",
|
|
1238
|
+
description: "最多展示条数(默认 100)"
|
|
1239
|
+
}
|
|
1240
|
+
},
|
|
1241
|
+
output: {
|
|
1242
|
+
schema: {
|
|
1243
|
+
type: "object",
|
|
1244
|
+
additionalProperties: false,
|
|
1245
|
+
properties: {
|
|
1246
|
+
ok: {
|
|
1247
|
+
type: "boolean",
|
|
1248
|
+
required: true
|
|
1249
|
+
},
|
|
1250
|
+
message: {
|
|
1251
|
+
type: "string",
|
|
1252
|
+
required: true
|
|
1253
|
+
},
|
|
1254
|
+
bookCount: { type: "number" },
|
|
1255
|
+
albumsCount: { type: "number" },
|
|
1256
|
+
mpCount: { type: "number" }
|
|
1257
|
+
}
|
|
1258
|
+
},
|
|
1259
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1260
|
+
},
|
|
1261
|
+
async execute(args) {
|
|
1262
|
+
const api = await requireApi(ctx);
|
|
1263
|
+
const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.min(Math.floor(args.limit), 200) : 100;
|
|
1264
|
+
try {
|
|
1265
|
+
if (args?.useCache === true) {
|
|
1266
|
+
const cache = await readCache();
|
|
1267
|
+
if (cache.shelfBooks.length === 0 && cache.updatedAt === "") return {
|
|
1268
|
+
ok: false,
|
|
1269
|
+
message: "本地缓存为空:请先 weread_sync 或去掉 useCache 实时拉取。",
|
|
1270
|
+
bookCount: 0,
|
|
1271
|
+
albumsCount: 0,
|
|
1272
|
+
mpCount: 0
|
|
1273
|
+
};
|
|
1274
|
+
return renderShelf(cache.shelfBooks, cache.albumsCount, cache.mpCount, cache.notebooks, limit, "缓存(更新于 " + (cache.updatedAt !== "" ? formatDate(Math.floor(new Date(cache.updatedAt).getTime() / 1e3)) : "?") + ")");
|
|
1275
|
+
}
|
|
1276
|
+
const [shelf, notebooks] = await Promise.all([api.shelf(), api.notebooks(200).catch(() => void 0)]);
|
|
1277
|
+
const books = Array.isArray(shelf.books) ? shelf.books : [];
|
|
1278
|
+
const albums = Array.isArray(shelf.albums) ? shelf.albums : [];
|
|
1279
|
+
const mpCount = shelf.mp !== void 0 && shelf.mp !== null ? 1 : 0;
|
|
1280
|
+
const notebookEntries = Array.isArray(notebooks?.books) ? notebooks.books : [];
|
|
1281
|
+
return renderShelf(books, albums.length, mpCount, notebookEntries, limit, "实时");
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
return {
|
|
1284
|
+
ok: false,
|
|
1285
|
+
message: "拉取书架失败:" + apiError(error),
|
|
1286
|
+
bookCount: 0,
|
|
1287
|
+
albumsCount: 0,
|
|
1288
|
+
mpCount: 0
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
/** Shared shelf renderer. */
|
|
1295
|
+
function renderShelf(books, albumsCount, mpCount, notebookEntries, limit, source) {
|
|
1296
|
+
const progressByBookId = /* @__PURE__ */ new Map();
|
|
1297
|
+
for (const entry of notebookEntries) if (typeof entry.readingProgress === "number" && entry.bookId !== void 0) progressByBookId.set(entry.bookId, entry.readingProgress);
|
|
1298
|
+
const lines = books.slice(0, limit).map((book) => shelfLine(book, progressByBookId));
|
|
1299
|
+
const extra = books.length > limit ? "\n…(共 " + books.length + " 本,仅展示前 " + limit + " 本)" : "";
|
|
1300
|
+
return {
|
|
1301
|
+
ok: true,
|
|
1302
|
+
message: "书架可见条目共 " + (books.length + albumsCount + mpCount) + ":" + books.length + " 本电子书" + (albumsCount > 0 ? " + " + albumsCount + " 部有声书" : "") + (mpCount > 0 ? " + " + mpCount + " 个文章收藏" : "") + "(" + source + "):" + (lines.length > 0 ? "\n" + lines.join("\n") + extra : "\n(书架为空)"),
|
|
1303
|
+
bookCount: books.length,
|
|
1304
|
+
albumsCount,
|
|
1305
|
+
mpCount
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
/** Notes tool: notebook overview or per-book highlights + thoughts. */
|
|
1309
|
+
function wereadNotesTool(ctx) {
|
|
1310
|
+
return defineTool$1({
|
|
1311
|
+
name: "weread_notes",
|
|
1312
|
+
description: "查看微信读书笔记:不给 bookId 时返回「笔记本概览」(所有有笔记的书,含划线/想法/笔记数、阅读进度);给 bookId 时返回该书全部划线(markText + 章节 + 时间)与想法(点评 + 章节 + 时间),并附跳转链接。",
|
|
1313
|
+
parameters: {
|
|
1314
|
+
bookId: {
|
|
1315
|
+
type: "string",
|
|
1316
|
+
description: "书籍 bookId(省略=笔记本概览)"
|
|
1317
|
+
},
|
|
1318
|
+
count: {
|
|
1319
|
+
type: "number",
|
|
1320
|
+
description: "概览模式返回条数上限(默认 50)"
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
output: {
|
|
1324
|
+
schema: {
|
|
1325
|
+
type: "object",
|
|
1326
|
+
additionalProperties: false,
|
|
1327
|
+
properties: {
|
|
1328
|
+
ok: {
|
|
1329
|
+
type: "boolean",
|
|
1330
|
+
required: true
|
|
1331
|
+
},
|
|
1332
|
+
message: {
|
|
1333
|
+
type: "string",
|
|
1334
|
+
required: true
|
|
1335
|
+
},
|
|
1336
|
+
bookId: { type: "string" },
|
|
1337
|
+
highlightCount: { type: "number" },
|
|
1338
|
+
reviewCount: { type: "number" }
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1342
|
+
},
|
|
1343
|
+
async execute(args) {
|
|
1344
|
+
const api = await requireApi(ctx);
|
|
1345
|
+
const bookId = (args?.bookId ?? "").trim();
|
|
1346
|
+
if (bookId === "") try {
|
|
1347
|
+
const data = await api.notebooks(typeof args?.count === "number" && args.count > 0 ? Math.min(Math.floor(args.count), 200) : 50);
|
|
1348
|
+
const lines = notebookLines(Array.isArray(data.books) ? data.books : []);
|
|
1349
|
+
return {
|
|
1350
|
+
ok: true,
|
|
1351
|
+
message: "笔记本概览(共 " + String(data.totalBookCount ?? "?") + " 本书、" + String(data.totalNoteCount ?? "?") + " 条笔记/想法/划线):\n" + (lines.length > 0 ? lines.join("\n") : "(暂无笔记)"),
|
|
1352
|
+
highlightCount: 0,
|
|
1353
|
+
reviewCount: 0
|
|
1354
|
+
};
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
return {
|
|
1357
|
+
ok: false,
|
|
1358
|
+
message: "拉取笔记本概览失败:" + apiError(error)
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
const [bookmarks, reviews, info] = await Promise.all([
|
|
1363
|
+
api.bookmarklist(bookId),
|
|
1364
|
+
api.reviewListMine(bookId, 50).catch(() => void 0),
|
|
1365
|
+
api.bookInfo(bookId).catch(() => void 0)
|
|
1366
|
+
]);
|
|
1367
|
+
const highlights = Array.isArray(bookmarks.updated) ? bookmarks.updated : [];
|
|
1368
|
+
const thoughts = Array.isArray(reviews?.reviews) ? reviews.reviews : [];
|
|
1369
|
+
const markdown = buildNotesMarkdown(info?.title ?? "未知书名", info?.author ?? "", highlights, thoughts, bookmarks.chapters);
|
|
1370
|
+
const link = deepLink(bookId, info?.deepLink);
|
|
1371
|
+
return {
|
|
1372
|
+
ok: true,
|
|
1373
|
+
message: markdown + (link !== "" ? "\n\n链接:" + link : ""),
|
|
1374
|
+
bookId,
|
|
1375
|
+
highlightCount: highlights.length,
|
|
1376
|
+
reviewCount: thoughts.length
|
|
1377
|
+
};
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
return {
|
|
1380
|
+
ok: false,
|
|
1381
|
+
message: "拉取笔记失败:" + apiError(error),
|
|
1382
|
+
bookId
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
/** Readdata tool: reading statistics. */
|
|
1389
|
+
function wereadReaddataTool(ctx) {
|
|
1390
|
+
return defineTool$1({
|
|
1391
|
+
name: "weread_readdata",
|
|
1392
|
+
description: "查看微信读书阅读统计:模式支持 weekly/monthly/annually/overall(默认 monthly,可 baseTime 指定统计周期内的 Unix 秒时间戳)。返回总阅读时长、阅读天数、日均时长、阅读/听书时长、排名、偏好分类与读得最久的书。",
|
|
1393
|
+
parameters: {
|
|
1394
|
+
mode: {
|
|
1395
|
+
type: "string",
|
|
1396
|
+
description: "统计模式:weekly / monthly / annually / overall(默认 monthly)"
|
|
1397
|
+
},
|
|
1398
|
+
baseTime: {
|
|
1399
|
+
type: "number",
|
|
1400
|
+
description: "目标周期内的 Unix 时间戳(秒),overall 用 0"
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
output: {
|
|
1404
|
+
schema: {
|
|
1405
|
+
type: "object",
|
|
1406
|
+
additionalProperties: false,
|
|
1407
|
+
properties: {
|
|
1408
|
+
ok: {
|
|
1409
|
+
type: "boolean",
|
|
1410
|
+
required: true
|
|
1411
|
+
},
|
|
1412
|
+
message: {
|
|
1413
|
+
type: "string",
|
|
1414
|
+
required: true
|
|
1415
|
+
},
|
|
1416
|
+
mode: { type: "string" },
|
|
1417
|
+
totalReadTime: { type: "number" },
|
|
1418
|
+
readDays: { type: "number" }
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1422
|
+
},
|
|
1423
|
+
async execute(args) {
|
|
1424
|
+
const mode = typeof args?.mode === "string" && [
|
|
1425
|
+
"weekly",
|
|
1426
|
+
"monthly",
|
|
1427
|
+
"annually",
|
|
1428
|
+
"overall"
|
|
1429
|
+
].includes(args.mode) ? args.mode : "monthly";
|
|
1430
|
+
try {
|
|
1431
|
+
const data = await (await requireApi(ctx)).readdata(mode, args?.baseTime);
|
|
1432
|
+
const lines = ["📊 阅读统计(" + modeLabel(mode) + ")"];
|
|
1433
|
+
const total = data.totalReadTime ?? 0;
|
|
1434
|
+
lines.push("- 总阅读时长:" + formatDuration(total));
|
|
1435
|
+
if (typeof data.readDays === "number") lines.push("- 阅读天数:" + data.readDays + " 天");
|
|
1436
|
+
if (typeof data.dayAverageReadTime === "number" && data.dayAverageReadTime > 0) lines.push("- 日均阅读:" + formatDuration(data.dayAverageReadTime) + "(按自然日)");
|
|
1437
|
+
if (typeof data.readRate === "number" && data.readRate > 0) lines.push("- 文字阅读占比:" + data.readRate + "%" + (typeof data.wrListenTime === "number" && data.wrListenTime > 0 ? "(听书 " + formatDuration(data.wrListenTime) + ")" : ""));
|
|
1438
|
+
const compare = formatCompare(data.compare);
|
|
1439
|
+
if (compare !== "") lines.push("- 较上期日均:" + compare);
|
|
1440
|
+
const rankText = rankTextOf(data.rank);
|
|
1441
|
+
if (rankText !== "") lines.push("- " + rankText);
|
|
1442
|
+
const stats = formatReadStat(data.readStat);
|
|
1443
|
+
if (stats !== "") lines.push("- " + stats);
|
|
1444
|
+
const longest = formatLongest(data.readLongest);
|
|
1445
|
+
if (longest !== "") lines.push("- 读得最久的书:" + longest);
|
|
1446
|
+
const categories = formatCategories(data.preferCategory);
|
|
1447
|
+
if (categories !== "") lines.push("- 偏好分类:" + categories);
|
|
1448
|
+
const authors = formatAuthors(data.preferAuthor);
|
|
1449
|
+
if (authors !== "") lines.push("- 偏好作者:" + authors);
|
|
1450
|
+
if (typeof data.preferTimeWord === "string" && data.preferTimeWord !== "") lines.push("- 偏好时段:" + data.preferTimeWord);
|
|
1451
|
+
return {
|
|
1452
|
+
ok: true,
|
|
1453
|
+
message: lines.join("\n"),
|
|
1454
|
+
mode,
|
|
1455
|
+
totalReadTime: total,
|
|
1456
|
+
readDays: data.readDays ?? 0
|
|
1457
|
+
};
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
return {
|
|
1460
|
+
ok: false,
|
|
1461
|
+
message: "拉取阅读统计失败:" + apiError(error),
|
|
1462
|
+
mode
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
/** Statistic mode label. */
|
|
1469
|
+
function modeLabel(mode) {
|
|
1470
|
+
switch (mode) {
|
|
1471
|
+
case "weekly": return "本周";
|
|
1472
|
+
case "monthly": return "本月";
|
|
1473
|
+
case "annually": return "今年";
|
|
1474
|
+
case "overall": return "累计";
|
|
1475
|
+
default: return mode;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/** compare (ratio vs last period): 0.2 → +20%. */
|
|
1479
|
+
function formatCompare(compare) {
|
|
1480
|
+
if (typeof compare !== "number" || !Number.isFinite(compare)) return "";
|
|
1481
|
+
const percent = Math.round(compare * 100);
|
|
1482
|
+
return (percent >= 0 ? "+" : "") + percent + "%";
|
|
1483
|
+
}
|
|
1484
|
+
/** rank is an object { text, scheme } in the current gateway format. */
|
|
1485
|
+
function rankTextOf(rank) {
|
|
1486
|
+
if (typeof rank !== "object" || rank === null) return "";
|
|
1487
|
+
const record = rank;
|
|
1488
|
+
return typeof record.text === "string" ? record.text : "";
|
|
1489
|
+
}
|
|
1490
|
+
/** readStat[]: { stat, counts } e.g. 读过/读完/笔记 with文案 like '12本'. */
|
|
1491
|
+
function formatReadStat(list) {
|
|
1492
|
+
if (!Array.isArray(list)) return "";
|
|
1493
|
+
const parts = [];
|
|
1494
|
+
for (const entry of list.slice(0, 6)) {
|
|
1495
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1496
|
+
const record = entry;
|
|
1497
|
+
const stat = typeof record.stat === "string" ? record.stat : "";
|
|
1498
|
+
const counts = typeof record.counts === "string" ? record.counts : "";
|
|
1499
|
+
if (stat !== "" && counts !== "") parts.push(stat + " " + counts);
|
|
1500
|
+
}
|
|
1501
|
+
return parts.join(" · ");
|
|
1502
|
+
}
|
|
1503
|
+
/** readLongest[]: { book: {title}, albumInfo: {name}, readTime(秒) }. */
|
|
1504
|
+
function formatLongest(list) {
|
|
1505
|
+
if (!Array.isArray(list)) return "";
|
|
1506
|
+
const parts = [];
|
|
1507
|
+
for (const entry of list.slice(0, 3)) {
|
|
1508
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1509
|
+
const record = entry;
|
|
1510
|
+
const book = typeof record.book === "object" && record.book !== null ? record.book : null;
|
|
1511
|
+
const album = typeof record.albumInfo === "object" && record.albumInfo !== null ? record.albumInfo : null;
|
|
1512
|
+
const title = (book !== null && typeof book.title === "string" ? book.title : "") || (album !== null && typeof album.name === "string" ? album.name : "");
|
|
1513
|
+
if (title === "") continue;
|
|
1514
|
+
const time = typeof record.readTime === "number" && record.readTime > 0 ? "(" + formatDuration(record.readTime) + ")" : "";
|
|
1515
|
+
parts.push("《" + title + "》" + time);
|
|
1516
|
+
}
|
|
1517
|
+
return parts.join("、");
|
|
1518
|
+
}
|
|
1519
|
+
/** preferCategory[]: { categoryTitle, readingTime(秒) }. */
|
|
1520
|
+
function formatCategories(list) {
|
|
1521
|
+
if (!Array.isArray(list)) return "";
|
|
1522
|
+
const parts = [];
|
|
1523
|
+
for (const entry of list.slice(0, 4)) {
|
|
1524
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1525
|
+
const record = entry;
|
|
1526
|
+
const title = typeof record.categoryTitle === "string" ? record.categoryTitle : "";
|
|
1527
|
+
if (title === "") continue;
|
|
1528
|
+
const time = typeof record.readingTime === "number" && record.readingTime > 0 ? "(" + formatDuration(record.readingTime) + ")" : "";
|
|
1529
|
+
parts.push(title + time);
|
|
1530
|
+
}
|
|
1531
|
+
return parts.join("、");
|
|
1532
|
+
}
|
|
1533
|
+
/** preferAuthor[]: { name, count(本), readTime(格式化字符串) }. */
|
|
1534
|
+
function formatAuthors(list) {
|
|
1535
|
+
if (!Array.isArray(list)) return "";
|
|
1536
|
+
const parts = [];
|
|
1537
|
+
for (const entry of list.slice(0, 3)) {
|
|
1538
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1539
|
+
const record = entry;
|
|
1540
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
1541
|
+
if (name === "") continue;
|
|
1542
|
+
const count = typeof record.count === "number" && record.count > 0 ? "(" + record.count + " 本)" : "";
|
|
1543
|
+
parts.push(name + count);
|
|
1544
|
+
}
|
|
1545
|
+
return parts.join("、");
|
|
1546
|
+
}
|
|
1547
|
+
/** Sync tool: pull shelf + notebooks into the local cache. */
|
|
1548
|
+
function wereadSyncTool(ctx) {
|
|
1549
|
+
return defineTool$1({
|
|
1550
|
+
name: "weread_sync",
|
|
1551
|
+
description: "同步微信读书到本地缓存(~/.dsh/weread-export-cache.json):拉取书架与笔记本概览(有笔记的书),更新最近同步时间。之后 weread_shelf useCache / 设置面板可读缓存。",
|
|
1552
|
+
parameters: {},
|
|
1553
|
+
output: {
|
|
1554
|
+
schema: {
|
|
1555
|
+
type: "object",
|
|
1556
|
+
additionalProperties: false,
|
|
1557
|
+
properties: {
|
|
1558
|
+
ok: {
|
|
1559
|
+
type: "boolean",
|
|
1560
|
+
required: true
|
|
1561
|
+
},
|
|
1562
|
+
message: {
|
|
1563
|
+
type: "string",
|
|
1564
|
+
required: true
|
|
1565
|
+
},
|
|
1566
|
+
shelfBooks: { type: "number" },
|
|
1567
|
+
notebooks: { type: "number" }
|
|
1568
|
+
}
|
|
1569
|
+
},
|
|
1570
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1571
|
+
},
|
|
1572
|
+
async execute() {
|
|
1573
|
+
try {
|
|
1574
|
+
const result = await doSync(await requireApi(ctx));
|
|
1575
|
+
await ctx.store.patch({ lastSyncAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1576
|
+
return {
|
|
1577
|
+
ok: result.ok,
|
|
1578
|
+
message: result.message,
|
|
1579
|
+
shelfBooks: result.cache.shelfBooks.length,
|
|
1580
|
+
notebooks: result.cache.notebooks.length
|
|
1581
|
+
};
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
return {
|
|
1584
|
+
ok: false,
|
|
1585
|
+
message: "同步失败:" + apiError(error),
|
|
1586
|
+
shelfBooks: 0,
|
|
1587
|
+
notebooks: 0
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Core export pipeline: pull highlights (+ thoughts), optionally process
|
|
1595
|
+
* through the LLM prompt, then deliver to flomo / local file / Notion.
|
|
1596
|
+
*/
|
|
1597
|
+
async function runExport(ctx, req) {
|
|
1598
|
+
const bookId = (req.bookId ?? "").trim();
|
|
1599
|
+
if (bookId === "") return {
|
|
1600
|
+
ok: false,
|
|
1601
|
+
message: "请提供 bookId。",
|
|
1602
|
+
dest: "flomo"
|
|
1603
|
+
};
|
|
1604
|
+
const creds = await ctx.store.load();
|
|
1605
|
+
const dest = req.dest === "local" || req.dest === "notion" ? req.dest : creds.exportDest;
|
|
1606
|
+
try {
|
|
1607
|
+
const api = await requireApi(ctx);
|
|
1608
|
+
const [bookmarks, info, reviews] = await Promise.all([
|
|
1609
|
+
api.bookmarklist(bookId),
|
|
1610
|
+
api.bookInfo(bookId).catch(() => void 0),
|
|
1611
|
+
api.reviewListMine(bookId, 50).catch(() => void 0)
|
|
1612
|
+
]);
|
|
1613
|
+
const highlights = Array.isArray(bookmarks.updated) ? bookmarks.updated : [];
|
|
1614
|
+
const thoughts = Array.isArray(reviews?.reviews) ? reviews.reviews : [];
|
|
1615
|
+
const title = info?.title ?? "未知书名";
|
|
1616
|
+
const author = info?.author ?? "";
|
|
1617
|
+
if (highlights.length === 0 && thoughts.length === 0) return {
|
|
1618
|
+
ok: false,
|
|
1619
|
+
message: "《" + title + "》暂无划线与想法,未导出。",
|
|
1620
|
+
dest,
|
|
1621
|
+
bookId
|
|
1622
|
+
};
|
|
1623
|
+
const limit = typeof req.limit === "number" && Number.isFinite(req.limit) ? Math.max(0, Math.floor(req.limit)) : creds.exportLimit;
|
|
1624
|
+
const sliceHighlights = limit === 0 || limit >= highlights.length ? highlights : highlights.slice(0, limit);
|
|
1625
|
+
let content = buildExportMarkdown(title, author, sliceHighlights, thoughts, bookmarks.chapters);
|
|
1626
|
+
if (typeof req.usePrompt === "boolean" ? req.usePrompt : creds.usePrompt) {
|
|
1627
|
+
const llm = {
|
|
1628
|
+
baseUrl: creds.llmBaseUrl,
|
|
1629
|
+
apiKey: creds.llmApiKey,
|
|
1630
|
+
model: creds.llmModel
|
|
1631
|
+
};
|
|
1632
|
+
if (!llmConfigured(llm)) return {
|
|
1633
|
+
ok: false,
|
|
1634
|
+
message: "已启用 prompt 处理但 LLM 未配置:请在设置面板「AI」区填写 API Key / Base URL / 模型,或关闭 prompt 开关。",
|
|
1635
|
+
dest,
|
|
1636
|
+
bookId
|
|
1637
|
+
};
|
|
1638
|
+
const template = (req.prompt ?? "").trim() !== "" ? req.prompt : creds.exportPrompt;
|
|
1639
|
+
const highlightsText = sliceHighlights.map((h) => "- “" + (h.markText ?? "").trim() + "”").join("\n");
|
|
1640
|
+
const thoughtsText = thoughts.map((t) => "- " + (t.review?.content ?? "").trim()).join("\n");
|
|
1641
|
+
try {
|
|
1642
|
+
content = await processWithPrompt(llm, template, {
|
|
1643
|
+
title,
|
|
1644
|
+
author,
|
|
1645
|
+
highlights: highlightsText,
|
|
1646
|
+
thoughts: thoughtsText
|
|
1647
|
+
});
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
return {
|
|
1650
|
+
ok: false,
|
|
1651
|
+
message: "LLM 处理失败:" + apiError(error),
|
|
1652
|
+
dest,
|
|
1653
|
+
bookId
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
if (dest === "local") {
|
|
1658
|
+
const dir = (req.localDir ?? "").trim() !== "" ? req.localDir : creds.localExportDir;
|
|
1659
|
+
if (dir === "") return {
|
|
1660
|
+
ok: false,
|
|
1661
|
+
message: "本地导出需要提供目录:请填 localDir 参数(或面板导出时填写导出路径)。",
|
|
1662
|
+
dest,
|
|
1663
|
+
bookId
|
|
1664
|
+
};
|
|
1665
|
+
const file = await exportToLocal(dir, title, content);
|
|
1666
|
+
return {
|
|
1667
|
+
ok: true,
|
|
1668
|
+
message: "已导出 " + (sliceHighlights.length === highlights.length ? "全部 " + highlights.length + " 条" : sliceHighlights.length + " 条(共 " + highlights.length + " 条)") + " 划线到本地:" + file,
|
|
1669
|
+
dest,
|
|
1670
|
+
sent: sliceHighlights.length,
|
|
1671
|
+
file,
|
|
1672
|
+
bookId
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
if (dest === "notion") {
|
|
1676
|
+
if (creds.notionToken.trim() === "") return {
|
|
1677
|
+
ok: false,
|
|
1678
|
+
message: "Notion 未配置:请在设置面板「Notion」区填写 Integration Token(notion.so/my-integrations 创建,页面需分享给该 Integration)。",
|
|
1679
|
+
dest,
|
|
1680
|
+
bookId
|
|
1681
|
+
};
|
|
1682
|
+
if (creds.notionTargetPageId.trim() === "") return {
|
|
1683
|
+
ok: false,
|
|
1684
|
+
message: "Notion 目标页面未配置:请在设置面板「Notion」区填写目标页面 URL 或 ID。",
|
|
1685
|
+
dest,
|
|
1686
|
+
bookId
|
|
1687
|
+
};
|
|
1688
|
+
const pageId = await exportToNotion(creds.notionToken, creds.notionTargetPageId, title, content);
|
|
1689
|
+
return {
|
|
1690
|
+
ok: true,
|
|
1691
|
+
message: "已导出 " + (sliceHighlights.length === highlights.length ? "全部 " + highlights.length + " 条" : sliceHighlights.length + " 条(共 " + highlights.length + " 条)") + " 划线到 Notion 页面:https://www.notion.so/" + pageId,
|
|
1692
|
+
dest,
|
|
1693
|
+
sent: sliceHighlights.length,
|
|
1694
|
+
pageId,
|
|
1695
|
+
bookId
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
const flomoUrl = await resolveFlomoUrl();
|
|
1699
|
+
if (flomoUrl === null) return {
|
|
1700
|
+
ok: false,
|
|
1701
|
+
message: "flomo 未配置:请先在 Web 设置页「Flomo」面板或 flomo_config 配置 API URL / API Key(flomo 设置页 https://flomoapp.com/mine?source=incoming_webhook 获取)。",
|
|
1702
|
+
dest,
|
|
1703
|
+
bookId
|
|
1704
|
+
};
|
|
1705
|
+
const tag = (req.tag ?? "").trim() || creds.defaultFlomoTag;
|
|
1706
|
+
const result = await exportToFlomo(flomoUrl, title, content, tag);
|
|
1707
|
+
const scope = sliceHighlights.length === highlights.length ? "全部 " + highlights.length + " 条" : sliceHighlights.length + " 条(共 " + highlights.length + " 条)";
|
|
1708
|
+
return {
|
|
1709
|
+
ok: result.failed === 0,
|
|
1710
|
+
message: result.sent > 0 ? "已导出 " + scope + " 划线到 flomo(#" + tag + "):" + result.message : result.message,
|
|
1711
|
+
dest,
|
|
1712
|
+
sent: sliceHighlights.length,
|
|
1713
|
+
memoCount: result.memoCount,
|
|
1714
|
+
bookId
|
|
1715
|
+
};
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
return {
|
|
1718
|
+
ok: false,
|
|
1719
|
+
message: "导出失败:" + apiError(error),
|
|
1720
|
+
dest,
|
|
1721
|
+
bookId
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
/** Multi-target export tool: flomo / local file / Notion + optional prompt. */
|
|
1726
|
+
function wereadExportTool(ctx) {
|
|
1727
|
+
return defineTool$1({
|
|
1728
|
+
name: "weread_export",
|
|
1729
|
+
description: "把微信读书某本书的划线/想法导出到指定目标:dest=flomo(默认,复用「Flomo」面板凭据,超长自动拆多条 MEMO)、dest=local(导出到本地 Markdown 文件,需提供 localDir 目录,每次必填)、dest=notion(用本插件配置的 Notion Token 与目标页面创建子页面)。可按配置 exportLimit 控制条数(0=全部)。若配置 usePrompt(或传 prompt),会先按 prompt 用 LLM 整理内容再导出(AI 配置见设置面板)。tag 仅 flomo 用。",
|
|
1730
|
+
parameters: {
|
|
1731
|
+
bookId: {
|
|
1732
|
+
type: "string",
|
|
1733
|
+
required: true,
|
|
1734
|
+
description: "书籍 bookId(来自 weread_shelf / weread_search)"
|
|
1735
|
+
},
|
|
1736
|
+
dest: {
|
|
1737
|
+
type: "string",
|
|
1738
|
+
enum: [
|
|
1739
|
+
"flomo",
|
|
1740
|
+
"local",
|
|
1741
|
+
"notion"
|
|
1742
|
+
],
|
|
1743
|
+
description: "导出目标(默认配置 exportDest,通常是 flomo)"
|
|
1744
|
+
},
|
|
1745
|
+
localDir: {
|
|
1746
|
+
type: "string",
|
|
1747
|
+
description: "dest=local 时必填:本地导出目录(绝对路径)"
|
|
1748
|
+
},
|
|
1749
|
+
tag: {
|
|
1750
|
+
type: "string",
|
|
1751
|
+
description: "flomo 标签(不带 #,可空格分隔多个)"
|
|
1752
|
+
},
|
|
1753
|
+
prompt: {
|
|
1754
|
+
type: "string",
|
|
1755
|
+
description: "临时覆盖导出 prompt(需配合 usePrompt: true 或配置开启)"
|
|
1756
|
+
},
|
|
1757
|
+
usePrompt: {
|
|
1758
|
+
type: "boolean",
|
|
1759
|
+
description: "本次是否用 LLM 按 prompt 处理后再导出(不填用配置 usePrompt)"
|
|
1760
|
+
},
|
|
1761
|
+
limit: {
|
|
1762
|
+
type: "number",
|
|
1763
|
+
description: "临时覆盖导出条数:0=全部,N>0=最多 N 条(不填用配置 exportLimit)"
|
|
1764
|
+
}
|
|
1765
|
+
},
|
|
1766
|
+
output: {
|
|
1767
|
+
schema: {
|
|
1768
|
+
type: "object",
|
|
1769
|
+
additionalProperties: false,
|
|
1770
|
+
properties: {
|
|
1771
|
+
ok: {
|
|
1772
|
+
type: "boolean",
|
|
1773
|
+
required: true
|
|
1774
|
+
},
|
|
1775
|
+
message: {
|
|
1776
|
+
type: "string",
|
|
1777
|
+
required: true
|
|
1778
|
+
},
|
|
1779
|
+
dest: { type: "string" },
|
|
1780
|
+
sent: { type: "number" },
|
|
1781
|
+
file: { type: "string" },
|
|
1782
|
+
pageId: { type: "string" },
|
|
1783
|
+
memoCount: { type: "number" },
|
|
1784
|
+
bookId: { type: "string" }
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1788
|
+
},
|
|
1789
|
+
async execute(args) {
|
|
1790
|
+
return runExport(ctx, args ?? {});
|
|
1791
|
+
}
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
/** Flomo tool: convenience wrapper around weread_export with dest=flomo. */
|
|
1795
|
+
function wereadFlomoTool(ctx) {
|
|
1796
|
+
return defineTool$1({
|
|
1797
|
+
name: "weread_flomo",
|
|
1798
|
+
description: "把微信读书某本书的划线导出到 flomo(浮墨笔记):发送带 #标签 的 MEMO(书名 + 划线列表)。默认按插件配置的导出条数(exportLimit,见 weread_status / 设置面板;0=全部导出,超长自动拆成多条 MEMO 发送)。limit 参数可临时覆盖(0=全部)。标签可用 tag 参数自定义(不填用插件默认标签)。若配置了 usePrompt,导出前会用 LLM 按 prompt 整理(AI 配置见设置面板)。复用 ~/.dsh/dsh-flomo.json 的 flomo 凭据,无需重复配置。",
|
|
1799
|
+
parameters: {
|
|
1800
|
+
bookId: {
|
|
1801
|
+
type: "string",
|
|
1802
|
+
required: true,
|
|
1803
|
+
description: "书籍 bookId(来自 weread_shelf / weread_search)"
|
|
1804
|
+
},
|
|
1805
|
+
tag: {
|
|
1806
|
+
type: "string",
|
|
1807
|
+
description: "flomo 标签(不带 #,可用空格分隔多个,如 读书笔记 微信读书)"
|
|
1808
|
+
},
|
|
1809
|
+
limit: {
|
|
1810
|
+
type: "number",
|
|
1811
|
+
description: "临时覆盖导出条数:0=全部导出,N>0=最多 N 条(不填用配置 exportLimit)"
|
|
1812
|
+
}
|
|
1813
|
+
},
|
|
1814
|
+
output: {
|
|
1815
|
+
schema: {
|
|
1816
|
+
type: "object",
|
|
1817
|
+
additionalProperties: false,
|
|
1818
|
+
properties: {
|
|
1819
|
+
ok: {
|
|
1820
|
+
type: "boolean",
|
|
1821
|
+
required: true
|
|
1822
|
+
},
|
|
1823
|
+
message: {
|
|
1824
|
+
type: "string",
|
|
1825
|
+
required: true
|
|
1826
|
+
},
|
|
1827
|
+
sent: { type: "number" },
|
|
1828
|
+
memoCount: { type: "number" },
|
|
1829
|
+
bookId: { type: "string" }
|
|
1830
|
+
}
|
|
1831
|
+
},
|
|
1832
|
+
render: (_args, value) => text(String(value.message ?? ""))
|
|
1833
|
+
},
|
|
1834
|
+
async execute(args) {
|
|
1835
|
+
return runExport(ctx, {
|
|
1836
|
+
...args ?? {},
|
|
1837
|
+
dest: "flomo"
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
/** Build every weread tool. */
|
|
1843
|
+
function buildTools(ctx) {
|
|
1844
|
+
return [
|
|
1845
|
+
wereadStatusTool(ctx),
|
|
1846
|
+
wereadConfigTool(ctx),
|
|
1847
|
+
wereadSearchTool(ctx),
|
|
1848
|
+
wereadBookTool(ctx),
|
|
1849
|
+
wereadShelfTool(ctx),
|
|
1850
|
+
wereadNotesTool(ctx),
|
|
1851
|
+
wereadReaddataTool(ctx),
|
|
1852
|
+
wereadSyncTool(ctx),
|
|
1853
|
+
wereadExportTool(ctx),
|
|
1854
|
+
wereadFlomoTool(ctx)
|
|
1855
|
+
];
|
|
1856
|
+
}
|
|
1857
|
+
//#endregion
|
|
1858
|
+
//#region src/routes.ts
|
|
1859
|
+
/** Route paths. */
|
|
1860
|
+
const WEREAD_API = {
|
|
1861
|
+
config: "/api/weread-export/config",
|
|
1862
|
+
status: "/api/weread-export/status",
|
|
1863
|
+
test: "/api/weread-export/test",
|
|
1864
|
+
sync: "/api/weread-export/sync",
|
|
1865
|
+
export: "/api/weread-export/export",
|
|
1866
|
+
flomo: "/api/weread-export/flomo",
|
|
1867
|
+
books: "/api/weread-export/books"
|
|
1868
|
+
};
|
|
1869
|
+
/** Cap on JSON request bodies. */
|
|
1870
|
+
const MAX_JSON_BODY_BYTES = 256 * 1024;
|
|
1871
|
+
/** Strict loopback fence for all routes. */
|
|
1872
|
+
function isLoopbackRequest(request) {
|
|
1873
|
+
const address = request.socket.remoteAddress;
|
|
1874
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
1875
|
+
const host = request.headers.host;
|
|
1876
|
+
if (typeof host !== "string") return false;
|
|
1877
|
+
let hostUrl;
|
|
1878
|
+
try {
|
|
1879
|
+
hostUrl = new URL(`http://${host}`);
|
|
1880
|
+
} catch {
|
|
1881
|
+
return false;
|
|
1882
|
+
}
|
|
1883
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
1884
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1885
|
+
const origin = request.headers.origin;
|
|
1886
|
+
if (origin === void 0) return true;
|
|
1887
|
+
try {
|
|
1888
|
+
return new URL(origin).host === hostUrl.host;
|
|
1889
|
+
} catch {
|
|
1890
|
+
return false;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
/** One JSON response. */
|
|
1894
|
+
function writeJson(res, status, body) {
|
|
1895
|
+
const payload = JSON.stringify(body);
|
|
1896
|
+
res.writeHead(status, {
|
|
1897
|
+
"content-type": "application/json; charset=utf-8",
|
|
1898
|
+
"referrer-policy": "no-referrer"
|
|
1899
|
+
});
|
|
1900
|
+
res.end(payload);
|
|
1901
|
+
}
|
|
1902
|
+
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
1903
|
+
async function readJsonBody(req) {
|
|
1904
|
+
const chunks = [];
|
|
1905
|
+
let size = 0;
|
|
1906
|
+
for await (const chunk of req) {
|
|
1907
|
+
const buffer = chunk;
|
|
1908
|
+
size += buffer.length;
|
|
1909
|
+
if (size > MAX_JSON_BODY_BYTES) return void 0;
|
|
1910
|
+
chunks.push(buffer);
|
|
1911
|
+
}
|
|
1912
|
+
try {
|
|
1913
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1914
|
+
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
1915
|
+
} catch {
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
/** Build the api client from the store's current key. */
|
|
1920
|
+
async function apiFor(store) {
|
|
1921
|
+
return new WereadApi((await store.load()).apiKey);
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Build every /api/weread-export route (exact paths).
|
|
1925
|
+
* @param deps - store (the API client is built lazily per request).
|
|
1926
|
+
* @returns the route list.
|
|
1927
|
+
*/
|
|
1928
|
+
function makeRoutes(deps) {
|
|
1929
|
+
const { store } = deps;
|
|
1930
|
+
const guard = (req, res, method) => {
|
|
1931
|
+
if (!isLoopbackRequest(req)) {
|
|
1932
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1933
|
+
return false;
|
|
1934
|
+
}
|
|
1935
|
+
if (req.method !== method) {
|
|
1936
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1937
|
+
return false;
|
|
1938
|
+
}
|
|
1939
|
+
return true;
|
|
1940
|
+
};
|
|
1941
|
+
return [
|
|
1942
|
+
{
|
|
1943
|
+
kind: "exact",
|
|
1944
|
+
path: WEREAD_API.config,
|
|
1945
|
+
handler: async (req, res) => {
|
|
1946
|
+
const method = req.method ?? "GET";
|
|
1947
|
+
if (method === "GET") {
|
|
1948
|
+
if (!guard(req, res, "GET")) return;
|
|
1949
|
+
writeJson(res, 200, await store.view());
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
if (method === "POST") {
|
|
1953
|
+
if (!guard(req, res, "POST")) return;
|
|
1954
|
+
const body = await readJsonBody(req);
|
|
1955
|
+
if (body === void 0) {
|
|
1956
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
writeJson(res, 200, await store.patch(body));
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
1963
|
+
}
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
kind: "exact",
|
|
1967
|
+
path: WEREAD_API.status,
|
|
1968
|
+
handler: async (req, res) => {
|
|
1969
|
+
if (!guard(req, res, "GET")) return;
|
|
1970
|
+
const view = await store.view();
|
|
1971
|
+
const cache = await readCache();
|
|
1972
|
+
const flomoOk = await resolveFlomoUrl().then((url) => url !== null).catch(() => false);
|
|
1973
|
+
const noteBooks = cache.notebooks.filter((b) => (b.reviewCount ?? 0) + (b.noteCount ?? 0) + (b.bookmarkCount ?? 0) > 0).length;
|
|
1974
|
+
writeJson(res, 200, {
|
|
1975
|
+
...view,
|
|
1976
|
+
flomoConfigured: flomoOk,
|
|
1977
|
+
cachedShelfBooks: cache.shelfBooks.length,
|
|
1978
|
+
cachedNoteBooks: noteBooks,
|
|
1979
|
+
cacheUpdatedAt: cache.updatedAt
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
},
|
|
1983
|
+
{
|
|
1984
|
+
kind: "exact",
|
|
1985
|
+
path: WEREAD_API.test,
|
|
1986
|
+
handler: async (req, res) => {
|
|
1987
|
+
if (!guard(req, res, "POST")) return;
|
|
1988
|
+
if (!(await store.view()).configured) {
|
|
1989
|
+
writeJson(res, 400, { error: "未配置微信读书 API Key:请先在面板填写 Key。" });
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
try {
|
|
1993
|
+
await (await apiFor(store)).list();
|
|
1994
|
+
writeJson(res, 200, {
|
|
1995
|
+
ok: true,
|
|
1996
|
+
message: "连接成功:微信读书 Skills 网关可用。"
|
|
1997
|
+
});
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
writeJson(res, 200, {
|
|
2000
|
+
ok: false,
|
|
2001
|
+
message: "连接失败:" + String(error instanceof Error ? error.message : error)
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
},
|
|
2006
|
+
{
|
|
2007
|
+
kind: "exact",
|
|
2008
|
+
path: WEREAD_API.sync,
|
|
2009
|
+
handler: async (req, res) => {
|
|
2010
|
+
if (!guard(req, res, "POST")) return;
|
|
2011
|
+
if (!(await store.view()).configured) {
|
|
2012
|
+
writeJson(res, 400, { error: "未配置微信读书 API Key:请先在面板填写 Key。" });
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
const result = await doSync(await apiFor(store));
|
|
2017
|
+
await store.patch({ lastSyncAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2018
|
+
writeJson(res, 200, result);
|
|
2019
|
+
} catch (error) {
|
|
2020
|
+
writeJson(res, 200, {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
message: "同步失败:" + String(error instanceof Error ? error.message : error)
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
},
|
|
2027
|
+
{
|
|
2028
|
+
kind: "exact",
|
|
2029
|
+
path: WEREAD_API.books,
|
|
2030
|
+
handler: async (req, res) => {
|
|
2031
|
+
if (!guard(req, res, "GET")) return;
|
|
2032
|
+
const cache = await readCache();
|
|
2033
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2034
|
+
for (const book of cache.shelfBooks) if (book.bookId !== void 0 && book.bookId !== "") byId.set(book.bookId, {
|
|
2035
|
+
bookId: book.bookId,
|
|
2036
|
+
title: book.title ?? "未知书名",
|
|
2037
|
+
author: book.author ?? ""
|
|
2038
|
+
});
|
|
2039
|
+
for (const entry of cache.notebooks) if (entry.bookId !== void 0 && entry.bookId !== "" && !byId.has(entry.bookId)) byId.set(entry.bookId, {
|
|
2040
|
+
bookId: entry.bookId,
|
|
2041
|
+
title: entry.book?.title ?? "未知书名",
|
|
2042
|
+
author: entry.book?.author ?? ""
|
|
2043
|
+
});
|
|
2044
|
+
const books = [...byId.values()].sort((a, b) => a.title.localeCompare(b.title, "zh"));
|
|
2045
|
+
writeJson(res, 200, {
|
|
2046
|
+
books,
|
|
2047
|
+
count: books.length
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
},
|
|
2051
|
+
{
|
|
2052
|
+
kind: "exact",
|
|
2053
|
+
path: WEREAD_API.export,
|
|
2054
|
+
handler: async (req, res) => {
|
|
2055
|
+
if (!guard(req, res, "POST")) return;
|
|
2056
|
+
if (!(await store.view()).configured) {
|
|
2057
|
+
writeJson(res, 400, { error: "未配置微信读书 API Key。" });
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
const body = await readJsonBody(req) ?? {};
|
|
2061
|
+
const req2 = {
|
|
2062
|
+
bookId: typeof body.bookId === "string" ? body.bookId : void 0,
|
|
2063
|
+
dest: body.dest === "local" || body.dest === "notion" ? body.dest : void 0,
|
|
2064
|
+
localDir: typeof body.localDir === "string" ? body.localDir : void 0,
|
|
2065
|
+
tag: typeof body.tag === "string" ? body.tag : void 0,
|
|
2066
|
+
prompt: typeof body.prompt === "string" ? body.prompt : void 0,
|
|
2067
|
+
usePrompt: typeof body.usePrompt === "boolean" ? body.usePrompt : void 0,
|
|
2068
|
+
limit: typeof body.limit === "number" && Number.isFinite(body.limit) ? body.limit : void 0
|
|
2069
|
+
};
|
|
2070
|
+
if ((req2.bookId ?? "").trim() === "") {
|
|
2071
|
+
writeJson(res, 400, { error: "缺少 bookId。" });
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
writeJson(res, 200, await runExport({ store }, req2));
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
{
|
|
2078
|
+
kind: "exact",
|
|
2079
|
+
path: WEREAD_API.flomo,
|
|
2080
|
+
handler: async (req, res) => {
|
|
2081
|
+
if (!guard(req, res, "POST")) return;
|
|
2082
|
+
if (!(await store.view()).configured) {
|
|
2083
|
+
writeJson(res, 400, { error: "未配置微信读书 API Key。" });
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const body = await readJsonBody(req) ?? {};
|
|
2087
|
+
const req2 = {
|
|
2088
|
+
bookId: typeof body.bookId === "string" ? body.bookId : void 0,
|
|
2089
|
+
dest: "flomo",
|
|
2090
|
+
tag: typeof body.tag === "string" ? body.tag : void 0,
|
|
2091
|
+
limit: typeof body.limit === "number" && Number.isFinite(body.limit) ? body.limit : void 0
|
|
2092
|
+
};
|
|
2093
|
+
if ((req2.bookId ?? "").trim() === "") {
|
|
2094
|
+
writeJson(res, 400, { error: "缺少 bookId。" });
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
writeJson(res, 200, await runExport({ store }, req2));
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
];
|
|
2101
|
+
}
|
|
2102
|
+
//#endregion
|
|
2103
|
+
//#region src/index.ts
|
|
2104
|
+
/** Stable cordis plugin name. */
|
|
2105
|
+
const name = "weread";
|
|
2106
|
+
/** Services required before the weread surfaces can mount. */
|
|
2107
|
+
const inject = [
|
|
2108
|
+
"tools",
|
|
2109
|
+
"systemPrompt",
|
|
2110
|
+
"webServer"
|
|
2111
|
+
];
|
|
2112
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
2113
|
+
const SECTION_ORDER = 160;
|
|
2114
|
+
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
2115
|
+
const WEREAD_GUIDANCE = "本机已安装 weread-export 插件(微信读书集成):配置一次官方 Skills API Key(wrk- 开头,在 https://weread.qq.com/r/weread-skills 用微信读书账号登录后「创建 Key」获取)后,可用 weread_shelf 查看书架、weread_notes 导出划线/想法/书签(不给 bookId 时返回笔记本概览)、weread_search 搜索书城、weread_book 查看书籍详情/进度/章节、weread_readdata 查看阅读统计(weekly/monthly/annually/overall)、weread_sync 同步本地缓存。导出:weread_export 支持三个目标——flomo(默认,超长自动拆多条 MEMO)、local(本地 Markdown 文件,需 localDir)、notion(本插件独立配置 Token 与目标页);weread_flomo 是 flomo 快捷方式。可按配置 exportLimit 控制条数(0=全部),并可用 usePrompt/exportPrompt 让 LLM 按自定义 prompt 整理后再导出(AI 配置在设置面板,OpenAI 兼容,可自定义 Base URL/Key/模型)。凭据存 ~/.dsh/weread-export.json(权限 0600),同步快照存 ~/.dsh/weread-export-cache.json;weread_status 查看状态与导出配置(不回显完整 Key)。也可在 Web 设置页「微信读书」面板中配置 Key、导出目标(flomo/本地/Notion)、导出条数、prompt 与 AI 配置、测试连接、同步与快捷导出。用户提到「微信读书 / weread / 读书笔记 / 导出划线 / 阅读统计」时即指本插件,请据此协作。";
|
|
2116
|
+
/**
|
|
2117
|
+
* Mount the weread tools, routes, and announcement.
|
|
2118
|
+
* @param ctx - host plugin context carrying tools/systemPrompt/webServer.
|
|
2119
|
+
* @param config - plugin config from the composition row.
|
|
2120
|
+
*/
|
|
2121
|
+
function apply(ctx, config) {
|
|
2122
|
+
const announceToAgent = config?.announceToAgent !== false;
|
|
2123
|
+
const enabled = config?.enabled !== false;
|
|
2124
|
+
const store = new WereadStore();
|
|
2125
|
+
const toolContext = { store };
|
|
2126
|
+
let disposeTools;
|
|
2127
|
+
let disposeRoutes;
|
|
2128
|
+
let disposeSection;
|
|
2129
|
+
const sync = () => {
|
|
2130
|
+
if (disposeTools !== void 0) {
|
|
2131
|
+
disposeTools();
|
|
2132
|
+
disposeTools = void 0;
|
|
2133
|
+
}
|
|
2134
|
+
if (disposeRoutes !== void 0) {
|
|
2135
|
+
disposeRoutes();
|
|
2136
|
+
disposeRoutes = void 0;
|
|
2137
|
+
}
|
|
2138
|
+
if (disposeSection !== void 0) {
|
|
2139
|
+
disposeSection();
|
|
2140
|
+
disposeSection = void 0;
|
|
2141
|
+
}
|
|
2142
|
+
if (!enabled) return;
|
|
2143
|
+
disposeTools = ctx.effect(() => {
|
|
2144
|
+
const disposers = buildTools(toolContext).map((tool) => ctx.tools.register(tool));
|
|
2145
|
+
return () => {
|
|
2146
|
+
for (const dispose of disposers) dispose();
|
|
2147
|
+
};
|
|
2148
|
+
}, "weread-export: tools");
|
|
2149
|
+
disposeRoutes = ctx.effect(() => {
|
|
2150
|
+
const disposers = makeRoutes({ store }).map((route) => ctx.webServer.register(route));
|
|
2151
|
+
return () => {
|
|
2152
|
+
for (const dispose of disposers) dispose();
|
|
2153
|
+
};
|
|
2154
|
+
}, "weread-export: routes");
|
|
2155
|
+
if (announceToAgent) disposeSection = ctx.systemPrompt.section({
|
|
2156
|
+
name: "plugin:weread-export",
|
|
2157
|
+
order: SECTION_ORDER,
|
|
2158
|
+
text: WEREAD_GUIDANCE
|
|
2159
|
+
});
|
|
2160
|
+
};
|
|
2161
|
+
sync();
|
|
2162
|
+
}
|
|
2163
|
+
//#endregion
|
|
2164
|
+
export { DEFAULT_EXPORT_PROMPT, FLOMO_CONFIG_FILE, FLOMO_MAX_CHARS, NOTION_API, NOTION_VERSION, SKILL_VERSION, WEREAD_API, WEREAD_GATEWAY, WEREAD_GUIDANCE, WereadApi, WereadApiError, WereadStore, apply, buildExportMarkdown, buildFlomoMemo, buildFlomoMemos, buildNotesMarkdown, buildTaggedContent, buildTools, cachePath, chatComplete, chunkText, configPath, dateLabel, deepLink, defineTool, doSync, emptyCache, exportToFlomo, exportToLocal, exportToNotion, flomoConfigured, formatDate, formatDuration, formatRating, inject, llmConfigured, makeRoutes, mask, name, normalizeNotionPageId, notebookLines, postMemo, processWithPrompt, readCache, renderPrompt, resolveFlomoUrl, runExport, shelfLine, toNotionBlocks, wereadBookTool, wereadConfigTool, wereadExportTool, wereadFlomoTool, wereadNotesTool, wereadReaddataTool, wereadSearchTool, wereadShelfTool, wereadStatusTool, wereadSyncTool, writeCache };
|