dsh-miniflux 0.8.0 → 0.9.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 +25 -0
- package/lib/client.js +1 -0
- package/lib/index.js +127 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,20 @@ Settings → Plugins → Miniflux. Changes apply live, no restart.
|
|
|
22
22
|
|
|
23
23
|
## 安装 / Install
|
|
24
24
|
|
|
25
|
+
**从 npm 安装(发布后可用)**
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
dsh plugin --profile <name> add dsh-miniflux
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**从 GitHub 安装(当前可用)**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/niupkk/dsh-miniflux.git
|
|
35
|
+
cd dsh-miniflux && npm install
|
|
36
|
+
dsh plugin --profile <name> add link:$PWD
|
|
37
|
+
```
|
|
38
|
+
|
|
25
39
|
**从仓库链接安装(开发态)**
|
|
26
40
|
|
|
27
41
|
```bash
|
|
@@ -34,6 +48,17 @@ dsh plugin --profile <name> add link:$PWD
|
|
|
34
48
|
|
|
35
49
|
装完重启一次 dsh web,左侧栏就会出现 **Miniflux** 图标。
|
|
36
50
|
|
|
51
|
+
|
|
52
|
+
### 自选源 EPUB(不经过模型)
|
|
53
|
+
|
|
54
|
+
按设置的源列表,把最近的文章打包成一本电子书:
|
|
55
|
+
|
|
56
|
+
- **纯本地拼装**,不调用任何模型,所以零 token 消耗、速度只取决于图片下载
|
|
57
|
+
- **按 RSS 源分组**:一本书里一个源一章,文章在它下面按时间倒序
|
|
58
|
+
- **双层目录**:阅读器的目录面板是「源 → 文章」的嵌套结构;书开头还有一页可见的目录,能先总览再读
|
|
59
|
+
- 篇数上限由 `feedEpubMax` 控制(默认 120,最大 500)
|
|
60
|
+
- 生成完成后,收录的条目会标为已读
|
|
61
|
+
|
|
37
62
|
## 设置项 / Settings
|
|
38
63
|
|
|
39
64
|
| 字段 Field | 说明 Description |
|
package/lib/client.js
CHANGED
|
@@ -67,6 +67,7 @@ window.__ModuleLoader__.load({
|
|
|
67
67
|
{ key: 'aiBatchSize', label: 'AI 每批篇数 / AI batch size', placeholder: '12' },
|
|
68
68
|
{ key: 'maxCandidates', label: '日报候选上限 / Digest candidates', placeholder: '200' },
|
|
69
69
|
{ key: 'picksCandidates', label: '精选取未读篇数 / Picks candidates', placeholder: '200' },
|
|
70
|
+
{ key: 'feedEpubMax', label: '自选源篇数上限 / Feed EPUB max', placeholder: '120' },
|
|
70
71
|
{ key: 'imageProxy', label: '抓图代理 / Image proxy', placeholder: 'http://user:pass@host:port' },
|
|
71
72
|
{ key: 'language', label: '语言 / Language', placeholder: '中文' },
|
|
72
73
|
]
|
package/lib/index.js
CHANGED
|
@@ -72,6 +72,8 @@ export const Config = z.object({
|
|
|
72
72
|
.description(B('日报:每次最多把多少篇交给模型(24 小时内的文章)', 'Daily digest: max entries handed to the model (within the lookback window)')),
|
|
73
73
|
picksSkipScreened: z.boolean().default(true).volatile()
|
|
74
74
|
.description(B('精选跳过已筛过的条目(避免每次重复消耗模型)', 'Picks skip entries already screened (avoids re-spending model calls)')),
|
|
75
|
+
feedEpubMax: z.number().step(10).min(10).max(500).default(120).volatile()
|
|
76
|
+
.description(B('自选源每本最多收多少篇(按源分组,纯本地拼装不调模型)', 'Max entries per per-feed EPUB (grouped by feed, no model calls)')),
|
|
75
77
|
picksCandidates: z.number().step(10).min(10).max(500).default(200).volatile()
|
|
76
78
|
.description(B('精选:每次从最新未读里取多少篇交给模型', 'Picks: how many newest unread entries to hand to the model')),
|
|
77
79
|
digestMaxItems: z.number().step(1).min(0).max(60).default(0).volatile()
|
|
@@ -334,6 +336,12 @@ function makeZip(entries) {
|
|
|
334
336
|
}
|
|
335
337
|
|
|
336
338
|
const EPUB_CSS = `
|
|
339
|
+
nav ol, .book-toc ol { list-style: none; padding-left: 0; }
|
|
340
|
+
nav ol ol, .book-toc ol ol { padding-left: 1.1em; margin: 0.25em 0 0.9em; }
|
|
341
|
+
.toc-group > a strong { font-size: 1.05em; }
|
|
342
|
+
.toc-count { opacity: 0.6; font-size: 0.85em; margin-left: 0.35em; }
|
|
343
|
+
.toc-list li, .book-toc li { margin: 0.3em 0; line-height: 1.45; }
|
|
344
|
+
|
|
337
345
|
body{font-family:"Noto Serif CJK SC","Source Han Serif SC",serif;line-height:1.75;margin:0 4%;color:#222}
|
|
338
346
|
h1{font-size:1.5em;margin:0 0 .3em}.meta{color:#777;font-size:.82em;margin-bottom:1.4em;border-bottom:1px solid #ddd;padding-bottom:.6em}
|
|
339
347
|
.meta a{color:#4a90d9;text-decoration:none}p{margin:0 0 .85em;text-align:justify}
|
|
@@ -357,7 +365,9 @@ function randomId() {
|
|
|
357
365
|
return `${out.slice(0, 8)}-${out.slice(8, 12)}-${out.slice(12, 16)}-${out.slice(16, 20)}-${out.slice(20)}`
|
|
358
366
|
}
|
|
359
367
|
|
|
360
|
-
function buildEpub({ title, author = 'dsh-miniflux', chapters }) {
|
|
368
|
+
function buildEpub({ title, author = 'dsh-miniflux', chapters, groups }) {
|
|
369
|
+
// groups: [{ title, articles: [chapter...] }];不传则退化成平铺 chapters
|
|
370
|
+
const sources = Array.isArray(groups) && groups.length ? groups : [{ title: '', articles: chapters || [] }]
|
|
361
371
|
const files = [
|
|
362
372
|
{ name: 'mimetype', data: 'application/epub+zip', store: true },
|
|
363
373
|
{ name: 'META-INF/container.xml', data: `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -368,33 +378,69 @@ function buildEpub({ title, author = 'dsh-miniflux', chapters }) {
|
|
|
368
378
|
]
|
|
369
379
|
const manifest = ['<item id="css" href="style.css" media-type="text/css"/>',
|
|
370
380
|
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
|
|
371
|
-
'<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>'
|
|
372
|
-
|
|
373
|
-
const
|
|
381
|
+
'<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>',
|
|
382
|
+
'<item id="toc" href="toc.xhtml" media-type="application/xhtml+xml"/>']
|
|
383
|
+
const spine = ['cover', 'toc']
|
|
384
|
+
const navGroups = []
|
|
374
385
|
const seenImages = new Set()
|
|
386
|
+
let articleIndex = 0
|
|
387
|
+
let groupIndex = 0
|
|
388
|
+
|
|
389
|
+
for (const group of sources) {
|
|
390
|
+
const articles = group.articles || []
|
|
391
|
+
if (!articles.length) continue
|
|
392
|
+
|
|
393
|
+
const entries = []
|
|
394
|
+
for (const chapter of articles) {
|
|
395
|
+
articleIndex += 1
|
|
396
|
+
const id = `c${String(articleIndex).padStart(3, '0')}`
|
|
397
|
+
files.push({
|
|
398
|
+
name: `OEBPS/${id}.xhtml`,
|
|
399
|
+
data: xhtml(chapter.title, `<h1>${escapeHtml(chapter.title)}</h1>${chapter.meta ? `<div class="meta">${chapter.meta}</div>` : ''}${chapter.html || ''}`),
|
|
400
|
+
})
|
|
401
|
+
manifest.push(`<item id="${id}" href="${id}.xhtml" media-type="application/xhtml+xml"/>`)
|
|
402
|
+
entries.push({ id, title: chapter.title })
|
|
403
|
+
|
|
404
|
+
for (const image of chapter.images || []) {
|
|
405
|
+
if (seenImages.has(image.name)) continue
|
|
406
|
+
seenImages.add(image.name)
|
|
407
|
+
files.push({ name: `OEBPS/images/${image.name}`, data: image.data, store: true })
|
|
408
|
+
manifest.push(`<item id="img${seenImages.size}" href="images/${image.name}" media-type="${image.type}"/>`)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
375
411
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
if (seenImages.has(image.name)) continue
|
|
388
|
-
seenImages.add(image.name)
|
|
389
|
-
files.push({ name: `OEBPS/images/${image.name}`, data: image.data, store: true })
|
|
390
|
-
manifest.push(`<item id="img${seenImages.size}" href="images/${image.name}" media-type="${image.type}"/>`)
|
|
412
|
+
let groupId = null
|
|
413
|
+
if (group.title) {
|
|
414
|
+
groupIndex += 1
|
|
415
|
+
groupId = `g${String(groupIndex).padStart(3, '0')}`
|
|
416
|
+
const list = entries.map((entry) => `<li><a href="${entry.id}.xhtml">${escapeHtml(entry.title)}</a></li>`).join('')
|
|
417
|
+
files.push({
|
|
418
|
+
name: `OEBPS/${groupId}.xhtml`,
|
|
419
|
+
data: xhtml(group.title, `<h1>${escapeHtml(group.title)}</h1><div class="meta">${articles.length} 篇</div><ol class="toc-list">${list}</ol>`),
|
|
420
|
+
})
|
|
421
|
+
manifest.push(`<item id="${groupId}" href="${groupId}.xhtml" media-type="application/xhtml+xml"/>`)
|
|
422
|
+
spine.push(groupId)
|
|
391
423
|
}
|
|
392
|
-
|
|
424
|
+
for (const entry of entries) spine.push(entry.id)
|
|
425
|
+
navGroups.push({ id: groupId, title: group.title || entries[0].title, entries })
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const navList = navGroups.map((group) => (group.id
|
|
429
|
+
? `<li><a href="${group.id}.xhtml">${escapeHtml(group.title)}</a><ol>${group.entries.map((entry) => `<li><a href="${entry.id}.xhtml">${escapeHtml(entry.title)}</a></li>`).join('')}</ol></li>`
|
|
430
|
+
: group.entries.map((entry) => `<li><a href="${entry.id}.xhtml">${escapeHtml(entry.title)}</a></li>`).join(''))).join('')
|
|
393
431
|
|
|
394
|
-
|
|
395
|
-
|
|
432
|
+
const tocList = navGroups.map((group) => (group.id
|
|
433
|
+
? `<li class="toc-group"><a href="${group.id}.xhtml"><strong>${escapeHtml(group.title)}</strong></a> <span class="toc-count">${group.entries.length} 篇</span><ol>${group.entries.map((entry) => `<li><a href="${entry.id}.xhtml">${escapeHtml(entry.title)}</a></li>`).join('')}</ol></li>`
|
|
434
|
+
: group.entries.map((entry) => `<li><a href="${entry.id}.xhtml">${escapeHtml(entry.title)}</a></li>`).join(''))).join('')
|
|
435
|
+
|
|
436
|
+
const articleCount = articleIndex
|
|
437
|
+
const groupCount = navGroups.filter((group) => group.id).length
|
|
438
|
+
files.push({ name: 'OEBPS/cover.xhtml', data: xhtml(title, `<div class="cover"><h1>${escapeHtml(title)}</h1><div class="sub">${escapeHtml(author)} · ${articleCount} 篇${groupCount ? ` · ${groupCount} 个源` : ''} · ${new Date().toISOString().slice(0, 10)}</div></div>`) })
|
|
439
|
+
files.push({ name: 'OEBPS/toc.xhtml', data: xhtml('目录', `<h1>目录</h1><nav class="book-toc"><ol class="toc-groups">${tocList}</ol></nav>`) })
|
|
440
|
+
files.push({ name: 'OEBPS/nav.xhtml', data: xhtml('目录', `<nav epub:type="toc" xmlns:epub="http://www.idpf.org/2007/ops"><h1>目录</h1><ol>${navList}</ol></nav>`) })
|
|
396
441
|
|
|
397
442
|
const now = new Date().toISOString().replace(/\.\d+Z$/, 'Z')
|
|
443
|
+
const cover = files.filter((file) => file.name === 'OEBPS/cover.xhtml')
|
|
398
444
|
files.splice(2, 0, {
|
|
399
445
|
name: 'OEBPS/content.opf',
|
|
400
446
|
data: `<?xml version="1.0" encoding="utf-8"?>
|
|
@@ -405,14 +451,14 @@ function buildEpub({ title, author = 'dsh-miniflux', chapters }) {
|
|
|
405
451
|
<dc:creator>${escapeHtml(author)}</dc:creator><dc:date>${now}</dc:date>
|
|
406
452
|
<meta property="dcterms:modified">${now}</meta>
|
|
407
453
|
</metadata>
|
|
408
|
-
<manifest>${manifest.join('')}</manifest
|
|
454
|
+
<manifest>${manifest.join('')}</manifest>
|
|
455
|
+
<spine>${spine.map((ref) => `<itemref idref="${ref}"/>`).join('')}</spine>
|
|
409
456
|
</package>`,
|
|
410
457
|
})
|
|
411
458
|
|
|
412
459
|
return makeZip(files)
|
|
413
460
|
}
|
|
414
461
|
|
|
415
|
-
/** 文章正文 -> 清洗 + 抓图 + 改写成本地路径。 */
|
|
416
462
|
async function chapterFromEntry(entry, cfg, index, cache) {
|
|
417
463
|
const images = []
|
|
418
464
|
const raw = sanitize(stripProxyUrls(entry.content))
|
|
@@ -671,25 +717,43 @@ async function collectNewestUnread(client, cfg) {
|
|
|
671
717
|
return (data?.entries || []).filter((entry) => scope.has(entry.feed?.id))
|
|
672
718
|
}
|
|
673
719
|
|
|
674
|
-
async function writeEpub(cfg, name, chapters, title, author = 'dsh-miniflux') {
|
|
720
|
+
async function writeEpub(cfg, name, chapters, title, author = 'dsh-miniflux', groups) {
|
|
675
721
|
const dir = resolveDir(cfg)
|
|
676
722
|
await mkdir(dir, { recursive: true })
|
|
677
723
|
const target = join(dir, `${name}.epub`)
|
|
678
724
|
const cache = new Map()
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
725
|
+
let counter = 0
|
|
726
|
+
|
|
727
|
+
const build = async (chapter) => {
|
|
728
|
+
counter += 1
|
|
682
729
|
if (chapter.rawHtml !== undefined) {
|
|
683
|
-
const { html, images } = await chapterFromEntry({ content: chapter.rawHtml }, cfg,
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
|
|
730
|
+
const { html, images } = await chapterFromEntry({ content: chapter.rawHtml }, cfg, counter, cache)
|
|
731
|
+
return { title: chapter.title, meta: chapter.meta, html, images }
|
|
732
|
+
}
|
|
733
|
+
return { title: chapter.title, meta: chapter.meta, html: chapter.html }
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
let buffer
|
|
737
|
+
let builtCount = 0
|
|
738
|
+
if (Array.isArray(groups) && groups.length) {
|
|
739
|
+
const builtGroups = []
|
|
740
|
+
for (const group of groups) {
|
|
741
|
+
const articles = []
|
|
742
|
+
for (const chapter of group.articles || []) articles.push(await build(chapter))
|
|
743
|
+
if (articles.length) builtGroups.push({ title: group.title, articles })
|
|
744
|
+
builtCount += articles.length
|
|
687
745
|
}
|
|
746
|
+
buffer = buildEpub({ title, author, groups: builtGroups })
|
|
747
|
+
} else {
|
|
748
|
+
const built = []
|
|
749
|
+
for (const chapter of chapters || []) built.push(await build(chapter))
|
|
750
|
+
builtCount = built.length
|
|
751
|
+
buffer = buildEpub({ title, author, chapters: built })
|
|
688
752
|
}
|
|
689
|
-
|
|
753
|
+
|
|
690
754
|
await writeFile(target, buffer)
|
|
691
|
-
const images =
|
|
692
|
-
return { target, bytes: buffer.length, chapters:
|
|
755
|
+
const images = [...cache.values()].reduce((sum, list) => sum + list.length, 0)
|
|
756
|
+
return { target, bytes: buffer.length, chapters: builtCount, images }
|
|
693
757
|
}
|
|
694
758
|
|
|
695
759
|
async function generateDigest(ctx, client, cfg, kind) {
|
|
@@ -839,16 +903,34 @@ async function generatePicks(ctx, client, cfg) {
|
|
|
839
903
|
async function generateFeedEpub(client, cfg) {
|
|
840
904
|
const candidates = await collect(client, cfg)
|
|
841
905
|
if (!candidates.length) return { ok: false, message: '自选源最近没有新文章 / no entries in the selected feeds' }
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
906
|
+
|
|
907
|
+
const limit = Math.max(10, Number(cfg.feedEpubMax) || 120)
|
|
908
|
+
const picked = candidates.slice(0, limit)
|
|
909
|
+
|
|
910
|
+
// 纯本地拼装,不经过模型:按源分组,一个源一章,文章按时间倒序
|
|
911
|
+
const byFeed = new Map()
|
|
912
|
+
for (const entry of picked) {
|
|
913
|
+
const key = entry.feed?.title || entry.feed?.id || '未分类'
|
|
914
|
+
if (!byFeed.has(key)) byFeed.set(key, [])
|
|
915
|
+
byFeed.get(key).push(entry)
|
|
916
|
+
}
|
|
917
|
+
const groups = [...byFeed.entries()]
|
|
918
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
919
|
+
.map(([feedTitle, entries]) => ({
|
|
920
|
+
title: feedTitle,
|
|
921
|
+
articles: entries.map((entry) => ({
|
|
922
|
+
title: entry.title,
|
|
923
|
+
meta: `<a href="${escapeHtml(entry.url)}">原文</a> · ${new Date(entry.published_at).toLocaleString('zh-CN', { hour12: false })}`,
|
|
924
|
+
rawHtml: entry.content,
|
|
925
|
+
})),
|
|
926
|
+
}))
|
|
927
|
+
|
|
928
|
+
const title = `${stamp()} 自选源 · ${groups.length} 个源 ${picked.length} 篇`
|
|
929
|
+
const clock = new Date().toTimeString().slice(0, 5).replace(':', '')
|
|
930
|
+
const epub = await writeEpub(cfg, `${stamp()}-${clock}-自选源`, [], title, 'dsh-miniflux', groups)
|
|
849
931
|
// 自选源:生成完成后已读化
|
|
850
932
|
await client.call('PUT', '/v1/entries', { entry_ids: picked.map((entry) => entry.id), status: 'read' })
|
|
851
|
-
return { ok: true, title, epub, candidates: candidates.length, read: picked.length }
|
|
933
|
+
return { ok: true, title, epub, candidates: candidates.length, read: picked.length, feeds: groups.length }
|
|
852
934
|
}
|
|
853
935
|
|
|
854
936
|
// ---------------------------------------------------------------------------
|
|
@@ -1081,7 +1163,7 @@ async function writeStore(patch) {
|
|
|
1081
1163
|
const EDITABLE_FIELDS = ['minifluxUrl', 'minifluxToken', 'aiSource', 'dshProvider', 'dshModel',
|
|
1082
1164
|
'aiBaseUrl', 'aiApiKey', 'aiModel', 'digestCron', 'picksCron', 'feedEpubCron',
|
|
1083
1165
|
'epubFeedNames', 'epubDir', 'withImages', 'imageProxy', 'autoStar', 'language',
|
|
1084
|
-
'lookbackHours', 'digestMaxItems', 'aiMaxPicks', 'modelTimeoutSec', 'aiBatchSize', 'maxCandidates', 'picksCandidates', 'picksSkipScreened']
|
|
1166
|
+
'lookbackHours', 'digestMaxItems', 'aiMaxPicks', 'modelTimeoutSec', 'aiBatchSize', 'maxCandidates', 'picksCandidates', 'picksSkipScreened', 'feedEpubMax']
|
|
1085
1167
|
|
|
1086
1168
|
function applyImpl(ctx, config) {
|
|
1087
1169
|
// 面板保存的值;启动时从文件载入,保存后即时替换
|
|
@@ -1105,6 +1187,7 @@ function applyImpl(ctx, config) {
|
|
|
1105
1187
|
lookbackHours: Number(value(config?.lookbackHours, 24)) || 24,
|
|
1106
1188
|
maxCandidates: Number(value(config?.maxCandidates, 200)) || 200,
|
|
1107
1189
|
picksSkipScreened: value(config?.picksSkipScreened, true),
|
|
1190
|
+
feedEpubMax: Number(value(config?.feedEpubMax, 120)) || 120,
|
|
1108
1191
|
picksCandidates: Number(value(config?.picksCandidates, 200)) || 200,
|
|
1109
1192
|
digestMaxItems: Number(value(config?.digestMaxItems, 0)) || 0,
|
|
1110
1193
|
aiMaxPicks: Number(value(config?.aiMaxPicks, 0)) || 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-miniflux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Miniflux for the dsh web GUI: AI-written morning/evening digests, per-feed daily EPUB picks, agent tools (miniflux_feeds / miniflux_search / miniflux_read / miniflux_digest / miniflux_epub / miniflux_star). Configured from the plugin settings page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|