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 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
- const spine = ['<itemref idref="cover"/>']
373
- const navItems = []
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
- chapters.forEach((chapter, index) => {
377
- const id = `c${String(index + 1).padStart(3, '0')}`
378
- files.push({
379
- name: `OEBPS/${id}.xhtml`,
380
- data: xhtml(chapter.title, `<h1>${escapeHtml(chapter.title)}</h1>${chapter.meta ? `<div class="meta">${chapter.meta}</div>` : ''}${chapter.html || ''}`),
381
- })
382
- manifest.push(`<item id="${id}" href="${id}.xhtml" media-type="application/xhtml+xml"/>`)
383
- spine.push(`<itemref idref="${id}"/>`)
384
- navItems.push(`<li><a href="${id}.xhtml">${escapeHtml(chapter.title)}</a></li>`)
385
-
386
- for (const image of chapter.images || []) {
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
- files.push({ name: 'OEBPS/cover.xhtml', data: xhtml(title, `<div class="cover"><h1>${escapeHtml(title)}</h1><div class="sub">${escapeHtml(author)} · ${chapters.length} 篇 · ${new Date().toISOString().slice(0, 10)}</div></div>`) })
395
- files.push({ name: 'OEBPS/nav.xhtml', data: xhtml('目录', `<nav epub:type="toc" xmlns:epub="http://www.idpf.org/2007/ops"><h1>目录</h1><ol>${navItems.join('')}</ol></nav>`) })
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><spine>${spine.join('')}</spine>
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
- const built = []
680
- for (let i = 0; i < chapters.length; i++) {
681
- const chapter = chapters[i]
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, i + 1, cache)
684
- built.push({ title: chapter.title, meta: chapter.meta, html, images })
685
- } else {
686
- built.push({ title: chapter.title, meta: chapter.meta, html: chapter.html })
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
- const buffer = buildEpub({ title, author, chapters: built })
753
+
690
754
  await writeFile(target, buffer)
691
- const images = built.reduce((sum, chapter) => sum + (chapter.images?.length || 0), 0)
692
- return { target, bytes: buffer.length, chapters: built.length, images }
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
- const title = `${stamp()} 自选源`
843
- const picked = candidates.slice(0, 40)
844
- const epub = await writeEpub(cfg, `${stamp()}-自选源`, picked.map((entry) => ({
845
- title: entry.title,
846
- meta: `${escapeHtml(entry.feed?.title || '')} · <a href="${escapeHtml(entry.url)}">原文</a>`,
847
- rawHtml: entry.content,
848
- })), title)
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.8.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",