dsh-rss 0.4.3 → 0.5.1

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 CHANGED
@@ -1,4 +1,20 @@
1
- # dsh-rss
1
+ # dsh-rss
2
+
3
+ ## 0.5.1 update (2026-10-02)
4
+
5
+ Fixes public feeds being rejected when a VPN returns Fake-IP DNS addresses. Windows uses the enabled manual system proxy by default. For `198.18.0.0/15` answers, HTTPS DNS obtains real public addresses; connections are pinned to validated addresses while preserving the original hostname and TLS certificate verification. Keep the VPN enabled without changing its configuration or allowing private networks. Request-owned connections are released after redirects, errors or body reads.
6
+
7
+ All 106 Windows tests pass. With the VPN enabled and native DNS returning Fake-IP, normal plugin calls pass check/add/fetch/persisted incremental deduplication/search for SSPAI, Python Insider, NASA and GitHub Atom, with no injected DNS or fetch results. The legacy Python feed URL also remains readable. These results do not establish separate native desktop UI acceptance.
8
+
9
+ ## 0.5.0 update (2026-10-02)
10
+
11
+ 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.
12
+
13
+ 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.
14
+
15
+ 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.
16
+
17
+ 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.
2
18
 
3
19
  ## 0.4.3 update (2026-10-01)
4
20
 
@@ -26,7 +42,17 @@ Fetches honor the Harness cancellation signal through DNS, response streaming, a
26
42
  dsh plugin --profile web add dsh-rss
27
43
  ```
28
44
 
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.
45
+ 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.
46
+
47
+ 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.
48
+
49
+ ## Subscription panel
50
+
51
+ 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.
52
+
53
+ 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.
54
+
55
+ Cards show the most recent check/fetch time, article count or error during this run. Read-only configurations allow viewing, checking and exporting.
30
56
 
31
57
  ## Uninstall
32
58
 
@@ -45,7 +71,9 @@ Override the plugin row in your profile's `cordis.patch.yml` (the plugin also lo
45
71
  - id: rss
46
72
  name: 'dsh-rss'
47
73
  config:
48
- # proxyUrl: http://127.0.0.1:7890 # enable when a feed needs a special proxy
74
+ # proxyUrl: http://127.0.0.1:7890 # optional override for the Windows system proxy
75
+ # useSystemProxy: true # use the enabled Windows manual proxy by default
76
+ # fakeIpDnsFallback: true # validate real public DNS answers when Fake-IP is detected
49
77
  timeoutMs: 15000 # fetch timeout in ms (default 15000)
50
78
  # maxBodyBytes: 5242880 # response size cap (default 5MB, guards oversized responses)
51
79
  # userAgent: 'dsh-rss/0.2.0' # custom fetch UA
@@ -86,7 +114,9 @@ On Harness 0.1.7, subscriptions and read cursors are stored in the current profi
86
114
 
87
115
  ## Proxy
88
116
 
89
- Most feeds are reachable directly; a few require a special proxy from your network. When you hit a `fetch failed` error suggesting a proxy, set `proxyUrl` to your local proxy address (e.g. `http://127.0.0.1:7890`) and restart. The proxy only routes this plugin's fetch requests and does not affect other plugins in the same process.
117
+ Windows uses its enabled manual proxy by default, honoring protocol selection and bypass entries. No PAC scripts are evaluated and OS settings are never modified. An explicit `proxyUrl` takes precedence; `useSystemProxy: false` disables automatic detection. Other platforms can still configure an HTTP(S) `proxyUrl`. These routes apply only to this plugin.
118
+
119
+ When native DNS returns VPN Fake-IP (`198.18.0.0/15`), only the feed hostname is sent to [Cloudflare HTTPS DNS](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/), with Google HTTPS DNS as a service-failure fallback. No URL paths, feed content, credentials or subscription lists are sent. Answers must pass public-address checks, and actual connections are pinned to those addresses. Disable this behavior with `fakeIpDnsFallback: false`. Ordinary public DNS, private DNS and IP literals do not trigger it. Trusted internal feeds still require an explicit `allowPrivateNetwork: true`.
90
120
 
91
121
  ## Parsing capabilities
92
122
 
package/README.md CHANGED
@@ -1,6 +1,22 @@
1
1
  [English](README.en.md)
2
2
 
