dsh-tingdong 0.3.1 → 0.5.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 +18 -4
- package/bin/dsh-tingdong.js +30 -0
- package/lib/doc.js +132 -0
- package/lib/docprep.js +178 -0
- package/lib/edge.js +19 -0
- package/lib/extract.js +139 -0
- package/lib/importer.js +132 -5
- package/lib/server.js +84 -4
- package/lib/store.js +2 -0
- package/lib/tracks.js +38 -1
- package/lib/translate.js +8 -6
- package/lib/tts-worker.js +25 -0
- package/package.json +4 -3
- package/plugin/client.js +102 -7
- package/plugin/tools.js +29 -0
- package/web/core.js +3 -1
- package/web/listen.js +3 -3
- package/web/panel.css +35 -0
- package/web/panel.js +205 -78
package/README.md
CHANGED
|
@@ -5,7 +5,20 @@
|
|
|
5
5
|
|
|
6
6
|
## 它做什么
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
首页有两个入口:**导入音频**(有声书、讲座、引导音频)和**导入文档**(文章、手册、书)。
|
|
9
|
+
|
|
10
|
+
**导入文档**:直接选文件(或拖进来)——PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown;没有文件也可以粘贴英文。
|
|
11
|
+
|
|
12
|
+
1. 插件先取出文字,给你一张**确认卡片**:读到了多少词,预计分几课、朗读多长、处理要多久、大约用多少模型 token,并说明「会用 AI 整理文字」。
|
|
13
|
+
2. 你点确定后,在后台:**AI 整理**(去掉页眉页码目录、修好断行、分好章节,原文一个字都不改、不翻译;长文分段、几段同时整理,逐段核对单词数,模型删多了就重来或改用原文)→ **存档**(原文件、取出的原始文字、整理后的文字都留在 `~/.dsh/dsh-tingdong/docs/`,课程概览里能下载)→ **分课**(每个大标题一课,太长的按段落拆开)→ **朗读**(默认微软 Edge,14 种美式 / 英式男女声,可选离线 Kokoro)→ **逐句翻译讲解**。
|
|
14
|
+
3. 做好一课就能先学一课,之后和音频课一样:精听、复习、磨耳朵、连续听、导出。
|
|
15
|
+
|
|
16
|
+
**大小和耗时**:文件最大 100 MB;文字最多约 6 万个英文单词(40 万字符,相当于 7 小时左右的朗读),再长请拆开导入。真正花时间和 token 的是 AI 整理和翻译:1 万词大约整理 4 万字符、朗读 1 小时多,处理约十几分钟(看模型速度)。扫描版 PDF(每页是图片)读不出文字,要先用 OCR 或 AI 转成文字。
|
|
17
|
+
在 DSH 对话里也可以把 PDF 发给 AI,说「整理后导入听懂」(AI 工具 `tingdong_import_text`)。
|
|
18
|
+
|
|
19
|
+
**导入音频**:
|
|
20
|
+
|
|
21
|
+
1. **识别**:选一个 MP3(或整个文件夹),本机识别出每一句英文和它的起止时间。
|
|
9
22
|
识别用的是 DSH 自带语音输入的同一套模型(SenseVoice + Silero VAD),音频不上传;用过 DSH 语音输入就不用再下载。
|
|
10
23
|
2. **讲解**:用你在 DSH 里选的大模型逐句校对英文(修正识别错误,如 "resna tuning" → "resonant tuning")、
|
|
11
24
|
翻成口语化中文、挑出生词(带音标)、写一句「听音提示」(哪里连读、弱读、吞音),再写全篇概述和关键术语表。
|
|
@@ -46,9 +59,9 @@
|
|
|
46
59
|
|
|
47
60
|
电脑快捷键:空格 重听 · 回车 下一步 · 1/2/3 评分 · ←/→ 上一句/下一句 · S 变速(1× / 0.85× / 0.7×)· L 单句循环
|
|
48
61
|
|
|
49
|
-
在 DSH 对话里也可以直接说:「把 /Volumes/…/CD4
|
|
62
|
+
在 DSH 对话里也可以直接说:「把 /Volumes/…/CD4 导入听懂」「把这份 PDF 整理后导入听懂」「我这课哪些句子总听不懂?给我讲讲」——插件给 AI 注册了 `tingdong_import`、`tingdong_import_text`、`tingdong_lessons`、`tingdong_sentences` 四个工具。
|
|
50
63
|
|
|
51
|
-
**更新 /
|
|
64
|
+
**更新 / 卸载**:在 DSH 的「设置 → 听懂」里。打开时自动查 npm 上有没有新版本;有就出现「更新到 vX」,点一下由 DSH 的插件管理器装好(和插件市场同一套机制),重启 DSH 生效。「卸载」会先确认,只移除插件,课程和学习进度留在 `~/.dsh/dsh-tingdong`。学习面板首页标题旁显示版本号,有新版本时会提醒。
|
|
52
65
|
|
|
53
66
|
## 安装
|
|
54
67
|
|
|
@@ -92,6 +105,7 @@ dsh plugin --profile desktop add /path/to/dsh-go
|
|
|
92
105
|
```bash
|
|
93
106
|
node bin/dsh-tingdong.js import "/Volumes/kuozhan/门罗/CD4" # 整个文件夹,每个音频一课
|
|
94
107
|
node bin/dsh-tingdong.js serve # 打开 http://127.0.0.1:4810
|
|
108
|
+
node bin/dsh-tingdong.js import-doc 手册.md --voice en-US-AndrewNeural # 导入整理好的英文文档
|
|
95
109
|
node bin/dsh-tingdong.js list
|
|
96
110
|
node bin/dsh-tingdong.js voices all --track kokoro --voice am_michael # 批量生成声音轨
|
|
97
111
|
```
|
|
@@ -120,7 +134,7 @@ npm test
|
|
|
120
134
|
- `lib/translate.js` 校对 + 翻译 + 生词 + 听音提示;缺句自动缩小批次重试,失败的留空,之后可「补全翻译」
|
|
121
135
|
- `lib/tracks.js` 声音轨:原声按句截取拼接;朗读交给 `lib/tts-worker.js` 子进程(Kokoro / macOS say),`lib/pcm.js` 统一拼接编码并出时间表
|
|
122
136
|
- `lib/srs.js` 间隔复习;`lib/store.js` 原子写入的 JSON 存储;`lib/today.js` 今日学习(按用户时区算日期)
|
|
123
|
-
- `lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
|
|
137
|
+
- `lib/doc.js` 文档:切句、分章、整理要求;`lib/export.js` 文字导出;`lib/edge.js` Edge 语音组件(edge-tts)的安装和调用
|
|
124
138
|
- `lib/drill.js` 磨耳朵音频;`lib/lookup.js` 点词查意思、重新讲解;`lib/vps.js` 经 SSH 发送到 VPS
|
|
125
139
|
- `plugin/` DSH 宿主端(路由、AI 工具)和界面入口(右侧栏、设置);路由先过 DSH 的 `connection.requestRejection()` 登录校验
|
|
126
140
|
- `web/` 学习面板(原生 JS 模块,无构建):`panel.js` 路由和首页 · `study.js` 精听、复习、今日学习 · `listen.js` 连续听、磨耳朵、词汇 · `edit.js` 改句子、查词 · `core.js` 播放器和公共状态
|
package/bin/dsh-tingdong.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// dsh-tingdong import <文件或文件夹> [--title 标题] 识别 + 翻译,做成课
|
|
5
5
|
// dsh-tingdong serve [--port 4810] 在浏览器里打开学习面板
|
|
6
6
|
// dsh-tingdong list 看有哪些课
|
|
7
|
+
// dsh-tingdong import-doc <文档> 导入英文文档:AI 整理 → 分课 → Edge 朗读 + 翻译
|
|
7
8
|
// dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro] [--voice 名字] [--speed 0.9]
|
|
8
9
|
// 生成声音轨(去空白原声 / 压背景 / 系统朗读 / Kokoro)
|
|
9
10
|
//
|
|
@@ -50,6 +51,34 @@ async function main() {
|
|
|
50
51
|
return
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
if (cmd === 'import-doc') {
|
|
55
|
+
if (!rest[0]) throw new Error('用法:dsh-tingdong import-doc <PDF / Word / EPUB / 网页 / TXT / MD> [--title 标题] [--voice en-US-AndrewNeural] [--engine edge|kokoro]')
|
|
56
|
+
const llm = await cliLlm(flags)
|
|
57
|
+
console.log(llm ? `翻译模型:${llm.label()}(${llm.via})` : '没有可用的大模型:只朗读英文')
|
|
58
|
+
const importer = new Importer({ llm: () => llm, options, log: m => console.error(m) })
|
|
59
|
+
let last = ''
|
|
60
|
+
importer.onChange(() => {
|
|
61
|
+
for (const j of importer.list()) {
|
|
62
|
+
if (j.state !== 'running') continue
|
|
63
|
+
const key = `${j.id}|${j.stage}|${j.total ? Math.floor(j.done / j.total * 4) : 0}`
|
|
64
|
+
if (key !== last) { last = key; console.log(`${j.title}:${j.stage}${j.total ? ` ${j.done}/${j.total}` : ''}`) }
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
// 和界面一样:取出文字、预估 → 确认 → AI 整理、存档 → 分课 → 朗读、翻译
|
|
68
|
+
const { basename } = await import('node:path')
|
|
69
|
+
const doc = await importer.prepareDoc(rest[0], { name: basename(rest[0]) })
|
|
70
|
+
const e = doc.estimate
|
|
71
|
+
console.log(`${doc.name}:${doc.type}${doc.pages ? ` ${doc.pages} 页` : ''},约 ${e.words} 词 → 约 ${e.lessons} 课,朗读约 ${e.audioMinutes} 分钟,处理约 ${e.minutes} 分钟,约 ${e.tokens} token;${doc.needsCleanup ? 'AI 整理' : '已是整理好的格式'}`)
|
|
72
|
+
await importer.confirmDoc(doc.docId, { title: typeof flags.title === 'string' ? flags.title : doc.title, engine: flags.engine === 'kokoro' ? 'kokoro' : 'edge', voice: typeof flags.voice === 'string' ? flags.voice : undefined })
|
|
73
|
+
await new Promise(resolve => {
|
|
74
|
+
const check = () => { if (!importer.running && !importer.jobs.some(j => j.state === 'queued')) resolve() }
|
|
75
|
+
importer.onChange(() => setImmediate(check))
|
|
76
|
+
check()
|
|
77
|
+
})
|
|
78
|
+
for (const j of importer.list()) console.log(`${j.state === 'done' ? '✓' : '✗'} ${j.title}${j.error ? ':' + j.error : ''}`)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
if (cmd === 'voices') {
|
|
54
83
|
const which = rest[0]
|
|
55
84
|
if (!which) throw new Error('用法:dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro]')
|
|
@@ -132,6 +161,7 @@ async function main() {
|
|
|
132
161
|
dsh-tingdong import <文件或文件夹> [--title 标题]
|
|
133
162
|
dsh-tingdong serve [--port 4810]
|
|
134
163
|
dsh-tingdong list
|
|
164
|
+
dsh-tingdong import-doc <PDF / Word / EPUB / 网页 / TXT / MD> [--title 标题] [--voice en-US-AndrewNeural]
|
|
135
165
|
dsh-tingdong voices <课程id|all> [--track clean,cleannr,say,kokoro] [--voice 名字] [--speed 0.9]
|
|
136
166
|
|
|
137
167
|
模型:--base-url URL --api-key KEY --model ID(或 TINGDONG_BASE_URL / TINGDONG_API_KEY / TINGDONG_MODEL)
|
package/lib/doc.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// lib/doc.js — 导入英文文档:整理好的纯文本 → 一章一课,每课是一句一句的英文
|
|
2
|
+
//
|
|
3
|
+
// 约定的格式(让别的 AI 先整理好,界面上有现成的整理指令可以复制):
|
|
4
|
+
// # 章节标题 一行「# 」开头:新的一章,做成一课
|
|
5
|
+
// ## 小标题 小标题也读出来,算一句
|
|
6
|
+
// 段落之间空一行;段落内部的换行会被接起来(硬换行、断词的连字符都能处理)
|
|
7
|
+
// 没有任何「# 」时整篇算一章;一章太长(超过 MAX_SENTENCES 句)就按段落拆成几课。
|
|
8
|
+
|
|
9
|
+
export const MAX_CHARS = 400000
|
|
10
|
+
export const MAX_SENTENCES = 160
|
|
11
|
+
|
|
12
|
+
// 句点后面不算句子结束的缩写
|
|
13
|
+
const ABBR = new Set(['mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'vs', 'etc', 'e.g', 'i.e', 'fig', 'no', 'vol', 'p', 'pp', 'ch', 'u.s', 'u.k', 'a.m', 'p.m', 'inc', 'ltd', 'co', 'approx', 'dept', 'est', 'mt', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec'])
|
|
14
|
+
|
|
15
|
+
/** 文本清理:统一换行、去掉不可见字符、接上断词 */
|
|
16
|
+
export function normalize(text) {
|
|
17
|
+
return String(text || '')
|
|
18
|
+
.replace(/\r\n?/g, '\n')
|
|
19
|
+
.replace(/[]/g, '')
|
|
20
|
+
.replace(/‐|‑/g, '-')
|
|
21
|
+
.replace(/(\w)-\n(\w)/g, '$1$2') // 行尾断词
|
|
22
|
+
.replace(/[ \t]+/g, ' ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一段文字切成句子 */
|
|
26
|
+
export function splitSentences(paragraph) {
|
|
27
|
+
const p = paragraph.replace(/\s*\n\s*/g, ' ').replace(/\s+/g, ' ').trim()
|
|
28
|
+
if (!p) return []
|
|
29
|
+
const out = []
|
|
30
|
+
let start = 0
|
|
31
|
+
const re = /([.!?…]+)(["'”’)\]]*)(\s+)(?=["'“‘(\[]?[A-Z0-9])/g
|
|
32
|
+
let m
|
|
33
|
+
while ((m = re.exec(p))) {
|
|
34
|
+
const end = m.index + m[1].length + m[2].length
|
|
35
|
+
const before = p.slice(start, m.index)
|
|
36
|
+
const lastWord = (/([A-Za-z.]+)$/.exec(before) || [])[1] || ''
|
|
37
|
+
// 缩写(Mr. / e.g.)和单个大写字母(A. Monroe)后面的句点不断句
|
|
38
|
+
if (m[1] === '.' && (ABBR.has(lastWord.toLowerCase().replace(/\.$/, '')) || /^[A-Z]$/.test(lastWord))) continue
|
|
39
|
+
out.push(p.slice(start, end).trim())
|
|
40
|
+
start = end + m[3].length
|
|
41
|
+
}
|
|
42
|
+
out.push(p.slice(start).trim())
|
|
43
|
+
return out.filter(Boolean).flatMap(splitLong)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 太长的句子听起来累:超过 220 个字符的,在分号、冒号、破折号处拆开 */
|
|
47
|
+
function splitLong(s) {
|
|
48
|
+
if (s.length <= 220) return [s]
|
|
49
|
+
const parts = s.split(/(?<=[;:])\s+|\s+[—–]\s+|(?<=—)/).map(x => x.trim()).filter(Boolean)
|
|
50
|
+
if (parts.length < 2) return [s]
|
|
51
|
+
// 拆得太碎的再并回去
|
|
52
|
+
const merged = []
|
|
53
|
+
for (const x of parts) {
|
|
54
|
+
const last = merged[merged.length - 1]
|
|
55
|
+
if (last && (last.length < 60 || x.length < 40)) merged[merged.length - 1] = `${last} ${x}`
|
|
56
|
+
else merged.push(x)
|
|
57
|
+
}
|
|
58
|
+
return merged
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @returns {{title:string, chapters:{title:string, sentences:{text:string, heading?:boolean, para:number}[]}[]}}
|
|
63
|
+
*/
|
|
64
|
+
export function parseDoc(text, { title = '' } = {}) {
|
|
65
|
+
const src = normalize(text)
|
|
66
|
+
if (!src.trim()) throw Object.assign(new Error('没有内容'), { status: 400 })
|
|
67
|
+
if (src.length > MAX_CHARS) throw Object.assign(new Error(`太长了(${src.length} 字符):一次最多 ${MAX_CHARS} 字符,拆成几份导入`), { status: 400 })
|
|
68
|
+
if (/[一-鿿]/.test(src) && (src.match(/[一-鿿]/g) || []).length > src.length * 0.2) {
|
|
69
|
+
throw Object.assign(new Error('看起来大部分是中文:这里导入英文原文,中文由 AI 翻译'), { status: 400 })
|
|
70
|
+
}
|
|
71
|
+
const chapters = []
|
|
72
|
+
let cur = null
|
|
73
|
+
let para = 0
|
|
74
|
+
const newChapter = t => { cur = { title: t, sentences: [] }; chapters.push(cur) }
|
|
75
|
+
for (const block of src.split(/\n\s*\n/)) {
|
|
76
|
+
const lines = block.split('\n')
|
|
77
|
+
let buf = []
|
|
78
|
+
const flush = () => {
|
|
79
|
+
if (!buf.length) return
|
|
80
|
+
if (!cur) newChapter('')
|
|
81
|
+
para++
|
|
82
|
+
for (const s of splitSentences(buf.join('\n'))) cur.sentences.push({ text: s, para })
|
|
83
|
+
buf = []
|
|
84
|
+
}
|
|
85
|
+
for (const raw of lines) {
|
|
86
|
+
const line = raw.trim()
|
|
87
|
+
const h1 = /^#\s+(.+)$/.exec(line)
|
|
88
|
+
const h2 = /^#{2,6}\s+(.+)$/.exec(line)
|
|
89
|
+
if (h1) { flush(); newChapter(h1[1].trim()); continue }
|
|
90
|
+
if (h2) {
|
|
91
|
+
flush()
|
|
92
|
+
if (!cur) newChapter('')
|
|
93
|
+
para++
|
|
94
|
+
cur.sentences.push({ text: h2[1].trim().replace(/[*_`]/g, ''), heading: true, para })
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
// Markdown 的列表符号、强调符号不读
|
|
98
|
+
buf.push(line.replace(/^[-*+]\s+|^\d+[.)]\s+/, '').replace(/[*_`]+/g, ''))
|
|
99
|
+
}
|
|
100
|
+
flush()
|
|
101
|
+
}
|
|
102
|
+
const docTitle = title.trim() || (chapters[0] && chapters[0].title) || firstWords(chapters)
|
|
103
|
+
// 去掉空章;没标题的章用文档标题;太长的章按段落拆开
|
|
104
|
+
const out = []
|
|
105
|
+
for (const [k, c] of chapters.filter(c => c.sentences.length).entries()) {
|
|
106
|
+
const t = c.title || (chapters.length > 1 ? `${docTitle} · 第 ${k + 1} 部分` : docTitle)
|
|
107
|
+
if (c.sentences.length <= MAX_SENTENCES) { out.push({ title: t, sentences: c.sentences }); continue }
|
|
108
|
+
let part = []
|
|
109
|
+
let n = 1
|
|
110
|
+
for (const [j, s] of c.sentences.entries()) {
|
|
111
|
+
part.push(s)
|
|
112
|
+
const paraEnds = !c.sentences[j + 1] || c.sentences[j + 1].para !== s.para
|
|
113
|
+
if (part.length >= MAX_SENTENCES * 0.8 && paraEnds) { out.push({ title: `${t}(${n++})`, sentences: part }); part = [] }
|
|
114
|
+
}
|
|
115
|
+
if (part.length) out.push({ title: n > 1 ? `${t}(${n})` : t, sentences: part })
|
|
116
|
+
}
|
|
117
|
+
if (!out.length) throw Object.assign(new Error('没找到英文句子'), { status: 400 })
|
|
118
|
+
return { title: docTitle, chapters: out }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function firstWords(chapters) {
|
|
122
|
+
const s = chapters[0] && chapters[0].sentences[0]
|
|
123
|
+
return s ? s.text.split(/\s+/).slice(0, 6).join(' ') : '英文文档'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 给别的 AI 的整理指令:用户复制过去,连同 PDF / 网页 / 电子书一起发给它 */
|
|
127
|
+
export const PREP_PROMPT = `请把我发给你的英文材料整理成纯文本,用来导入英语听力学习软件。要求:
|
|
128
|
+
1. 只保留正文英文,删掉页眉、页脚、页码、目录、脚注编号、图片说明、网址和联系方式。
|
|
129
|
+
2. 修好因为换行断开的单词(比如 "resonant-" 换行接 "tuning"),段落内部不要硬换行;每个段落一行,段落之间空一行。
|
|
130
|
+
3. 每一章单独起一行「# 章节标题」。原文没有章节的,按内容大约每 800–1500 个英文单词分一章,自己起个简短的英文标题。章里的小标题用「## 小标题」。
|
|
131
|
+
4. 不要翻译、不要改写、不要总结,原文一个字都不要改;也不要加任何说明。
|
|
132
|
+
5. 直接输出整理好的文本(Markdown 纯文本),我会保存成 .txt 或 .md 文件导入。`
|
package/lib/docprep.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// lib/docprep.js — 用 AI 把从文档里取出的原始文字整理成能导入的格式(# 章节、段落、只留正文)
|
|
2
|
+
//
|
|
3
|
+
// 原始文字按约 1 万字符分段,几段同时整理;每段都告诉模型上一段的结尾和当前章节,接缝处才连得上。
|
|
4
|
+
// 整理完逐段核对英文单词数:模型删掉太多(少于 60%)就重来一次,还不行就用原文简单接好断行——宁可难看,不能丢内容。
|
|
5
|
+
|
|
6
|
+
import { normalize } from './doc.js'
|
|
7
|
+
|
|
8
|
+
export const CHUNK_CHARS = 10000
|
|
9
|
+
|
|
10
|
+
const SYSTEM = `你是文字整理员。给你的是从 PDF / Word / 电子书 / 网页里取出的英文原始文字(其中一段),要整理成英语听力软件能导入的纯文本。规则:
|
|
11
|
+
1. 只保留正文英文:删掉页眉、页脚、页码、目录、版权声明、联系方式、网址和邮箱、图片说明、脚注编号,以及 ® ™ 这类符号。
|
|
12
|
+
2. 修好断行:行尾被拆开的单词接回去;同一段落的多行接成一行;段落之间空一行。
|
|
13
|
+
3. 章节:原文的大标题(一章的开头)单独一行写成「# 标题」,小标题写成「## 小标题」。原文没有章节的长文,大约每 800–1500 个英文单词加一个「# 」标题,用几个英文单词概括。
|
|
14
|
+
4. 原文的句子一个字都不要改:不要翻译、改写、总结、补充,也不要加任何解释或前后说明。
|
|
15
|
+
5. 这一段可能从段落中间开始、在段落中间结束,照原样整理,不要补写缺的部分。
|
|
16
|
+
6. 格式必须严格:标题前后各空一行,段落和段落之间空一行。原文里单独成行、很短、没有句号的行(如 "Cautions and Warnings"、"Chapter 3—Free Flow")就是标题,一定要加「# 」或「## 」。
|
|
17
|
+
输出示例:
|
|
18
|
+
# The First Chapter
|
|
19
|
+
|
|
20
|
+
First paragraph. Its second sentence.
|
|
21
|
+
|
|
22
|
+
## A Section Title
|
|
23
|
+
|
|
24
|
+
Another paragraph.
|
|
25
|
+
|
|
26
|
+
只输出整理后的文字。`
|
|
27
|
+
|
|
28
|
+
const words = t => (String(t).match(/[A-Za-z][A-Za-z'’-]*/g) || []).length
|
|
29
|
+
|
|
30
|
+
/** 按行切成约 CHUNK_CHARS 的几段,尽量在空行处切 */
|
|
31
|
+
export function chunkText(raw, size = CHUNK_CHARS) {
|
|
32
|
+
const lines = normalize(raw).split('\n')
|
|
33
|
+
const chunks = []
|
|
34
|
+
let cur = []
|
|
35
|
+
let len = 0
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
cur.push(line)
|
|
38
|
+
len += line.length + 1
|
|
39
|
+
const blank = line.trim() === ''
|
|
40
|
+
if (len >= size && (blank || len >= size * 1.3)) { chunks.push(cur.join('\n').trim()); cur = []; len = 0 }
|
|
41
|
+
}
|
|
42
|
+
if (cur.join('').trim()) chunks.push(cur.join('\n').trim())
|
|
43
|
+
return chunks.filter(Boolean)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 原文简单整理(模型靠不住时的退路):接断行、段落空一行 */
|
|
47
|
+
export function plainTidy(raw) {
|
|
48
|
+
return normalize(raw)
|
|
49
|
+
.split(/\n\s*\n/)
|
|
50
|
+
.map(p => p.replace(/\s*\n\s*/g, ' ').trim())
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.join('\n\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 像标题的行:不长、没有句末标点、多数词首字母大写("Cautions and Warnings: Please Read") */
|
|
56
|
+
function looksLikeHeading(line, next) {
|
|
57
|
+
if (line.length > 90 || /[.!?,;]$/.test(line) || !/^[A-Z0-9"“‘'(]/.test(line)) return false
|
|
58
|
+
const ws = line.split(/\s+/).filter(Boolean)
|
|
59
|
+
if (ws.length > 12) return false
|
|
60
|
+
// 只看以字母开头的实词(「#4—NVC」「12」这种不算)
|
|
61
|
+
const big = ws.filter(w => w.length > 3 && /^["“‘'(]?[A-Za-z]/.test(w))
|
|
62
|
+
if (big.length && big.filter(w => /^["“‘'(]?[A-Z0-9]/.test(w)).length / big.length < 0.6) return false
|
|
63
|
+
// 下一行以小写开头:这是被断开的句子,不是标题
|
|
64
|
+
return !next || !/^[a-z]/.test(next)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把模型的输出(或原文)规整成导入格式,不管它守没守规矩:
|
|
69
|
+
* 认出标题加「## 」,句子中途断开的行接回去,每段之间空一行。已经合格的文字过一遍不变。
|
|
70
|
+
*/
|
|
71
|
+
export function ensureStructure(text) {
|
|
72
|
+
const lines = String(text).split('\n').map(l => l.trim())
|
|
73
|
+
const blocks = []
|
|
74
|
+
let para = ''
|
|
75
|
+
const flush = () => { if (para) { blocks.push(para); para = '' } }
|
|
76
|
+
for (let k = 0; k < lines.length; k++) {
|
|
77
|
+
const line = lines[k]
|
|
78
|
+
if (!line) { flush(); continue }
|
|
79
|
+
if (/^#{1,6}\s/.test(line)) { flush(); blocks.push(line); continue }
|
|
80
|
+
const next = lines.slice(k + 1).find(Boolean) || ''
|
|
81
|
+
if (!para && looksLikeHeading(line, next)) { blocks.push(`## ${line}`); continue }
|
|
82
|
+
para = para ? `${para} ${line}` : line
|
|
83
|
+
// 这一行说完了(句末标点)、下一行另起:就是一段的结尾
|
|
84
|
+
if (/[.!?…"”’)]$/.test(line) && !/^[a-z]/.test(next)) flush()
|
|
85
|
+
}
|
|
86
|
+
flush()
|
|
87
|
+
return blocks.join('\n\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 去掉模型偶尔加上的代码块围栏和开场白 */
|
|
91
|
+
function stripWrapper(t) {
|
|
92
|
+
return String(t || '').replace(/^```[a-z]*\n?|```\s*$/gi, '').trim()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 预估:词数、朗读时长、大约几课、要多久、多少 token
|
|
97
|
+
* 粗算:朗读每分钟 150 词;一课最多 160 句(约 2000 词);
|
|
98
|
+
* 模型输出 token:整理 ≈ 字符数 / 4,翻译讲解 ≈ 每句 120
|
|
99
|
+
*/
|
|
100
|
+
export function estimate(text, { needsCleanup = true } = {}) {
|
|
101
|
+
const w = words(text)
|
|
102
|
+
const chars = text.length
|
|
103
|
+
const sentences = Math.max(1, Math.round(w / 16))
|
|
104
|
+
const chunks = needsCleanup ? Math.max(1, Math.ceil(chars / CHUNK_CHARS)) : 0
|
|
105
|
+
const tokens = Math.round((needsCleanup ? chars / 4 : 0) + sentences * 120)
|
|
106
|
+
// 整理:每段约 40 秒、3 段并行;朗读(Edge)约为时长的 1/4;翻译:每 20 句一批约 15 秒、3 批并行
|
|
107
|
+
const seconds = Math.ceil(chunks / 3) * 40 + (w / 150) * 60 / 4 + Math.ceil(sentences / 20 / 3) * 15
|
|
108
|
+
return {
|
|
109
|
+
words: w, chars, sentences,
|
|
110
|
+
audioMinutes: Math.max(1, Math.round(w / 150)),
|
|
111
|
+
lessons: Math.max(1, Math.ceil(sentences / 130)),
|
|
112
|
+
chunks, tokens,
|
|
113
|
+
minutes: Math.max(1, Math.round(seconds / 60)),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} llm complete() 接口
|
|
119
|
+
* @param {string} raw 原始文字
|
|
120
|
+
* @param {{title?:string, signal?:AbortSignal, concurrency?:number, chunkChars?:number, onProgress?(done,total):void}} o
|
|
121
|
+
* @returns {Promise<{text:string, warnings:string[]}>}
|
|
122
|
+
*/
|
|
123
|
+
export async function cleanupText(llm, raw, o = {}) {
|
|
124
|
+
const chunks = chunkText(raw, o.chunkChars || CHUNK_CHARS)
|
|
125
|
+
const out = new Array(chunks.length)
|
|
126
|
+
const warnings = []
|
|
127
|
+
let done = 0
|
|
128
|
+
let next = 0
|
|
129
|
+
const one = async (k) => {
|
|
130
|
+
const chunk = chunks[k]
|
|
131
|
+
const prev = k > 0 ? chunks[k - 1].slice(-300).replace(/\s+/g, ' ') : ''
|
|
132
|
+
const user = [
|
|
133
|
+
o.title ? `材料:${o.title}` : '',
|
|
134
|
+
`这是第 ${k + 1} / ${chunks.length} 段。`,
|
|
135
|
+
prev ? `上一段原文的结尾是:「…${prev}」(只用来接上下文,不要输出)` : '这是开头。',
|
|
136
|
+
'',
|
|
137
|
+
'原始文字:',
|
|
138
|
+
chunk,
|
|
139
|
+
].filter(x => x !== null).join('\n')
|
|
140
|
+
const want = words(chunk)
|
|
141
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
142
|
+
if (o.signal && o.signal.aborted) throw new Error('已取消')
|
|
143
|
+
try {
|
|
144
|
+
const text = stripWrapper(await llm.complete({ system: SYSTEM, user, maxTokens: 16000, signal: o.signal }))
|
|
145
|
+
const got = words(text)
|
|
146
|
+
if (want < 30 || got >= want * 0.6) { out[k] = ensureStructure(text); return }
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (o.signal && o.signal.aborted) throw e
|
|
149
|
+
if (attempt === 1) warnings.push(`第 ${k + 1} 段整理失败(${e.message}),用了原文`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!out[k]) {
|
|
153
|
+
out[k] = ensureStructure(plainTidy(chunk))
|
|
154
|
+
if (!warnings.some(w => w.startsWith(`第 ${k + 1} 段`))) warnings.push(`第 ${k + 1} 段 AI 整理后少了很多内容,改用原文(可能留有页眉页码)`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const workers = Array.from({ length: Math.min(o.concurrency || 3, chunks.length) }, async () => {
|
|
158
|
+
while (next < chunks.length) {
|
|
159
|
+
const k = next++
|
|
160
|
+
await one(k)
|
|
161
|
+
done++
|
|
162
|
+
if (o.onProgress) o.onProgress(done, chunks.length)
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
await Promise.all(workers)
|
|
166
|
+
// 接缝:下一段开头重复了上一段最后的章节标题就去掉
|
|
167
|
+
const parts = []
|
|
168
|
+
let lastHeading = ''
|
|
169
|
+
for (const t of out) {
|
|
170
|
+
let s = t
|
|
171
|
+
const first = /^#\s+(.+)$/m.exec(s)
|
|
172
|
+
if (first && s.trimStart().startsWith('#') && first[1].trim() === lastHeading) s = s.replace(/^\s*#\s+.+\n?/, '')
|
|
173
|
+
const hs = [...s.matchAll(/^#\s+(.+)$/gm)]
|
|
174
|
+
if (hs.length) lastHeading = hs[hs.length - 1][1].trim()
|
|
175
|
+
parts.push(s.trim())
|
|
176
|
+
}
|
|
177
|
+
return { text: parts.filter(Boolean).join('\n\n'), warnings }
|
|
178
|
+
}
|
package/lib/edge.js
CHANGED
|
@@ -29,6 +29,25 @@ export const EDGE_ZH_VOICES = [
|
|
|
29
29
|
]
|
|
30
30
|
export const edgeVoice = id => EDGE_ZH_VOICES.find(v => v.id === id) || EDGE_ZH_VOICES[0]
|
|
31
31
|
|
|
32
|
+
/** 英文(导入文档时朗读用;默认 Andrew) */
|
|
33
|
+
export const EDGE_EN_VOICES = [
|
|
34
|
+
{ id: 'en-US-AndrewNeural', label: 'Andrew(美式男声 · 温和)', gender: 'm' },
|
|
35
|
+
{ id: 'en-US-BrianNeural', label: 'Brian(美式男声 · 随和)', gender: 'm' },
|
|
36
|
+
{ id: 'en-US-ChristopherNeural', label: 'Christopher(美式男声 · 沉稳)', gender: 'm' },
|
|
37
|
+
{ id: 'en-US-GuyNeural', label: 'Guy(美式男声)', gender: 'm' },
|
|
38
|
+
{ id: 'en-US-EricNeural', label: 'Eric(美式男声)', gender: 'm' },
|
|
39
|
+
{ id: 'en-GB-RyanNeural', label: 'Ryan(英式男声)', gender: 'm' },
|
|
40
|
+
{ id: 'en-GB-ThomasNeural', label: 'Thomas(英式男声)', gender: 'm' },
|
|
41
|
+
{ id: 'en-US-AvaNeural', label: 'Ava(美式女声 · 亲切)', gender: 'f' },
|
|
42
|
+
{ id: 'en-US-EmmaNeural', label: 'Emma(美式女声 · 清晰)', gender: 'f' },
|
|
43
|
+
{ id: 'en-US-JennyNeural', label: 'Jenny(美式女声)', gender: 'f' },
|
|
44
|
+
{ id: 'en-US-AriaNeural', label: 'Aria(美式女声 · 播音)', gender: 'f' },
|
|
45
|
+
{ id: 'en-US-MichelleNeural', label: 'Michelle(美式女声)', gender: 'f' },
|
|
46
|
+
{ id: 'en-GB-SoniaNeural', label: 'Sonia(英式女声)', gender: 'f' },
|
|
47
|
+
{ id: 'en-GB-LibbyNeural', label: 'Libby(英式女声)', gender: 'f' },
|
|
48
|
+
]
|
|
49
|
+
export const edgeEnVoice = id => EDGE_EN_VOICES.find(v => v.id === id) || EDGE_EN_VOICES[0]
|
|
50
|
+
|
|
32
51
|
export const edgeDir = () => join(dataRoot(), 'edge-tts')
|
|
33
52
|
const win = process.platform === 'win32'
|
|
34
53
|
const venvBin = name => join(edgeDir(), win ? 'Scripts' : 'bin', win ? `${name}.exe` : name)
|
package/lib/extract.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// lib/extract.js — 从文档里取出文字:PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT / Markdown
|
|
2
|
+
//
|
|
3
|
+
// 只取文字,不管排版:页眉页码、断行这些交给后面的 AI 整理(lib/docprep.js)。
|
|
4
|
+
// docx / epub 本质是 zip:这里用 Node 自带的 zlib 读,不另加依赖;PDF 用 unpdf(纯 JS)。
|
|
5
|
+
|
|
6
|
+
import { readFile } from 'node:fs/promises'
|
|
7
|
+
import { extname } from 'node:path'
|
|
8
|
+
import { inflateRawSync } from 'node:zlib'
|
|
9
|
+
|
|
10
|
+
export const DOC_EXT = ['.pdf', '.docx', '.epub', '.html', '.htm', '.txt', '.md', '.markdown']
|
|
11
|
+
|
|
12
|
+
// ———————— zip ————————
|
|
13
|
+
|
|
14
|
+
/** 读 zip 的目录,返回 name → 取内容的函数 */
|
|
15
|
+
export function readZip(buf) {
|
|
16
|
+
let eocd = -1
|
|
17
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) {
|
|
18
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break }
|
|
19
|
+
}
|
|
20
|
+
if (eocd < 0) throw new Error('不是有效的 zip 文件')
|
|
21
|
+
const count = buf.readUInt16LE(eocd + 10)
|
|
22
|
+
let p = buf.readUInt32LE(eocd + 16)
|
|
23
|
+
const files = new Map()
|
|
24
|
+
for (let k = 0; k < count; k++) {
|
|
25
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) break
|
|
26
|
+
const method = buf.readUInt16LE(p + 10)
|
|
27
|
+
const size = buf.readUInt32LE(p + 20)
|
|
28
|
+
const nameLen = buf.readUInt16LE(p + 28)
|
|
29
|
+
const extraLen = buf.readUInt16LE(p + 30)
|
|
30
|
+
const commentLen = buf.readUInt16LE(p + 32)
|
|
31
|
+
const local = buf.readUInt32LE(p + 42)
|
|
32
|
+
const name = buf.toString('utf8', p + 46, p + 46 + nameLen)
|
|
33
|
+
files.set(name, () => {
|
|
34
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28)
|
|
35
|
+
const data = buf.subarray(start, start + size)
|
|
36
|
+
if (method === 0) return data
|
|
37
|
+
if (method === 8) return inflateRawSync(data)
|
|
38
|
+
throw new Error(`zip 里用了不支持的压缩方式(${method})`)
|
|
39
|
+
})
|
|
40
|
+
p += 46 + nameLen + extraLen + commentLen
|
|
41
|
+
}
|
|
42
|
+
return files
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ———————— HTML / XML → 文字 ————————
|
|
46
|
+
|
|
47
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”' }
|
|
48
|
+
export function decodeEntities(s) {
|
|
49
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
50
|
+
if (e[0] === '#') return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
|
|
51
|
+
return ENTITIES[e.toLowerCase()] ?? m
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 网页 / EPUB 章节 → 文字:标题变成「# 」「## 」,段落之间空一行 */
|
|
56
|
+
export function htmlToText(html) {
|
|
57
|
+
return decodeEntities(String(html)
|
|
58
|
+
.replace(/<(script|style|head|nav|footer|aside)[\s\S]*?<\/\1>/gi, '')
|
|
59
|
+
.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, t) => `\n\n# ${t.replace(/<[^>]+>/g, '').trim()}\n\n`)
|
|
60
|
+
.replace(/<h[2-6][^>]*>([\s\S]*?)<\/h[2-6]>/gi, (_, t) => `\n\n## ${t.replace(/<[^>]+>/g, '').trim()}\n\n`)
|
|
61
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
62
|
+
.replace(/<\/(p|div|li|blockquote|section|article|tr)>/gi, '\n\n')
|
|
63
|
+
.replace(/<[^>]+>/g, ''))
|
|
64
|
+
.replace(/[ \t]+/g, ' ')
|
|
65
|
+
.replace(/\n[ \t]+/g, '\n')
|
|
66
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
67
|
+
.trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Word:word/document.xml 里一个 <w:p> 是一段,标题样式变成「# / ## 」 */
|
|
71
|
+
export function docxToText(buf) {
|
|
72
|
+
const zip = readZip(buf)
|
|
73
|
+
const doc = zip.get('word/document.xml')
|
|
74
|
+
if (!doc) throw new Error('这个 Word 文件里没有正文')
|
|
75
|
+
const xml = doc().toString('utf8')
|
|
76
|
+
const paras = []
|
|
77
|
+
for (const m of xml.matchAll(/<w:p[ >][\s\S]*?<\/w:p>/g)) {
|
|
78
|
+
const p = m[0]
|
|
79
|
+
const style = (/<w:pStyle w:val="([^"]+)"/.exec(p) || [])[1] || ''
|
|
80
|
+
const text = decodeEntities([...p.matchAll(/<w:t(?: [^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>/g)].map(t => (t[1] !== undefined ? t[1] : ' ')).join('')).trim()
|
|
81
|
+
if (!text) continue
|
|
82
|
+
const h = /^(Heading|标题)\s*(\d)/i.exec(style) || (/^Title$/i.test(style) ? [0, 0, '1'] : null)
|
|
83
|
+
paras.push(h ? `${h[2] === '1' ? '#' : '##'} ${text}` : text)
|
|
84
|
+
}
|
|
85
|
+
return paras.join('\n\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** EPUB:按目录(spine)的顺序把各章 XHTML 接起来 */
|
|
89
|
+
export function epubToText(buf) {
|
|
90
|
+
const zip = readZip(buf)
|
|
91
|
+
const container = zip.get('META-INF/container.xml')
|
|
92
|
+
const opfPath = container && (/full-path="([^"]+)"/.exec(container().toString('utf8')) || [])[1]
|
|
93
|
+
if (!opfPath || !zip.get(opfPath)) throw new Error('这个 EPUB 文件不完整')
|
|
94
|
+
const opf = zip.get(opfPath)().toString('utf8')
|
|
95
|
+
const base = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''
|
|
96
|
+
const items = new Map([...opf.matchAll(/<item\b[^>]*>/g)].map(m => [(/id="([^"]+)"/.exec(m[0]) || [])[1], (/href="([^"]+)"/.exec(m[0]) || [])[1]]))
|
|
97
|
+
const parts = []
|
|
98
|
+
for (const m of opf.matchAll(/<itemref\b[^>]*idref="([^"]+)"/g)) {
|
|
99
|
+
const href = items.get(m[1])
|
|
100
|
+
if (!href) continue
|
|
101
|
+
const name = decodeURIComponent(base + href.split('#')[0])
|
|
102
|
+
const f = zip.get(name)
|
|
103
|
+
if (f) parts.push(htmlToText(f().toString('utf8')))
|
|
104
|
+
}
|
|
105
|
+
return parts.filter(Boolean).join('\n\n')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** TXT:UTF-8 / UTF-16(带 BOM)都认 */
|
|
109
|
+
function decodeText(buf) {
|
|
110
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) return buf.subarray(2).toString('utf16le')
|
|
111
|
+
if (buf[0] === 0xfe && buf[1] === 0xff) return Buffer.from(buf.subarray(2)).swap16().toString('utf16le')
|
|
112
|
+
return buf.toString('utf8').replace(/^/, '')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function pdfToText(buf) {
|
|
116
|
+
const { extractText, getDocumentProxy } = await import('unpdf')
|
|
117
|
+
const pdf = await getDocumentProxy(new Uint8Array(buf))
|
|
118
|
+
const { totalPages, text } = await extractText(pdf, { mergePages: false })
|
|
119
|
+
return { text: text.join('\n\n'), pages: totalPages, perPage: text.map(t => t.trim().length) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @returns {Promise<{type:string, text:string, pages?:number, scanned?:boolean}>}
|
|
124
|
+
*/
|
|
125
|
+
export async function extractText(file, name = file) {
|
|
126
|
+
const ext = extname(name).toLowerCase()
|
|
127
|
+
if (!DOC_EXT.includes(ext)) throw Object.assign(new Error('不支持这种文件:可以导入 PDF、Word(.docx)、EPUB、网页(.html)、TXT、Markdown'), { status: 400 })
|
|
128
|
+
const buf = await readFile(file)
|
|
129
|
+
if (ext === '.pdf') {
|
|
130
|
+
const r = await pdfToText(buf)
|
|
131
|
+
// 每页平均不到 80 个字:多半是扫描版(整页是图片),读不出文字
|
|
132
|
+
const scanned = r.pages > 0 && r.text.replace(/\s/g, '').length / r.pages < 80
|
|
133
|
+
return { type: 'PDF', text: r.text, pages: r.pages, scanned }
|
|
134
|
+
}
|
|
135
|
+
if (ext === '.docx') return { type: 'Word', text: docxToText(buf) }
|
|
136
|
+
if (ext === '.epub') return { type: 'EPUB', text: epubToText(buf) }
|
|
137
|
+
if (ext === '.html' || ext === '.htm') return { type: '网页', text: htmlToText(decodeText(buf)) }
|
|
138
|
+
return { type: ext === '.txt' ? 'TXT' : 'Markdown', text: decodeText(buf) }
|
|
139
|
+
}
|