dsh-rss 0.4.2 → 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.en.md +26 -6
- package/README.md +26 -6
- package/lib/client.js +202 -0
- package/lib/feeds.d.ts +4 -2
- package/lib/feeds.js +12 -6
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -0
- package/lib/opml.d.ts +6 -1
- package/lib/opml.js +23 -3
- package/lib/subscriptions.d.ts +33 -0
- package/lib/subscriptions.js +76 -0
- package/lib/tools.js +27 -41
- package/lib/web.d.ts +27 -0
- package/lib/web.js +217 -0
- package/package.json +6 -1
package/README.en.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
# dsh-rss
|
|
1
|
+
# dsh-rss
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.5.0 update (2026-10-02)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Adds **Settings → RSS subscriptions** with add/edit/remove, search, category filters, feed checks and OPML import/export. The panel and conversation tools share the existing subscription list. No migration is needed. UI labels follow the host language and appearance settings.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Import previews new feeds, duplicate changes and rejected items before confirmation. Existing labels/categories are preserved by default; updates require an explicit checkbox. Failed saves and stale previews preserve your input. Removing a feed requires confirmation of its name and URL.
|
|
8
|
+
|
|
9
|
+
Fixes partial imports of malformed XML, acceptance of invalid URLs or embedded credentials, and lost subscriptions during concurrent additions. Check history lasts for the current DSH run; importing does not fetch every feed.
|
|
10
|
+
|
|
11
|
+
All 94 Windows tests pass. Actual browser interaction in official-source Harness `0.2.0-rc.2` covers add/edit/filter/check/import/remove, Tab/Enter/Escape, two-page conflicts and restart persistence. Native desktop and narrow-screen interaction remain unverified in this run. Synthetic feeds do not establish production-network compatibility.
|
|
12
|
+
|
|
13
|
+
## 0.4.3 update (2026-10-01)
|
|
14
|
+
|
|
15
|
+
Fixes URL comparison for add, remove, OPML import and search. Schemes, hosts and default ports are normalized while case-sensitive paths and query parameters remain distinct, preventing unrelated subscriptions from being merged or removed. Existing Fake-IP DNS guidance and reserved-address checks remain available.
|
|
16
|
+
|
|
17
|
+
Validation host: Harness `0.2.0-rc.2` built from official sources (commit `639ed01539`) on 2026-10-01. All 81 Windows tests, the 18-plugin co-load and nine RSS tool contracts pass. Production external feeds still require separate network validation.
|
|
8
18
|
|
|
9
19
|
   
