dsh-rss 0.5.0 → 0.5.2
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/CHANGELOG.md +34 -0
- package/README.en.md +42 -127
- package/README.md +42 -133
- package/cordis.patch.yml +3 -1
- package/docs/CHANGELOG.en.md +27 -0
- package/docs/USAGE.en.md +104 -0
- package/docs/USAGE.md +105 -0
- package/docs/VALIDATION.md +25 -0
- package/docs/validation-2026-10-02-fakeip.md +32 -0
- package/docs/validation-language-2026-10-03.md +16 -0
- package/lib/client.js +21 -4
- package/lib/config.d.ts +4 -0
- package/lib/config.js +4 -2
- package/lib/network.js +83 -98
- package/lib/proxy-fetch.d.ts +6 -0
- package/lib/proxy-fetch.js +42 -1
- package/lib/public-dns.d.ts +3 -0
- package/lib/public-dns.js +49 -0
- package/lib/response.d.ts +2 -0
- package/lib/response.js +37 -0
- package/lib/settings.d.ts +4 -0
- package/lib/settings.js +1 -0
- package/lib/system-proxy.d.ts +7 -0
- package/lib/system-proxy.js +52 -0
- package/package.json +11 -7
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# dsh-rss 验证记录
|
|
2
|
+
|
|
3
|
+
[0.5.2 中英文界面验收](validation-language-2026-10-03.md)。
|
|
4
|
+
|
|
5
|
+
本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
|
|
6
|
+
|
|
7
|
+
## 原中文记录
|
|
8
|
+
|
|
9
|
+
0.4.0 起适配官方源码构建的 Harness **0.1.7 及以上的声明式设置接口**;当前基线为 **0.2.0-rc.2**,18 个插件同载、工具契约、订阅读写与重启持久化在隔离环境通过验证;真实外部订阅源需要正常网络连接。Node 要求为 22.19 及以上的 22.x,或 24 及以上。
|
|
10
|
+
|
|
11
|
+
抓取接收 Harness 的取消信号,取消 DNS、网络读取或跨源搜索时保留原始取消原因。默认预检域名解析结果并拒绝回环、私网和链路本地地址;每次重定向都重新校验。可信内网源可显式配置 `allowPrivateNetwork: true`。
|
|
12
|
+
|
|
13
|
+
`rss_opml_export.path` 必须相对本次调用的会话工作区,父目录须已存在;工具拒绝绝对路径、目录穿越和指向工作区外的链接,并返回实际写入文件的绝对路径。文件写入默认通过 Harness 审批门,`opmlWriteApproval: false` 可关闭该门;省略 `path` 时仅返回 OPML 文本。
|
|
14
|
+
|
|
15
|
+
## Original English record
|
|
16
|
+
|
|
17
|
+
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
|
+
|
|
19
|
+
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
|
+
|
|
21
|
+
`rss_opml_export.path` is relative to the calling session's workspace, with an existing parent directory. Absolute paths, traversal, and links outside the workspace are rejected; the result returns the absolute written path. File writes request Harness approval by default (`opmlWriteApproval: false` disables this gate). Omit `path` to return only OPML text.
|
|
22
|
+
|
|
23
|
+
## 0.4.3 原验证说明
|
|
24
|
+
|
|
25
|
+
验证宿主:官方源码构建的 Harness `0.2.0-rc.2`(commit `639ed01539`,2026-10-01)。81 项 Windows 插件测试、18 个插件共同加载与 9 个 RSS 工具的契约检查通过。真实外部订阅源仍需正常网络连接。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# RSS 0.5.1 VPN / Fake-IP acceptance
|
|
2
|
+
|
|
3
|
+
Date: 2026-10-02. Host: official-source Harness `0.2.0-rc.2`, commit `639ed015397290b3745d163aafe02ffee4aa3f84`. Windows, Node 24.16.0, Undici 8.11.2.
|
|
4
|
+
|
|
5
|
+
## Reproduction and fix
|
|
6
|
+
|
|
7
|
+
Native Windows DNS returned `198.18.*` for public feed domains while FlClash remained enabled in Fake-IP mode. Version 0.5.0 rejected those answers before fetching. Version 0.5.1 uses the already-enabled manual Windows proxy and HTTPS DNS for Fake-IP answers. Feed connections are pinned to validated public addresses, with the original HTTP Host and TLS servername. Each redirect is separately validated. TLS verification and private-network protection remain enabled. Request-owned dispatchers are destroyed after completion or cancellation.
|
|
8
|
+
|
|
9
|
+
The VPN was not stopped or reconfigured. Its three process IDs and generated configuration SHA256 remained unchanged during acceptance. No user profile, API key or existing subscription was used or modified.
|
|
10
|
+
|
|
11
|
+
## Verification
|
|
12
|
+
|
|
13
|
+
- `pnpm test`: 106/106 on Windows. Includes real local CONNECT-proxy transport, preserved Host, no second socket DNS lookup, HTTPS DNS cancellation/body cap/redirect handling, private answers, opt-outs and Windows proxy parsing/bypass behavior.
|
|
14
|
+
- `pnpm audit --prod --registry https://registry.npmjs.org`: no known vulnerabilities.
|
|
15
|
+
- `node --check lib/client.js`: passed.
|
|
16
|
+
- Exact npm tarball installed into a fresh consumer: 34/34 relevant regressions passed. The tarball contains the compiled networking modules and client entry.
|
|
17
|
+
- Normal `buildRssTools` calls from that installation used production fetch/DNS without injection, the default 15-second timeout, no explicit `proxyUrl`, and `allowPrivateNetwork: false`.
|
|
18
|
+
|
|
19
|
+
| Real source | Format / entries | Check | Add / fetch / search | Incremental after tools rebuilt from disk |
|
|
20
|
+
| --- | --- | --- | --- | --- |
|
|
21
|
+
| `https://sspai.com/feed` | RSS / 10 | Pass | Pass | 10 first, 0 repeated |
|
|
22
|
+
| `https://blog.python.org/rss.xml` | RSS / 50 | Pass | Pass | 50 first, 0 repeated |
|
|
23
|
+
| `https://www.nasa.gov/feed/` | RSS / 10 | Pass | Pass | 10 first, 0 repeated |
|
|
24
|
+
| `https://github.com/deepseek-ai/deepseek-harness/releases.atom` | Atom / 10 | Pass | Pass | 10 first, 0 repeated |
|
|
25
|
+
|
|
26
|
+
The old Python URL `https://blog.python.org/feeds/posts/default?alt=rss` also passed. It returned a valid RSS feed without an HTTP redirect in the earlier URL diagnostic.
|
|
27
|
+
|
|
28
|
+
## Human browser operations
|
|
29
|
+
|
|
30
|
+
An isolated, keyless profile loaded the fresh tarball installation in the official host. Through Settings → RSS subscriptions, all four public feeds were entered and saved using the visible form. Every card displayed its real entry count and a passing check. Clicking the GitHub Atom check button completed successfully. Adding `http://127.0.0.1/private-feed` was rejected, retained the typed form values and left the four subscriptions intact. No console errors were recorded. Screenshots and DOM snapshots were retained in the local validation directory. The temporary browser tab and test host closed after acceptance.
|
|
31
|
+
|
|
32
|
+
This covers the shared browser settings surface and production networking. It does not constitute separate native desktop-window acceptance or live testing of other plugins.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# 0.5.2:中英文界面验收
|
|
2
|
+
|
|
3
|
+
2026-10-03,Windows / Node 24.16.0。采用 E 盘独立 DSH_HOME、合成资料与无密钥 profile;用户资料、账号、密钥、桌面窗口与 VPN 保持原状。
|
|
4
|
+
|
|
5
|
+
- 自动测试:107 项全部通过。
|
|
6
|
+
- 可见 Web 操作:英文无效 OPML 显示可恢复说明;混合合法 / 非 HTTP 地址预览正确显示跳过原因,并只导入一条合成中文命名订阅。中文界面读取同一订阅,原名称保留;无效 OPML 中文提示保留。
|
|
7
|
+
- 宿主:官方 DSH 0.2.1-alpha.1 Web;官方 RC2 与 alpha SDK 同载 18 组件,110 工具、35 插件技能、19 输出样例注册与 schema 检查通过。无账号的健康结果不代表真实连接。
|
|
8
|
+
- 边界:导入本身不抓取文章。本轮语言补丁没有重复执行外部订阅网络矩阵,网络验收见之前版本记录。
|
|
9
|
+
|
|
10
|
+
中英文共用同一份资料;界面翻译不会改写标题、正文、路径、引用或项目文件。系统文件选择控件和文件对话框跟随 Windows / 浏览器语言。未知技术诊断与原始引擎日志保留原文;英文界面提供操作恢复引导。
|
|
11
|
+
|
|
12
|
+
此记录不代表原生桌面窗口内的新功能已逐项操作通过。发布包完整性、GitHub CI 与公开 npm 全新安装在发布时另行核对。
|
|
13
|
+
|
|
14
|
+
## English scope
|
|
15
|
+
|
|
16
|
+
The Chinese and English UI share the same data. UI translation preserves original content, paths and project files. Visible interactions were performed in an isolated official alpha Web profile; both RC2 and alpha SDK registration/schema checks passed. Native file controls use the OS/browser language, and original diagnostic logs are retained. Browser results do not certify native desktop interaction or real-service delivery.
|
package/lib/client.js
CHANGED
|
@@ -27,6 +27,19 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
|
27
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
28
|
};
|
|
29
29
|
const t = key => (words[key] || [key, key])[language === 'en' ? 1 : 0];
|
|
30
|
+
|
|
31
|
+
function feedError(message, code) {
|
|
32
|
+
if (language !== 'en' || !/[\u3400-\u9fff]/.test(message || '')) return message;
|
|
33
|
+
if (['conflict', 'duplicate', 'readonly', 'bodyLarge', 'session'].includes(code)) return t(code);
|
|
34
|
+
if (/HTTP/.test(message)) return 'Feed request failed. Check the URL, connection and proxy settings, then retry.';
|
|
35
|
+
if (/YAML|feedsYaml/.test(message)) return 'The feed configuration is invalid. Fix the feedsYaml list in settings before retrying.';
|
|
36
|
+
if (/OPML|outline|xmlUrl/.test(message)) return 'The OPML content is invalid. Export it again with xmlUrl attributes on feed outlines.';
|
|
37
|
+
if (/地址|URL|url|http|https|协议/.test(message)) return 'Enter a valid HTTP or HTTPS feed URL. Local and private-network addresses are not allowed.';
|
|
38
|
+
if (/写入|只读/.test(message)) return 'Could not save the feed configuration. Check write access and reload the list before retrying; your input is preserved.';
|
|
39
|
+
if (/未找到|找不到|没有找到/.test(message)) return 'No matching feed was found. Refresh the subscription list.';
|
|
40
|
+
return 'The feed could not be read. Check the URL, network and proxy settings, then retry.';
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
const CSS = `
|
|
31
44
|
.dshr{color:var(--dsw-alias-label-primary,#202737);font-size:14px;line-height:1.55;max-width:920px;min-width:0}
|
|
32
45
|
.dshr *,.dshr-dialog *{box-sizing:border-box}.dshr h2{font-size:22px;margin:0 0 6px}.dshr p{margin:0 0 12px}
|
|
@@ -54,7 +67,7 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
|
54
67
|
let value;
|
|
55
68
|
try { value = await response.json(); } catch { throw new Error(response.status === 401 ? t('session') : t('loadError')); }
|
|
56
69
|
if (!response.ok) {
|
|
57
|
-
const error = new Error(['conflict', 'duplicate', 'readonly'].includes(value.code) ? t(value.code) : value.error || t('loadError'));
|
|
70
|
+
const error = new Error(['conflict', 'duplicate', 'readonly'].includes(value.code) ? t(value.code) : feedError(value.error, value.code) || t('loadError'));
|
|
58
71
|
error.snapshot = value.snapshot;
|
|
59
72
|
throw error;
|
|
60
73
|
}
|
|
@@ -151,7 +164,7 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
|
151
164
|
h('span', { className: 'dshr-tag' }, feed.category || t('uncategorized')),
|
|
152
165
|
h('div', { className: 'dshr-status' }, feed.check ? [
|
|
153
166
|
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,
|
|
167
|
+
feed.check.ok ? feed.check.entryCount + ' ' + t('entries') + ' · ' + t('checked') : t('failed') + (language === 'en' ? ': ' : ':') + feedError(feed.check.error),
|
|
155
168
|
] : t('pending'))),
|
|
156
169
|
h('div', { className: 'dshr-actions' },
|
|
157
170
|
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')),
|
|
@@ -178,7 +191,7 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
|
178
191
|
(preview.duplicates || []).slice(0, 20).map((item, index) => h('li', { key: 'duplicate-' + index },
|
|
179
192
|
t('duplicates'), ':', item.before.name || item.url, ' · ', item.before.category || t('uncategorized'),
|
|
180
193
|
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))),
|
|
194
|
+
preview.skipped.slice(0, 20).map((item, index) => h('li', { key: index }, item.title, ' · ', item.url, language === 'en' ? ': ' : ':', feedError(item.reason)))),
|
|
182
195
|
preview.added.length > 20 || preview.skipped.length > 20 || (preview.duplicates || []).length > 20 ? h('p', { className: 'dshr-muted' }, t('previewLimit')) : null) : null,
|
|
183
196
|
h('div', { className: 'dshr-buttons' }, h('button', { type: 'button', disabled: !!busy, onClick: close }, t('cancel')),
|
|
184
197
|
h('button', { type: 'submit', disabled: !!busy || !opml.trim() }, t(busy === 'preview' ? 'saving' : 'preview')),
|
|
@@ -196,7 +209,11 @@ window.__ModuleLoader__.load({ id: 'dsh-rss', factory: require => {
|
|
|
196
209
|
const style = document.createElement('style'); style.dataset.pluginCss = 'dsh-rss/client'; style.textContent = CSS; document.head.appendChild(style);
|
|
197
210
|
return () => style.remove();
|
|
198
211
|
}, 'dsh-rss: styles');
|
|
199
|
-
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-rss', order: 55, label: () =>
|
|
212
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-rss', order: 55, label: () => {
|
|
213
|
+
let lang = language;
|
|
214
|
+
try { const service = ctx.get ? ctx.get('locale') : ctx.locale; if (service?.getSnapshot) lang = String(service.getSnapshot().active || '').startsWith('en') ? 'en' : 'zh'; } catch {}
|
|
215
|
+
return words.title[lang === 'en' ? 1 : 0];
|
|
216
|
+
}, inject: () => ({}) }, Panel));
|
|
200
217
|
}
|
|
201
218
|
return { apply, inject: ['slots', 'locale'] };
|
|
202
219
|
} });
|
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.
|
|
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/network.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** RSS 网络边界:地址校验、重定向、响应大小与调用取消。 */
|
|
2
2
|
import { lookup } from 'node:dns/promises';
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
|
-
import {
|
|
4
|
+
import { createFeedTransport } from './proxy-fetch.js';
|
|
5
|
+
import { lookupPublicDns } from './public-dns.js';
|
|
6
|
+
import { readResponseText } from './response.js';
|
|
7
|
+
import { readWindowsProxy, selectWindowsProxy } from './system-proxy.js';
|
|
5
8
|
const MAX_REDIRECTS = 5;
|
|
6
|
-
const PROXY_HINT = '
|
|
9
|
+
const PROXY_HINT = '。请检查网络与代理连接;也可在 dsh-rss 配置中指定 proxyUrl。';
|
|
7
10
|
function parseHttpUrl(url) {
|
|
8
11
|
let parsed;
|
|
9
12
|
try {
|
|
@@ -101,7 +104,11 @@ async function lookupWithSignal(hostname, lookupImpl, signal) {
|
|
|
101
104
|
});
|
|
102
105
|
});
|
|
103
106
|
}
|
|
104
|
-
|
|
107
|
+
function isFakeIp(address) {
|
|
108
|
+
const bytes = ipv4Bytes(address);
|
|
109
|
+
return bytes !== null && bytes[0] === 198 && (bytes[1] === 18 || bytes[1] === 19);
|
|
110
|
+
}
|
|
111
|
+
async function resolvePublicUrl(url, cfg, lookupImpl, signal, fallbackLookup) {
|
|
105
112
|
if (cfg.allowPrivateNetwork)
|
|
106
113
|
return;
|
|
107
114
|
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
@@ -112,7 +119,7 @@ async function assertPublicUrl(url, cfg, lookupImpl, signal) {
|
|
|
112
119
|
if (isBlockedNetworkAddress(hostname)) {
|
|
113
120
|
throw new Error('出于安全原因,默认禁止访问回环、私网、链路本地或保留地址:' + hostname + '。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
|
|
114
121
|
}
|
|
115
|
-
return;
|
|
122
|
+
return [{ address: hostname, family: isIP(hostname) }];
|
|
116
123
|
}
|
|
117
124
|
let addresses;
|
|
118
125
|
try {
|
|
@@ -124,113 +131,91 @@ async function assertPublicUrl(url, cfg, lookupImpl, signal) {
|
|
|
124
131
|
}
|
|
125
132
|
if (addresses.length === 0)
|
|
126
133
|
throw new Error('无法安全解析订阅源域名 ' + hostname + ':DNS 未返回地址。');
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
if (v4 !== null && v4[0] === 198 && (v4[1] === 18 || v4[1] === 19)) {
|
|
131
|
-
throw new Error('订阅源域名 ' + hostname + ' 被解析为保留地址 ' + blocked.address + ',可能是代理软件的 Fake-IP,已拒绝访问。请在代理 DNS 设置中让该域名返回真实 IP(如加入 fake-ip-filter),然后重试。无需开启内网访问。');
|
|
132
|
-
}
|
|
133
|
-
throw new Error('出于安全原因,域名 ' + hostname + ' 解析到了非公网地址 ' + blocked.address + ',已拒绝访问。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
|
|
134
|
+
const privateAddress = addresses.find(entry => isBlockedNetworkAddress(entry.address) && !isFakeIp(entry.address));
|
|
135
|
+
if (privateAddress !== undefined) {
|
|
136
|
+
throw new Error('出于安全原因,域名 ' + hostname + ' 解析到了非公网地址 ' + privateAddress.address + ',已拒绝访问。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
|
|
134
137
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (cfg.proxyUrl === '')
|
|
139
|
-
return globalThis.fetch;
|
|
140
|
-
return createProxyFetch(cfg.proxyUrl);
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* 抓取订阅源 XML:校验地址、限时、限制体积、剥 BOM。
|
|
144
|
-
*/
|
|
145
|
-
async function readResponseText(response, maxBodyBytes, signal) {
|
|
146
|
-
signal.throwIfAborted();
|
|
147
|
-
const declaredLength = Number(response.headers.get('content-length'));
|
|
148
|
-
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
|
149
|
-
await response.body?.cancel('response body exceeds maxBodyBytes');
|
|
150
|
-
throw new Error('订阅源内容超过 ' + maxBodyBytes + ' 字节上限,已停止读取。');
|
|
151
|
-
}
|
|
152
|
-
if (response.body === null)
|
|
153
|
-
return '';
|
|
154
|
-
const reader = response.body.getReader();
|
|
155
|
-
const onAbort = () => { void reader.cancel(signal.reason).catch(() => { }); };
|
|
156
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
157
|
-
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
158
|
-
let byteLength = 0;
|
|
159
|
-
let text = '';
|
|
160
|
-
try {
|
|
161
|
-
while (true) {
|
|
162
|
-
signal.throwIfAborted();
|
|
163
|
-
const chunk = await reader.read();
|
|
164
|
-
signal.throwIfAborted();
|
|
165
|
-
if (chunk.done)
|
|
166
|
-
break;
|
|
167
|
-
byteLength += chunk.value.byteLength;
|
|
168
|
-
if (byteLength > maxBodyBytes) {
|
|
169
|
-
await reader.cancel('response body exceeds maxBodyBytes');
|
|
170
|
-
throw new Error('订阅源内容超过 ' + maxBodyBytes + ' 字节上限,已停止读取。');
|
|
171
|
-
}
|
|
172
|
-
text += decoder.decode(chunk.value, { stream: true });
|
|
138
|
+
if (addresses.some(entry => isFakeIp(entry.address))) {
|
|
139
|
+
if (!cfg.fakeIpDnsFallback) {
|
|
140
|
+
throw new Error('无法安全解析订阅源域名 ' + hostname + ':检测到代理 Fake-IP。可启用 fakeIpDnsFallback 查询真实 IP 后重试,VPN 可以保持开启。');
|
|
173
141
|
}
|
|
174
|
-
|
|
142
|
+
addresses = await lookupWithSignal(hostname, fallbackLookup, signal);
|
|
143
|
+
if (addresses.length === 0)
|
|
144
|
+
throw new Error('无法安全解析订阅源域名 ' + hostname + ':HTTPS DNS 未返回地址。');
|
|
175
145
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
146
|
+
const blocked = addresses.find((entry) => isBlockedNetworkAddress(entry.address));
|
|
147
|
+
if (blocked !== undefined) {
|
|
148
|
+
throw new Error('出于安全原因,域名 ' + hostname + ' 解析到了非公网地址 ' + blocked.address + ',已拒绝访问。若确需访问可信内网源,请显式配置 allowPrivateNetwork: true。');
|
|
179
149
|
}
|
|
150
|
+
return addresses;
|
|
180
151
|
}
|
|
181
152
|
export async function fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl = async (hostname) => await lookup(hostname, { all: true, verbatim: true })) {
|
|
182
153
|
let currentUrl = parseHttpUrl(url);
|
|
183
|
-
const fetcher = fetchImpl ?? makeFetch(cfg);
|
|
184
154
|
const signal = mergedSignal(cfg.timeoutMs, exec?.signal);
|
|
185
155
|
let response;
|
|
156
|
+
let transport;
|
|
186
157
|
try {
|
|
187
|
-
|
|
158
|
+
const systemProxy = fetchImpl === undefined && cfg.proxyUrl === '' && cfg.useSystemProxy
|
|
159
|
+
? await readWindowsProxy(signal) : undefined;
|
|
160
|
+
const fallbackLookup = hostname => lookupPublicDns(hostname, signal, cfg.proxyUrl, systemProxy, fetchImpl);
|
|
161
|
+
const validate = (target) => resolvePublicUrl(target, cfg, lookupImpl, signal, fallbackLookup);
|
|
162
|
+
try {
|
|
163
|
+
for (let redirectCount = 0;; redirectCount++) {
|
|
164
|
+
signal.throwIfAborted();
|
|
165
|
+
const addresses = await validate(currentUrl);
|
|
166
|
+
transport = fetchImpl === undefined ? createFeedTransport(currentUrl, cfg.proxyUrl || selectWindowsProxy(systemProxy, currentUrl), addresses) : undefined;
|
|
167
|
+
response = await (fetchImpl ?? transport.fetch)(currentUrl.href, {
|
|
168
|
+
headers: {
|
|
169
|
+
'user-agent': cfg.userAgent,
|
|
170
|
+
accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
|
|
171
|
+
},
|
|
172
|
+
redirect: 'manual',
|
|
173
|
+
signal,
|
|
174
|
+
});
|
|
175
|
+
signal.throwIfAborted();
|
|
176
|
+
if (![301, 302, 303, 307, 308].includes(response.status))
|
|
177
|
+
break;
|
|
178
|
+
const location = response.headers.get('location');
|
|
179
|
+
if (location === null)
|
|
180
|
+
break;
|
|
181
|
+
await response.body?.cancel('following validated redirect');
|
|
182
|
+
await transport?.dispose();
|
|
183
|
+
transport = undefined;
|
|
184
|
+
if (redirectCount >= MAX_REDIRECTS)
|
|
185
|
+
throw new Error('抓取失败:重定向次数超过 ' + MAX_REDIRECTS + ' 次上限。');
|
|
186
|
+
currentUrl = parseHttpUrl(new URL(location, currentUrl).href);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
188
190
|
signal.throwIfAborted();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
if (error instanceof Error && (error.message.includes('出于安全原因') || error.message.includes('无法安全解析') || error.message.includes('重定向次数超过')))
|
|
192
|
+
throw error;
|
|
193
|
+
throw new Error('抓取失败:' + (error instanceof Error ? error.message : String(error)) + PROXY_HINT);
|
|
194
|
+
}
|
|
195
|
+
if (response === undefined)
|
|
196
|
+
throw new Error('抓取失败:未收到服务器响应。');
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
await response.body?.cancel('unsuccessful feed response');
|
|
199
|
+
throw new Error('抓取失败:服务器返回 HTTP ' + response.status + '。');
|
|
200
|
+
}
|
|
201
|
+
const reportedUrl = response.url === '' ? currentUrl : parseHttpUrl(response.url);
|
|
202
|
+
if (reportedUrl.href !== currentUrl.href)
|
|
203
|
+
await validate(reportedUrl);
|
|
204
|
+
let xml;
|
|
205
|
+
try {
|
|
206
|
+
xml = await readResponseText(response, cfg.maxBodyBytes, signal);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
198
209
|
signal.throwIfAborted();
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (location === null)
|
|
203
|
-
break;
|
|
204
|
-
await response.body?.cancel('following validated redirect');
|
|
205
|
-
if (redirectCount >= MAX_REDIRECTS)
|
|
206
|
-
throw new Error('抓取失败:重定向次数超过 ' + MAX_REDIRECTS + ' 次上限。');
|
|
207
|
-
currentUrl = parseHttpUrl(new URL(location, currentUrl).href);
|
|
210
|
+
if (error instanceof Error && error.message.includes('字节上限'))
|
|
211
|
+
throw error;
|
|
212
|
+
throw new Error('读取订阅源内容失败:' + (error instanceof Error ? error.message : String(error)));
|
|
208
213
|
}
|
|
214
|
+
xml = xml.replace(/^\uFEFF/, '');
|
|
215
|
+
return { xml, finalUrl: reportedUrl.href };
|
|
209
216
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
throw error;
|
|
214
|
-
throw new Error('抓取失败:' + (error instanceof Error ? error.message : String(error)) + PROXY_HINT);
|
|
215
|
-
}
|
|
216
|
-
if (response === undefined)
|
|
217
|
-
throw new Error('抓取失败:未收到服务器响应。');
|
|
218
|
-
if (!response.ok) {
|
|
219
|
-
await response.body?.cancel('unsuccessful feed response');
|
|
220
|
-
throw new Error('抓取失败:服务器返回 HTTP ' + response.status + '。');
|
|
221
|
-
}
|
|
222
|
-
const reportedUrl = response.url === '' ? currentUrl : parseHttpUrl(response.url);
|
|
223
|
-
await assertPublicUrl(reportedUrl, cfg, lookupImpl, signal);
|
|
224
|
-
let xml;
|
|
225
|
-
try {
|
|
226
|
-
xml = await readResponseText(response, cfg.maxBodyBytes, signal);
|
|
227
|
-
}
|
|
228
|
-
catch (error) {
|
|
229
|
-
signal.throwIfAborted();
|
|
230
|
-
if (error instanceof Error && error.message.includes('字节上限'))
|
|
231
|
-
throw error;
|
|
232
|
-
throw new Error('读取订阅源内容失败:' + (error instanceof Error ? error.message : String(error)));
|
|
217
|
+
finally {
|
|
218
|
+
await response?.body?.cancel().catch(() => { });
|
|
219
|
+
await transport?.dispose();
|
|
233
220
|
}
|
|
234
|
-
xml = xml.replace(/^\uFEFF/, '');
|
|
235
|
-
return { xml, finalUrl: reportedUrl.href };
|
|
236
221
|
}
|
package/lib/proxy-fetch.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type { FetchLike, DnsLookupLike } from './network.js';
|
|
2
|
+
/** One request/redirect hop owns its dispatcher until its body has been read. */
|
|
3
|
+
export declare function createFeedTransport(url: URL, proxyUrl: string, addresses?: Awaited<ReturnType<DnsLookupLike>>): {
|
|
4
|
+
fetch: FetchLike;
|
|
5
|
+
dispose: () => Promise<void>;
|
|
6
|
+
};
|
|
1
7
|
/**
|
|
2
8
|
* 构造一个把所有请求路由到 proxyUrl 的 fetch。
|
|
3
9
|
* @param proxyUrl - 如 http://127.0.0.1:7890
|
package/lib/proxy-fetch.js
CHANGED
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 插件级 HTTP 代理:给 RSS 抓取请求一个走指定代理的 fetch,不影响同进程其他插件。
|
|
3
3
|
*/
|
|
4
|
-
import { ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
4
|
+
import { Agent, Client, ProxyAgent, fetch as undiciFetch } from 'undici';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
/** One request/redirect hop owns its dispatcher until its body has been read. */
|
|
7
|
+
export function createFeedTransport(url, proxyUrl, addresses) {
|
|
8
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
9
|
+
const servername = isIP(hostname) === 0 ? hostname : undefined;
|
|
10
|
+
const dispatcher = proxyUrl === ''
|
|
11
|
+
? new Agent({ connect: addresses === undefined ? {} : {
|
|
12
|
+
lookup: (name, options, callback) => {
|
|
13
|
+
if (name !== hostname) {
|
|
14
|
+
callback(new Error('Unexpected RSS connection hostname'), '', 4);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const candidates = addresses.filter(entry => !options.family || entry.family === options.family);
|
|
18
|
+
if (candidates.length === 0) {
|
|
19
|
+
callback(new Error('No validated RSS address for this family'), '', 4);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (options.all)
|
|
23
|
+
callback(null, [...candidates]);
|
|
24
|
+
else
|
|
25
|
+
callback(null, candidates[0].address, candidates[0].family);
|
|
26
|
+
},
|
|
27
|
+
} })
|
|
28
|
+
: new ProxyAgent({ uri: proxyUrl, proxyTunnel: true, requestTls: { servername },
|
|
29
|
+
factory: (origin, options) => {
|
|
30
|
+
const delegate = options.connect;
|
|
31
|
+
if (addresses === undefined || typeof delegate !== 'function')
|
|
32
|
+
return new Client(origin, options);
|
|
33
|
+
return new Client(origin, { ...options, connect: (socketOptions, callback) => {
|
|
34
|
+
const address = addresses[0].address;
|
|
35
|
+
const port = socketOptions.port || (url.protocol === 'https:' ? '443' : '80');
|
|
36
|
+
const host = (isIP(address) === 6 ? '[' + address + ']' : address) + ':' + port;
|
|
37
|
+
delegate({ ...socketOptions, hostname: address, host, port, servername }, callback);
|
|
38
|
+
} });
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
fetch: async (input, init) => await undiciFetch(input, { ...init, redirect: 'manual', dispatcher }),
|
|
43
|
+
dispose: async () => { await dispatcher.destroy(); },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
5
46
|
/**
|
|
6
47
|
* 构造一个把所有请求路由到 proxyUrl 的 fetch。
|
|
7
48
|
* @param proxyUrl - 如 http://127.0.0.1:7890
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FetchLike, DnsLookupLike } from './network.js';
|
|
2
|
+
import { type SystemProxy } from './system-proxy.js';
|
|
3
|
+
export declare function lookupPublicDns(hostname: string, signal: AbortSignal, proxyUrl: string, systemProxy?: SystemProxy, fetchImpl?: FetchLike): Promise<Awaited<ReturnType<DnsLookupLike>>>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** HTTPS DNS is used only when the OS returns a VPN's 198.18/15 Fake-IP. */
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { createFeedTransport } from './proxy-fetch.js';
|
|
4
|
+
import { readResponseText } from './response.js';
|
|
5
|
+
import { selectWindowsProxy } from './system-proxy.js';
|
|
6
|
+
const PROVIDERS = ['https://cloudflare-dns.com/dns-query', 'https://dns.google/resolve'];
|
|
7
|
+
export async function lookupPublicDns(hostname, signal, proxyUrl, systemProxy, fetchImpl) {
|
|
8
|
+
let lastError;
|
|
9
|
+
for (const provider of PROVIDERS) {
|
|
10
|
+
try {
|
|
11
|
+
for (const family of [4, 6]) {
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
const url = new URL(provider);
|
|
14
|
+
url.searchParams.set('name', hostname);
|
|
15
|
+
url.searchParams.set('type', family === 4 ? 'A' : 'AAAA');
|
|
16
|
+
const transport = fetchImpl === undefined ? createFeedTransport(url, proxyUrl || selectWindowsProxy(systemProxy, url)) : undefined;
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await (fetchImpl ?? transport.fetch)(url.href, { redirect: 'manual', signal, headers: { accept: 'application/dns-json' } });
|
|
20
|
+
if (!response.ok)
|
|
21
|
+
throw new Error('HTTPS DNS 返回 HTTP ' + response.status);
|
|
22
|
+
const value = JSON.parse(await readResponseText(response, 64 * 1024, signal));
|
|
23
|
+
if (value.Status !== 0 || value.TC === true)
|
|
24
|
+
throw new Error('HTTPS DNS 未返回完整有效的解析结果');
|
|
25
|
+
const records = value.Answer ?? [];
|
|
26
|
+
if (!Array.isArray(records))
|
|
27
|
+
throw new Error('HTTPS DNS 解析结果格式无效');
|
|
28
|
+
const answers = records.filter(record => record.type === (family === 4 ? 1 : 28)).map(record => {
|
|
29
|
+
if (typeof record.data !== 'string' || isIP(record.data) !== family)
|
|
30
|
+
throw new Error('HTTPS DNS 返回无效 IP 地址');
|
|
31
|
+
return { address: record.data, family };
|
|
32
|
+
});
|
|
33
|
+
if (answers.length > 0)
|
|
34
|
+
return answers;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
await response?.body?.cancel().catch(() => { });
|
|
38
|
+
await transport?.dispose();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error('HTTPS DNS 未返回地址');
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
lastError = error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error('无法安全解析订阅源域名 ' + hostname + ' 的真实 IP:' + (lastError instanceof Error ? lastError.message : String(lastError)) + '。VPN 可以保持开启,请检查代理连接。');
|
|
49
|
+
}
|
package/lib/response.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Read a bounded response while retaining the caller's cancellation reason. */
|
|
2
|
+
export async function readResponseText(response, maxBodyBytes, signal) {
|
|
3
|
+
signal.throwIfAborted();
|
|
4
|
+
const declaredLength = Number(response.headers.get('content-length'));
|
|
5
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
|
6
|
+
await response.body?.cancel('response body exceeds maxBodyBytes');
|
|
7
|
+
throw new Error('订阅源内容超过 ' + maxBodyBytes + ' 字节上限,已停止读取。');
|
|
8
|
+
}
|
|
9
|
+
if (response.body === null)
|
|
10
|
+
return '';
|
|
11
|
+
const reader = response.body.getReader();
|
|
12
|
+
const onAbort = () => { void reader.cancel(signal.reason).catch(() => { }); };
|
|
13
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
14
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
15
|
+
let byteLength = 0;
|
|
16
|
+
let text = '';
|
|
17
|
+
try {
|
|
18
|
+
while (true) {
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const chunk = await reader.read();
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
if (chunk.done)
|
|
23
|
+
break;
|
|
24
|
+
byteLength += chunk.value.byteLength;
|
|
25
|
+
if (byteLength > maxBodyBytes) {
|
|
26
|
+
await reader.cancel('response body exceeds maxBodyBytes');
|
|
27
|
+
throw new Error('订阅源内容超过 ' + maxBodyBytes + ' 字节上限,已停止读取。');
|
|
28
|
+
}
|
|
29
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
30
|
+
}
|
|
31
|
+
return text + decoder.decode();
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
signal.removeEventListener('abort', onAbort);
|
|
35
|
+
reader.releaseLock();
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/settings.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare const Config: modern<Schemastery.ObjectS<NoInfer<{
|
|
|
5
5
|
proxyUrl: modern<string, string, "plain">;
|
|
6
6
|
timeoutMs: modern<number, number, "plain">;
|
|
7
7
|
maxBodyBytes: modern<number, number, "plain">;
|
|
8
|
+
useSystemProxy: modern<boolean, boolean, "plain">;
|
|
9
|
+
fakeIpDnsFallback: modern<boolean, boolean, "plain">;
|
|
8
10
|
userAgent: modern<string, string, "plain">;
|
|
9
11
|
allowPrivateNetwork: modern<boolean, boolean, "plain">;
|
|
10
12
|
opmlWriteApproval: modern<boolean, boolean, "plain">;
|
|
@@ -15,6 +17,8 @@ export declare const Config: modern<Schemastery.ObjectS<NoInfer<{
|
|
|
15
17
|
proxyUrl: modern<string, string, "plain">;
|
|
16
18
|
timeoutMs: modern<number, number, "plain">;
|
|
17
19
|
maxBodyBytes: modern<number, number, "plain">;
|
|
20
|
+
useSystemProxy: modern<boolean, boolean, "plain">;
|
|
21
|
+
fakeIpDnsFallback: modern<boolean, boolean, "plain">;
|
|
18
22
|
userAgent: modern<string, string, "plain">;
|
|
19
23
|
allowPrivateNetwork: modern<boolean, boolean, "plain">;
|
|
20
24
|
opmlWriteApproval: modern<boolean, boolean, "plain">;
|
package/lib/settings.js
CHANGED
|
@@ -8,6 +8,7 @@ import modern from '@deepseek-ai/schemastery';
|
|
|
8
8
|
export const Config = modern.object({
|
|
9
9
|
feedsYaml: modern.string().volatile(), cursorsJson: modern.string().volatile(),
|
|
10
10
|
proxyUrl: modern.string(), timeoutMs: modern.number(), maxBodyBytes: modern.number(),
|
|
11
|
+
useSystemProxy: modern.boolean(), fakeIpDnsFallback: modern.boolean(),
|
|
11
12
|
userAgent: modern.string(), allowPrivateNetwork: modern.boolean(), opmlWriteApproval: modern.boolean(),
|
|
12
13
|
legacySettingsImported: modern.boolean().volatile(),
|
|
13
14
|
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface SystemProxy {
|
|
2
|
+
server: string;
|
|
3
|
+
bypass: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseWindowsProxy(output: string): SystemProxy | undefined;
|
|
6
|
+
export declare function selectWindowsProxy(proxy: SystemProxy | undefined, url: URL): string;
|
|
7
|
+
export declare function readWindowsProxy(signal: AbortSignal): Promise<SystemProxy | undefined>;
|