dsh-rss 0.6.0 → 0.6.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 CHANGED
@@ -1,9 +1,18 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.6.2 (2026-10-15)
4
+
5
+ - 加固直连、HTTP CONNECT 与 TLS 握手的套接字错误隔离,抓取结束会销毁本次请求仍持有的连接,避免错误在请求结束后逃逸。
6
+ - 更新 Undici 至 8.12.1;新增独立进程的直连失败、代理 TLS 失败和超时取消回归检查。
7
+
3
8
  [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
4
9
 
5
10
  [历史英文记录](docs/CHANGELOG.en.md)
6
11
 
12
+ ## 0.6.1 (2026-10-05)
13
+
14
+ - 增量抓取只消费本次返回的条目,剩余条目可继续分页;并发抓取串行合并游标,避免覆盖其他订阅进度。OPML 限制大小与嵌套深度。
15
+
7
16
  ## 0.6.0 (2026-10-04)
8
17
 
9
18
  - 新增推荐订阅目录:16 个网站原生 RSS/Atom,提供五类主题、关键词与内容语言筛选、近期文章预览、可用性检查和「已订阅」状态。浏览目录不会自动订阅或抓取全部来源。
package/README.en.md CHANGED
@@ -6,7 +6,9 @@
6
6
 
7
7
  Subscribe to and read RSS or Atom feeds, and manage updates from DSH.
8
8
 
9
- [![npm](https://img.shields.io/npm/v/dsh-rss)](https://www.npmjs.com/package/dsh-rss) [![downloads](https://img.shields.io/npm/dm/dsh-rss)](https://www.npmjs.com/package/dsh-rss)
9
+ [![npm](https://img.shields.io/npm/v/dsh-rss)](https://www.npmjs.com/package/dsh-rss) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-rss-downloads.svg)](https://www.npmjs.com/package/dsh-rss)
10
+
11
+ Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-rss/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-rss/pulls).
10
12
 
11
13
  ## What it does
12
14
 
package/README.md CHANGED
@@ -6,7 +6,9 @@
6
6
 
7
7
  订阅和阅读 RSS / Atom,在 DSH 中查看与管理更新。
8
8
 
9
- [![npm](https://img.shields.io/npm/v/dsh-rss)](https://www.npmjs.com/package/dsh-rss) [![downloads](https://img.shields.io/npm/dm/dsh-rss)](https://www.npmjs.com/package/dsh-rss)
9
+ [![npm](https://img.shields.io/npm/v/dsh-rss)](https://www.npmjs.com/package/dsh-rss) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-rss-downloads.svg)](https://www.npmjs.com/package/dsh-rss)
10
+
11
+ 欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-rss/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-rss/pulls)。
10
12
 
11
13
  ## 功能
12
14
 
@@ -4,6 +4,10 @@
4
4
 
5
5
  These English notes preserve the earlier translations. The main changelog contains the consolidated version history.
6
6
 
7
+ ## 0.6.1 (2026-10-05)
8
+
9
+ - Consume only returned entries during incremental fetches; remaining entries stay available. Serialize cursor updates to preserve concurrent progress. Bound OPML size and nesting.
10
+
7
11
  ## 0.5.1 (2026-10-02)
8
12
 
9
13
  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.
package/docs/USAGE.en.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
4
 
5
+ ## Current improvements
6
+
7
+ Repeat rss_fetch with incremental=true and a limit to consume remaining new entries; remaining reports the pending count. Each feed retains up to 5000 deduplication IDs rather than a permanent article archive. OPML is limited to 1 MiB and 64 nesting levels.
8
+
5
9
  ## Installation
6
10
 
7
11
  ```bash
package/docs/USAGE.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
4
 
5
+ ## 本次改进
6
+
7
+ rss_fetch { incremental: true, limit: 20 } 可重复调用取得余下新条目;remaining 表示本次剩余数量。每源最多保留 5000 个去重 ID,不是永久文章归档。OPML 文本最多 1 MiB、嵌套最多 64 层。
8
+
5
9
  ## 安装
6
10
 
7
11
  ```bash
package/lib/opml.js CHANGED
@@ -43,6 +43,8 @@ export function parseOpml(text) {
43
43
  const trimmed = (text ?? '').trim();
44
44
  if (trimmed === '')
45
45
  throw new Error('OPML 内容为空,请提供 OPML XML 文本。');
46
+ if (Buffer.byteLength(trimmed, 'utf8') > 1024 * 1024)
47
+ throw new Error('OPML 内容超过 1 MiB,请拆分后导入。');
46
48
  const validation = XMLValidator.validate(trimmed);
47
49
  if (validation !== true)
48
50
  throw new Error('OPML 解析失败:第 ' + validation.err.line + ' 行,' + validation.err.msg);
@@ -60,7 +62,9 @@ export function parseOpml(text) {
60
62
  const head = asRecord(opml.head);
61
63
  const body = asRecord(opml.body);
62
64
  const outlines = [];
63
- const walk = (node, inheritedCategory) => {
65
+ const walk = (node, inheritedCategory, depth = 0) => {
66
+ if (depth > 64)
67
+ throw new Error('OPML 分类嵌套超过 64 层,请简化分类后导入。');
64
68
  const rec = asRecord(node);
65
69
  const xmlUrl = attr(rec, '@_xmlUrl') || attr(rec, '@_xmlurl');
66
70
  const text = attr(rec, '@_text') || attr(rec, '@_title') || firstText(rec);
@@ -77,7 +81,7 @@ export function parseOpml(text) {
77
81
  const children = Array.isArray(rawChildren) ? rawChildren : (rawChildren === undefined || rawChildren === null ? [] : [rawChildren]);
78
82
  const nextCategory = text !== '' ? text : inheritedCategory;
79
83
  for (const child of children)
80
- walk(child, nextCategory);
84
+ walk(child, nextCategory, depth + 1);
81
85
  };
82
86
  const rawBodyOutlines = body.outline;
83
87
  const bodyOutlines = Array.isArray(rawBodyOutlines) ? rawBodyOutlines : (rawBodyOutlines === undefined || rawBodyOutlines === null ? [] : [rawBodyOutlines]);
@@ -1,14 +1,39 @@
1
1
  /**
2
2
  * 插件级 HTTP 代理:给 RSS 抓取请求一个走指定代理的 fetch,不影响同进程其他插件。
3
3
  */
4
- import { Agent, Client, ProxyAgent, fetch as undiciFetch } from 'undici';
4
+ import { Agent, Client, ProxyAgent, buildConnector, fetch as undiciFetch } from 'undici';
5
5
  import { isIP } from 'node:net';
6
6
  /** One request/redirect hop owns its dispatcher until its body has been read. */
7
7
  export function createFeedTransport(url, proxyUrl, addresses) {
8
8
  const hostname = url.hostname.replace(/^\[|\]$/g, '');
9
9
  const servername = isIP(hostname) === 0 ? hostname : undefined;
10
+ const sockets = new Set();
11
+ const onLateError = () => { };
12
+ function watch(socket) {
13
+ // CONNECT detaches the HTTP client's listeners before handing over its socket.
14
+ // Reattach after handover as well as immediately after socket construction.
15
+ if (!socket.listeners('error').includes(onLateError))
16
+ socket.on('error', onLateError);
17
+ if (!sockets.has(socket)) {
18
+ sockets.add(socket);
19
+ socket.once('close', () => sockets.delete(socket));
20
+ }
21
+ return socket;
22
+ }
23
+ function guarded(connect) {
24
+ return (options, callback) => {
25
+ const socket = connect(options, (error, connected) => {
26
+ if (error !== null)
27
+ callback(error, null);
28
+ else
29
+ callback(null, watch(connected));
30
+ });
31
+ if (socket !== undefined)
32
+ watch(socket);
33
+ };
34
+ }
10
35
  const dispatcher = proxyUrl === ''
11
- ? new Agent({ connect: addresses === undefined ? {} : {
36
+ ? new Agent({ connect: guarded(buildConnector(addresses === undefined ? {} : {
12
37
  lookup: (name, options, callback) => {
13
38
  if (name !== hostname) {
14
39
  callback(new Error('Unexpected RSS connection hostname'), '', 4);
@@ -24,23 +49,47 @@ export function createFeedTransport(url, proxyUrl, addresses) {
24
49
  else
25
50
  callback(null, candidates[0].address, candidates[0].family);
26
51
  },
27
- } })
52
+ })) })
28
53
  : new ProxyAgent({ uri: proxyUrl, proxyTunnel: true, requestTls: { servername },
54
+ clientFactory: (origin, rawOptions) => {
55
+ const options = rawOptions;
56
+ const connect = typeof options.connect === 'function' ? options.connect : buildConnector(options.connect ?? {});
57
+ return new Client(origin, { ...options, connect: guarded(connect) });
58
+ },
29
59
  factory: (origin, options) => {
30
60
  const delegate = options.connect;
31
- if (addresses === undefined || typeof delegate !== 'function')
32
- return new Client(origin, options);
61
+ if (typeof delegate !== 'function')
62
+ throw new Error('RSS proxy did not supply a tunnel connector');
63
+ const endpoint = guarded(buildConnector({ servername }));
33
64
  return new Client(origin, { ...options, connect: (socketOptions, callback) => {
34
- const address = addresses[0].address;
65
+ const address = addresses?.[0]?.address ?? hostname;
35
66
  const port = socketOptions.port || (url.protocol === 'https:' ? '443' : '80');
36
67
  const host = (isIP(address) === 6 ? '[' + address + ']' : address) + ':' + port;
37
- delegate({ ...socketOptions, hostname: address, host, port, servername }, callback);
68
+ // Ask ProxyAgent for a raw CONNECT tunnel, then own the TLS socket too.
69
+ // A callback around the final TLS socket alone misses handshake errors.
70
+ delegate({ ...socketOptions, protocol: 'http:', hostname: address, host, port, servername }, (error, tunnel) => {
71
+ if (error !== null) {
72
+ callback(error, null);
73
+ return;
74
+ }
75
+ watch(tunnel);
76
+ if (url.protocol !== 'https:') {
77
+ callback(null, tunnel);
78
+ return;
79
+ }
80
+ endpoint({ ...socketOptions, hostname: address, host, port, servername, httpSocket: tunnel }, callback);
81
+ });
38
82
  } });
39
83
  },
40
84
  });
41
85
  return {
42
86
  fetch: async (input, init) => await undiciFetch(input, { ...init, redirect: 'manual', dispatcher }),
43
- dispose: async () => { await dispatcher.destroy(); },
87
+ dispose: async () => {
88
+ for (const socket of sockets)
89
+ socket.destroy();
90
+ await dispatcher.destroy();
91
+ sockets.clear();
92
+ },
44
93
  };
45
94
  }
46
95
  /**
package/lib/tools.js CHANGED
@@ -249,7 +249,8 @@ function renderOpmlImport(_args, value) {
249
249
  }
250
250
  return [{ type: 'text', text: lines.join('\n') }];
251
251
  }
252
- const CURSOR_GUID_LIMIT = 200;
252
+ const CURSOR_GUID_LIMIT = 5000;
253
+ const cursorWrites = new WeakMap();
253
254
  /** 条目唯一 id:优先 guid,退回 link,再退回 标题|发布时间。 */
254
255
  function entryId(entry) {
255
256
  const rec = asRecord(entry);
@@ -519,12 +520,24 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
519
520
  const entries = parsed.entries.slice(0, limit);
520
521
  return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
521
522
  }
522
- const cursors = parseCursors(readCursorsJson());
523
- const fresh = filterNewEntries(cursors[url], parsed.entries);
524
- cursors[url] = advanceCursor(cursors[url], parsed.entries, new Date().toISOString());
525
- await persistCursors(cursors);
526
- const entries = fresh.slice(0, limit);
527
- return { url: finalUrl, feed: parsed.feed, entries, truncated: fresh.length > limit, incremental: true, newCount: fresh.length };
523
+ const requestedUrl = url;
524
+ const task = (cursorWrites.get(settingsScope) ?? Promise.resolve()).catch(() => { }).then(async () => {
525
+ exec?.signal.throwIfAborted();
526
+ const cursors = parseCursors(readCursorsJson());
527
+ const fresh = filterNewEntries(cursors[requestedUrl], parsed.entries);
528
+ const entries = fresh.slice(0, limit);
529
+ cursors[requestedUrl] = advanceCursor(cursors[requestedUrl], entries, new Date().toISOString());
530
+ await persistCursors(cursors);
531
+ return { url: finalUrl, feed: parsed.feed, entries, truncated: fresh.length > limit, incremental: true, newCount: fresh.length, remaining: fresh.length - entries.length };
532
+ });
533
+ cursorWrites.set(settingsScope, task);
534
+ try {
535
+ return await task;
536
+ }
537
+ finally {
538
+ if (cursorWrites.get(settingsScope) === task)
539
+ cursorWrites.delete(settingsScope);
540
+ }
528
541
  },
529
542
  timeoutMs: cfg.timeoutMs,
530
543
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rss",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "订阅和阅读 RSS / Atom,在 DSH 中查看与管理更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -55,7 +55,7 @@
55
55
  "@deepseek-ai/schemastery": "^3.18.3",
56
56
  "fast-xml-parser": "^5.0.0",
57
57
  "schemastery": "^3.18.0",
58
- "undici": "^8.11.2",
58
+ "undici": "^8.12.1",
59
59
  "yaml": "^2.9.0"
60
60
  },
61
61
  "devDependencies": {