dsh-rss 0.5.0 → 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 +12 -2
- package/README.md +13 -3
- package/cordis.patch.yml +3 -1
- 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 +7 -5
package/README.en.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# dsh-rss
|
|
2
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
|
+
|
|
3
9
|
## 0.5.0 update (2026-10-02)
|
|
4
10
|
|
|
5
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.
|
|
@@ -65,7 +71,9 @@ Override the plugin row in your profile's `cordis.patch.yml` (the plugin also lo
|
|
|
65
71
|
- id: rss
|
|
66
72
|
name: 'dsh-rss'
|
|
67
73
|
config:
|
|
68
|
-
# proxyUrl: http://127.0.0.1:7890 #
|
|
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
|
|
69
77
|
timeoutMs: 15000 # fetch timeout in ms (default 15000)
|
|
70
78
|
# maxBodyBytes: 5242880 # response size cap (default 5MB, guards oversized responses)
|
|
71
79
|
# userAgent: 'dsh-rss/0.2.0' # custom fetch UA
|
|
@@ -106,7 +114,9 @@ On Harness 0.1.7, subscriptions and read cursors are stored in the current profi
|
|
|
106
114
|
|
|
107
115
|
## Proxy
|
|
108
116
|
|
|
109
|
-
|
|
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`.
|
|
110
120
|
|
|
111
121
|
## Parsing capabilities
|
|
112
122
|
|
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
# dsh-rss
|
|
4
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
|
+
|
|
5
11
|
## 0.5.0 更新(2026-10-02)
|
|
6
12
|
|
|
7
13
|
新增 **设置 → RSS 订阅** 面板:添加、编辑、移除、搜索、分类筛选、检查可用性与 OPML 导入导出。面板与对话工具共用原有订阅列表,不需要迁移数据。跟随 DSH 的中文/英文和外观设置。
|
|
@@ -70,7 +76,9 @@ dsh plugin --profile web remove dsh-rss
|
|
|
70
76
|
- id: rss
|
|
71
77
|
name: 'dsh-rss'
|
|
72
78
|
config:
|
|
73
|
-
# 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 下查询并校验真实公网地址
|
|
74
82
|
timeoutMs: 15000 # 抓取超时(毫秒,默认 15000)
|
|
75
83
|
# maxBodyBytes: 5242880 # 订阅源体积上限(默认 5MB,防超大响应)
|
|
76
84
|
# userAgent: 'dsh-rss/0.2.0' # 自定义抓取 UA
|
|
@@ -108,9 +116,11 @@ rss_opml_import { opml: "<?xml version=\"1.0\"?>..." }
|
|
|
108
116
|
|
|
109
117
|
Harness 0.1.7 把订阅列表和读取游标保存在当前 profile 的 `rss` 配置行;增删后立即生效,重启仍保留。默认安装会自动导入旧 `settings.yaml` 或 `settings.yaml.imported` 的 `dsh-rss` 数据一次,保留原文件,且不覆盖已有 profile 值。旧版宿主继续使用原 settings 存储。同名订阅请用 URL 区分。
|
|
110
118
|
|
|
111
|
-
##
|
|
119
|
+
## 代理与 VPN
|
|
112
120
|
|
|
113
|
-
|
|
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`。
|
|
114
124
|
|
|
115
125
|
## 解析能力
|
|
116
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/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>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Read the enabled Windows manual proxy; never modify OS or VPN settings. */
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
export function parseWindowsProxy(output) {
|
|
4
|
+
const enabled = /^\s*ProxyEnable\s+REG_DWORD\s+(\S+)\s*$/mi.exec(output);
|
|
5
|
+
if (enabled === null || Number(enabled[1]) !== 1)
|
|
6
|
+
return undefined;
|
|
7
|
+
const server = /^\s*ProxyServer\s+REG_SZ\s+(.+)$/mi.exec(output)?.[1].trim();
|
|
8
|
+
if (!server)
|
|
9
|
+
return undefined;
|
|
10
|
+
return { server, bypass: /^\s*ProxyOverride\s+REG_SZ\s+(.+)$/mi.exec(output)?.[1].trim() ?? '' };
|
|
11
|
+
}
|
|
12
|
+
export function selectWindowsProxy(proxy, url) {
|
|
13
|
+
if (proxy === undefined)
|
|
14
|
+
return '';
|
|
15
|
+
const hostname = url.hostname.toLowerCase();
|
|
16
|
+
for (const pattern of proxy.bypass.split(';').map(value => value.trim().toLowerCase()).filter(Boolean)) {
|
|
17
|
+
if (pattern === '<local>' && !hostname.includes('.'))
|
|
18
|
+
return '';
|
|
19
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
20
|
+
if (new RegExp('^' + escaped + '$', 'i').test(hostname))
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
const entries = proxy.server.split(';').map(value => value.trim()).filter(Boolean);
|
|
24
|
+
const protocol = url.protocol.slice(0, -1);
|
|
25
|
+
const selected = entries.some(value => value.includes('='))
|
|
26
|
+
? entries.find(value => value.startsWith(protocol + '='))?.slice(protocol.length + 1)
|
|
27
|
+
: entries[0];
|
|
28
|
+
if (!selected)
|
|
29
|
+
return '';
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(selected.includes('://') ? selected : 'http://' + selected);
|
|
32
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
33
|
+
return '';
|
|
34
|
+
return parsed.href;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function readWindowsProxy(signal) {
|
|
41
|
+
signal.throwIfAborted();
|
|
42
|
+
if (process.platform !== 'win32')
|
|
43
|
+
return undefined;
|
|
44
|
+
return await new Promise((resolveProxy, reject) => {
|
|
45
|
+
execFile('reg.exe', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'], { encoding: 'utf8', windowsHide: true, timeout: 1500, maxBuffer: 128 * 1024, signal }, (error, stdout) => {
|
|
46
|
+
if (signal.aborted)
|
|
47
|
+
reject(signal.reason);
|
|
48
|
+
else
|
|
49
|
+
resolveProxy(error === null ? parseWindowsProxy(stdout) : undefined);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rss",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "DSH RSS 插件:订阅源管理、OPML 导入导出、抓取解析 RSS/Atom 并保留正文。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
},
|
|
29
29
|
"dsh": {
|
|
30
30
|
"client": {
|
|
31
|
-
"inject": [
|
|
31
|
+
"inject": [
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
33
|
+
],
|
|
32
34
|
"platform": "web"
|
|
33
35
|
},
|
|
34
36
|
"bundle": {
|
|
@@ -48,11 +50,11 @@
|
|
|
48
50
|
"node": "^22.19.0 || >=24.0.0"
|
|
49
51
|
},
|
|
50
52
|
"dependencies": {
|
|
53
|
+
"@deepseek-ai/schemastery": "^3.18.3",
|
|
51
54
|
"fast-xml-parser": "^5.0.0",
|
|
52
55
|
"schemastery": "^3.18.0",
|
|
53
|
-
"undici": "^8.
|
|
54
|
-
"yaml": "^2.9.0"
|
|
55
|
-
"@deepseek-ai/schemastery": "^3.18.3"
|
|
56
|
+
"undici": "^8.11.2",
|
|
57
|
+
"yaml": "^2.9.0"
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@types/node": "^24.0.0",
|