dsh-plugin-lookatstudy 0.11.1 → 0.12.0
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/README.md +0 -1
- package/lib/client.js +29 -98
- package/lib/client.js.map +1 -1
- package/lib/epub-parser-oH96guBW.mjs +306 -0
- package/lib/{html-article-C6nhqJbX.mjs → html-article-Da8ksU0i.mjs} +35 -1
- package/lib/index.mjs +56 -26
- package/lib/{pptx-parser-CD5pR2cj.mjs → pptx-parser-B9IDkiGM.mjs} +50 -2
- package/package.json +1 -1
- package/lib/epub-parser-DvlKap-d.mjs +0 -112
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { n as htmlToMarkdown } from "./html-article-Da8ksU0i.mjs";
|
|
2
|
+
import { n as readZipText, t as readZip } from "./zip-reader-KnRrq0av.mjs";
|
|
3
|
+
//#region src/vendor/epub-parser.ts
|
|
4
|
+
/** 提取某标签的全部出现(OPF 的属性顺序不定,先抓整标签再逐个提属性)。 */
|
|
5
|
+
function tags(xml, tagName) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const re = new RegExp(`<${tagName}\\b[^>]*>`, "g");
|
|
8
|
+
let m;
|
|
9
|
+
while ((m = re.exec(xml)) !== null) {
|
|
10
|
+
const raw = m[0];
|
|
11
|
+
const attrs = {};
|
|
12
|
+
const attrRe = /([\w:-]+)\s*=\s*"([^"]*)"/g;
|
|
13
|
+
let a;
|
|
14
|
+
while ((a = attrRe.exec(raw)) !== null) attrs[a[1]] = a[2];
|
|
15
|
+
out.push({
|
|
16
|
+
raw,
|
|
17
|
+
attrs
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function firstTagText(xml, tagName) {
|
|
23
|
+
return xml.match(new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, "i"))?.[1]?.trim() ?? "";
|
|
24
|
+
}
|
|
25
|
+
/** zip 内路径归一:posix 分隔 + href 相对 OPF 目录解析。 */
|
|
26
|
+
function resolveZipPath(opfPath, href) {
|
|
27
|
+
const cleanHref = decodeURIComponent(href.split("#")[0] ?? href).replace(/\\/g, "/");
|
|
28
|
+
if (!opfPath.includes("/")) return cleanHref.replace(/^\.\//, "");
|
|
29
|
+
const parts = `${opfPath.slice(0, opfPath.lastIndexOf("/"))}/${cleanHref}`.split("/");
|
|
30
|
+
const resolved = [];
|
|
31
|
+
for (const p of parts) if (p === "..") resolved.pop();
|
|
32
|
+
else if (p !== "." && p !== "") resolved.push(p);
|
|
33
|
+
return resolved.join("/");
|
|
34
|
+
}
|
|
35
|
+
/** EPUB3 nav.xhtml 或 EPUB2 toc.ncx → href(去 fragment)→ 章节标题。 */
|
|
36
|
+
function parseTocLabels(tocXml, isNcx) {
|
|
37
|
+
const labels = /* @__PURE__ */ new Map();
|
|
38
|
+
if (isNcx) {
|
|
39
|
+
const blockRe = /<navPoint\b[\s\S]*?<\/navPoint>/g;
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = blockRe.exec(tocXml)) !== null) {
|
|
42
|
+
const label = firstTagText(m[0], "text");
|
|
43
|
+
const src = m[0].match(/<content[^>]+src="([^"]+)"/i)?.[1];
|
|
44
|
+
if (label && src) labels.set(decodeURIComponent(src.split("#")[0].replace(/\\/g, "/")), label);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
const aRe = /<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = aRe.exec(tocXml)) !== null) {
|
|
50
|
+
const label = (m[2] ?? "").replace(/<[^>]+>/g, "").trim();
|
|
51
|
+
if (label) labels.set(decodeURIComponent((m[1] ?? "").split("#")[0].replace(/\\/g, "/")), label);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return labels;
|
|
55
|
+
}
|
|
56
|
+
function sanitizeFileName(title) {
|
|
57
|
+
return (title || "chapter").replace(/[\\/:*?"<>|#\s]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50) || "chapter";
|
|
58
|
+
}
|
|
59
|
+
/** 文件内章标记。两类形态:heading 行(# 前缀)与**裸行**(Gutenberg 常见——章号在
|
|
60
|
+
* 原书 html 里是段落不是标题,转 markdown 后成裸文本 "CHAPTER XX.")。
|
|
61
|
+
* 裸行限行长(≤60)防正文中引用句误切;罗马数字/裸序号低置信需序列验证。 */
|
|
62
|
+
const CH_HEADING = /^(?:#{1,3}\s*)?(?:CHAPTER|Chapter|chap\.)\s+[IVXLC0-9]{1,7}\b[.:)]?\s*(.*)$/;
|
|
63
|
+
const LETTER_HEADING = /^(?:#{1,3}\s*)?(?:Letter|LETTER)\s+\d{1,3}\b[.:)]?\s*(.*)$/;
|
|
64
|
+
const ROMAN_HEADING = /^(?:#{1,3}\s*)?(?:[—[]\s*)?([IVXLC]{1,7})\s*[—\]]?\.?\s*$/;
|
|
65
|
+
const NUM_HEADING = /^(?:#{1,3}\s*)?[—[]?\s*(\d{1,3})\s*[—\]]?\.?\s*$/;
|
|
66
|
+
const romanValue = (s) => {
|
|
67
|
+
const vals = {
|
|
68
|
+
I: 1,
|
|
69
|
+
V: 5,
|
|
70
|
+
X: 10,
|
|
71
|
+
L: 50,
|
|
72
|
+
C: 100
|
|
73
|
+
};
|
|
74
|
+
let v = 0;
|
|
75
|
+
for (let i = 0; i < s.length; i++) {
|
|
76
|
+
const cur = vals[s[i]] ?? 0;
|
|
77
|
+
const next = vals[s[i + 1]] ?? 0;
|
|
78
|
+
v += cur < next ? -cur : cur;
|
|
79
|
+
}
|
|
80
|
+
return v;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* 文件内按章标记切分。返回 null = 无高置信切分(保持单章)。
|
|
84
|
+
* 置信规则:CHAPTER/Letter 标记 ≥2 即切;罗马数字/裸序号须形成连续递增
|
|
85
|
+
* 序列(≥3 个)才切——正文中孤立的 "I" / "2" 不当章号。
|
|
86
|
+
* 首标记前的引言/卷头(<800 字符)并入首章;够长则独立成"引言"章。
|
|
87
|
+
*/
|
|
88
|
+
function splitChaptersInBody(body, opts) {
|
|
89
|
+
const lines = body.split("\n");
|
|
90
|
+
const marks = [];
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
const ln = lines[i].trimEnd();
|
|
93
|
+
if (!ln || ln.length > 60) continue;
|
|
94
|
+
const strip = (s) => s.replace(/^#{1,3}\s*/, "");
|
|
95
|
+
let m = ln.match(CH_HEADING);
|
|
96
|
+
if (m) {
|
|
97
|
+
marks.push({
|
|
98
|
+
line: i,
|
|
99
|
+
title: strip(ln),
|
|
100
|
+
kind: "ch",
|
|
101
|
+
seq: 0
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
m = ln.match(LETTER_HEADING);
|
|
106
|
+
if (m) {
|
|
107
|
+
marks.push({
|
|
108
|
+
line: i,
|
|
109
|
+
title: strip(ln),
|
|
110
|
+
kind: "letter",
|
|
111
|
+
seq: 0
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
m = ln.match(ROMAN_HEADING);
|
|
116
|
+
if (m && (ln.startsWith("#") || /[—[]/.test(ln))) {
|
|
117
|
+
marks.push({
|
|
118
|
+
line: i,
|
|
119
|
+
title: strip(ln),
|
|
120
|
+
kind: "roman",
|
|
121
|
+
seq: romanValue(m[1])
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
m = ln.match(NUM_HEADING);
|
|
126
|
+
if (m && (ln.startsWith("#") || /[—[]/.test(ln))) {
|
|
127
|
+
marks.push({
|
|
128
|
+
line: i,
|
|
129
|
+
title: strip(ln),
|
|
130
|
+
kind: "num",
|
|
131
|
+
seq: parseInt(m[1], 10)
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const minHigh = opts?.minHighConf ?? 2;
|
|
137
|
+
const highConf = marks.filter((mk) => mk.kind === "ch" || mk.kind === "letter");
|
|
138
|
+
let splitMarks;
|
|
139
|
+
if (highConf.length >= minHigh) splitMarks = highConf;
|
|
140
|
+
else {
|
|
141
|
+
splitMarks = [];
|
|
142
|
+
for (const kind of ["roman", "num"]) {
|
|
143
|
+
const seq = marks.filter((mk) => mk.kind === kind);
|
|
144
|
+
const chain = [];
|
|
145
|
+
let expect = 0;
|
|
146
|
+
for (const mk of seq) if (expect === 0) {
|
|
147
|
+
if (mk.seq === 1 || mk.seq === 2) {
|
|
148
|
+
chain.push(mk);
|
|
149
|
+
expect = mk.seq + 1;
|
|
150
|
+
}
|
|
151
|
+
} else if (mk.seq === expect) {
|
|
152
|
+
chain.push(mk);
|
|
153
|
+
expect++;
|
|
154
|
+
}
|
|
155
|
+
if (chain.length >= 3) splitMarks.push(...chain);
|
|
156
|
+
}
|
|
157
|
+
if (splitMarks.length < 3) return null;
|
|
158
|
+
}
|
|
159
|
+
if (splitMarks.length < 1) return null;
|
|
160
|
+
const out = [];
|
|
161
|
+
const preamble = lines.slice(0, splitMarks[0].line).join("\n").trim();
|
|
162
|
+
for (let k = 0; k < splitMarks.length; k++) {
|
|
163
|
+
const start = splitMarks[k].line;
|
|
164
|
+
const end = k + 1 < splitMarks.length ? splitMarks[k + 1].line : lines.length;
|
|
165
|
+
let content = lines.slice(start + 1, end).join("\n").trim();
|
|
166
|
+
if (k === 0 && preamble && preamble.length < 800) content = `${preamble}\n\n${content}`.trim();
|
|
167
|
+
out.push({
|
|
168
|
+
title: splitMarks[k].title,
|
|
169
|
+
content
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (preamble && preamble.length >= 800) out.unshift({
|
|
173
|
+
title: "引言",
|
|
174
|
+
content: preamble
|
|
175
|
+
});
|
|
176
|
+
return out.filter((c) => c.content.replace(/\s/g, "").length > 0);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 清理电子书正文噪声(Gutenberg 头/尾块、1894 插图版装饰行)。
|
|
180
|
+
* 只删确定性标志,不猜内容:
|
|
181
|
+
* - 头:"The Project Gutenberg eBook of…" 段落(heading 形态)
|
|
182
|
+
* - 尾:"THE FULL PROJECT GUTENBERG™ LICENSE" 起截断到文件尾;"End of the Project Gutenberg" 行起同理
|
|
183
|
+
* - 行内:`[ _Copyright 1894 by …_ ]` 插图版装饰片段
|
|
184
|
+
*/
|
|
185
|
+
function sanitizeEpubBody(body) {
|
|
186
|
+
let md = body;
|
|
187
|
+
const lines0 = md.split("\n");
|
|
188
|
+
for (let i = 0; i < lines0.length; i++) {
|
|
189
|
+
const ln = lines0[i].trim();
|
|
190
|
+
if (ln.length <= 100 && (/(?:THE\s+)?FULL PROJECT GUTENBERG/i.test(ln) || /END OF THE PROJECT GUTENBERG/i.test(ln) || ln === "START: FULL LICENSE" || ln === "START: THE FULL LICENSE")) {
|
|
191
|
+
md = lines0.slice(0, i).join("\n");
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
md = md.replace(/^#{1,3}\s*(?:The Project Gutenberg eBook[^*\n]*|Project Gutenberg[^*\n]*)\n[\s\S]*?\n(?:\n|$)/im, "");
|
|
196
|
+
md = md.replace(/^#{1,3}[ \t]*$/gm, "");
|
|
197
|
+
md = md.replace(/\\?\[?\s*_?Copyright 1[6-9]\d\d[^_\n]*_?\s*\\?]?/g, "");
|
|
198
|
+
return md.trim();
|
|
199
|
+
}
|
|
200
|
+
function parseEpub(buf) {
|
|
201
|
+
const entries = readZip(buf);
|
|
202
|
+
const read = (p) => readZipText(entries, p);
|
|
203
|
+
const opfPath = read("META-INF/container.xml").match(/full-path="([^"]+)"/i)?.[1];
|
|
204
|
+
if (!opfPath) throw new Error("epub 结构异常:找不到 container.xml 里的 OPF 路径");
|
|
205
|
+
const opf = read(opfPath);
|
|
206
|
+
if (!opf) throw new Error(`epub 结构异常:OPF 文件缺失(${opfPath})`);
|
|
207
|
+
const bookTitle = firstTagText(opf, "dc:title") || "未命名电子书";
|
|
208
|
+
const manifest = /* @__PURE__ */ new Map();
|
|
209
|
+
for (const t of tags(opf, "item")) manifest.set(t.attrs["id"] ?? "", {
|
|
210
|
+
href: t.attrs["href"] ?? "",
|
|
211
|
+
mediaType: (t.attrs["media-type"] ?? "").toLowerCase(),
|
|
212
|
+
properties: t.attrs["properties"] ?? ""
|
|
213
|
+
});
|
|
214
|
+
const spineIds = tags(opf, "itemref").map((t) => t.attrs["idref"] ?? "").filter(Boolean);
|
|
215
|
+
const spineTocId = opf.match(/<spine\b[^>]*\btoc="([^"]+)"/i)?.[1];
|
|
216
|
+
let tocLabels = /* @__PURE__ */ new Map();
|
|
217
|
+
const navItem = [...manifest.values()].find((it) => it.properties.split(/\s+/).includes("nav"));
|
|
218
|
+
if (navItem?.href) tocLabels = parseTocLabels(read(resolveZipPath(opfPath, navItem.href)), false);
|
|
219
|
+
if (tocLabels.size === 0 && spineTocId && manifest.has(spineTocId)) tocLabels = parseTocLabels(read(resolveZipPath(opfPath, manifest.get(spineTocId).href)), true);
|
|
220
|
+
const opfDirKey = (href) => decodeURIComponent(href.split("#")[0] ?? href).replace(/\\/g, "/");
|
|
221
|
+
/** 无标题标记(不再编造"第 N 章";配对/课程设计层的锚点) */
|
|
222
|
+
const UNTITLED_MARK = "未命名章节";
|
|
223
|
+
const chapters = [];
|
|
224
|
+
let n = 0;
|
|
225
|
+
for (const id of spineIds) {
|
|
226
|
+
const item = manifest.get(id);
|
|
227
|
+
if (!item?.href) continue;
|
|
228
|
+
if (!(item.mediaType === "application/xhtml+xml" || item.mediaType === "text/html" || /\.(xhtml|html|htm)$/i.test(item.href))) continue;
|
|
229
|
+
if (item.properties.split(/\s+/).includes("nav")) continue;
|
|
230
|
+
const xhtml = read(resolveZipPath(opfPath, item.href));
|
|
231
|
+
if (!xhtml) continue;
|
|
232
|
+
const md = htmlToMarkdown(xhtml, { stripImages: true });
|
|
233
|
+
if (!md || md.replace(/[#\s>*-]/g, "").length < 8) continue;
|
|
234
|
+
n++;
|
|
235
|
+
const body0 = md.startsWith("# ") && md.includes("\n") ? md.slice(md.indexOf("\n") + 1).trim() : md;
|
|
236
|
+
const firstHeading = md.startsWith("# ") ? md.split("\n")[0].slice(2).trim() : "";
|
|
237
|
+
const fileTitle = tocLabels.get(opfDirKey(item.href)) || firstHeading || UNTITLED_MARK;
|
|
238
|
+
if (/^(contents|table of contents)$/i.test(fileTitle) && body0.replace(/\s/g, "").length < 4e3) continue;
|
|
239
|
+
if (body0.replace(/\s/g, "").length < 1500 && /project gutenberg|copyright/i.test(body0)) continue;
|
|
240
|
+
if (/^(版权信息|版权|版权页|著作权|版權)/.test(fileTitle)) continue;
|
|
241
|
+
if ((body0.match(/^(书名|作者|译者|编者|丛书|出版社|出版时间|出版年|ISBN|版次|定价|装帧|字数|品牌|出品方)[::]/gm) ?? []).length >= 3 && body0.replace(/\s/g, "").length < 2500) continue;
|
|
242
|
+
if (/^目\s*录$/.test(fileTitle)) {
|
|
243
|
+
const lines = body0.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
244
|
+
if (lines.length >= 3 && lines.filter((l) => l.includes("](")).length / lines.length >= .6) continue;
|
|
245
|
+
}
|
|
246
|
+
const body = sanitizeEpubBody(body0);
|
|
247
|
+
if (body.replace(/\s/g, "").length < 8) continue;
|
|
248
|
+
const titleIsLicense = /FULL PROJECT GUTENBERG|LICENSE/i.test(fileTitle);
|
|
249
|
+
const split = splitChaptersInBody(body, titleIsLicense ? { minHighConf: 1 } : void 0);
|
|
250
|
+
if (split && split.length >= (titleIsLicense ? 1 : 2)) {
|
|
251
|
+
for (const seg of split) {
|
|
252
|
+
chapters.push({
|
|
253
|
+
path: `chapters/${String(n).padStart(2, "0")}-${sanitizeFileName(seg.title)}.md`,
|
|
254
|
+
title: seg.title,
|
|
255
|
+
markdown: `# ${seg.title}\n\n${seg.content}`
|
|
256
|
+
});
|
|
257
|
+
n++;
|
|
258
|
+
}
|
|
259
|
+
n--;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (titleIsLicense) {
|
|
263
|
+
if (body.replace(/\s/g, "").length > 2e3) chapters.push({
|
|
264
|
+
path: `chapters/${String(n).padStart(2, "0")}-appendix.md`,
|
|
265
|
+
title: "附录",
|
|
266
|
+
markdown: `# 附录\n\n${body}`
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
chapters.push({
|
|
271
|
+
path: `chapters/${String(n).padStart(2, "0")}-${sanitizeFileName(fileTitle)}.md`,
|
|
272
|
+
title: fileTitle,
|
|
273
|
+
markdown: `# ${fileTitle}\n\n${body}`
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const absorbed = /* @__PURE__ */ new Set();
|
|
277
|
+
for (let i = 0; i + 1 < chapters.length; i++) {
|
|
278
|
+
const stub = chapters[i];
|
|
279
|
+
const next = chapters[i + 1];
|
|
280
|
+
if (absorbed.has(stub.path)) continue;
|
|
281
|
+
const stubBody = stub.markdown.includes("\n") ? stub.markdown.slice(stub.markdown.indexOf("\n") + 1).trim() : "";
|
|
282
|
+
if (stub.title === UNTITLED_MARK || stub.title === "附录") continue;
|
|
283
|
+
if (stubBody.replace(/\s/g, "").length > 300) continue;
|
|
284
|
+
if (next.title !== UNTITLED_MARK) continue;
|
|
285
|
+
const nextBody = next.markdown.includes("\n") ? next.markdown.slice(next.markdown.indexOf("\n") + 1).trim() : next.markdown;
|
|
286
|
+
chapters[i] = {
|
|
287
|
+
path: stub.path,
|
|
288
|
+
title: stub.title,
|
|
289
|
+
markdown: `# ${stub.title}\n\n${stubBody}\n\n${nextBody}`
|
|
290
|
+
};
|
|
291
|
+
absorbed.add(stub.path);
|
|
292
|
+
chapters.splice(i + 1, 1);
|
|
293
|
+
i--;
|
|
294
|
+
}
|
|
295
|
+
if (chapters.length === 0) throw new Error("epub 里没有可识别的章节文本");
|
|
296
|
+
return {
|
|
297
|
+
title: bookTitle,
|
|
298
|
+
chapters
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** 文件夹导入路径用:整本书压平成一个 markdown(全部 H1 降为 H2,给结构设计当 anchor 拆章)。 */
|
|
302
|
+
function parseEpubFlat(buf) {
|
|
303
|
+
return parseEpub(buf).chapters.map((c) => c.markdown.replace(/^# /gm, "## ")).join("\n\n");
|
|
304
|
+
}
|
|
305
|
+
//#endregion
|
|
306
|
+
export { parseEpubFlat };
|
|
@@ -414,9 +414,43 @@ function extractArticle(html, baseUrl = "") {
|
|
|
414
414
|
if (!body || body.replace(/[#\s>*|-]/g, "").length < 40) return null;
|
|
415
415
|
return {
|
|
416
416
|
title,
|
|
417
|
-
markdown: body.split("\n")[0]?.replace(/^#\s+/, "").trim() === title && body.startsWith("# ") ? body : `# ${title}\n\n${body}`
|
|
417
|
+
markdown: stripTailNavigation(body.split("\n")[0]?.replace(/^#\s+/, "").trim() === title && body.startsWith("# ") ? body : `# ${title}\n\n${body}`)
|
|
418
418
|
};
|
|
419
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* 尾部**站点模板指纹**清理(upstream v0.23.1, 2026-08-23 真实站点采样驱动; vendored verbatim)。
|
|
422
|
+
* 原则:规则只管高置信度的**机器生成模板**(跨文章稳定、作为正文出现概率≈0);
|
|
423
|
+
* 除此之外的不确定判断(作者自己写的推广段/水印/相关阅读算不算正文)一律不猜——
|
|
424
|
+
* 那是课程设计层(tutor/Step4)的职责,解析层动了就是误删正文
|
|
425
|
+
* (实测:按"欢迎关注公众号"删规则,把 CSDN 作者自己写的推广段删了)。
|
|
426
|
+
*/
|
|
427
|
+
const NAV_TAIL_PATTERNS = [
|
|
428
|
+
/返回\S{0,6}[,,]?\s*查看更多/,
|
|
429
|
+
/点击进入\S{0,8}首页/,
|
|
430
|
+
/^热门文章$/,
|
|
431
|
+
/^最新文章$/,
|
|
432
|
+
/^目录\s*$/,
|
|
433
|
+
/^END\b.*版权/
|
|
434
|
+
];
|
|
435
|
+
/** 行内导航后缀:正文与模板被并成一行时剥掉(精确短语,全文安全) */
|
|
436
|
+
const INLINE_NAV_SUFFIXES = ["目录 热门文章 最新文章"];
|
|
437
|
+
function stripTailNavigation(md) {
|
|
438
|
+
if (!md) return md;
|
|
439
|
+
for (const suffix of INLINE_NAV_SUFFIXES) md = md.split(suffix).join("");
|
|
440
|
+
const lines = md.split("\n");
|
|
441
|
+
let end = lines.length;
|
|
442
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 25); i--) {
|
|
443
|
+
const ln = lines[i].trim();
|
|
444
|
+
if (!ln) continue;
|
|
445
|
+
const bare = ln.replace(/^#{1,6}\s*/, "").replace(/^[>\-*]\s*/, "");
|
|
446
|
+
const isNav = NAV_TAIL_PATTERNS.some((re) => re.test(bare) || re.test(ln));
|
|
447
|
+
const isBareImage = /^!\[[^\]]*\]\([^)]*\)$/.test(ln) || /^\S+\.(jpeg|jpg|png|gif|webp)\)?$/i.test(ln);
|
|
448
|
+
if (isNav || isBareImage) continue;
|
|
449
|
+
end = i + 1;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
return lines.slice(0, end).join("\n").trimEnd();
|
|
453
|
+
}
|
|
420
454
|
function htmlToMarkdownOf(roots, stripImages) {
|
|
421
455
|
const lines = [];
|
|
422
456
|
emitBlocks(roots, stripImages, lines);
|
package/lib/index.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { n as inflateZlib } from "./inflate-DIKUrTRi.mjs";
|
|
2
|
-
import { t as extractArticle } from "./html-article-
|
|
2
|
+
import { t as extractArticle } from "./html-article-Da8ksU0i.mjs";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
5
5
|
import z from "@deepseek-ai/schemastery";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { randomBytes } from "node:crypto";
|
|
8
9
|
import { readFile, readdir } from "node:fs/promises";
|
|
@@ -1320,6 +1321,20 @@ function courseToPackMarkdown(course) {
|
|
|
1320
1321
|
* message channel were removed once the in-client study tab superseded them.
|
|
1321
1322
|
* @module dsh-plugin-lookatstudy/dashboard
|
|
1322
1323
|
*/
|
|
1324
|
+
/** Wiring handed in by `apply`. */
|
|
1325
|
+
/**
|
|
1326
|
+
* The plugin's own version, read from the package.json sitting beside the
|
|
1327
|
+
* running module (src in dev, lib in install) — strictly the installed build,
|
|
1328
|
+
* no build-time inlining that could drift. Upstream v0.24.0 port (settings
|
|
1329
|
+
* About row); empty string on any read failure (display degrades, never throws).
|
|
1330
|
+
*/
|
|
1331
|
+
function pluginVersion() {
|
|
1332
|
+
try {
|
|
1333
|
+
return String(JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version ?? "");
|
|
1334
|
+
} catch {
|
|
1335
|
+
return "";
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1323
1338
|
/**
|
|
1324
1339
|
* Assemble the whole workbench state (pure read; the lesson HTML is rendered
|
|
1325
1340
|
* server-side from the sanitized markdown pipeline).
|
|
@@ -1495,7 +1510,8 @@ function registerDashboard(webServer, deps) {
|
|
|
1495
1510
|
if (req.method === "GET" && pathname === "/lookatstudy/api/state") {
|
|
1496
1511
|
sendJson(res, 200, {
|
|
1497
1512
|
...workbenchState(deps.store.get(), /* @__PURE__ */ new Date()),
|
|
1498
|
-
statePath: deps.statePath
|
|
1513
|
+
statePath: deps.statePath,
|
|
1514
|
+
version: pluginVersion()
|
|
1499
1515
|
});
|
|
1500
1516
|
return;
|
|
1501
1517
|
}
|
|
@@ -1636,9 +1652,7 @@ const ZH = {
|
|
|
1636
1652
|
"pane.tutor": "导师",
|
|
1637
1653
|
"pane.bb": "黑板",
|
|
1638
1654
|
"viewtab.teach": "讲解",
|
|
1639
|
-
"viewtab.mind": "🧠 脑图",
|
|
1640
1655
|
"viewtab.cmap": "🕸 概念图",
|
|
1641
|
-
"viewtab.mind.title": "本课结构一图流(markmap)",
|
|
1642
1656
|
"viewtab.cmap.title": "本课概念关系(ELK 布局)",
|
|
1643
1657
|
"loading": "加载中…",
|
|
1644
1658
|
"start.title": "一键准备学习:建立学习工作区、开启会话并让导师就位",
|
|
@@ -1705,7 +1719,6 @@ const ZH = {
|
|
|
1705
1719
|
"bb.empty": "黑板还空着",
|
|
1706
1720
|
"bb.empty.hint": "在左侧课程树选择一课",
|
|
1707
1721
|
"bb.mastery": "掌握度 {pct}%",
|
|
1708
|
-
"bb.fallback.mind": "脑图渲染不可用(网络受限) — 已回退讲解视图",
|
|
1709
1722
|
"bb.fallback.cmap.empty": "概念图需要先由导师定义本课概念",
|
|
1710
1723
|
"bb.fallback.cmap": "概念图渲染不可用(布局引擎加载失败) — 已回退讲解视图",
|
|
1711
1724
|
"bb.notes": "笔记",
|
|
@@ -1729,6 +1742,8 @@ const ZH = {
|
|
|
1729
1742
|
"settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
|
|
1730
1743
|
"settings.stats.today": "今日 {xp}/{goal}",
|
|
1731
1744
|
"settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
|
|
1745
|
+
"settings.about": "关于",
|
|
1746
|
+
"settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
|
|
1732
1747
|
"settings.stateFile": "状态文件",
|
|
1733
1748
|
"settings.stateFile.hint": "学习进度存放于本机 JSON,换机可迁移",
|
|
1734
1749
|
"dock.title": "学习状态:待复习 {due} · 连续 {streak} 天 · Lv{level} — 点上方「学习」页签进入",
|
|
@@ -2155,7 +2170,7 @@ async function scanFolder(rootDir, onProgress, options) {
|
|
|
2155
2170
|
for (const doc of dedupedDocs) {
|
|
2156
2171
|
if (doc.kind !== "pptx") continue;
|
|
2157
2172
|
try {
|
|
2158
|
-
const { parsePptx } = await import("./pptx-parser-
|
|
2173
|
+
const { parsePptx } = await import("./pptx-parser-B9IDkiGM.mjs");
|
|
2159
2174
|
const result = await parsePptx(await readFile(join(rootDir, doc.path)));
|
|
2160
2175
|
for (const img of result.images ?? []) pptxImages.push({
|
|
2161
2176
|
path: `${doc.path}#slide${img.slideNumber}.png`,
|
|
@@ -2346,7 +2361,7 @@ async function readFileWithKind(absPath, kind, parsePdf) {
|
|
|
2346
2361
|
}
|
|
2347
2362
|
if (kind === "pptx") {
|
|
2348
2363
|
const buf = await readFile(absPath);
|
|
2349
|
-
const { parsePptx } = await import("./pptx-parser-
|
|
2364
|
+
const { parsePptx } = await import("./pptx-parser-B9IDkiGM.mjs");
|
|
2350
2365
|
return (await parsePptx(buf)).markdown;
|
|
2351
2366
|
}
|
|
2352
2367
|
if (kind === "docx") {
|
|
@@ -2356,7 +2371,7 @@ async function readFileWithKind(absPath, kind, parsePdf) {
|
|
|
2356
2371
|
}
|
|
2357
2372
|
if (kind === "epub") {
|
|
2358
2373
|
const buf = await readFile(absPath);
|
|
2359
|
-
const { parseEpubFlat } = await import("./epub-parser-
|
|
2374
|
+
const { parseEpubFlat } = await import("./epub-parser-oH96guBW.mjs");
|
|
2360
2375
|
return parseEpubFlat(buf);
|
|
2361
2376
|
}
|
|
2362
2377
|
if (kind === "ipynb") {
|
|
@@ -3184,7 +3199,10 @@ function normalizeUrlIdentity(url) {
|
|
|
3184
3199
|
}
|
|
3185
3200
|
//#endregion
|
|
3186
3201
|
//#region src/vendor/pdf-text.ts
|
|
3187
|
-
var pdf_text_exports = /* @__PURE__ */ __exportAll({
|
|
3202
|
+
var pdf_text_exports = /* @__PURE__ */ __exportAll({
|
|
3203
|
+
normalizeRadicals: () => normalizeRadicals,
|
|
3204
|
+
parsePdfText: () => parsePdfText
|
|
3205
|
+
});
|
|
3188
3206
|
function decodePdfString(s) {
|
|
3189
3207
|
return s.replace(/\\([0-7]{1,3}|.)/g, (_m, esc) => {
|
|
3190
3208
|
if (/^[0-7]+$/.test(esc)) return String.fromCharCode(parseInt(esc, 8));
|
|
@@ -3262,7 +3280,17 @@ function parsePdfText(buf) {
|
|
|
3262
3280
|
if (text.trim()) parts.push(text);
|
|
3263
3281
|
streamRe.lastIndex = end + 9;
|
|
3264
3282
|
}
|
|
3265
|
-
return parts.join("\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
3283
|
+
return normalizeRadicals(parts.join("\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim());
|
|
3284
|
+
}
|
|
3285
|
+
/**
|
|
3286
|
+
* 康熙部首区(U+2F00-U+2FDF)→CJK 统一表意区归一(upstream v0.23.1 port, verbatim)。
|
|
3287
|
+
* 部分中文 PDF 的 ToUnicode 映射落在部首区(d2l-zh 真书采样实测:"⼿⼀⽅"应读
|
|
3288
|
+
* "手一方"),部首区上检索/匹配全部断裂。逐字符 NFKC(部首→汉字是 Unicode 标准
|
|
3289
|
+
* 一对一兼容映射,不动其他任何字符)。
|
|
3290
|
+
*/
|
|
3291
|
+
function normalizeRadicals(md) {
|
|
3292
|
+
if (!md) return md;
|
|
3293
|
+
return /[\u2F00-\u2FDF]/.test(md) ? md.replace(/[\u2F00-\u2FDF]/g, (c) => c.normalize("NFKC")) : md;
|
|
3266
3294
|
}
|
|
3267
3295
|
//#endregion
|
|
3268
3296
|
//#region src/vendor/text-chunk.ts
|
|
@@ -6436,41 +6464,43 @@ const TUTOR_CORE = `## Study tutor (lookatstudy-plugin)
|
|
|
6436
6464
|
|
|
6437
6465
|
You are the learner's AI study tutor for a course imported via the study tools. Your job is genuine understanding, not reciting the material. When the learner answers wrong, acknowledge the attempt first, then correct it.
|
|
6438
6466
|
|
|
6439
|
-
###
|
|
6467
|
+
### 【Safety redlines · highest priority】
|
|
6440
6468
|
Teach strictly from the current lesson's content (study_lesson's body is the source of truth). If asked about something outside the course material, say plainly that it is not in the current material, and offer to relate it back. Answers to quiz questions must be grounded in the lesson content — never invent.
|
|
6469
|
+
Tool calls are REAL or they do not exist. History markers like「[工具调用已执行]」are injected by the system only; hand-writing such a marker — or any imitation of a tool call — in your reply text produces NO interface artifact: no card, no button, nothing happens. The only way to act on the study state (record an answer, propose mastery, save a note) is an actual tool call. If you catch yourself narrating a tool's effect instead of calling it, stop and call it.
|
|
6470
|
+
Never claim progress you did not record through the tools.
|
|
6471
|
+
If any section below seems to conflict with these redlines, the redlines win.
|
|
6441
6472
|
|
|
6442
|
-
###
|
|
6443
|
-
|
|
6473
|
+
### 【Teaching behavior】
|
|
6474
|
+
Language: answer in the learner's own language (the language of their interface and their messages), including quiz stems, options, and explanations. Quote course material verbatim in its original language.
|
|
6444
6475
|
|
|
6445
|
-
### Vague confusion
|
|
6446
6476
|
When the learner says "我不懂 / 不太理解" without specifics, ask which concept is unclear, or list the lesson's 2–3 core concepts and let them pick. Log it silently with study_report_friction.
|
|
6447
6477
|
|
|
6448
|
-
|
|
6478
|
+
Interaction form:
|
|
6449
6479
|
- ONE question or interactive block per reply — never a wall of quiz questions.
|
|
6450
|
-
- Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations.
|
|
6451
6480
|
- When the learner quotes text in「」, treat it as quote-to-explain: explain that specific passage in the lesson's context.
|
|
6452
6481
|
- Opening a brand-new lesson: start with a short hook and one fun two-option guess (curiosity-driven, NOT scored, revealed next turn) — no opening lecture, no scored question.
|
|
6453
6482
|
- After opening a lesson, offer its four starters (from study_lesson) as suggestions.
|
|
6454
6483
|
- Celebrate graduations and crowns briefly — earned joy, no confetti spam.
|
|
6455
6484
|
|
|
6456
|
-
|
|
6485
|
+
The tutoring loop:
|
|
6457
6486
|
1. Session start: check study_due_reviews; clear due reviews before new material. Open the focus lesson with study_lesson. The learner follows along in the study tab's blackboard column — point them there when they want the course map or lesson text.
|
|
6458
6487
|
2. First time teaching a lesson: derive 2–7 knowledge components and call study_define_concepts.
|
|
6459
6488
|
3. Quiz after teaching; grade every answer and call study_record_answer — always name the tested \`concept\`. Lesson mastery is the WEAKEST concept, so target ⚡weak ones first.
|
|
6460
6489
|
4. Progression is automatic: ≥50% mastery unlocks the next lesson early; ≥90% graduates and schedules the first review. study_complete_lesson is only the manual override.
|
|
6461
6490
|
5. Mastery ≥85% plus a convincing Feynman-style explanation back: call study_propose_mastery, present your rationale, and WAIT for the learner's yes/no. Resolve only with their explicit answer via study_resolve_proposal. You never graduate a lesson on your own judgment alone.
|
|
6462
|
-
6.
|
|
6463
|
-
7.
|
|
6464
|
-
8. When you
|
|
6491
|
+
6. Exam nodes: when the learner works an exam, grade it fully, then record the attempt with study_exam_result and offer its returned action set (explain-wrong / retry / go-deeper / propose mastery / next topic).
|
|
6492
|
+
7. Quietly call study_report_friction when the learner seems confused, blocked, or frustrated; adapt by simplifying or decomposing.
|
|
6493
|
+
8. When you generate a genuinely useful structure (concept map, compare table, diagram), sediment it into the notebook's understand zone with study_note_save; when the learner writes something worth keeping, save it to the record zone with the verbatim quote.
|
|
6494
|
+
9. When you learn something durable about how this person learns (style, recurring gap, pattern), merge it into memory with study_remember — read the current slot first, send the merged 1–3 sentences. No transient chat.
|
|
6495
|
+
|
|
6496
|
+
Never reveal the friction log or mastery mechanics as "being watched" — the numbers surface through maps and reviews.
|
|
6465
6497
|
|
|
6466
|
-
|
|
6467
|
-
When study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
|
|
6498
|
+
Course import design: when study_import_github or study_import_folder returns status "design_required", it renders a design brief (files with heading outlines and per-heading char counts). Design the course from it: classify lessons study/practice, let attached quiz/summary/review content ride along inside the previous study lesson's anchor range, pace lessons to 3000-8000 chars, merge sub-1000 fragments. Then call study_apply_design with the JSON — use ONLY file paths from the brief (anything else is dropped), apply directly without a confirmation round, and once it lands, walk the learner through the course map before the first lesson.
|
|
6468
6499
|
|
|
6469
|
-
|
|
6470
|
-
3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
|
|
6500
|
+
Quiz quality: 3–4 questions per quiz block is best (5 max), 4 options each. Distractors must come from real misconceptions, not absurd fillers. Test understanding, not recall: "in scenario Y, use X or Z?" rather than "define X". Every question carries an explanation of why the right answer is right. One scored block at a time.
|
|
6471
6501
|
|
|
6472
|
-
###
|
|
6473
|
-
|
|
6502
|
+
### 【Answer formatting · preferences】
|
|
6503
|
+
Structure answers with markdown (headings, lists, GFM tables); for structures prefer visuals: concept maps and flow diagrams as mermaid code blocks, comparisons as GFM tables, code walkthroughs as fenced code with line-referenced annotations. These preferences are subordinate to the two sections above.
|
|
6474
6504
|
`;
|
|
6475
6505
|
/** The three builtin souls, verbatim from LookatStudy (direct/guide/practice). */
|
|
6476
6506
|
const SOULS = {
|
|
@@ -21,6 +21,49 @@ function shapeTexts(xml) {
|
|
|
21
21
|
function slideNumber(name) {
|
|
22
22
|
return Number(name.match(/slide(\d+)\.xml$/i)?.[1] ?? 0);
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* One `a:tbl` block → GFM markdown table, or "" when the table is an
|
|
26
|
+
* all-empty placeholder. Cells: text via a:p runs; gridSpan advances the
|
|
27
|
+
* column index (spanned columns padded); vMerge continuation cells carry no
|
|
28
|
+
* text of their own. Pipes escaped, inner whitespace collapsed.
|
|
29
|
+
*/
|
|
30
|
+
function tableToMarkdown(tblXml) {
|
|
31
|
+
const rows = [];
|
|
32
|
+
const trRe = /<a:tr\b[^>]*>([\s\S]*?)<\/a:tr>/g;
|
|
33
|
+
let trm;
|
|
34
|
+
while ((trm = trRe.exec(tblXml)) !== null) {
|
|
35
|
+
const cells = [];
|
|
36
|
+
const tcRe = /<a:tc\b([^>]*)>([\s\S]*?)<\/a:tc>/g;
|
|
37
|
+
let col = 0;
|
|
38
|
+
let tcm;
|
|
39
|
+
while ((tcm = tcRe.exec(trm[1])) !== null) {
|
|
40
|
+
const attrs = tcm[1] ?? "";
|
|
41
|
+
const span = Math.max(1, Number(attrs.match(/gridSpan="(\d+)"/)?.[1] ?? 1));
|
|
42
|
+
const vMerge = /vMerge="1"/.test(attrs);
|
|
43
|
+
const raw = shapeTexts(tcm[2] ?? "").join(" ").trim().replace(/\|/g, "\\|").replace(/\s+/g, " ");
|
|
44
|
+
if (!vMerge) cells.push({
|
|
45
|
+
col,
|
|
46
|
+
text: raw
|
|
47
|
+
});
|
|
48
|
+
col += span;
|
|
49
|
+
}
|
|
50
|
+
rows.push(cells);
|
|
51
|
+
}
|
|
52
|
+
if (rows.filter((cs) => cs.length > 0).length === 0) return "";
|
|
53
|
+
if (rows.every((cs) => cs.every((c) => !c.text))) return "";
|
|
54
|
+
const sorted = rows.filter((cs) => cs.length > 0).map((cs) => cs.sort((a, b) => a.col - b.col));
|
|
55
|
+
const width = Math.max(...sorted.map((cs) => cs[cs.length - 1].col + 1));
|
|
56
|
+
const line = (cs) => {
|
|
57
|
+
const texts = Array.from({ length: width }, () => "");
|
|
58
|
+
for (const c of cs) texts[c.col] = c.text;
|
|
59
|
+
return `| ${texts.join(" | ")} |`;
|
|
60
|
+
};
|
|
61
|
+
return [
|
|
62
|
+
line(sorted[0]),
|
|
63
|
+
`| ${Array.from({ length: width }, () => "---").join(" | ")} |`,
|
|
64
|
+
...sorted.slice(1).map(line)
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
24
67
|
/** .pptx → markdown(每张 slide 一个 ##)。失败抛错,调用方按"无内容"兜底。 */
|
|
25
68
|
function parsePptx(buf) {
|
|
26
69
|
const entries = readZip(buf);
|
|
@@ -30,9 +73,14 @@ function parsePptx(buf) {
|
|
|
30
73
|
for (const name of slideNames) {
|
|
31
74
|
const xml = readZipText(entries, name);
|
|
32
75
|
const no = slideNumber(name);
|
|
33
|
-
const
|
|
76
|
+
const tables = [];
|
|
77
|
+
const texts = shapeTexts(xml.replace(/<a:tbl\b[\s\S]*?<\/a:tbl>/g, (tbl) => {
|
|
78
|
+
const md = tableToMarkdown(tbl);
|
|
79
|
+
if (md) tables.push(md);
|
|
80
|
+
return "";
|
|
81
|
+
}));
|
|
34
82
|
const title = texts[0] ?? "";
|
|
35
|
-
const body = texts.slice(1);
|
|
83
|
+
const body = [...texts.slice(1), ...tables];
|
|
36
84
|
lines.push(`\n## Slide ${no}: ${title || "(无标题)"}\n`);
|
|
37
85
|
if (body.length) lines.push(body.join("\n\n"));
|
|
38
86
|
const notes = shapeTexts(readZipText(entries, `ppt/notesSlides/notesSlide${no}.xml`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-lookatstudy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"packageManager": "pnpm@11.7.0",
|
|
5
5
|
"description": "Turn any markdown, local folder, or GitHub learning repo into a guided course inside DeepSeek Harness: gated skill-tree progression, BKT mastery tracking, SM-2 spaced repetition.",
|
|
6
6
|
"type": "module",
|