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 +9 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/CHANGELOG.en.md +4 -0
- package/docs/USAGE.en.md +4 -0
- package/docs/USAGE.md +4 -0
- package/lib/opml.js +6 -2
- package/lib/proxy-fetch.js +57 -8
- package/lib/tools.js +20 -7
- package/package.json +2 -2
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
|
-
[](https://www.npmjs.com/package/dsh-rss) [](https://www.npmjs.com/package/dsh-rss) [](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
|
-
[](https://www.npmjs.com/package/dsh-rss) [](https://www.npmjs.com/package/dsh-rss) [](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
|
|
package/docs/CHANGELOG.en.md
CHANGED
|
@@ -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
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]);
|
package/lib/proxy-fetch.js
CHANGED
|
@@ -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 (
|
|
32
|
-
|
|
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]
|
|
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
|
-
|
|
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 () => {
|
|
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 =
|
|
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
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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.
|
|
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.
|
|
58
|
+
"undici": "^8.12.1",
|
|
59
59
|
"yaml": "^2.9.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|