3
- # dsh-rss
3
+ # dsh-rss
4
+
5
+ ## 0.5.1 更新(2026-10-02)
6
+
7
+ 修复 VPN 开启 Fake-IP 后外部订阅源被拦截的问题。Windows 默认沿用已启用的手动系统代理;检测到 `198.18.0.0/15` 时,通过 HTTPS DNS 查询真实公网地址,再固定连接地址并保留原域名和 TLS 证书校验。VPN 可以保持开启,无需修改代理软件配置或放开内网访问。抓取、跳转或读取结束后释放本次请求的连接。
8
+
9
+ 106 项 Windows 测试通过;在 VPN 开启、原生 DNS 返回 Fake-IP 的环境中,少数派、Python Insider、NASA 和 GitHub Atom 的检查、添加、抓取、持久化增量去重与搜索通过正常插件调用验证,没有注入测试 DNS 或抓取结果。Python 旧订阅地址也仍可读取。桌面原生窗口的单独验收不包含在这些结果中。
10
+
11
+ ## 0.5.0 更新(2026-10-02)
12
+
13
+ 新增 **设置 → RSS 订阅** 面板:添加、编辑、移除、搜索、分类筛选、检查可用性与 OPML 导入导出。面板与对话工具共用原有订阅列表,不需要迁移数据。跟随 DSH 的中文/英文和外观设置。
14
+
15
+ OPML 导入先预览新增、重复和无效项,再确认保存。默认保留重复订阅的名称和分类;勾选更新时会展示变化。保存失败或预览过期时保留输入。移除订阅需要确认具体名称和地址。
16
+
17
+ 同时修复损坏 XML 被部分导入、无效/含密码 URL 被接受,以及并发添加覆盖另一笔订阅的问题。检查记录只保留在本次 DSH 运行中,导入不会自动抓取全部订阅。
18
+
19
+ 94 项 Windows 测试通过;在官方 Harness `0.2.0-rc.2` 中实际操作了添加、编辑、筛选、检查、导入、移除、Tab/Enter/Esc、双页面冲突和重启持久化。桌面原生窗口与窄屏交互本轮未完成验收,外部生产订阅未替代为本地模拟测试。
4
20
 
5
21
  ## 0.4.3 更新(2026-10-01)
6
22
 
@@ -31,7 +47,17 @@ DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓
31
47
  dsh plugin --profile web add dsh-rss
32
48
  ```
33
49
 
34
- 安装后重启 Web 服务即可。直接对助手说「订阅这个 RSS 地址,命名为技术资讯」或「列出我的订阅」即可使用,日常增删订阅不需要编辑配置文件。
50
+ 安装后重启 Web 服务即可。直接对助手说「订阅这个 RSS 地址,命名为技术资讯」或「列出我的订阅」即可使用,日常增删订阅不需要编辑配置文件。
51
+
52
+ 桌面版使用 `dsh plugin --profile desktop add dsh-rss`,安装或更新后重新打开 DSH。网页和桌面的共享设置页面均可显示订阅面板;已安装的旧版本请先通过插件管理器更新。
53
+
54
+ ## 订阅面板
55
+
56
+ 打开 **设置 → RSS 订阅**。添加时填写 RSS/Atom 地址,名称可留空,分类可选。新地址通过抓取检查后才会保存;编辑原地址的名称或分类不需要源网站在线。
57
+
58
+ 导入可选择不超过 1 MiB 的 OPML 文件或粘贴文本。先检查预览,再点击确认;跳过的地址会说明原因。已有订阅发生变化时重新预览即可,原输入仍保留。导出通过浏览器下载 OPML,不会修改订阅。
59
+
60
+ 列表卡片显示最近一次手动检查或工具抓取的时间和文章数,失败时显示具体原因。只读配置仍可查看、检查和导出。
35
61
 
36
62
  ## 卸载
37
63
 
@@ -50,7 +76,9 @@ dsh plugin --profile web remove dsh-rss
50
76
  - id: rss
51
77
  name: 'dsh-rss'
52
78
  config:
53
- # proxyUrl: http://127.0.0.1:7890 # 部分订阅源需要特殊代理(梯子)才能访问时启用
79
+ # proxyUrl: http://127.0.0.1:7890 # 可选;覆盖自动读取的 Windows 系统代理
80
+ # useSystemProxy: true # 默认读取 Windows 已启用的手动系统代理
81
+ # fakeIpDnsFallback: true # 默认在 Fake-IP 下查询并校验真实公网地址
54
82
  timeoutMs: 15000 # 抓取超时(毫秒,默认 15000)
55
83
  # maxBodyBytes: 5242880 # 订阅源体积上限(默认 5MB,防超大响应)
56
84
  # userAgent: 'dsh-rss/0.2.0' # 自定义抓取 UA
@@ -88,9 +116,11 @@ rss_opml_import { opml: "<?xml version=\"1.0\"?>..." }
88
116
 
89
117
  Harness 0.1.7 把订阅列表和读取游标保存在当前 profile 的 `rss` 配置行;增删后立即生效,重启仍保留。默认安装会自动导入旧 `settings.yaml` 或 `settings.yaml.imported` 的 `dsh-rss` 数据一次,保留原文件,且不覆盖已有 profile 值。旧版宿主继续使用原 settings 存储。同名订阅请用 URL 区分。
90
118
 
91
- ## 特殊代理(梯子)
119
+ ## 代理与 VPN
92
120
 
93
- 大部分订阅源可以直接访问;少数订阅源需要特殊代理(梯子)才能连通。遇到 `抓取失败` 且提示需要代理时,把 `proxyUrl` 配成你的本地代理地址(如 `http://127.0.0.1:7890`)并重启即可。代理只作用于本插件的抓取请求,不影响同进程其他插件。
121
+ Windows 上默认读取已启用的手动系统代理,尊重协议与绕过列表;不执行 PAC 脚本,也不修改系统设置。`proxyUrl` 显式配置优先;`useSystemProxy: false` 可关闭自动读取。其他平台仍可通过 `proxyUrl` 配置 HTTP(S) 代理。这些代理仅作用于本插件。
122
+
123
+ 当系统 DNS 返回 VPN 的 Fake-IP(`198.18.0.0/15`)时,默认只向 [Cloudflare HTTPS DNS](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/) 查询订阅源域名;服务不可用时尝试 Google HTTPS DNS。查询不会发送订阅源路径、正文、密钥或订阅列表,回答仍须通过公网地址检查;实际连接固定到检查过的地址。`fakeIpDnsFallback: false` 可关闭该兼容方式。普通公网 DNS、私网 DNS 和 IP 字面量不会触发此查询。可信内网订阅仍需单独显式配置 `allowPrivateNetwork: true`。
94
124
 
