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/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 { createProxyFetch } from './proxy-fetch.js';
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 = '。若该订阅源需要特殊代理(梯子)才能访问,请在 cordis.patch.yml 里给 dsh-rss 配置 proxyUrl(如 http://127.0.0.1:7890)后重启。';
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
- async function assertPublicUrl(url, cfg, lookupImpl, signal) {
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 blocked = addresses.find((entry) => isBlockedNetworkAddress(entry.address));
128
- if (blocked !== undefined) {
129
- const v4 = ipv4Bytes(blocked.address);
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
- /** 构造默认 fetch:配置了 proxyUrl 时走插件级代理。 */
137
- function makeFetch(cfg) {
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
- return text + decoder.decode();
142
+ addresses = await lookupWithSignal(hostname, fallbackLookup, signal);
143
+ if (addresses.length === 0)
144
+ throw new Error('无法安全解析订阅源域名 ' + hostname + ':HTTPS DNS 未返回地址。');
175
145
  }
176
- finally {
177
- signal.removeEventListener('abort', onAbort);
178
- reader.releaseLock();
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
- for (let redirectCount = 0;; redirectCount++) {
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
- await assertPublicUrl(currentUrl, cfg, lookupImpl, signal);
190
- response = await fetcher(currentUrl.href, {
191
- headers: {
192
- 'user-agent': cfg.userAgent,
193
- accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
194
- },
195
- redirect: 'manual',
196
- signal,
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 (![301, 302, 303, 307, 308].includes(response.status))
200
- break;
201
- const location = response.headers.get('location');
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
- catch (error) {
211
- signal.throwIfAborted();
212
- if (error instanceof Error && (error.message.includes('出于安全原因') || error.message.includes('无法安全解析') || error.message.includes('重定向次数超过')))
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/opml.d.ts CHANGED
@@ -27,6 +27,11 @@ export interface OpmlImportResult {
27
27
  existedCount: number;
28
28
  skippedCount: number;
29
29
  skipped: OpmlSkipped[];
30
+ duplicates: Array<{
31
+ url: string;
32
+ before: Feed;
33
+ after: Feed;
34
+ }>;
30
35
  }
31
36
  /** OPML 文档解析结果。 */
32
37
  export interface OpmlDocument {
@@ -40,4 +45,4 @@ export declare function parseOpml(text: string): OpmlDocument;
40
45
  /** 把订阅列表序列化为 OPML 2.0 文本。 */
41
46
  export declare function buildOpml(feeds: Feed[]): string;
42
47
  /** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
43
- export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument): OpmlImportResult;
48
+ export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument, updateExisting?: boolean): OpmlImportResult;
package/lib/opml.js CHANGED
@@ -6,8 +6,9 @@
6
6
  *
7
7
  * @module dsh-rss/opml
8
8
  */
9
- import { XMLParser } from 'fast-xml-parser';
10
- import { addFeed } from './feeds.js';
9
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
10
+ import { addFeed, sameFeedUrl } from './feeds.js';
11
+ import { assertHttpUrl } from './network.js';
11
12
  const opmlParser = new XMLParser({
12
13
  ignoreAttributes: false,
13
14
  attributeNamePrefix: '@_',
@@ -42,6 +43,9 @@ export function parseOpml(text) {
42
43
  const trimmed = (text ?? '').trim();
43
44
  if (trimmed === '')
44
45
  throw new Error('OPML 内容为空,请提供 OPML XML 文本。');
46
+ const validation = XMLValidator.validate(trimmed);
47
+ if (validation !== true)
48
+ throw new Error('OPML 解析失败:第 ' + validation.err.line + ' 行,' + validation.err.msg);
45
49
  let doc;
46
50
  try {
47
51
  doc = opmlParser.parse(trimmed);
@@ -120,21 +124,36 @@ export function buildOpml(feeds) {
120
124
  }
121
125
  const HTTP_URL = /^https?:\/\//i;
122
126
  /** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
123
- export function importOpmlFeeds(current, document) {
127
+ export function importOpmlFeeds(current, document, updateExisting = true) {
124
128
  const feeds = [...current];
125
129
  const added = [];
126
130
  const skipped = [];
131
+ const duplicates = [];
127
132
  let existedCount = 0;
128
133
  for (const outline of document.outlines) {
129
134
  if (!HTTP_URL.test(outline.url)) {
130
135
  skipped.push({ title: outline.name, url: outline.url, reason: 'URL 不是 http(s) 地址' });
131
136
  continue;
132
137
  }
138
+ try {
139
+ assertHttpUrl(outline.url);
140
+ }
141
+ catch (error) {
142
+ skipped.push({ title: outline.name, url: outline.url, reason: error instanceof Error ? error.message : String(error) });
143
+ continue;
144
+ }
145
+ const previous = feeds.find(feed => sameFeedUrl(feed.url, outline.url));
146
+ if (!updateExisting && previous) {
147
+ existedCount += 1;
148
+ duplicates.push({ url: outline.url, before: previous, after: previous });
149
+ continue;
150
+ }
133
151
  const before = feeds.length;
134
152
  const outcome = addFeed(feeds, outline.url, outline.name, outline.category);
135
153
  feeds.splice(0, feeds.length, ...outcome.feeds);
136
154
  if (outcome.existed) {
137
155
  existedCount += 1;
156
+ duplicates.push({ url: outline.url, before: previous, after: outcome.added });
138
157
  }
139
158
  else if (feeds.length > before) {
140
159
  added.push(outcome.added);
@@ -147,5 +166,6 @@ export function importOpmlFeeds(current, document) {
147
166
  existedCount,
148
167
  skippedCount: skipped.length,
149
168
  skipped,
169
+ duplicates,
150
170
  };
151
171
  }
@@ -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
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** Read a bounded response while retaining the caller's cancellation reason. */
2
+ export declare function readResponseText(response: Response, maxBodyBytes: number, signal: AbortSignal): Promise<string>;
@@ -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,33 @@
1
+ import { type Feed } from './feeds.js';
2
+ import type { RssSettingsScope } from './tools.js';
3
+ import type { ResolvedRssConfig } from './config.js';
4
+ import type { RssToolExecution } from './execution.js';
5
+ import { type FetchLike, type DnsLookupLike } from './network.js';
6
+ export declare class SubscriptionError extends Error {
7
+ readonly status: number;
8
+ readonly code: string;
9
+ constructor(message: string, status?: number, code?: string);
10
+ }
11
+ export declare const feedId: (url: string) => string;
12
+ export declare function subscriptionSnapshot(scope: RssSettingsScope): {
13
+ feeds: Feed[];
14
+ revision: string;
15
+ };
16
+ /** Read inside the queue: concurrent tool/UI writes cannot overwrite one another. */
17
+ export declare function mutateSubscriptions<T>(scope: RssSettingsScope, mutate: (feeds: Feed[]) => {
18
+ feeds: Feed[];
19
+ value: T;
20
+ }, expectedRevision?: string): Promise<T>;
21
+ export interface FeedCheck {
22
+ checkedAt: string;
23
+ ok: boolean;
24
+ title?: string;
25
+ entryCount?: number;
26
+ error?: string;
27
+ }
28
+ export declare function recentFeedChecks(scope: RssSettingsScope): Map<string, FeedCheck>;
29
+ /** Same network boundary, parsing and status for tool calls and manual checks. */
30
+ export declare function readRssFeed(scope: RssSettingsScope, url: string, config: ResolvedRssConfig, exec?: RssToolExecution, fetchImpl?: FetchLike, lookupImpl?: DnsLookupLike): Promise<{
31
+ parsed: import("./parser.js").ParsedFeed;
32
+ finalUrl: string;
33
+ }>;
@@ -0,0 +1,76 @@
1
+ /** Shared subscription storage for tools and the settings panel. */
2
+ import { createHash } from 'node:crypto';
3
+ import { parseFeedsYaml, serializeFeeds } from './feeds.js';
4
+ import { fetchFeedXml } from './network.js';
5
+ import { parseFeed } from './parser.js';
6
+ export class SubscriptionError extends Error {
7
+ status;
8
+ code;
9
+ constructor(message, status = 400, code = 'invalid') {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ }
14
+ }
15
+ const writes = new WeakMap();
16
+ export const feedId = (url) => createHash('sha256').update(url).digest('hex');
17
+ export function subscriptionSnapshot(scope) {
18
+ const value = scope.get();
19
+ const yaml = value && typeof value.feedsYaml === 'string' ? value.feedsYaml : '';
20
+ return { feeds: parseFeedsYaml(yaml), revision: feedId(yaml) };
21
+ }
22
+ /** Read inside the queue: concurrent tool/UI writes cannot overwrite one another. */
23
+ export async function mutateSubscriptions(scope, mutate, expectedRevision) {
24
+ const task = (writes.get(scope) ?? Promise.resolve()).catch(() => { }).then(async () => {
25
+ const snapshot = subscriptionSnapshot(scope);
26
+ if (expectedRevision !== undefined && snapshot.revision !== expectedRevision) {
27
+ throw new SubscriptionError('订阅列表已经变化。请刷新列表后重新预览或保存;你的输入已保留。', 409, 'conflict');
28
+ }
29
+ const result = mutate(snapshot.feeds);
30
+ try {
31
+ await scope.update({ feedsYaml: serializeFeeds(result.feeds) });
32
+ }
33
+ catch (error) {
34
+ throw new SubscriptionError('写入订阅配置失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)), 503, 'save_failed');
35
+ }
36
+ return result.value;
37
+ });
38
+ writes.set(scope, task);
39
+ try {
40
+ return await task;
41
+ }
42
+ finally {
43
+ if (writes.get(scope) === task)
44
+ writes.delete(scope);
45
+ }
46
+ }
47
+ const checks = new WeakMap();
48
+ export function recentFeedChecks(scope) {
49
+ let map = checks.get(scope);
50
+ if (!map) {
51
+ map = new Map();
52
+ checks.set(scope, map);
53
+ }
54
+ return map;
55
+ }
56
+ /** Same network boundary, parsing and status for tool calls and manual checks. */
57
+ export async function readRssFeed(scope, url, config, exec, fetchImpl, lookupImpl) {
58
+ const map = recentFeedChecks(scope);
59
+ const save = (check) => {
60
+ map.delete(url);
61
+ map.set(url, check);
62
+ if (map.size > 256)
63
+ map.delete(map.keys().next().value);
64
+ };
65
+ try {
66
+ const { xml, finalUrl } = await fetchFeedXml(url, config, exec, fetchImpl, lookupImpl);
67
+ const parsed = parseFeed(xml, finalUrl);
68
+ save({ checkedAt: new Date().toISOString(), ok: true, title: parsed.feed.title, entryCount: parsed.entries.length });
69
+ return { parsed, finalUrl };
70
+ }
71
+ catch (error) {
72
+ if (!exec?.signal.aborted)
73
+ save({ checkedAt: new Date().toISOString(), ok: false, error: error instanceof Error ? error.message : String(error) });
74
+ throw error;
75
+ }
76
+ }
@@ -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>;