🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

搜书听 AIcivilization/dsh-audiobook

DeepSeek Harness
AIcivilization12 天前有提交已提供中文文档扫描:含敏感能力

搜公版书或导入文件,让语音把书读给你听

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/21 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add AIcivilization/dsh-audiobook
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么
dsh 原生插件 · other
装得上吗
本站已在 dsh 0.2.1-rc.2 下真实安装成功
非静态推断
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 12 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件写入剪贴板(不计风险)读取剪贴板或密钥环境变量发起外部网络请求
查看扫描证据(共 125 处,展示前 123 条)
  • 读取本地文件
    plugin/index.js:45
    try { return JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf8')) } catch { return {} }
  • 发起网络请求
    plugin/client.js:46
    const r = await fetch('/api-dsh-audiobook/' + path, {
  • 写入剪贴板
    plugin/client.js:58
    try { await navigator.clipboard.writeText(command) } catch {
  • 发起网络请求
    plugin/client.js:160
    fetch('/api-dsh-audiobook/status', { credentials: 'same-origin' }).then(r => r.json()).then(j => { if (alive) setSt(j) }).catch(() => {})
  • 读取本地文件
    lib/asr.js:39
    try { return (await stat(path)).size === asset.bytes } catch { return false }
  • 读取本地文件
    lib/asr.js:48
    if (existsSync(p) && await sizeMatches(p, a)) { found[key] = p; break }
  • 读取本地文件
    lib/asr.js:57
    for await (const c of createReadStream(path)) h.update(c)
  • 写入本地文件
    lib/asr.js:68
    await mkdir(dir, { recursive: true })
  • 发起网络请求
    lib/asr.js:74
    const res = await fetch(origin + asset.path, { redirect: 'follow' })
  • 写入本地文件
    lib/asr.js:77
    await pipeline(Readable.fromWeb(res.body), meter, createWriteStream(part))
  • 写入本地文件
    lib/asr.js:79
    await rename(part, dest)
  • 删除本地文件
    lib/asr.js:84
    await rm(part, { force: true })
  • 执行外部命令来自配置/变量
    lib/asr.js:130
    const c = spawn(process.execPath, [WORKER, JSON.stringify({ models, threads })], {
  • 读取本地文件
    lib/edge.js:50
    for (const root of [edgeDir(), join(tingdongRoot(), 'edge-tts')]) if (existsSync(venvBin(root, 'edge-tts'))) return venvBin(root, 'edge-tts')
  • 读取本地文件
    lib/edge.js:51
    for (const p of ['/opt/homebrew/bin/edge-tts', '/usr/local/bin/edge-tts', '/usr/bin/edge-tts']) if (existsSync(p)) return p
  • 执行外部命令来自配置/变量
    lib/edge.js:57
    execFile(cmd, args, { timeout, maxBuffer: 8 << 20, env: env ? { ...process.env, ...env } : undefined }, (err, stdout, stderr) => {
  • 读取本地文件
    lib/edge.js:68
    if (p.includes('/') && !existsSync(p)) continue
  • 发起网络请求
    lib/edge.js:79
    const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(180000) })
  • 写入本地文件
    lib/edge.js:81
    await mkdir(dirname(file), { recursive: true })
  • 写入本地文件
    lib/edge.js:82
    await pipeline(Readable.fromWeb(res.body), createWriteStream(file))
  • 读取本地文件
    lib/edge.js:91
    if (!existsSync(venvPy)) {
  • 删除本地文件递归
    lib/edge.js:92
    await rm(edgeDir(), { recursive: true, force: true })
  • 删除本地文件递归
    lib/edge.js:95
    await rm(edgeDir(), { recursive: true, force: true })
  • 删除本地文件
    lib/edge.js:103
    await rm(getPip, { force: true })
  • 读取本地文件
    lib/edge.js:124
    let uv = [join(uvDir, exe), join(uvDir, asset.replace(/ (tar gz|zip)$/, ''), exe)].find(p => existsSync(p))
  • 删除本地文件
    lib/edge.js:128
    await rm(pkg, { force: true })
  • 读取本地文件
    lib/edge.js:129
    uv = [join(uvDir, exe), join(uvDir, asset.replace(/ (tar gz|zip)$/, ''), exe)].find(p => existsSync(p))
  • 删除本地文件递归
    lib/edge.js:133
    await rm(edgeDir(), { recursive: true, force: true })
  • 发起网络请求
    lib/llm.js:50
    const res = await fetch(base + '/chat/completions', {
  • 读取本地文件
    lib/llm.js:72
    const cfg = JSON.parse(await readFile(join(root, 'config.json'), 'utf8'))
  • 读取本地文件
    lib/llm.js:73
    const yaml = await readFile(join(root, 'engine.yaml'), 'utf8')
  • 读取密钥环境变量
    lib/llm.js:86
    let apiKey = flags['api-key'] || process.env.AUDIOBOOK_API_KEY || ''
  • 发起网络请求
    lib/llm.js:95
    const r = await fetch(baseURL.replace(/ +$/, '') + '/models', { headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}, signal: AbortSignal.tim
  • 读取本地文件
    lib/paths.js:45
    return candidates.find(p => existsSync(p)) || null
  • 读取本地文件
    lib/targets.js:44
    const j = JSON.parse(readFileSync(cfg, 'utf8'))
  • 读取本地文件
    lib/targets.js:45
    return Object.values(j.vaults || {}).map(v => v.path).filter(p => p && existsSync(p))
  • 读取本地文件
    lib/targets.js:53
    return readdirSync(dir).map(f => /^logseq_local_(.+)\.transit$/.exec(f)).filter(Boolean)
  • 读取本地文件
    lib/targets.js:54
    .map(m => m[1].replace(/ /g, '/')).filter(p => existsSync(p))
  • 读取本地文件
    lib/targets.js:68
    const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
  • 读取本地文件
    lib/targets.js:74
    const patch = readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')
  • 执行外部命令固定命令
    lib/targets.js:109
    return new Promise((resolve, reject) => execFile('/usr/bin/osascript', ['-e', script, ...args], { timeout: 30000 },
  • 写入本地文件
    lib/targets.js:131
    await mkdir(dir, { recursive: true })
  • 写入本地文件
    lib/targets.js:132
    await writeFile(join(dir, file), markdown, 'utf8')
  • 写入本地文件
    lib/targets.js:137
    await mkdir(dir, { recursive: true })
  • 写入本地文件
    lib/targets.js:138
    await writeFile(join(dir, file), markdown, 'utf8')
  • 读取本地文件
    lib/server.js:84
    try { st = await stat(file) } catch { return send(res, 404, { error: '这段声音还没有合成' }) }
  • 读取本地文件
    lib/server.js:92
    return createReadStream(file).pipe(res)
  • 读取本地文件
    lib/server.js:103
    createReadStream(file, { start, end }).pipe(res)
  • 读取本地文件
    lib/server.js:133
    return res.end(await readFile(join(o.pkgRoot, rel)))
  • 写入本地文件
    lib/server.js:210
    await mkdir(uploadsDir(), { recursive: true })
  • 写入本地文件
    lib/server.js:214
    await pipeline(req, createWriteStream(tmp))
  • 删除本地文件
    lib/server.js:217
    await rm(tmp, { force: true })
  • 写入本地文件
    lib/server.js:225
    await mkdir(uploadsDir(), { recursive: true })
  • 写入本地文件
    lib/server.js:228
    await pipeline(req, createWriteStream(tmp))
  • 删除本地文件
    lib/server.js:235
    await rm(tmp, { force: true })
  • 读取本地文件
    lib/speech.js:70
    for (const ext of ['.mp3', '.wav']) if (existsSync(base + ext)) return base + ext
  • 写入本地文件
    lib/speech.js:82
    await mkdir(join(base, '..'), { recursive: true })
  • 写入本地文件
    lib/speech.js:85
    await writeFile(tmp + '.wav', silenceWav())
  • 写入本地文件
    lib/speech.js:86
    await rename(tmp + '.wav', base + '.wav')
  • 写入本地文件
    lib/speech.js:92
    await rename(tmp + '.mp3', base + '.mp3')
  • 执行外部命令来自配置/变量
    lib/speech.js:101
    await new Promise((resolve, reject) => execFile(ff, ['-hide_banner', '-nostdin', '-y', '-i', tmp + '.wav', '-codec:a', 'libmp3lame', '-b:a', '64k', tm
  • 写入本地文件
    lib/speech.js:103
    await rename(tmp + '.mp3', base + '.mp3')
  • 删除本地文件
    lib/speech.js:104
    await rm(tmp + '.wav', { force: true })
  • 写入本地文件
    lib/speech.js:107
    await rename(tmp + '.wav', base + '.wav')
  • 删除本地文件
    lib/speech.js:110
    await rm(tmp + '.mp3', { force: true })
  • 删除本地文件
    lib/speech.js:111
    await rm(tmp + '.wav', { force: true })
  • 读取本地文件
    lib/speech.js:158
    try { names = await readdir(dir, { withFileTypes: true }) } catch { return }
  • 读取本地文件
    lib/speech.js:162
    else if (e.isFile()) { try { const s = await stat(p); files.push({ p, size: s.size, t: s.mtimeMs }) } catch { } }
  • 删除本地文件
    lib/speech.js:171
    await rm(f.p, { force: true })
  • 删除本地文件递归
    lib/speech.js:212
    await rm(join(cacheDir(), id), { recursive: true, force: true })
  • 读取本地文件
    plugin/manage.js:56
    const readPkg = async () => { try { return JSON.parse(await readFile(join(o.pkgRoot, 'package.json'), 'utf8')) } catch { return {} } }
  • 发起网络请求
    plugin/manage.js:64
    const res = await fetch(`${r}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(6000) })
  • 读取本地文件
    lib/importer.js:20
    const st = await stat(file).catch(() => null)
  • 删除本地文件
    lib/importer.js:77
    await rm(file, { force: true })
  • 读取本地文件
    lib/store.js:15
    try { return JSON.parse(await readFile(path, 'utf8')) } catch { return fallback }
  • 写入本地文件
    lib/store.js:21
    await writeFile(tmp, pretty ? JSON.stringify(value, null, 1) : JSON.stringify(value))
  • 写入本地文件
    lib/store.js:22
    await rename(tmp, path)
  • 写入本地文件
    lib/store.js:57
    await mkdir(bookDir(book.id), { recursive: true })
  • 删除本地文件递归
    lib/store.js:88
    await rm(bookDir(checkId(id)), { recursive: true, force: true })
  • 读取本地文件
    lib/store.js:123
    try { names = await readdir(booksDir()) } catch { return [] }
  • 写入本地文件
    lib/store.js:162
    await mkdir(dataRoot(), { recursive: true })
  • 执行外部命令固定命令
    lib/extract.js:282
    execFile('/usr/bin/textutil', ['-convert', 'txt', '-stdout', '-encoding', 'UTF-8', file], { maxBuffer: 256 << 20, timeout: 120000 },
  • 读取本地文件
    lib/extract.js:300
    const buf = await readFile(file)
  • 读取本地文件
    lib/kokoro.js:31
    const complete = d => existsSync(join(d, 'model.int8.onnx')) && existsSync(join(d, 'lexicon-zh.txt')) && existsSync(join(d, 'dict'))
  • 写入本地文件
    lib/kokoro.js:57
    await mkdir(root, { recursive: true })
  • 发起网络请求
    lib/kokoro.js:60
    const res = await fetch(MODEL.url, { redirect: 'follow' })
  • 写入本地文件
    lib/kokoro.js:63
    await pipeline(Readable.fromWeb(res.body), meter, createWriteStream(part))
  • 读取本地文件
    lib/kokoro.js:64
    if ((await stat(part)).size !== MODEL.bytes) throw new Error('文件大小不对')
  • 删除本地文件递归
    lib/kokoro.js:66
    await rm(tmpDir, { recursive: true, force: true })
  • 写入本地文件
    lib/kokoro.js:67
    await mkdir(tmpDir)
  • 执行外部命令固定命令
    lib/kokoro.js:69
    const t = spawn('tar', ['-xjf', part, '-C', tmpDir], { stdio: 'ignore' })
  • 删除本地文件递归
    lib/kokoro.js:73
    await rm(ownDir(), { recursive: true, force: true })
  • 写入本地文件
    lib/kokoro.js:74
    await rename(join(tmpDir, MODEL.name), ownDir())
  • 删除本地文件递归
    lib/kokoro.js:75
    await rm(tmpDir, { recursive: true, force: true })
  • 删除本地文件
    lib/kokoro.js:80
    await rm(part, { force: true })
  • 执行外部命令来自配置/变量
    lib/kokoro.js:105
    const c = spawn(process.execPath, [WORKER, JSON.stringify({ dir, threads })], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ELECTRON_RUN_A
  • 读取本地文件
    lib/notes.js:18
    try { return JSON.parse(await readFile(path, 'utf8')) } catch { return fallback }
  • 写入本地文件
    lib/notes.js:21
    await mkdir(join(path, '..'), { recursive: true })
  • 写入本地文件
    lib/notes.js:23
    await writeFile(tmp, JSON.stringify(value, null, 1))
  • 写入本地文件
    lib/notes.js:24
    await rename(tmp, path)
  • 发起网络请求
    lib/sources.js:36
    const res = await fetch(url, { headers: { 'user-agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(timeout) })
  • 发起网络请求
    lib/sources.js:256
    const res = await fetch(url, { headers: { 'user-agent': UA }, redirect: 'follow', signal: AbortSignal.timeout(300000) })
  • 写入本地文件
    lib/sources.js:259
    await pipeline(Readable.fromWeb(res.body), createWriteStream(file))
  • 读取本地文件
    lib/sources.js:260
    if ((await stat(file)).size < 200) throw new Error('下载到的文件是空的')
  • 写入本地文件
    lib/sources.js:269
    await mkdir(uploadsDir(), { recursive: true })
  • 删除本地文件
    lib/sources.js:279
    } catch { await rm(tmp + ext, { force: true }) }
  • 删除本地文件
    lib/sources.js:290
    await rm(tmp + '.epub', { force: true })
  • 删除本地文件
    lib/sources.js:306
    await rm(tmp + f.ext, { force: true })
  • 删除本地文件
    lib/sources.js:317
    await rm(tmp + '.epub', { force: true })
  • 发起网络请求
    lib/custom.js:92
    const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000), headers: { accept: 'application/atom+xml, application/xml;q=0.9
  • 发起网络请求
    lib/custom.js:115
    const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(300000) })
  • 写入本地文件
    lib/custom.js:125
    await mkdir(uploadsDir(), { recursive: true })
  • 写入本地文件
    lib/custom.js:128
    await pipeline(Readable.fromWeb(res.body), createWriteStream(file))
  • 读取本地文件
    lib/custom.js:129
    if ((await stat(file)).size < 100) throw new Error('下载到的文件是空的')
  • 删除本地文件
    lib/custom.js:131
    await rm(file, { force: true })
  • 读取本地文件
    lib/asr-worker.js:80
    emit({ id: req.id, text: transcribe(readWav(readFileSync(req.file))) })
  • 写入本地文件
    lib/kokoro-worker.js:49
    writeFileSync(req.out, wav(a.samples, a.sampleRate))
  • 发起网络请求
    web/panel.js:30
    const r = await fetch(path, init)
  • 发起网络请求
    web/panel.js:174
    const r = await fetch(`upload?name=${encodeURIComponent(file.name)}`, {
  • 发起网络请求
    web/player.js:80
    .then(url => fetch(url, { credentials: 'same-origin' }))
  • 发起网络请求
    web/talk.js:119
    const r = await fetch('asr', { method: 'POST', credentials: 'same-origin', headers: { 'x-audiobook': '1', 'content-type': 'audio/wav' }, body: blob })
  • 发起网络请求
    web/talk.js:251
    const r = await fetch('say', {
  • 写入剪贴板
    web/talk.js:556
    try { await navigator.clipboard.writeText(r.text); b.textContent = '已复制' } catch { toast('复制不了:手动选中文字复制') }
扫描于 2026/11/1(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

把任意一本书变成有声书,就在 DeepSeek Harness(dsh)里 · 搜书听:导入一本书,在 DSH 里读给你听

README

由 DeepSeek 最新模型翻译生成
搜书听 · dsh-audiobook 在 DeepSeek Harness(dsh)里把任何一本书变成有声书。 按书名、作者、角色名,或者只记得一半的情节,去公版书库(古腾堡计划、维基文库、Internet Archive、Standard Ebooks)里搜;也可以导入你自己的 EPUB、PDF、TXT、DOCX 或 Markdown 文件,然后在侧边栏里听——微软 Edge 神经网络语音、离线 Kokoro,或者设备自带的语音;语速可调;自动记住你听到哪。 搜一本书,或者导入一本书,读给你听。 书名、作者、角色名、大概的情节都能搜;声音、语速可选,自动记住听到哪。DSH 插件,也能脱离 DSH 单独用。 dsh plugin --profile desktop add dsh-audiobook 它做什么 1. 搜书:面板顶上的搜索框,输入书名、作者,或者只记得的角色名、大概情节(「主角叫贾宝玉的那本」)。在 DSH 里会先让 DSH 选的模型猜是哪几本书,再去书源里找;找到点「加入书架」,下载完直接出现在书单里。
展开完整文档(README 剩余 1 节)
搜书听 · dsh-audiobook

在 DeepSeek Harness(dsh)里把任何一本书变成有声书。
按书名、作者、角色名,或者只记得一半的情节,去公版书库(古腾堡计划、维基文库、Internet Archive、Standard Ebooks)里搜;也可以导入你自己的 EPUB、PDF、TXT、DOCX 或 Markdown 文件,然后在侧边栏里听——微软 Edge 神经网络语音、离线 Kokoro,或者设备自带的语音;语速可调;自动记住你听到哪。

搜一本书,或者导入一本书,读给你听。 书名、作者、角色名、大概的情节都能搜;声音、语速可选,自动记住听到哪。DSH 插件,也能脱离 DSH 单独用。

dsh plugin --profile desktop add dsh-audiobook

它做什么

1. 搜书:面板顶上的搜索框,输入书名、作者,或者只记得的角色名、大概情节(「主角叫贾宝玉的那本」)。在 DSH 里会先让 DSH 选的模型猜是哪几本书,再去书源里找;找到点「加入书架」,下载完直接出现在书单里。

| 书源 | 有什么 |
| 古腾堡计划 | 7 万多本公版书,英文为主,也有《紅樓夢》《西遊記》等中文古籍(多为繁体) |
| 中文维基文库 | 中文公版古籍、民国作品(鲁迅等),整部作品导出成 EPUB(大书要一两分钟) |
| 英文维基文库 | 英文公版作品 |
| Internet Archive | 只收标明公有领域或 CC 授权、而且有文字版的书;有不少中文旧书。扫描版的文字是识别出来的,可能有错字,结果里会标出来 |
| Standard Ebooks | 精校排版的英文公版书 |

只接这些公版和授权免费的书源。受版权保护的现代书(比如《围城》)搜不到,AI 猜到了也会照实说「书源里没有」:请买正版电子书后导入。

常规网页搜索:同时用 DSH 自己的网页搜索(就是「插件 → 网页搜索」里配的那个)搜一遍,结果是链接,点「打开」在浏览器里看;网页上下载到的书,用「导入」加进书架。

去别处搜 / 自定义搜索地址:搜索框下面一排链接(默认百度、必应、Google、豆瓣读书、微信读书),点一下在浏览器里打开那个网站的搜索结果。「管理搜索地址」里可以增删改,地址里用 {q} 代表要搜的词。地址类型选「OPDS 书库」时(Calibre 内容服务器、calibre-web、Kavita 等自建书库,比如 http://地址:8080/opds/search/{q}),结果直接合进上面的书源结果,可以一键加入书架;只会从这个书库自己的地址下载。

书是 DSH 所在的机器去下载的:DSH 装在海外 VPS 上时,在中国大陆用手机打开面板,维基文库也能用;DSH 装在大陆、又不能访问外网的电脑上,维基文库连不上,只剩古腾堡。

2. 导入:把文件拖进面板、选文件,或者填本机路径。支持的格式:

| 格式 | 怎么读 |
| TXT | 自动认编码:UTF-8、UTF-16、GBK / GB18030、Big5 |
| EPUB | 按书脊顺序,目录里的标题当章名(DRM 加密的读不了) |
| PDF | 取文字层,把版面折行接回段落(扫描版没有文字层,读不了) |
| DOCX | 标题样式当章节 |
| Markdown / HTML | # 标题、 当章节 |
| RTF / DOC | 用 macOS 自带的 textutil 转换(只在 Mac 上) |

纯文本按「第X章」「Chapter N」「序章」「尾声」这类标题分章;认不出标题的每约 8000 字分一个「第 N 部分」,方便跳转。

3. 朗读(右侧栏「搜书听」页签,或手机浏览器直接打开):正文按约 20–30 秒一段切开,读到哪段高亮到哪段,点任意一段从那里读。

| 朗读方式 | 说明 |
| 浏览器朗读 | 不用安装,用这台设备自带的语音,文字不出这台设备。Mac 上在系统设置里下载「高级 / 增强」中文语音后会排在最前。手机锁屏后可能会停 |
| 微软 Edge | 最自然。云希、晓晓、云健等 12 个中文声音(含台湾、粤语、方言),另有 6 个英文声音。调用开源工具 edge-tts,插件启动时自动装好,装在插件自己的目录里,不用管理员权限:有 Python 3 就用它(没有 venv / pip 也能装),连 Python 都没有就自动下载一份。要联网,文字会发到微软的服务器;这不是微软公开的正式接口,可能失效 |
| 本地离线 | Kokoro 中文模型,55 个女声、45 个男声,完全离线。第一次用时下载约 147 MB 模型。比 Edge 生硬,合成也慢:开播的第一段要等十几秒,之后一边播一边提前合成 |

已经装了「听懂」的话,它下好的 Kokoro 中文模型和装好的 Edge 语音组件直接借用,不用再下。

4. 语速:0.75× 到 2.5×。Edge 和本地语音由播放器加速,音调不变,换语速不用重新合成。

5. 进度:每读一段存一次,存在服务器上和本机。关掉面板、换设备打开,都从上次的地方接着读。
6. 书单:每本书右边的「⋯」可以改书名和作者、从头开始、删除。

7. 其他:目录跳章、全书进度条拖动、定时停止(15 分钟到 1.5 小时,或读完本章停),锁屏界面和耳机线控(播放 / 暂停 / 上一段 / 下一段)。

电脑快捷键:空格 播放 / 暂停 · ← / → 上一段 / 下一段

在 DSH 对话里

插件给 AI 注册了 6 个工具,可以直接说:

- 「帮我找那本主角叫贾宝玉的书来听」:AI 自己判断书名,用 audiobook_search 搜,audiobook_download 下进书架
- 「把 ~/Downloads/三体.epub 导入搜书听」:audiobook_import
- 「我在听哪些书?听到哪了?」:audiobook_books
- 「刚才那段讲了什么?」「阿青第一次出场在哪?」「把第三章讲给我听」:audiobook_text,可以取刚听过的内容、在全书里找词、按章取正文
- 「跳到第十章」「跳到阿青出场的地方」:audiobook_goto。面板没在播放时几秒内就会跟上新位置

更新 / 卸载:在 DSH 的「设置 → 搜书听」里。卸载只移除插件,书和收听进度留在 ~/.dsh/dsh-audiobook。

安装

装进 DSH(把 desktop 换成你的 profile 名;先完全退出 DSH):

dsh plugin --profile desktop add dsh-audiobook

重新打开 DSH,在对话页打开右侧栏,选「搜书听 · 读书给你听」。VPS 上的 DSH 一样装(以运行 DSH 的用户执行,再重启 DSH),手机浏览器打开 https://<你的域名>/api-dsh-audiobook/panel 就能听,点「选择文件」直接从手机里挑书(文件只传一次,服务器取出文字后就删掉原文件)。VPS 上建议用微软 Edge 或浏览器朗读:本地离线语音很吃 CPU,小 VPS 跟不上。前面有 nginx 的话,把 client_max_body_size 调大(默认只放行 1 MB)。

微软 Edge 语音组件会在插件启动后自动在后台装好(几秒到一分钟)。

ffmpeg 可选:有就把本地语音压成 MP3(隔着 VPS 用手机听更流畅),没有就直接给 WAV。

可选配置

在 profile 的 cordis.patch.yml 里:

- id: dsh-audiobook
config:
provider: dsh-model-workbuddy-ai # 搜书时猜书名用的模型;不填就用 DSH 的默认模型 model: workbuddy-ai/deepseek-v4.1-flash-sg edge: false # 不要自动安装 Edge 语音组件 ffmpeg: /opt/homebrew/bin/ffmpeg # 找不到 ffmpeg 时填 threads: 2 # 每个本地语音进程的线程数 workers: 2 # 同时开几个本地语音进程(每个占 300–400 MB 内存;VPS 内存小就设 1)

不开 DSH 也能用

npx dsh-audiobook import ~/Downloads/书.epub # 导入
npx dsh-audiobook serve # 打开 http://127.0.0.1:4811
npx dsh-audiobook list

数据

都在 ~/.dsh/dsh-audiobook/:

- books/<书>/book.json:书名、作者、目录
- books/<书>/text.json:切好的朗读段
- books/<书>/progress.json:听到第几段
- settings.json:朗读方式、声音、语速
- edge-tts/:Edge 语音组件(Python 虚拟环境);没有 Python 的机器上还有 uv/、python/
- cache/:合成好的声音,超过 500 MB 自动删最久没用的,可以随时整个删掉

同一本书换个位置再导入,还是同一本(编号按内容算),进度接得上。DSH 插件和命令行版共用这份数据。

开发

npm install
npm test

- lib/extract.js 各种格式取文字;lib/zip.js 读 EPUB / DOCX 用的最小 ZIP 解包
- lib/segment.js 分章、分句、切朗读段
- lib/speech.js 服务器端合成:缓存、排队、预取(正在听的那段插队)
- lib/sources.js 书源(古腾堡、维基文库)的搜索、下载和清理;lib/finder.js 搜书(让模型按角色名、情节猜书名);lib/llm.js 调 DSH 的模型
- lib/edge.js Edge 语音组件(edge-tts)的自动安装和调用;lib/kokoro.js + lib/kokoro-worker.js 本地中文语音的常驻子进程
- lib/store.js 书库、进度、设置(原子写入的 JSON);lib/server.js 面板接口
- plugin/ DSH 宿主端(路由、AI 工具、更新卸载)和界面入口(右侧栏、设置);路由先过 DSH 的 connection.requestRejection() 登录校验
- web/ 朗读面板(原生 JS 模块,无构建):panel.js 书库和朗读页,player.js 一段接一段地读(始终用同一个 ,下一段提前下载进内存:段间几乎不停顿,iPhone 锁屏后也能接着读)
预览分享卡片长什么样 ↓AIcivilization/dsh-audiobook
分享 · DeepSeek Harness Hub
AIcivilization/dsh-audiobook

搜公版书或导入文件,让语音把书读给你听

回到顶部