95
125
  ## 解析能力
96
126
 
package/cordis.patch.yml CHANGED
@@ -7,7 +7,9 @@
7
7
  # - id: rss
8
8
  # name: 'dsh-rss'
9
9
  # config:
10
- # # proxyUrl: http://127.0.0.1:7890 # 部分订阅源需要特殊代理(梯子)时启用
10
+ # # proxyUrl: http://127.0.0.1:7890 # 可选;覆盖 Windows 系统代理
11
+ # # useSystemProxy: true # 默认自动读取 Windows 手动系统代理
12
+ # # fakeIpDnsFallback: true # 默认兼容 VPN Fake-IP,仍校验真实公网 IP
11
13
  # timeoutMs: 15000 # 抓取超时(毫秒)
12
14
  # # feedsYaml: | # 可选:预置订阅列表(也可用 rss_add 工具添加)
13
15
  # # - url: https://example.com/feed.xml
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/config.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  /** 插件行配置(cordis.patch.yml 里的 config 段,可缺省)。 */
8
8
  export interface RssConfig {
9
9
  proxyUrl?: string;
10
+ useSystemProxy?: boolean;
11
+ fakeIpDnsFallback?: boolean;
10
12
  timeoutMs?: number;
11
13
  maxBodyBytes?: number;
12
14
  userAgent?: string;
@@ -18,6 +20,8 @@ export interface RssConfig {
18
20
  /** 解析后的配置:所有字段都有值。 */
19
21
  export interface ResolvedRssConfig {
20
22
  proxyUrl: string;
23
+ useSystemProxy: boolean;
24
+ fakeIpDnsFallback: boolean;
21
25
  timeoutMs: number;
22
26
  maxBodyBytes: number;
23
27
  userAgent: string;
package/lib/config.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  const DEFAULT_TIMEOUT_MS = 15000;
8
8
  const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;
9
- const DEFAULT_USER_AGENT = 'dsh-rss/0.3.1 (DeepSeek Harness RSS plugin)';
9
+ const DEFAULT_USER_AGENT = 'dsh-rss/0.5.1 (DeepSeek Harness RSS plugin)';
10
10
  function configRecord(config) {
11
11
  if (config === undefined || config === null)
12
12
  return {};
@@ -68,6 +68,8 @@ export function resolveConfig(config) {
68
68
  throw new Error('userAgent 不能为空字符串。');
69
69
  const feedsYaml = optionalString(cfg, 'feedsYaml') ?? '';
70
70
  const allowPrivateNetwork = optionalBoolean(cfg, 'allowPrivateNetwork', false);
71
+ const useSystemProxy = optionalBoolean(cfg, 'useSystemProxy', true);
72
+ const fakeIpDnsFallback = optionalBoolean(cfg, 'fakeIpDnsFallback', true);
71
73
  const opmlWriteApproval = optionalBoolean(cfg, 'opmlWriteApproval', true);
72
- return { proxyUrl, timeoutMs, maxBodyBytes, userAgent, feedsYaml, allowPrivateNetwork, opmlWriteApproval };
74
+ return { proxyUrl, useSystemProxy, fakeIpDnsFallback, timeoutMs, maxBodyBytes, userAgent, feedsYaml, allowPrivateNetwork, opmlWriteApproval };
73
75
  }
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';