|
|
10
20
|
|
|
@@ -14,7 +24,7 @@ DSH (DeepSeek Harness) plugin for RSS/Atom subscriptions: manage feeds, fetch an
|
|
|
14
24
|
|
|
15
25
|
## Compatibility
|
|
16
26
|
|
|
17
|
-
Version 0.4.0 supports
|
|
27
|
+
Version 0.4.0 supports the declarative settings interface introduced in Harness **0.1.7**. The current baseline is official-source **0.2.0-rc.2**. All 18 plugins load together; tool contracts, subscription updates and restart persistence pass in isolation. Live feeds require working network access. Requires Node 22.19 or later within 22.x, or 24 or later.
|
|
18
28
|
|
|
19
29
|
Fetches honor the Harness cancellation signal through DNS, response streaming, and cross-feed searches. Hostname preflight checks reject loopback, private, and link-local addresses by default, including redirect destinations. Set `allowPrivateNetwork: true` for trusted internal feeds.
|
|
20
30
|
|
|
@@ -26,7 +36,17 @@ Fetches honor the Harness cancellation signal through DNS, response streaming, a
|
|
|
26
36
|
dsh plugin --profile web add dsh-rss
|
|
27
37
|
```
|
|
28
38
|
|
|
29
|
-
Restart the web service after installing. Ask your assistant to subscribe to an RSS URL or list your subscriptions; ordinary subscription management needs no configuration-file editing.
|
|
39
|
+
Restart the web service after installing. Ask your assistant to subscribe to an RSS URL or list your subscriptions; ordinary subscription management needs no configuration-file editing.
|
|
40
|
+
|
|
41
|
+
For the desktop profile, use `dsh plugin --profile desktop add dsh-rss` and reopen DSH after installation or updates. The shared web settings surface provides the panel in both shells. Update older installed versions through the plugin manager first.
|
|
42
|
+
|
|
43
|
+
## Subscription panel
|
|
44
|
+
|
|
45
|
+
Open **Settings → RSS subscriptions**. New URLs are fetched and checked before saving; the name and category are optional. Renaming or recategorizing an unchanged URL works without the remote feed being online.
|
|
46
|
+
|
|
47
|
+
Choose an OPML file (up to 1 MiB) or paste its text, review the preview, then confirm. Rejected URLs include a reason. If another page or tool changed the list, preview again without retyping. Export downloads OPML without changing subscriptions.
|
|
48
|
+
|
|
49
|
+
Cards show the most recent check/fetch time, article count or error during this run. Read-only configurations allow viewing, checking and exporting.
|
|
30
50
|
|
|
31
51
|
## Uninstall
|
|
32
52
|
|
package/README.md
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
[English](README.en.md)
|
|
2
2
|
|
|
3
|
-
# dsh-rss
|
|
3
|
+
# dsh-rss
|
|
4
4
|
|
|
5
|
-
## 0.
|
|
5
|
+
## 0.5.0 更新(2026-10-02)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
新增 **设置 → RSS 订阅** 面板:添加、编辑、移除、搜索、分类筛选、检查可用性与 OPML 导入导出。面板与对话工具共用原有订阅列表,不需要迁移数据。跟随 DSH 的中文/英文和外观设置。
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
OPML 导入先预览新增、重复和无效项,再确认保存。默认保留重复订阅的名称和分类;勾选更新时会展示变化。保存失败或预览过期时保留输入。移除订阅需要确认具体名称和地址。
|
|
10
|
+
|
|
11
|
+
同时修复损坏 XML 被部分导入、无效/含密码 URL 被接受,以及并发添加覆盖另一笔订阅的问题。检查记录只保留在本次 DSH 运行中,导入不会自动抓取全部订阅。
|
|
12
|
+
|
|
13
|
+
94 项 Windows 测试通过;在官方 Harness `0.2.0-rc.2` 中实际操作了添加、编辑、筛选、检查、导入、移除、Tab/Enter/Esc、双页面冲突和重启持久化。桌面原生窗口与窄屏交互本轮未完成验收,外部生产订阅未替代为本地模拟测试。
|
|
14
|
+
|
|
15
|
+
## 0.4.3 更新(2026-10-01)
|
|
16
|
+
|
|
17
|
+
修复订阅 URL 的大小写比较:添加、删除、OPML 导入与搜索统一归一化协议、域名和默认端口,保留路径和查询参数的大小写,避免合并或删除不同订阅源。保留此前的代理 Fake-IP DNS 提示和保留地址检查。
|
|
18
|
+
|
|
19
|
+
验证宿主:官方源码构建的 Harness `0.2.0-rc.2`(commit `639ed01539`,2026-10-01)。81 项 Windows 插件测试、18 个插件共同加载与 9 个 RSS 工具的契约检查通过。真实外部订阅源仍需正常网络连接。
|
|
10
20
|
|
|
11
21
|
> **agent 的资讯雷达**:订阅管理 + RSS/Atom 抓取解析。
|
|
12
22
|
|
|
@@ -19,7 +29,7 @@ DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓
|
|
|
19
29
|
|
|
20
30
|
## 兼容性
|
|
21
31
|
|
|
22
|
-
0.4.0 起适配官方源码构建的 Harness **0.1.7 及以上的声明式设置接口**;当前基线为 **0.2.0-rc.
|
|
32
|
+
0.4.0 起适配官方源码构建的 Harness **0.1.7 及以上的声明式设置接口**;当前基线为 **0.2.0-rc.2**,18 个插件同载、工具契约、订阅读写与重启持久化在隔离环境通过验证;真实外部订阅源需要正常网络连接。Node 要求为 22.19 及以上的 22.x,或 24 及以上。
|
|
23
33
|
|
|
24
34
|
抓取接收 Harness 的取消信号,取消 DNS、网络读取或跨源搜索时保留原始取消原因。默认预检域名解析结果并拒绝回环、私网和链路本地地址;每次重定向都重新校验。可信内网源可显式配置 `allowPrivateNetwork: true`。
|
|
25
35
|
|
|
@@ -31,7 +41,17 @@ DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓
|
|
|
31
41
|
dsh plugin --profile web add dsh-rss
|
|
32
42
|
```
|
|
33
43
|
|
|
34
|
-
安装后重启 Web 服务即可。直接对助手说「订阅这个 RSS 地址,命名为技术资讯」或「列出我的订阅」即可使用,日常增删订阅不需要编辑配置文件。
|
|
44
|
+
安装后重启 Web 服务即可。直接对助手说「订阅这个 RSS 地址,命名为技术资讯」或「列出我的订阅」即可使用,日常增删订阅不需要编辑配置文件。
|
|
45
|
+
|
|
46
|
+
桌面版使用 `dsh plugin --profile desktop add dsh-rss`,安装或更新后重新打开 DSH。网页和桌面的共享设置页面均可显示订阅面板;已安装的旧版本请先通过插件管理器更新。
|
|
47
|
+
|
|
48
|
+
## 订阅面板
|
|
49
|
+
|
|
50
|
+
打开 **设置 → RSS 订阅**。添加时填写 RSS/Atom 地址,名称可留空,分类可选。新地址通过抓取检查后才会保存;编辑原地址的名称或分类不需要源网站在线。
|
|
51
|
+
|
|
52
|
+
导入可选择不超过 1 MiB 的 OPML 文件或粘贴文本。先检查预览,再点击确认;跳过的地址会说明原因。已有订阅发生变化时重新预览即可,原输入仍保留。导出通过浏览器下载 OPML,不会修改订阅。
|
|
53
|
+
|
|
54
|
+
列表卡片显示最近一次手动检查或工具抓取的时间和文章数,失败时显示具体原因。只读配置仍可查看、检查和导出。
|
|
35
55
|
|
|
36
56
|
## 卸载
|
|
37
57
|
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// This is the browser source. tsc does not rebuild it; check with node --check.
|
|
2
|
+
window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
3
|
+
const React = require('react');
|
|
4
|
+
const { useState, useEffect, useRef, useId } = React;
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const ROUTE = '_dsh/dsh-rss/subscriptions';
|
|
7
|
+
let language = 'zh';
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
const words = {
|
|
10
|
+
title: ['RSS 订阅', 'RSS subscriptions'], intro: ['在这里管理订阅,也可以在对话里让 AI 读取和搜索文章。', 'Manage feeds here, then ask AI to read or search articles in a conversation.'],
|
|
11
|
+
add: ['添加订阅', 'Add feed'], import: ['导入 OPML', 'Import OPML'], export: ['导出 OPML', 'Export OPML'], refresh: ['刷新列表', 'Refresh list'],
|
|
12
|
+
search: ['搜索名称或地址', 'Search name or URL'], category: ['分类', 'Category'], all: ['全部分类', 'All categories'], uncategorized: ['未分类', 'Uncategorized'],
|
|
13
|
+
name: ['显示名称', 'Display name'], nameHint: ['留空时使用订阅源的标题', 'Leave blank to use the feed title'], url: ['订阅源地址', 'Feed URL'], categoryHint: ['例如:科技、阅读', 'For example: Tech, Reading'],
|
|
14
|
+
save: ['保存订阅', 'Save feed'], saving: ['正在处理…', 'Working…'], cancel: ['取消', 'Cancel'], close: ['关闭', 'Close'], edit: ['编辑', 'Edit'], remove: ['移除', 'Remove'], check: ['检查可用性', 'Check feed'],
|
|
15
|
+
empty: ['还没有订阅', 'No subscriptions yet'], emptyHint: ['添加 RSS/Atom 地址,或从阅读器导入 OPML 文件。', 'Add an RSS/Atom URL or import an OPML file from your reader.'], noMatch: ['没有匹配的订阅,试试清空筛选。', 'No matches. Try clearing the filters.'], clear: ['清空筛选', 'Clear filters'],
|
|
16
|
+
readonly: ['当前配置为只读。你可以查看、检查和导出订阅。', 'These settings are read-only. You can view, check and export feeds.'],
|
|
17
|
+
pending: ['本次运行尚未检查', 'Not checked during this run'], entries: ['篇文章', 'articles'], checked: ['检查通过', 'Check passed'], failed: ['检查失败', 'Check failed'],
|
|
18
|
+
statusHint: ['检查记录仅保留在本次 DSH 运行中;导入不会自动抓取所有订阅。', 'Check history lasts for this DSH run. Import does not fetch every feed.'],
|
|
19
|
+
deleteTitle: ['移除这个订阅?', 'Remove this feed?'], deleteHint: ['只移除订阅,不会删除原网站的文章。', 'This removes the subscription; the original articles remain on the website.'],
|
|
20
|
+
opml: ['OPML 文本', 'OPML text'], file: ['选择 OPML 文件', 'Choose OPML file'], importHint: ['选择文件或粘贴内容,先预览,再确认导入。最多 1 MiB。', 'Choose a file or paste its content, preview, then confirm. Maximum 1 MiB.'],
|
|
21
|
+
update: ['用导入文件的名称和分类更新重复订阅', 'Update duplicate names and categories from this file'], preserve: ['默认保留现有订阅的名称和分类。', 'Existing names and categories are preserved by default.'],
|
|
22
|
+
preview: ['预览导入', 'Preview import'], confirm: ['确认导入', 'Confirm import'], new: ['新增', 'New'], duplicates: ['重复', 'Duplicates'], invalid: ['跳过无效项', 'Invalid items skipped'], previewEmpty: ['文件中没有可导入的订阅。请确认 outline 包含 xmlUrl。', 'No importable feeds. Check that outlines contain xmlUrl.'],
|
|
23
|
+
keep: ['保留', 'Keep'], change: ['更新为', 'Change to'],
|
|
24
|
+
previewLimit: ['预览最多显示前 20 项,确认会处理全部有效项。', 'Preview shows up to 20 items. Confirmation processes all valid items.'], saved: ['订阅已保存', 'Feed saved'], removed: ['订阅已移除', 'Feed removed'], imported: ['导入完成', 'Import complete'], downloaded: ['已下载 OPML 文件', 'OPML downloaded'],
|
|
25
|
+
loading: ['正在读取订阅…', 'Loading subscriptions…'], retry: ['重新读取', 'Retry'], loadError: ['无法读取订阅。请确认 DSH 仍在运行,然后重试。', 'Cannot load feeds. Check that DSH is running, then retry.'],
|
|
26
|
+
conflict: ['订阅列表已变化,请刷新列表,再保存或重新预览。输入已保留。', 'The list has changed. Refresh it, then save or preview again. Your input is preserved.'], duplicate: ['这个地址已经订阅,请编辑现有订阅。', 'This URL is already subscribed. Edit the existing feed.'],
|
|
27
|
+
bodyLarge: ['文件超过 1 MiB,请拆分后导入。', 'File exceeds 1 MiB. Split it before importing.'], session: ['DSH 会话已失效,请重新打开 DSH 页面。', 'The DSH session has expired. Reopen the DSH page.'], fileError: ['无法读取文件,请重新选择或粘贴 OPML 文本。', 'Cannot read this file. Choose it again or paste OPML text.'],
|
|
28
|
+
};
|
|
29
|
+
const t = key => (words[key] || [key, key])[language === 'en' ? 1 : 0];
|
|
30
|
+
const CSS = `
|
|
31
|
+
.dshr{color:var(--dsw-alias-label-primary,#202737);font-size:14px;line-height:1.55;max-width:920px;min-width:0}
|
|
32
|
+
.dshr *,.dshr-dialog *{box-sizing:border-box}.dshr h2{font-size:22px;margin:0 0 6px}.dshr p{margin:0 0 12px}
|
|
33
|
+
.dshr-muted{color:var(--dsw-alias-label-tertiary,#657083);font-size:13px}.dshr-toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0}
|
|
34
|
+
.dshr button,.dshr-dialog button{font:inherit;min-height:38px;padding:7px 12px;border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:9px;color:inherit;background:var(--dsw-alias-bg-layer-1,#fff);cursor:pointer}
|
|
35
|
+
.dshr button:disabled,.dshr-dialog button:disabled{opacity:.5;cursor:not-allowed}.dshr button:hover:not(:disabled),.dshr-dialog button:hover:not(:disabled){border-color:#5685d4}
|
|
36
|
+
.dshr .primary,.dshr-dialog .primary{background:#366fd2;color:#fff;border-color:#366fd2}.dshr-dialog .danger{background:#b93838;border-color:#b93838;color:#fff}
|
|
37
|
+
.dshr button:focus-visible,.dshr-dialog button:focus-visible,.dshr input:focus-visible,.dshr select:focus-visible,.dshr-dialog input:focus-visible,.dshr-dialog textarea:focus-visible{outline:2px solid #5685d4;outline-offset:2px}
|
|
38
|
+
.dshr-filters{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}.dshr-filters label:first-child{flex:1 1 220px}.dshr label,.dshr-dialog label{display:flex;flex-direction:column;gap:5px;font-size:13px}
|
|
39
|
+
.dshr input,.dshr select,.dshr-dialog input,.dshr-dialog textarea{font:inherit;font-size:16px;width:100%;min-width:0;min-height:40px;border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:8px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}
|
|
40
|
+
.dshr-list{display:grid;gap:10px;margin:0;padding:0;list-style:none}.dshr-card{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:12px;padding:14px;display:flex;flex-wrap:wrap;gap:12px}
|
|
41
|
+
.dshr-info{flex:1 1 240px;min-width:0}.dshr-info h3{font-size:15px;margin:0 0 4px;overflow-wrap:anywhere}.dshr-url{font-size:12px;overflow-wrap:anywhere;color:var(--dsw-alias-label-tertiary,#657083)}
|
|
42
|
+
.dshr-tag{display:inline-block;border-radius:6px;padding:2px 7px;background:var(--dsw-alias-bg-layer-2,#edf3fc);margin-top:7px;font-size:12px}.dshr-actions{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
43
|
+
.dshr-status{margin-top:8px;font-size:12px;overflow-wrap:anywhere}.dshr-alert{padding:10px 12px;border-radius:8px;margin:12px 0;overflow-wrap:anywhere;border:1px solid #dba2a2;background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-state-error-primary,#a42a2a)}
|
|
44
|
+
.dshr-notice{padding:9px 12px;border-left:3px solid #366fd2;margin:10px 0;background:var(--dsw-alias-bg-layer-2,#edf3fc);overflow-wrap:anywhere}.dshr-empty{text-align:center;border:1px dashed var(--dsw-alias-border-l2,#c7d2e4);border-radius:12px;padding:32px 16px}
|
|
45
|
+
.dshr-dialog{font:inherit;line-height:1.55;color:var(--dsw-alias-label-primary,#202737);background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#d9dee7);border-radius:14px;padding:22px;max-width:620px;width:calc(100% - 28px);max-height:calc(100dvh - 28px);overflow:auto;box-shadow:0 18px 70px #0003}
|
|
46
|
+
.dshr-dialog::backdrop{background:#14203366}.dshr-dialog h3{font-size:19px;margin:0 0 12px}.dshr-dialog form{display:grid;gap:14px}.dshr-dialog textarea{min-height:140px;resize:vertical}.dshr-dialog p{margin:0}.dshr-dialog .dshr-buttons{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:14px}
|
|
47
|
+
.dshr-dialog label.dshr-checkbox{display:flex;flex-direction:row;align-items:flex-start;gap:8px}.dshr-checkbox input{width:18px;height:18px;min-height:18px;margin-top:3px;flex-shrink:0}.dshr-preview{border-top:1px solid var(--dsw-alias-border-l2,#d9dee7);padding-top:12px;overflow-wrap:anywhere}.dshr-preview ul{margin:8px 0;padding-left:20px}.dshr-preview li{margin-bottom:5px}
|
|
48
|
+
@media(max-width:520px){.dshr-dialog{padding:16px}.dshr-actions{width:100%}.dshr-filters label{width:100%}.dshr-toolbar button{flex:1 1 auto}.dshr-card{padding:12px}}
|
|
49
|
+
`;
|
|
50
|
+
async function request(action, args, signal) {
|
|
51
|
+
const response = await fetch(new URL(ROUTE, document.baseURI), action ? {
|
|
52
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...args }), signal,
|
|
53
|
+
} : { credentials: 'same-origin', signal });
|
|
54
|
+
let value;
|
|
55
|
+
try { value = await response.json(); } catch { throw new Error(response.status === 401 ? t('session') : t('loadError')); }
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const error = new Error(['conflict', 'duplicate', 'readonly'].includes(value.code) ? t(value.code) : value.error || t('loadError'));
|
|
58
|
+
error.snapshot = value.snapshot;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function Dialog({ title, busy, onClose, children }) {
|
|
64
|
+
const ref = useRef(null);
|
|
65
|
+
const id = useId();
|
|
66
|
+
useEffect(() => { ref.current.showModal(); return () => ref.current?.close(); }, []);
|
|
67
|
+
return h('dialog', { ref, className: 'dshr-dialog', 'aria-labelledby': id,
|
|
68
|
+
onKeyDownCapture: event => {
|
|
69
|
+
if (event.nativeEvent.isComposing) return;
|
|
70
|
+
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); }
|
|
71
|
+
if (event.key === 'Tab') {
|
|
72
|
+
event.stopPropagation();
|
|
73
|
+
const targets = [...event.currentTarget.querySelectorAll('input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button:not([disabled])')];
|
|
74
|
+
const first = targets[0], last = targets[targets.length - 1];
|
|
75
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
|
76
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
onCancel: event => { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); } },
|
|
80
|
+
h('h3', { id }, title), children);
|
|
81
|
+
}
|
|
82
|
+
function Panel() {
|
|
83
|
+
const [, setLanguage] = useState(language);
|
|
84
|
+
const [state, setState] = useState(null), [loading, setLoading] = useState(true), [busy, setBusy] = useState('');
|
|
85
|
+
const [error, setError] = useState(''), [notice, setNotice] = useState(''), [query, setQuery] = useState(''), [category, setCategory] = useState('*');
|
|
86
|
+
const [modal, setModal] = useState(null), [draft, setDraft] = useState({ url: '', name: '', category: '' });
|
|
87
|
+
const [opml, setOpml] = useState(''), [updateExisting, setUpdateExisting] = useState(false), [preview, setPreview] = useState(null);
|
|
88
|
+
const controller = useRef(null), trigger = useRef(null);
|
|
89
|
+
useEffect(() => { listeners.add(setLanguage); return () => listeners.delete(setLanguage); }, []);
|
|
90
|
+
useEffect(() => { void run(null); return () => controller.current?.abort(); }, []);
|
|
91
|
+
async function run(action, args = {}, onSuccess) {
|
|
92
|
+
if (controller.current) return;
|
|
93
|
+
const abort = new AbortController(); controller.current = abort;
|
|
94
|
+
setBusy(action === 'check' && args.id ? args.id : action || 'load'); setError(''); setNotice('');
|
|
95
|
+
try {
|
|
96
|
+
const value = await request(action, args, abort.signal);
|
|
97
|
+
if (value.snapshot) setState(value.snapshot);
|
|
98
|
+
if (onSuccess) onSuccess(value);
|
|
99
|
+
return value;
|
|
100
|
+
} catch (failure) {
|
|
101
|
+
if (!abort.signal.aborted) { if (failure.snapshot) setState(failure.snapshot); setError(failure.message || t('loadError')); }
|
|
102
|
+
} finally { if (!abort.signal.aborted) { setBusy(''); setLoading(false); } controller.current = null; }
|
|
103
|
+
}
|
|
104
|
+
function close() { setModal(null); setError(''); setPreview(null); queueMicrotask(() => trigger.current?.focus()); }
|
|
105
|
+
function open(type, feed, event) {
|
|
106
|
+
trigger.current = event.currentTarget; setError(''); setNotice(''); setPreview(null);
|
|
107
|
+
if (type === 'feed') setDraft(feed ? { url: feed.url, name: feed.name, category: feed.category } : { url: '', name: '', category: '' });
|
|
108
|
+
if (type === 'import') { setOpml(''); setUpdateExisting(false); }
|
|
109
|
+
setModal({ type, feed });
|
|
110
|
+
}
|
|
111
|
+
async function chooseFile(event) {
|
|
112
|
+
const file = event.target.files?.[0];
|
|
113
|
+
event.target.value = '';
|
|
114
|
+
if (!file) return;
|
|
115
|
+
if (file.size > 1024 * 1024) { setError(t('bodyLarge')); return; }
|
|
116
|
+
try { setOpml(await file.text()); setPreview(null); setError(''); } catch { setError(t('fileError')); }
|
|
117
|
+
}
|
|
118
|
+
const field = (key, hint) => h('label', { key }, t(key), h('input', {
|
|
119
|
+
name: key, type: key === 'url' ? 'url' : 'text', value: draft[key], placeholder: hint, required: key === 'url', disabled: !!busy,
|
|
120
|
+
autoFocus: key === 'url', onChange: event => setDraft({ ...draft, [key]: event.target.value }),
|
|
121
|
+
}));
|
|
122
|
+
const buttons = submit => h('div', { className: 'dshr-buttons' },
|
|
123
|
+
h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')), submit);
|
|
124
|
+
const alert = error ? h('div', { role: 'alert', className: 'dshr-alert' }, error) : null;
|
|
125
|
+
const feeds = state?.feeds || [], writable = state?.writable === true;
|
|
126
|
+
const categories = [...new Set(feeds.map(feed => feed.category))].sort();
|
|
127
|
+
const filtered = feeds.filter(feed => (category === '*' || feed.category === category) && (feed.name + ' ' + feed.url).toLowerCase().includes(query.trim().toLowerCase()));
|
|
128
|
+
return h('section', { className: 'dshr', 'aria-label': t('title') },
|
|
129
|
+
h('h2', null, t('title')), h('p', { className: 'dshr-muted' }, t('intro')),
|
|
130
|
+
state && !writable ? h('p', { className: 'dshr-notice' }, t('readonly')) : null,
|
|
131
|
+
h('div', { className: 'dshr-toolbar' },
|
|
132
|
+
h('button', { className: 'primary', disabled: !writable || !!busy, onClick: event => open('feed', null, event) }, t('add')),
|
|
133
|
+
h('button', { disabled: !writable || !!busy, onClick: event => open('import', null, event) }, t('import')),
|
|
134
|
+
h('button', { disabled: !state || !!busy, onClick: () => run('export', {}, value => {
|
|
135
|
+
const url = URL.createObjectURL(new Blob([value.opml], { type: 'text/x-opml;charset=utf-8' }));
|
|
136
|
+
const a = document.createElement('a'); a.href = url; a.download = 'rss-subscriptions.opml'; a.click();
|
|
137
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000); setNotice(t('downloaded'));
|
|
138
|
+
}) }, t('export')),
|
|
139
|
+
h('button', { disabled: !!busy, onClick: () => run(null) }, t('refresh'))),
|
|
140
|
+
!modal ? alert : null, notice ? h('div', { role: 'status', className: 'dshr-notice' }, notice) : null,
|
|
141
|
+
loading ? h('p', { role: 'status' }, t('loading')) : !state ? h('button', { disabled: !!busy, onClick: () => run(null) }, t('retry')) :
|
|
142
|
+
h(React.Fragment, null,
|
|
143
|
+
feeds.length ? h('div', { className: 'dshr-filters' },
|
|
144
|
+
h('label', null, t('search'), h('input', { type: 'search', value: query, onChange: event => setQuery(event.target.value) })),
|
|
145
|
+
h('label', null, t('category'), h('select', { value: category, onChange: event => setCategory(event.target.value) },
|
|
146
|
+
h('option', { value: '*' }, t('all')), categories.map(value => h('option', { key: value, value }, value || t('uncategorized')))))) : null,
|
|
147
|
+
!feeds.length ? h('div', { className: 'dshr-empty' }, h('strong', null, t('empty')), h('p', { className: 'dshr-muted' }, t('emptyHint'))) :
|
|
148
|
+
!filtered.length ? h('div', { className: 'dshr-empty' }, h('p', null, t('noMatch')), h('button', { onClick: () => { setQuery(''); setCategory('*'); } }, t('clear'))) :
|
|
149
|
+
h('ul', { className: 'dshr-list' }, filtered.map(feed => h('li', { key: feed.id, className: 'dshr-card', 'aria-label': feed.name || feed.url },
|
|
150
|
+
h('div', { className: 'dshr-info' }, h('h3', null, feed.name || feed.url), h('div', { className: 'dshr-url' }, feed.url),
|
|
151
|
+
h('span', { className: 'dshr-tag' }, feed.category || t('uncategorized')),
|
|
152
|
+
h('div', { className: 'dshr-status' }, feed.check ? [
|
|
153
|
+
new Date(feed.check.checkedAt).toLocaleString(language === 'en' ? 'en-US' : 'zh-CN'), ' · ',
|
|
154
|
+
feed.check.ok ? feed.check.entryCount + ' ' + t('entries') + ' · ' + t('checked') : t('failed') + ':' + feed.check.error,
|
|
155
|
+
] : t('pending'))),
|
|
156
|
+
h('div', { className: 'dshr-actions' },
|
|
157
|
+
h('button', { disabled: !!busy, onClick: () => run('check', { id: feed.id }, value => setNotice(t('checked') + ' · ' + value.check.entryCount + ' ' + t('entries'))) }, busy === feed.id ? t('saving') : t('check')),
|
|
158
|
+
h('button', { disabled: !writable || !!busy, onClick: event => open('feed', feed, event) }, t('edit')),
|
|
159
|
+
h('button', { disabled: !writable || !!busy, onClick: event => open('remove', feed, event) }, t('remove')))))),
|
|
160
|
+
feeds.length ? h('p', { className: 'dshr-muted', style: { marginTop: 12 } }, t('statusHint')) : null),
|
|
161
|
+
modal ? h(Dialog, { title: modal.type === 'import' ? t('import') : modal.type === 'remove' ? t('deleteTitle') : modal.feed ? t('edit') : t('add'), busy: !!busy, onClose: close },
|
|
162
|
+
alert,
|
|
163
|
+
modal.type === 'feed' ? h('form', { onSubmit: event => { event.preventDefault(); void run(modal.feed ? 'edit' : 'add', { ...draft, id: modal.feed?.id, revision: state.revision }, () => { close(); setNotice(t('saved')); }); } },
|
|
164
|
+
field('url', 'https://example.com/feed.xml'), field('name', t('nameHint')), field('category', t('categoryHint')),
|
|
165
|
+
error ? h('button', { type: 'button', disabled: !!busy, onClick: () => run(null) }, t('refresh')) : null,
|
|
166
|
+
buttons(h('button', { type: 'submit', className: 'primary', disabled: !!busy }, t(busy ? 'saving' : 'save')))) :
|
|
167
|
+
modal.type === 'remove' ? h('div', null, h('strong', null, modal.feed.name || modal.feed.url), h('p', { className: 'dshr-url' }, modal.feed.url), h('p', null, t('deleteHint')),
|
|
168
|
+
buttons(h('button', { className: 'danger', disabled: !!busy, onClick: () => run('remove', { id: modal.feed.id, revision: state.revision }, () => { close(); setNotice(t('removed')); }) }, t(busy ? 'saving' : 'remove')))) :
|
|
169
|
+
h('form', { onSubmit: event => { event.preventDefault(); void run('preview', { opml, updateExisting }, value => setPreview(value.preview)); } },
|
|
170
|
+
h('p', { className: 'dshr-muted' }, t('importHint')), h('label', null, t('file'), h('input', { type: 'file', accept: '.opml,.xml,text/xml,application/xml,text/x-opml', disabled: !!busy, onChange: chooseFile })),
|
|
171
|
+
h('label', null, t('opml'), h('textarea', { required: true, autoFocus: true, value: opml, disabled: !!busy, onChange: event => { setOpml(event.target.value); setPreview(null); } })),
|
|
172
|
+
h('label', { className: 'dshr-checkbox' }, h('input', { type: 'checkbox', checked: updateExisting, disabled: !!busy, onChange: event => { setUpdateExisting(event.target.checked); setPreview(null); } }), t('update')),
|
|
173
|
+
h('p', { className: 'dshr-muted' }, t('preserve')),
|
|
174
|
+
preview ? h('div', { className: 'dshr-preview', role: 'status' },
|
|
175
|
+
h('strong', null, `${t('new')} ${preview.addedCount} · ${t('duplicates')} ${preview.existedCount} · ${t('invalid')} ${preview.skippedCount}`),
|
|
176
|
+
!preview.addedCount && !preview.existedCount ? h('p', null, t('previewEmpty')) : null,
|
|
177
|
+
h('ul', null, preview.added.slice(0, 20).map(feed => h('li', { key: feed.url }, t('new'), ':', feed.name || feed.url, ' · ', feed.category || t('uncategorized'))),
|
|
178
|
+
(preview.duplicates || []).slice(0, 20).map((item, index) => h('li', { key: 'duplicate-' + index },
|
|
179
|
+
t('duplicates'), ':', item.before.name || item.url, ' · ', item.before.category || t('uncategorized'),
|
|
180
|
+
updateExisting ? ' → ' + (item.after.name || item.url) + ' · ' + (item.after.category || t('uncategorized')) : ' · ' + t('keep'))),
|
|
181
|
+
preview.skipped.slice(0, 20).map((item, index) => h('li', { key: index }, item.title, ' · ', item.url, ':', item.reason))),
|
|
182
|
+
preview.added.length > 20 || preview.skipped.length > 20 || (preview.duplicates || []).length > 20 ? h('p', { className: 'dshr-muted' }, t('previewLimit')) : null) : null,
|
|
183
|
+
h('div', { className: 'dshr-buttons' }, h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')),
|
|
184
|
+
h('button', { type: 'submit', disabled: !!busy || !opml.trim() }, t(busy === 'preview' ? 'saving' : 'preview')),
|
|
185
|
+
preview ? h('button', { type: 'button', className: 'primary', disabled: !!busy || !(preview.addedCount || updateExisting && preview.existedCount), onClick: () => run('import', { opml, updateExisting, revision: preview.revision, token: preview.token }, value => {
|
|
186
|
+
close(); setNotice(`${t('imported')} · ${t('new')} ${value.result.addedCount} · ${t('duplicates')} ${value.result.existedCount} · ${t('invalid')} ${value.result.skippedCount}`);
|
|
187
|
+
}) }, t(busy === 'import' ? 'saving' : 'confirm')) : null))) : null);
|
|
188
|
+
}
|
|
189
|
+
function apply(ctx) {
|
|
190
|
+
ctx.effect(() => {
|
|
191
|
+
const service = ctx.get ? ctx.get('locale') : ctx.locale;
|
|
192
|
+
const update = () => { language = String(service?.getSnapshot?.().active || 'zh').startsWith('en') ? 'en' : 'zh'; for (const notify of listeners) notify(language); };
|
|
193
|
+
update(); return service?.subscribe?.(update) || (() => {});
|
|
194
|
+
}, 'dsh-rss: locale');
|
|
195
|
+
ctx.effect(() => {
|
|
196
|
+
const style = document.createElement('style'); style.dataset.pluginCss = 'dsh-rss/client'; style.textContent = CSS; document.head.appendChild(style);
|
|
197
|
+
return () => style.remove();
|
|
198
|
+
}, 'dsh-rss: styles');
|
|
199
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-rss', order: 55, label: () => t('title'), inject: () => ({}) }, Panel));
|
|
200
|
+
}
|
|
201
|
+
return { apply, inject: ['slots', 'locale'] };
|
|
202
|
+
} });
|
package/lib/feeds.d.ts
CHANGED
|
@@ -10,8 +10,10 @@ export interface Feed {
|
|
|
10
10
|
export declare function parseFeedsYaml(text: string): Feed[];
|
|
11
11
|
/** 订阅数组序列化回 YAML 文本(带使用说明头)。 */
|
|
12
12
|
export declare function serializeFeeds(feeds: Feed[]): string;
|
|
13
|
+
/** Normalize URL scheme, host and default port while preserving path/query case. */
|
|
14
|
+
export declare function sameFeedUrl(a: string, b: string): boolean;
|
|
13
15
|
/**
|
|
14
|
-
* 添加订阅:按
|
|
16
|
+
* 添加订阅:按 URL 去重,保留路径与查询值的大小写;已存在时合并更新 name/category。
|
|
15
17
|
*/
|
|
16
18
|
export declare function addFeed(feeds: Feed[], url: string, name: string, category: string): {
|
|
17
19
|
feeds: Feed[];
|
|
@@ -19,7 +21,7 @@ export declare function addFeed(feeds: Feed[], url: string, name: string, catego
|
|
|
19
21
|
existed: boolean;
|
|
20
22
|
};
|
|
21
23
|
/**
|
|
22
|
-
* 删除订阅:
|
|
24
|
+
* 删除订阅:URL 路径与查询值精确匹配,或 name 匹配所有同名项(不区分大小写)。
|
|
23
25
|
* 两者都缺或没有匹配项时抛中文错误。
|
|
24
26
|
*/
|
|
25
27
|
export declare function removeFeed(feeds: Feed[], url?: string, name?: string): {
|
package/lib/feeds.js
CHANGED
|
@@ -65,17 +65,23 @@ export function serializeFeeds(feeds) {
|
|
|
65
65
|
const body = YAML.stringify(rows).trimEnd();
|
|
66
66
|
return FEEDS_HEADER.join('\n') + body + '\n';
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/** Normalize URL scheme, host and default port while preserving path/query case. */
|
|
69
|
+
export function sameFeedUrl(a, b) {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(a.trim()).href === new URL(b.trim()).href;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return a.trim() === b.trim();
|
|
75
|
+
}
|
|
70
76
|
}
|
|
71
77
|
/**
|
|
72
|
-
* 添加订阅:按
|
|
78
|
+
* 添加订阅:按 URL 去重,保留路径与查询值的大小写;已存在时合并更新 name/category。
|
|
73
79
|
*/
|
|
74
80
|
export function addFeed(feeds, url, name, category) {
|
|
75
81
|
const cleanUrl = url.trim();
|
|
76
82
|
const cleanName = name.trim();
|
|
77
83
|
const cleanCategory = category.trim();
|
|
78
|
-
const existing = feeds.find((feed) =>
|
|
84
|
+
const existing = feeds.find((feed) => sameFeedUrl(feed.url, cleanUrl));
|
|
79
85
|
if (existing) {
|
|
80
86
|
const merged = {
|
|
81
87
|
url: existing.url,
|
|
@@ -89,7 +95,7 @@ export function addFeed(feeds, url, name, category) {
|
|
|
89
95
|
return { feeds: [...feeds, added], added, existed: false };
|
|
90
96
|
}
|
|
91
97
|
/**
|
|
92
|
-
* 删除订阅:
|
|
98
|
+
* 删除订阅:URL 路径与查询值精确匹配,或 name 匹配所有同名项(不区分大小写)。
|
|
93
99
|
* 两者都缺或没有匹配项时抛中文错误。
|
|
94
100
|
*/
|
|
95
101
|
export function removeFeed(feeds, url, name) {
|
|
@@ -100,7 +106,7 @@ export function removeFeed(feeds, url, name) {
|
|
|
100
106
|
}
|
|
101
107
|
const removed = [];
|
|
102
108
|
const next = feeds.filter((feed) => {
|
|
103
|
-
const match = (cleanUrl !== '' &&
|
|
109
|
+
const match = (cleanUrl !== '' && sameFeedUrl(feed.url, cleanUrl)) || (cleanName !== '' && feed.name.toLowerCase() === cleanName.toLowerCase());
|
|
104
110
|
if (match)
|
|
105
111
|
removed.push(feed);
|
|
106
112
|
return !match;
|
package/lib/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ type RssPreExecuteListener = (exec: RssPendingToolExecution, next: () => Promise
|
|
|
28
28
|
/** 插件所需的最小 ctx 面(社区插件不依赖宿主内部类型)。 */
|
|
29
29
|
export interface RssPluginContext {
|
|
30
30
|
settings: {
|
|
31
|
+
readonly writable?: boolean;
|
|
31
32
|
register?(ns: string, schema: unknown, options?: {
|
|
32
33
|
base?: Record<string, unknown>;
|
|
33
34
|
applies?: string;
|
|
@@ -41,6 +42,7 @@ export interface RssPluginContext {
|
|
|
41
42
|
};
|
|
42
43
|
};
|
|
43
44
|
};
|
|
45
|
+
inject?: Function;
|
|
44
46
|
tools: {
|
|
45
47
|
register(definition: RssToolDefinition): () => void;
|
|
46
48
|
};
|
|
@@ -60,3 +62,4 @@ export * from './parser.js';
|
|
|
60
62
|
export * from './proxy-fetch.js';
|
|
61
63
|
export * from './settings.js';
|
|
62
64
|
export * from './tools.js';
|
|
65
|
+
export * from './web.js';
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveConfig } from './config.js';
|
|
|
10
10
|
import { RSS_SETTINGS_NAMESPACE, RssSettingsSchema, liveConfig } from './settings.js';
|
|
11
11
|
import { buildRssTools } from './tools.js';
|
|
12
12
|
import { installLegacySettingsImport } from './legacy-settings.js';
|
|
13
|
+
import { createRssSettingsBackend, installRssSettingsWeb } from './web.js';
|
|
13
14
|
/** cordis 服务注入:apply 里要用 ctx.settings 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
|
|
14
15
|
export const name = 'rss';
|
|
15
16
|
export const inject = ['settings', 'tools'];
|
|
@@ -34,6 +35,7 @@ export function apply(ctx, config) {
|
|
|
34
35
|
},
|
|
35
36
|
};
|
|
36
37
|
const disposers = [];
|
|
38
|
+
installRssSettingsWeb(ctx, createRssSettingsBackend(cfg, settingsScope, { writable: () => ctx.settings.writable !== false }));
|
|
37
39
|
for (const definition of buildRssTools(cfg, settingsScope)) {
|
|
38
40
|
disposers.push(ctx.tools.register(definition));
|
|
39
41
|
}
|
|
@@ -63,3 +65,4 @@ export * from './parser.js';
|
|
|
63
65
|
export * from './proxy-fetch.js';
|
|
64
66
|
export * from './settings.js';
|
|
65
67
|
export * from './tools.js';
|
|
68
|
+
export * from './web.js';
|
package/lib/opml.d.ts
CHANGED
|
@@ -27,6 +27,11 @@ export interface OpmlImportResult {
|
|
|
27
27
|
existedCount: number;
|
|
28
28
|
skippedCount: number;
|
|
29
29
|
skipped: OpmlSkipped[];
|
|
30
|
+
duplicates: Array<{
|
|
31
|
+
url: string;
|
|
32
|
+
before: Feed;
|
|
33
|
+
after: Feed;
|
|
34
|
+
}>;
|
|
30
35
|
}
|
|
31
36
|
/** OPML 文档解析结果。 */
|
|
32
37
|
export interface OpmlDocument {
|
|
@@ -40,4 +45,4 @@ export declare function parseOpml(text: string): OpmlDocument;
|
|
|
40
45
|
/** 把订阅列表序列化为 OPML 2.0 文本。 */
|
|
41
46
|
export declare function buildOpml(feeds: Feed[]): string;
|
|
42
47
|
/** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
|
|
43
|
-
export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument): OpmlImportResult;
|
|
48
|
+
export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument, updateExisting?: boolean): OpmlImportResult;
|
package/lib/opml.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module dsh-rss/opml
|
|
8
8
|
*/
|
|
9
|
-
import { XMLParser } from 'fast-xml-parser';
|
|
10
|
-
import { addFeed } from './feeds.js';
|
|
9
|
+
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
|
10
|
+
import { addFeed, sameFeedUrl } from './feeds.js';
|
|
11
|
+
import { assertHttpUrl } from './network.js';
|
|
11
12
|
const opmlParser = new XMLParser({
|
|
12
13
|
ignoreAttributes: false,
|
|
13
14
|
attributeNamePrefix: '@_',
|
|
@@ -42,6 +43,9 @@ export function parseOpml(text) {
|
|
|
42
43
|
const trimmed = (text ?? '').trim();
|
|
43
44
|
if (trimmed === '')
|
|
44
45
|
throw new Error('OPML 内容为空,请提供 OPML XML 文本。');
|
|
46
|
+
const validation = XMLValidator.validate(trimmed);
|
|
47
|
+
if (validation !== true)
|
|
48
|
+
throw new Error('OPML 解析失败:第 ' + validation.err.line + ' 行,' + validation.err.msg);
|
|
45
49
|
let doc;
|
|
46
50
|
try {
|
|
47
51
|
doc = opmlParser.parse(trimmed);
|
|
@@ -120,21 +124,36 @@ export function buildOpml(feeds) {
|
|
|
120
124
|
}
|
|
121
125
|
const HTTP_URL = /^https?:\/\//i;
|
|
122
126
|
/** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
|
|
123
|
-
export function importOpmlFeeds(current, document) {
|
|
127
|
+
export function importOpmlFeeds(current, document, updateExisting = true) {
|
|
124
128
|
const feeds = [...current];
|
|
125
129
|
const added = [];
|
|
126
130
|
const skipped = [];
|
|
131
|
+
const duplicates = [];
|
|
127
132
|
let existedCount = 0;
|
|
128
133
|
for (const outline of document.outlines) {
|
|
129
134
|
if (!HTTP_URL.test(outline.url)) {
|
|
130
135
|
skipped.push({ title: outline.name, url: outline.url, reason: 'URL 不是 http(s) 地址' });
|
|
131
136
|
continue;
|
|
132
137
|
}
|
|
138
|
+
try {
|
|
139
|
+
assertHttpUrl(outline.url);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
skipped.push({ title: outline.name, url: outline.url, reason: error instanceof Error ? error.message : String(error) });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const previous = feeds.find(feed => sameFeedUrl(feed.url, outline.url));
|
|
146
|
+
if (!updateExisting && previous) {
|
|
147
|
+
existedCount += 1;
|
|
148
|
+
duplicates.push({ url: outline.url, before: previous, after: previous });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
133
151
|
const before = feeds.length;
|
|
134
152
|
const outcome = addFeed(feeds, outline.url, outline.name, outline.category);
|
|
135
153
|
feeds.splice(0, feeds.length, ...outcome.feeds);
|
|
136
154
|
if (outcome.existed) {
|
|
137
155
|
existedCount += 1;
|
|
156
|
+
duplicates.push({ url: outline.url, before: previous, after: outcome.added });
|
|
138
157
|
}
|
|
139
158
|
else if (feeds.length > before) {
|
|
140
159
|
added.push(outcome.added);
|
|
@@ -147,5 +166,6 @@ export function importOpmlFeeds(current, document) {
|
|
|
147
166
|
existedCount,
|
|
148
167
|
skippedCount: skipped.length,
|
|
149
168
|
skipped,
|
|
169
|
+
duplicates,
|
|
150
170
|
};
|
|
151
171
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Feed } from './feeds.js';
|
|
2
|
+
import type { RssSettingsScope } from './tools.js';
|
|
3
|
+
import type { ResolvedRssConfig } from './config.js';
|
|
4
|
+
import type { RssToolExecution } from './execution.js';
|
|
5
|
+
import { type FetchLike, type DnsLookupLike } from './network.js';
|
|
6
|
+
export declare class SubscriptionError extends Error {
|
|
7
|
+
readonly status: number;
|
|
8
|
+
readonly code: string;
|
|
9
|
+
constructor(message: string, status?: number, code?: string);
|
|
10
|
+
}
|
|
11
|
+
export declare const feedId: (url: string) => string;
|
|
12
|
+
export declare function subscriptionSnapshot(scope: RssSettingsScope): {
|
|
13
|
+
feeds: Feed[];
|
|
14
|
+
revision: string;
|
|
15
|
+
};
|
|
16
|
+
/** Read inside the queue: concurrent tool/UI writes cannot overwrite one another. */
|
|
17
|
+
export declare function mutateSubscriptions<T>(scope: RssSettingsScope, mutate: (feeds: Feed[]) => {
|
|
18
|
+
feeds: Feed[];
|
|
19
|
+
value: T;
|
|
20
|
+
}, expectedRevision?: string): Promise<T>;
|
|
21
|
+
export interface FeedCheck {
|
|
22
|
+
checkedAt: string;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
title?: string;
|
|
25
|
+
entryCount?: number;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function recentFeedChecks(scope: RssSettingsScope): Map<string, FeedCheck>;
|
|
29
|
+
/** Same network boundary, parsing and status for tool calls and manual checks. */
|
|
30
|
+
export declare function readRssFeed(scope: RssSettingsScope, url: string, config: ResolvedRssConfig, exec?: RssToolExecution, fetchImpl?: FetchLike, lookupImpl?: DnsLookupLike): Promise<{
|
|
31
|
+
parsed: import("./parser.js").ParsedFeed;
|
|
32
|
+
finalUrl: string;
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Shared subscription storage for tools and the settings panel. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { parseFeedsYaml, serializeFeeds } from './feeds.js';
|
|
4
|
+
import { fetchFeedXml } from './network.js';
|
|
5
|
+
import { parseFeed } from './parser.js';
|
|
6
|
+
export class SubscriptionError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
code;
|
|
9
|
+
constructor(message, status = 400, code = 'invalid') {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const writes = new WeakMap();
|
|
16
|
+
export const feedId = (url) => createHash('sha256').update(url).digest('hex');
|
|
17
|
+
export function subscriptionSnapshot(scope) {
|
|
18
|
+
const value = scope.get();
|
|
19
|
+
const yaml = value && typeof value.feedsYaml === 'string' ? value.feedsYaml : '';
|
|
20
|
+
return { feeds: parseFeedsYaml(yaml), revision: feedId(yaml) };
|
|
21
|
+
}
|
|
22
|
+
/** Read inside the queue: concurrent tool/UI writes cannot overwrite one another. */
|
|
23
|
+
export async function mutateSubscriptions(scope, mutate, expectedRevision) {
|
|
24
|
+
const task = (writes.get(scope) ?? Promise.resolve()).catch(() => { }).then(async () => {
|
|
25
|
+
const snapshot = subscriptionSnapshot(scope);
|
|
26
|
+
if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) {
|
|
27
|
+
throw new SubscriptionError('订阅列表已经变化。请刷新列表后重新预览或保存;你的输入已保留。', 409, 'conflict');
|
|
28
|
+
}
|
|
29
|
+
const result = mutate(snapshot.feeds);
|
|
30
|
+
try {
|
|
31
|
+
await scope.update({ feedsYaml: serializeFeeds(result.feeds) });
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new SubscriptionError('写入订阅配置失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)), 503, 'save_failed');
|
|
35
|
+
}
|
|
36
|
+
return result.value;
|
|
37
|
+
});
|
|
38
|
+
writes.set(scope, task);
|
|
39
|
+
try {
|
|
40
|
+
return await task;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (writes.get(scope) === task)
|
|
44
|
+
writes.delete(scope);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const checks = new WeakMap();
|
|
48
|
+
export function recentFeedChecks(scope) {
|
|
49
|
+
let map = checks.get(scope);
|
|
50
|
+
if (!map) {
|
|
51
|
+
map = new Map();
|
|
52
|
+
checks.set(scope, map);
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
/** Same network boundary, parsing and status for tool calls and manual checks. */
|
|
57
|
+
export async function readRssFeed(scope, url, config, exec, fetchImpl, lookupImpl) {
|
|
58
|
+
const map = recentFeedChecks(scope);
|
|
59
|
+
const save = (check) => {
|
|
60
|
+
map.delete(url);
|
|
61
|
+
map.set(url, check);
|
|
62
|
+
if (map.size > 256)
|
|
63
|
+
map.delete(map.keys().next().value);
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const { xml, finalUrl } = await fetchFeedXml(url, config, exec, fetchImpl, lookupImpl);
|
|
67
|
+
const parsed = parseFeed(xml, finalUrl);
|
|
68
|
+
save({ checkedAt: new Date().toISOString(), ok: true, title: parsed.feed.title, entryCount: parsed.entries.length });
|
|
69
|
+
return { parsed, finalUrl };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (!exec?.signal.aborted)
|
|
73
|
+
save({ checkedAt: new Date().toISOString(), ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* @module dsh-rss/tools
|
|
6
6
|
*/
|
|
7
7
|
import { writeFile } from 'node:fs/promises';
|
|
8
|
-
import { addFeed, findFeedsByName,
|
|
8
|
+
import { addFeed, findFeedsByName, removeFeed, sameFeedUrl } from './feeds.js';
|
|
9
9
|
import { buildOpml, importOpmlFeeds, parseOpml } from './opml.js';
|
|
10
10
|
import { parseFeed } from './parser.js';
|
|
11
|
-
import { assertHttpUrl
|
|
11
|
+
import { assertHttpUrl } from './network.js';
|
|
12
|
+
import { subscriptionSnapshot, mutateSubscriptions, readRssFeed } from './subscriptions.js';
|
|
12
13
|
import { resolveOpmlOutputPath } from './workspace.js';
|
|
13
14
|
export { isBlockedNetworkAddress } from './network.js';
|
|
14
15
|
const TIMEOUT_MS = 30000;
|
|
@@ -405,19 +406,8 @@ function renderHealth(_args, value) {
|
|
|
405
406
|
*/
|
|
406
407
|
export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
407
408
|
const cfg = config;
|
|
408
|
-
const getFeeds = () =>
|
|
409
|
-
|
|
410
|
-
const yamlText = value !== null && typeof value === 'object' && typeof value.feedsYaml === 'string' ? value.feedsYaml : '';
|
|
411
|
-
return parseFeedsYaml(yamlText);
|
|
412
|
-
};
|
|
413
|
-
const persistFeeds = async (feeds) => {
|
|
414
|
-
try {
|
|
415
|
-
await settingsScope.update({ feedsYaml: serializeFeeds(feeds) });
|
|
416
|
-
}
|
|
417
|
-
catch (error) {
|
|
418
|
-
throw new Error('写入订阅配置失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)));
|
|
419
|
-
}
|
|
420
|
-
};
|
|
409
|
+
const getFeeds = () => subscriptionSnapshot(settingsScope).feeds;
|
|
410
|
+
const readFeed = (url, exec) => readRssFeed(settingsScope, url, cfg, exec, fetchImpl, lookupImpl);
|
|
421
411
|
const rssList = {
|
|
422
412
|
name: 'rss_list',
|
|
423
413
|
description: '列出已订阅的 RSS/Atom 源(存在 settings 的 dsh-rss.feedsYaml 里,可用 rss_add / rss_remove 管理)。返回每个订阅的 url、name、category 与总数。',
|
|
@@ -450,18 +440,18 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
450
440
|
assertHttpUrl(url);
|
|
451
441
|
const name = optionalString(args, 'name') ?? '';
|
|
452
442
|
const category = optionalString(args, 'category') ?? '';
|
|
453
|
-
const {
|
|
454
|
-
const parsed = parseFeed(xml, finalUrl);
|
|
443
|
+
const { parsed } = await readFeed(url, exec);
|
|
455
444
|
const effectiveName = name !== '' ? name : parsed.feed.title;
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
445
|
+
return mutateSubscriptions(settingsScope, current => {
|
|
446
|
+
const { feeds, added, existed } = addFeed(current, url, effectiveName, category);
|
|
447
|
+
return { feeds, value: { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType } };
|
|
448
|
+
});
|
|
459
449
|
},
|
|
460
450
|
timeoutMs: cfg.timeoutMs,
|
|
461
451
|
};
|
|
462
452
|
const rssRemove = {
|
|
463
453
|
name: 'rss_remove',
|
|
464
|
-
description: '删除已订阅的 RSS/Atom 源。url 与 name 至少给一个:url
|
|
454
|
+
description: '删除已订阅的 RSS/Atom 源。url 与 name 至少给一个:url 的协议、主机大小写与默认端口可归一化,路径和查询值精确匹配;name 匹配所有同名订阅。删除结果持久化到 settings。',
|
|
465
455
|
parameters: compileParameters({
|
|
466
456
|
url: { type: 'string', description: '要删除的订阅源地址(可选,与 name 至少给一个)。' },
|
|
467
457
|
name: { type: 'string', description: '要删除的订阅显示名(可选,与 url 至少给一个;同名全部删除)。' },
|
|
@@ -472,9 +462,10 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
472
462
|
},
|
|
473
463
|
async execute(rawArgs) {
|
|
474
464
|
const args = asRecord(rawArgs);
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
465
|
+
return mutateSubscriptions(settingsScope, current => {
|
|
466
|
+
const { feeds, removed } = removeFeed(current, optionalString(args, 'url'), optionalString(args, 'name'));
|
|
467
|
+
return { feeds, value: { removed, count: feeds.length } };
|
|
468
|
+
});
|
|
478
469
|
},
|
|
479
470
|
timeoutMs: TIMEOUT_MS,
|
|
480
471
|
};
|
|
@@ -523,8 +514,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
523
514
|
assertHttpUrl(url);
|
|
524
515
|
const limit = clampedInteger(args, 'limit', 20, 1, 100);
|
|
525
516
|
const incremental = args.incremental === true;
|
|
526
|
-
const {
|
|
527
|
-
const parsed = parseFeed(xml, finalUrl);
|
|
517
|
+
const { parsed, finalUrl } = await readFeed(url, exec);
|
|
528
518
|
if (!incremental) {
|
|
529
519
|
const entries = parsed.entries.slice(0, limit);
|
|
530
520
|
return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
|
|
@@ -552,8 +542,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
552
542
|
const args = asRecord(rawArgs);
|
|
553
543
|
const url = requiredString(args, 'url', '订阅源地址');
|
|
554
544
|
assertHttpUrl(url);
|
|
555
|
-
const {
|
|
556
|
-
const parsed = parseFeed(xml, finalUrl);
|
|
545
|
+
const { parsed, finalUrl } = await readFeed(url, exec);
|
|
557
546
|
return {
|
|
558
547
|
ok: true,
|
|
559
548
|
url: finalUrl,
|
|
@@ -605,16 +594,14 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
605
594
|
const args = asRecord(rawArgs);
|
|
606
595
|
const opmlText = requiredString(args, 'opml', 'OPML 文本');
|
|
607
596
|
const document = parseOpml(opmlText);
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
skipped: outcome.skipped,
|
|
617
|
-
};
|
|
597
|
+
return mutateSubscriptions(settingsScope, current => {
|
|
598
|
+
const outcome = importOpmlFeeds(current, document);
|
|
599
|
+
return { feeds: outcome.feeds, value: {
|
|
600
|
+
addedCount: outcome.addedCount, existedCount: outcome.existedCount,
|
|
601
|
+
skippedCount: outcome.skippedCount, totalCount: outcome.feeds.length,
|
|
602
|
+
added: outcome.added, skipped: outcome.skipped,
|
|
603
|
+
} };
|
|
604
|
+
});
|
|
618
605
|
},
|
|
619
606
|
timeoutMs: TIMEOUT_MS,
|
|
620
607
|
};
|
|
@@ -641,7 +628,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
641
628
|
const nameFilter = optionalString(args, 'name');
|
|
642
629
|
let targets = getFeeds();
|
|
643
630
|
if (urlFilter !== undefined)
|
|
644
|
-
targets = targets.filter((feed) => feed.url
|
|
631
|
+
targets = targets.filter((feed) => sameFeedUrl(feed.url, urlFilter));
|
|
645
632
|
if (nameFilter !== undefined)
|
|
646
633
|
targets = findFeedsByName(targets, nameFilter);
|
|
647
634
|
if (targets.length === 0) {
|
|
@@ -656,8 +643,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
|
|
|
656
643
|
if (hits.length >= limit)
|
|
657
644
|
break;
|
|
658
645
|
try {
|
|
659
|
-
const {
|
|
660
|
-
const parsed = parseFeed(xml, finalUrl);
|
|
646
|
+
const { parsed } = await readFeed(feed.url, exec);
|
|
661
647
|
for (const entry of parsed.entries) {
|
|
662
648
|
const rec = asRecord(entry);
|
|
663
649
|
const at = Date.parse(typeof rec.pubDate === 'string' ? rec.pubDate : '');
|
package/lib/web.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Authenticated, local settings route; no separate copy of subscription data. */
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import type { RssSettingsScope } from './tools.js';
|
|
4
|
+
import type { ResolvedRssConfig } from './config.js';
|
|
5
|
+
import { type FetchLike, type DnsLookupLike } from './network.js';
|
|
6
|
+
export declare const RSS_SETTINGS_ROUTE = "/_dsh/dsh-rss/subscriptions";
|
|
7
|
+
export declare function createRssSettingsBackend(config: ResolvedRssConfig, scope: RssSettingsScope, options?: {
|
|
8
|
+
writable?: () => boolean;
|
|
9
|
+
fetchImpl?: FetchLike;
|
|
10
|
+
lookupImpl?: DnsLookupLike;
|
|
11
|
+
}): {
|
|
12
|
+
snapshot: () => {
|
|
13
|
+
revision: string;
|
|
14
|
+
writable: boolean;
|
|
15
|
+
feeds: {
|
|
16
|
+
id: string;
|
|
17
|
+
check: import("./subscriptions.js").FeedCheck | null;
|
|
18
|
+
url: string;
|
|
19
|
+
name: string;
|
|
20
|
+
category: string;
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
export declare function installRssSettingsWeb(ctx: {
|
|
26
|
+
inject?: Function;
|
|
27
|
+
}, backend: ReturnType<typeof createRssSettingsBackend>): void;
|
package/lib/web.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { sameFeedUrl } from './feeds.js';
|
|
2
|
+
import { assertHttpUrl } from './network.js';
|
|
3
|
+
import { buildOpml, parseOpml, importOpmlFeeds } from './opml.js';
|
|
4
|
+
import { feedId, subscriptionSnapshot, mutateSubscriptions, recentFeedChecks, readRssFeed, SubscriptionError } from './subscriptions.js';
|
|
5
|
+
export const RSS_SETTINGS_ROUTE = '/_dsh/dsh-rss/subscriptions';
|
|
6
|
+
// JSON quoting can double the OPML file size; limit the actual text separately.
|
|
7
|
+
const MAX_OPML = 1024 * 1024;
|
|
8
|
+
const MAX_BODY = MAX_OPML * 2 + 16384;
|
|
9
|
+
const loopback = (host) => ['127.0.0.1', 'localhost', '::1', '[::1]', '::ffff:127.0.0.1'].includes(host.toLowerCase());
|
|
10
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
11
|
+
const text = (args, key) => typeof args[key] === 'string' ? args[key].trim() : '';
|
|
12
|
+
function requestGuard(req) {
|
|
13
|
+
const host = req.headers.host;
|
|
14
|
+
if (!host || !loopback(req.socket.remoteAddress ?? ''))
|
|
15
|
+
throw new SubscriptionError('设置面板仅允许本机访问。', 403, 'local_only');
|
|
16
|
+
let target;
|
|
17
|
+
try {
|
|
18
|
+
target = new URL('http://' + host);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new SubscriptionError('无效的请求地址。', 403);
|
|
22
|
+
}
|
|
23
|
+
if (!loopback(target.hostname) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) {
|
|
24
|
+
throw new SubscriptionError('设置面板仅允许本机访问。', 403, 'local_only');
|
|
25
|
+
}
|
|
26
|
+
const protocol = req.socket.encrypted ? 'https:' : 'http:';
|
|
27
|
+
target.protocol = protocol;
|
|
28
|
+
const origin = req.headers.origin;
|
|
29
|
+
if ((req.method === 'POST' && !origin) || (origin !== undefined && origin !== target.origin)) {
|
|
30
|
+
throw new SubscriptionError('请从当前 DSH 设置页面操作。', 403, 'origin');
|
|
31
|
+
}
|
|
32
|
+
if (req.method === 'POST' && !/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] ?? '')) {
|
|
33
|
+
throw new SubscriptionError('请求必须使用 JSON。', 415);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function readBody(req, signal) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let length = 0;
|
|
39
|
+
const chunks = [];
|
|
40
|
+
const cleanup = () => { req.off('data', data); req.off('end', end); req.off('error', error); signal.removeEventListener('abort', abort); };
|
|
41
|
+
const fail = (reason) => { cleanup(); reject(reason); };
|
|
42
|
+
const data = (chunk) => {
|
|
43
|
+
length += chunk.length;
|
|
44
|
+
if (length > MAX_BODY) {
|
|
45
|
+
chunks.length = 0;
|
|
46
|
+
fail(new SubscriptionError('内容超过 1 MiB,请拆分 OPML 文件后重试。', 413));
|
|
47
|
+
req.resume();
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
};
|
|
52
|
+
const error = (reason) => fail(reason);
|
|
53
|
+
const abort = () => fail(signal.reason);
|
|
54
|
+
const end = () => {
|
|
55
|
+
cleanup();
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
58
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
59
|
+
throw new Error();
|
|
60
|
+
resolve(value);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
reject(new SubscriptionError('请求内容不是有效的 JSON。'));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
if (signal.aborted) {
|
|
67
|
+
abort();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
req.on('data', data);
|
|
71
|
+
req.on('end', end);
|
|
72
|
+
req.on('error', error);
|
|
73
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function createRssSettingsBackend(config, scope, options = {}) {
|
|
77
|
+
const writable = () => options.writable?.() !== false;
|
|
78
|
+
const snapshot = () => {
|
|
79
|
+
const state = subscriptionSnapshot(scope);
|
|
80
|
+
const checks = recentFeedChecks(scope);
|
|
81
|
+
return { revision: state.revision, writable: writable(), feeds: state.feeds.map(feed => ({ ...feed, id: feedId(feed.url), check: checks.get(feed.url) ?? null })) };
|
|
82
|
+
};
|
|
83
|
+
const find = (feeds, id) => {
|
|
84
|
+
const feed = feeds.find(feed => feedId(feed.url) === id);
|
|
85
|
+
if (!feed)
|
|
86
|
+
throw new SubscriptionError('这个订阅已被移除,请刷新列表。', 409, 'conflict');
|
|
87
|
+
return feed;
|
|
88
|
+
};
|
|
89
|
+
const check = (url, signal) => {
|
|
90
|
+
assertHttpUrl(url);
|
|
91
|
+
return readRssFeed(scope, url, config, { signal }, options.fetchImpl, options.lookupImpl);
|
|
92
|
+
};
|
|
93
|
+
const preview = (args) => {
|
|
94
|
+
const opml = text(args, 'opml');
|
|
95
|
+
if (Buffer.byteLength(opml, 'utf8') > MAX_OPML)
|
|
96
|
+
throw new SubscriptionError('OPML 内容超过 1 MiB,请拆分后导入。', 413, 'bodyLarge');
|
|
97
|
+
const state = subscriptionSnapshot(scope);
|
|
98
|
+
const updateExisting = args.updateExisting === true;
|
|
99
|
+
const outcome = importOpmlFeeds(state.feeds, parseOpml(opml), updateExisting);
|
|
100
|
+
// Never return credentials embedded in rejected URLs.
|
|
101
|
+
const skipped = outcome.skipped.map(item => ({ ...item, url: item.url.replace(/(https?:\/\/)[^/@\s]+@/gi, '$1***@') }));
|
|
102
|
+
return { ...outcome, feeds: undefined, skipped, revision: state.revision,
|
|
103
|
+
token: feedId(JSON.stringify([state.revision, opml, updateExisting])) };
|
|
104
|
+
};
|
|
105
|
+
async function dispatch(args, signal) {
|
|
106
|
+
const action = text(args, 'action');
|
|
107
|
+
if (action === 'export')
|
|
108
|
+
return { opml: buildOpml(subscriptionSnapshot(scope).feeds) };
|
|
109
|
+
if (action === 'preview')
|
|
110
|
+
return { preview: preview(args) };
|
|
111
|
+
if (action === 'check') {
|
|
112
|
+
const url = text(args, 'id') ? find(subscriptionSnapshot(scope).feeds, text(args, 'id')).url : text(args, 'url');
|
|
113
|
+
const { parsed } = await check(url, signal);
|
|
114
|
+
return { check: { ok: true, title: parsed.feed.title, entryCount: parsed.entries.length } };
|
|
115
|
+
}
|
|
116
|
+
if (!['add', 'edit', 'remove', 'import'].includes(action))
|
|
117
|
+
throw new SubscriptionError('未知的操作。');
|
|
118
|
+
if (!writable())
|
|
119
|
+
throw new SubscriptionError('当前配置是只读的,无法保存订阅。', 403, 'readonly');
|
|
120
|
+
const revision = text(args, 'revision');
|
|
121
|
+
if (!revision)
|
|
122
|
+
throw new SubscriptionError('请先刷新订阅列表。', 409, 'conflict');
|
|
123
|
+
let result;
|
|
124
|
+
if (action === 'import') {
|
|
125
|
+
const candidate = preview(args);
|
|
126
|
+
if (candidate.revision !== revision || candidate.token !== text(args, 'token')) {
|
|
127
|
+
throw new SubscriptionError('订阅列表或导入内容已经变化,请重新预览;你的输入已保留。', 409, 'conflict');
|
|
128
|
+
}
|
|
129
|
+
result = await mutateSubscriptions(scope, feeds => {
|
|
130
|
+
signal.throwIfAborted();
|
|
131
|
+
const outcome = importOpmlFeeds(feeds, parseOpml(text(args, 'opml')), args.updateExisting === true);
|
|
132
|
+
return { feeds: outcome.feeds, value: { addedCount: outcome.addedCount, existedCount: outcome.existedCount, skippedCount: outcome.skippedCount } };
|
|
133
|
+
}, revision);
|
|
134
|
+
}
|
|
135
|
+
else if (action === 'remove') {
|
|
136
|
+
result = await mutateSubscriptions(scope, feeds => {
|
|
137
|
+
signal.throwIfAborted();
|
|
138
|
+
const target = find(feeds, text(args, 'id'));
|
|
139
|
+
return { feeds: feeds.filter(feed => feed !== target), value: { removed: target.name || target.url } };
|
|
140
|
+
}, revision);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const url = text(args, 'url');
|
|
144
|
+
assertHttpUrl(url);
|
|
145
|
+
const current = subscriptionSnapshot(scope);
|
|
146
|
+
if (current.revision !== revision)
|
|
147
|
+
throw new SubscriptionError('订阅列表已经变化,请刷新后再保存;你的输入已保留。', 409, 'conflict');
|
|
148
|
+
const old = action === 'edit' ? find(current.feeds, text(args, 'id')) : undefined;
|
|
149
|
+
let defaultName = '';
|
|
150
|
+
if (!old || !sameFeedUrl(old.url, url))
|
|
151
|
+
defaultName = (await check(url, signal)).parsed.feed.title;
|
|
152
|
+
result = await mutateSubscriptions(scope, feeds => {
|
|
153
|
+
signal.throwIfAborted();
|
|
154
|
+
const previous = action === 'edit' ? find(feeds, text(args, 'id')) : undefined;
|
|
155
|
+
if (feeds.some(feed => feed !== previous && sameFeedUrl(feed.url, url))) {
|
|
156
|
+
throw new SubscriptionError('这个地址已经订阅,请编辑现有订阅。', 409, 'duplicate');
|
|
157
|
+
}
|
|
158
|
+
const next = { url, name: text(args, 'name') || defaultName, category: text(args, 'category') };
|
|
159
|
+
return { feeds: previous ? feeds.map(feed => feed === previous ? next : feed) : [...feeds, next], value: { saved: next.name || next.url } };
|
|
160
|
+
}, revision);
|
|
161
|
+
}
|
|
162
|
+
return { result };
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
snapshot,
|
|
166
|
+
async handle(req, res) {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const abort = () => { if (!res.writableFinished)
|
|
169
|
+
controller.abort(new Error('请求已取消')); };
|
|
170
|
+
req.on('aborted', abort);
|
|
171
|
+
res.on('close', abort);
|
|
172
|
+
const send = (status, value) => {
|
|
173
|
+
if (res.destroyed)
|
|
174
|
+
return;
|
|
175
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
|
|
176
|
+
res.end(JSON.stringify(value));
|
|
177
|
+
};
|
|
178
|
+
try {
|
|
179
|
+
requestGuard(req);
|
|
180
|
+
if (req.method === 'GET')
|
|
181
|
+
send(200, { snapshot: snapshot() });
|
|
182
|
+
else if (req.method === 'POST') {
|
|
183
|
+
const result = await dispatch(await readBody(req, controller.signal), controller.signal);
|
|
184
|
+
send(200, { ...result, snapshot: snapshot() });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
res.setHeader('Allow', 'GET, POST');
|
|
188
|
+
send(405, { error: '仅支持 GET 和 POST。', code: 'method' });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (!controller.signal.aborted) {
|
|
193
|
+
let state;
|
|
194
|
+
try {
|
|
195
|
+
state = snapshot();
|
|
196
|
+
}
|
|
197
|
+
catch { /* Malformed saved YAML must not hide the original error. */ }
|
|
198
|
+
send(error instanceof SubscriptionError ? error.status : 400, {
|
|
199
|
+
error: error instanceof Error ? error.message : String(error), code: error instanceof SubscriptionError ? error.code : 'invalid', snapshot: state,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
req.off('aborted', abort);
|
|
205
|
+
res.off('close', abort);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function installRssSettingsWeb(ctx, backend) {
|
|
211
|
+
if (typeof ctx.inject !== 'function')
|
|
212
|
+
return;
|
|
213
|
+
ctx.inject(['webServer'], (webCtx) => webCtx.effect(() => webCtx.webServer.register({
|
|
214
|
+
kind: 'exact', path: RSS_SETTINGS_ROUTE,
|
|
215
|
+
handler: (req, res) => backend.handle(req, res),
|
|
216
|
+
}), 'dsh-rss: settings route'));
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "DSH RSS 插件:订阅源管理、OPML 导入导出、抓取解析 RSS/Atom 并保留正文。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
11
|
"default": "./lib/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./client": "./lib/client.js",
|
|
13
14
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
@@ -26,6 +27,10 @@
|
|
|
26
27
|
"prepublishOnly": "pnpm run build"
|
|
27
28
|
},
|
|
28
29
|
"dsh": {
|
|
30
|
+
"client": {
|
|
31
|
+
"inject": ["@deepseek-ai/dsh-client-ui-settings"],
|
|
32
|
+
"platform": "web"
|
|
33
|
+
},
|
|
29
34
|
"bundle": {
|
|
30
35
|
"patch": "./cordis.patch.yml"
|
|
31
36
|
}
|