dsh-rss 0.1.0 → 0.3.0

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 ADDED
@@ -0,0 +1,94 @@
1
+ # dsh-rss
2
+
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
+
5
+ DSH (DeepSeek Harness) plugin for RSS/Atom subscriptions: manage feeds, fetch and parse RSS 0.9x / 1.0 / 2.0 and Atom, with OPML bulk import/export, exposing seven model-facing tools.
6
+
7
+ ## Compatibility
8
+
9
+ Verified against `@deepseek-ai/dsh@0.1.1-rc.2` on 2026-08-26. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ dsh plugin --profile web add dsh-rss
15
+ ```
16
+
17
+ Restart the web service after installing.
18
+
19
+ ## Uninstall
20
+
21
+ ```bash
22
+ dsh plugin --profile web remove dsh-rss
23
+ ```
24
+
25
+ Then restart the web service. To clean up fully, also remove the plugin entry from your profile `cordis.patch.yml` if you overrode it.
26
+
27
+
28
+ ## Configuration
29
+
30
+ Override the plugin row in your profile's `cordis.patch.yml` (the plugin also loads with all defaults when absent):
31
+
32
+ ```yaml
33
+ - id: rss
34
+ name: 'dsh-rss'
35
+ config:
36
+ # proxyUrl: http://127.0.0.1:7890 # enable when a feed needs a special proxy
37
+ timeoutMs: 15000 # fetch timeout in ms (default 15000)
38
+ # maxBodyBytes: 5242880 # response size cap (default 5MB, guards oversized responses)
39
+ # userAgent: 'dsh-rss/0.2.0' # custom fetch UA
40
+ # feedsYaml: | # optional: pre-seed subscriptions (or use the rss_add tool)
41
+ # - url: https://example.com/feed.xml
42
+ # name: My feed
43
+ # category: tech
44
+ ```
45
+
46
+ ## Tools
47
+
48
+ | Tool | Purpose | Key parameters |
49
+ | :-- | :-- | :-- |
50
+ | `rss_list` | List subscribed feeds | none |
51
+ | `rss_add` | Add a subscription (fetches and validates the URL first) | `url` required; `name`/`category` optional |
52
+ | `rss_remove` | Remove a subscription | `url` or `name`, at least one |
53
+ | `rss_fetch` | Fetch and parse a feed, returning feed info and entries (with full `content`) | `url` or `name`, at least one; `limit` 1-100, default 20 |
54
+ | `rss_check` | Validate that a URL is a parseable feed | `url` required |
55
+ | `rss_opml_export` | Export subscriptions as OPML 2.0 text (optionally write a file) | `path` optional |
56
+ | `rss_opml_import` | Bulk-import subscriptions from OPML 2.0 text | `opml` required |
57
+
58
+ ### Examples
59
+
60
+ ```text
61
+ rss_add { url: https://example.com/feed.xml, name: my-feed }
62
+ rss_fetch { name: my-feed, limit: 10 }
63
+ rss_check { url: https://example.com/feed.xml }
64
+ rss_opml_export { path: subscriptions.opml }
65
+ rss_opml_import { opml: "<?xml version=\"1.0\"?>..." }
66
+ ```
67
+
68
+ ## Subscriptions
69
+
70
+ Subscriptions live in the settings namespace `dsh-rss` (the `feedsYaml` field): `rss_add` / `rss_remove` read and write it automatically and changes persist across restarts. You can also pre-seed subscriptions via the `feedsYaml` config field. Use `url` to distinguish feeds that share a name.
71
+
72
+ ## Proxy
73
+
74
+ Most feeds are reachable directly; a few require a special proxy from your network. When you hit a `fetch failed` error suggesting a proxy, set `proxyUrl` to your local proxy address (e.g. `http://127.0.0.1:7890`) and restart. The proxy only routes this plugin's fetch requests and does not affect other plugins in the same process.
75
+
76
+ ## Parsing capabilities
77
+
78
+ - RSS 2.0 / RSS 1.0 (RDF) / Atom, normalized to one output shape
79
+ - Entity decoding, CDATA, `content:encoded`, `dc:creator` and other common fields
80
+ - RFC 822 / ISO 8601 dates normalized to ISO 8601 UTC (`pubDate`); the raw text stays in `pubDateRaw`
81
+ - Relative links resolved against the feed URL
82
+ - Summaries stripped of HTML tags and truncated at 500 chars; RSS `content:encoded` / Atom `content` is preserved as a `content` field (tags stripped, up to 20000 chars)
83
+ - Safety: no DTD / external entity parsing; 5MB body cap; configurable fetch timeout
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ pnpm install
89
+ pnpm test # builds + 55 tests
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
package/README.md CHANGED
@@ -1,6 +1,19 @@
1
+ [English](README.en.md)
2
+
1
3
  # dsh-rss
2
4
 
3
- DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓取并解析 RSS 0.9x / 1.0 / 2.0 与 Atom,给模型提供五个可直接调用的工具。
5
+ > **agent 的资讯雷达**:订阅管理 + RSS/Atom 抓取解析。
6
+
7
+ ![npm version](https://img.shields.io/npm/v/dsh-rss?label=npm&color=blue) ![npm downloads](https://img.shields.io/npm/dm/dsh-rss) ![license](https://img.shields.io/npm/l/dsh-rss) ![stars](https://img.shields.io/github/stars/STARDUSTLC666/dsh-rss?style=social)
8
+
9
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
10
+
11
+
12
+ DSH(DeepSeek Harness)的 RSS/Atom 订阅工具插件:管理订阅源,抓取并解析 RSS 0.9x / 1.0 / 2.0 与 Atom,支持 OPML 批量导入导出,给模型提供九个可直接调用的工具。
13
+
14
+ ## 兼容性
15
+
16
+ 在 `@deepseek-ai/dsh@0.1.1-rc.2` 上验证(2026-08-26)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
4
17
 
5
18
  ## 安装
6
19
 
@@ -10,6 +23,15 @@ dsh plugin --profile web add dsh-rss
10
23
 
11
24
  安装后重启 Web 服务即可。
12
25
 
26
+ ## 卸载
27
+
28
+ ```bash
29
+ dsh plugin --profile web remove dsh-rss
30
+ ```
31
+
32
+ 卸载后重启 Web 服务。如需彻底清理,可再手动删除自己 profile `cordis.patch.yml` 中覆盖的插件行。
33
+
34
+
13
35
  ## 配置
14
36
 
15
37
  在你自己的 profile 的 `cordis.patch.yml` 里覆盖本插件行(缺省时插件也能加载,只是全部用默认值):
@@ -21,7 +43,7 @@ dsh plugin --profile web add dsh-rss
21
43
  # proxyUrl: http://127.0.0.1:7890 # 部分订阅源需要特殊代理(梯子)才能访问时启用
22
44
  timeoutMs: 15000 # 抓取超时(毫秒,默认 15000)
23
45
  # maxBodyBytes: 5242880 # 订阅源体积上限(默认 5MB,防超大响应)
24
- # userAgent: 'dsh-rss/0.1.0' # 自定义抓取 UA
46
+ # userAgent: 'dsh-rss/0.2.0' # 自定义抓取 UA
25
47
  # feedsYaml: | # 可选:预置订阅列表(也可用 rss_add 工具添加)
26
48
  # - url: https://example.com/feed.xml
27
49
  # name: 示例订阅
@@ -35,8 +57,12 @@ dsh plugin --profile web add dsh-rss
35
57
  | `rss_list` | 列出已订阅源 | 无 |
36
58
  | `rss_add` | 添加订阅(先抓取校验地址) | `url` 必填;`name`/`category` 可选 |
37
59
  | `rss_remove` | 删除订阅 | `url` 或 `name` 至少一个 |
38
- | `rss_fetch` | 抓取解析订阅源,返回源信息与条目 | `url` 或 `name` 至少一个;`limit` 1-100 默认 20 |
60
+ | `rss_fetch` | 抓取解析订阅源,返回源信息与条目(含正文 content) | `url` 或 `name` 至少一个;`limit` 1-100 默认 20 |
39
61
  | `rss_check` | 校验地址是否为可解析的订阅源 | `url` 必填 |
62
+ | `rss_opml_export` | 导出订阅列表为 OPML 2.0 文本(可写入文件) | `path` 可选 |
63
+ | `rss_opml_import` | 从 OPML 2.0 文本批量导入订阅源 | `opml` 必填 |
64
+ | `rss_search` | 跨订阅源关键词搜索条目(标题/摘要/正文/作者) | `query` 必填;`name`/`url`/`since`/`limit` 可选 |
65
+ | `rss_health` | 插件自检(配置/订阅/游标/解析器,离线) | 无 |
40
66
 
41
67
  ### 示例
42
68
 
@@ -44,6 +70,8 @@ dsh plugin --profile web add dsh-rss
44
70
  rss_add { url: https://example.com/feed.xml, name: 我的订阅 }
45
71
  rss_fetch { name: 我的订阅, limit: 10 }
46
72
  rss_check { url: https://example.com/feed.xml }
73
+ rss_opml_export { path: subscriptions.opml }
74
+ rss_opml_import { opml: "<?xml version=\"1.0\"?>..." }
47
75
  ```
48
76
 
49
77
  ## 订阅存储
@@ -60,18 +88,18 @@ rss_check { url: https://example.com/feed.xml }
60
88
  - 实体解码、CDATA、`content:encoded`、`dc:creator`、`itunes` 等常见字段
61
89
  - RFC 822 / ISO 8601 日期统一转 ISO 8601 UTC(`pubDate`),原始文本保留在 `pubDateRaw`
62
90
  - 相对链接按订阅源地址解析成绝对链接
63
- - 摘要去 HTML 标签并截断到 500 字符;正文全文读取在后续版本提供
91
+ - 摘要去 HTML 标签并截断到 500 字符;RSS `content:encoded` / Atom `content` 作为 `content` 字段保留(去标签后最多 20000 字符)
64
92
  - 安全:不解析 DTD/外部实体,内容只做文本抽取;响应体积上限 5MB;抓取超时可配
65
93
 
66
94
  ## 开发
67
95
 
68
96
  ```bash
69
97
  pnpm install
70
- pnpm test # 构建 + 47 个测试
98
+ pnpm test # 构建 + 63 个测试
71
99
  ```
72
100
 
73
101
  发布前门禁:危险模式扫描、manifest 自检、`pnpm audit --prod`、全量测试,以及全新 profile 的真实启动冒烟测试。
74
102
 
75
103
  ## License
76
104
 
77
- MIT
105
+ MIT
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.1.0 (DeepSeek Harness RSS plugin)';
9
+ const DEFAULT_USER_AGENT = 'dsh-rss/0.2.0 (DeepSeek Harness RSS plugin)';
10
10
  function optionalString(value) {
11
11
  return typeof value === 'string' ? value : undefined;
12
12
  }
package/lib/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * dsh-rss —— RSS/Atom 订阅工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):把五个面向模型的工具(rss_list / rss_add / rss_remove /
5
- * rss_fetch / rss_check)注册进宿主进程的工具注册表,并把订阅列表接入 settings
4
+ * 插件导出 apply(ctx, config):把七个面向模型的工具(rss_list / rss_add / rss_remove /
5
+ * rss_fetch / rss_check / rss_opml_export / rss_opml_import)注册进宿主进程的工具注册表,并把订阅列表接入 settings
6
6
  * 命名空间(dsh-rss.feedsYaml)。配置缺失时插件照常加载,工具在 execute 时才抛出
7
7
  * 带中文指引的错误。
8
8
  *
@@ -11,6 +11,7 @@
11
11
  import { type RssConfig } from './config.js';
12
12
  import { type RssSettingsScope, type RssToolDefinition } from './tools.js';
13
13
  /** cordis 服务注入:apply 里要用 ctx.settings 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
14
+ export declare const name = "rss";
14
15
  export declare const inject: string[];
15
16
  /** 插件所需的最小 ctx 面(社区插件不依赖宿主内部类型)。 */
16
17
  export interface RssPluginContext {
@@ -33,6 +34,7 @@ export interface RssPluginContext {
33
34
  export declare function apply(ctx: RssPluginContext, config?: RssConfig | null): void;
34
35
  export * from './config.js';
35
36
  export * from './feeds.js';
37
+ export * from './opml.js';
36
38
  export * from './parser.js';
37
39
  export * from './proxy-fetch.js';
38
40
  export * from './settings.js';
package/lib/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * dsh-rss —— RSS/Atom 订阅工具插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
- * 插件导出 apply(ctx, config):把五个面向模型的工具(rss_list / rss_add / rss_remove /
5
- * rss_fetch / rss_check)注册进宿主进程的工具注册表,并把订阅列表接入 settings
4
+ * 插件导出 apply(ctx, config):把七个面向模型的工具(rss_list / rss_add / rss_remove /
5
+ * rss_fetch / rss_check / rss_opml_export / rss_opml_import)注册进宿主进程的工具注册表,并把订阅列表接入 settings
6
6
  * 命名空间(dsh-rss.feedsYaml)。配置缺失时插件照常加载,工具在 execute 时才抛出
7
7
  * 带中文指引的错误。
8
8
  *
@@ -12,6 +12,7 @@ import { resolveConfig } from './config.js';
12
12
  import { RSS_SETTINGS_NAMESPACE, RssSettingsSchema } from './settings.js';
13
13
  import { buildRssTools } from './tools.js';
14
14
  /** cordis 服务注入:apply 里要用 ctx.settings 与 ctx.tools,必须显式声明,否则宿主会抛 cannot get property without inject。 */
15
+ export const name = 'rss';
15
16
  export const inject = ['settings', 'tools'];
16
17
  /**
17
18
  * 插件入口:解析配置、注册 settings 命名空间与五个 RSS 工具。
@@ -44,6 +45,7 @@ export function apply(ctx, config) {
44
45
  }
45
46
  export * from './config.js';
46
47
  export * from './feeds.js';
48
+ export * from './opml.js';
47
49
  export * from './parser.js';
48
50
  export * from './proxy-fetch.js';
49
51
  export * from './settings.js';
package/lib/opml.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * OPML 导入 / 导出:把 dsh-rss 的订阅列表与常见 RSS 阅读器的 OPML 2.0 互转。
3
+ *
4
+ * 导入只读取 outline 树中的 xmlUrl / title / text / category 属性,忽略脚本与
5
+ * 未知扩展;URL 必须 http(s),非法项跳过并报告原因,不中断整个导入。
6
+ *
7
+ * @module dsh-rss/opml
8
+ */
9
+ import { type Feed } from './feeds.js';
10
+ /** OPML 解析出的一个订阅。 */
11
+ export interface OpmlOutline {
12
+ name: string;
13
+ url: string;
14
+ category: string;
15
+ }
16
+ /** 导入时被跳过的项。 */
17
+ export interface OpmlSkipped {
18
+ title: string;
19
+ url: string;
20
+ reason: string;
21
+ }
22
+ /** OPML 导入结果。 */
23
+ export interface OpmlImportResult {
24
+ feeds: Feed[];
25
+ added: Feed[];
26
+ addedCount: number;
27
+ existedCount: number;
28
+ skippedCount: number;
29
+ skipped: OpmlSkipped[];
30
+ }
31
+ /** OPML 文档解析结果。 */
32
+ export interface OpmlDocument {
33
+ title: string;
34
+ outlines: OpmlOutline[];
35
+ }
36
+ /**
37
+ * 解析 OPML 2.0 文本。没有 xmlUrl 的 outline 当作分类节点,其 text 会传给后代。
38
+ */
39
+ export declare function parseOpml(text: string): OpmlDocument;
40
+ /** 把订阅列表序列化为 OPML 2.0 文本。 */
41
+ export declare function buildOpml(feeds: Feed[]): string;
42
+ /** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
43
+ export declare function importOpmlFeeds(current: Feed[], document: OpmlDocument): OpmlImportResult;
package/lib/opml.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * OPML 导入 / 导出:把 dsh-rss 的订阅列表与常见 RSS 阅读器的 OPML 2.0 互转。
3
+ *
4
+ * 导入只读取 outline 树中的 xmlUrl / title / text / category 属性,忽略脚本与
5
+ * 未知扩展;URL 必须 http(s),非法项跳过并报告原因,不中断整个导入。
6
+ *
7
+ * @module dsh-rss/opml
8
+ */
9
+ import { XMLParser } from 'fast-xml-parser';
10
+ import { addFeed } from './feeds.js';
11
+ const opmlParser = new XMLParser({
12
+ ignoreAttributes: false,
13
+ attributeNamePrefix: '@_',
14
+ processEntities: true,
15
+ trimValues: true,
16
+ parseTagValue: false,
17
+ isArray: (name) => name === 'outline',
18
+ });
19
+ function asRecord(value) {
20
+ return typeof value === 'object' && value !== null ? value : {};
21
+ }
22
+ function attr(rec, key) {
23
+ const value = rec[key];
24
+ return typeof value === 'string' ? value.trim() : '';
25
+ }
26
+ function firstText(value) {
27
+ if (typeof value === 'string')
28
+ return value.trim();
29
+ if (Array.isArray(value))
30
+ return firstText(value[0]);
31
+ if (typeof value === 'object' && value !== null) {
32
+ const text = value['#text'];
33
+ if (typeof text === 'string')
34
+ return text.trim();
35
+ }
36
+ return '';
37
+ }
38
+ /**
39
+ * 解析 OPML 2.0 文本。没有 xmlUrl 的 outline 当作分类节点,其 text 会传给后代。
40
+ */
41
+ export function parseOpml(text) {
42
+ const trimmed = (text ?? '').trim();
43
+ if (trimmed === '')
44
+ throw new Error('OPML 内容为空,请提供 OPML XML 文本。');
45
+ let doc;
46
+ try {
47
+ doc = opmlParser.parse(trimmed);
48
+ }
49
+ catch (error) {
50
+ throw new Error('OPML 解析失败:' + (error instanceof Error ? error.message : String(error)));
51
+ }
52
+ const root = asRecord(doc);
53
+ const opml = asRecord(root.opml);
54
+ if (Object.keys(opml).length === 0)
55
+ throw new Error('内容不是 OPML 文档(缺少 <opml> 根元素)。');
56
+ const head = asRecord(opml.head);
57
+ const body = asRecord(opml.body);
58
+ const outlines = [];
59
+ const walk = (node, inheritedCategory) => {
60
+ const rec = asRecord(node);
61
+ const xmlUrl = attr(rec, '@_xmlUrl') || attr(rec, '@_xmlurl');
62
+ const text = attr(rec, '@_text') || attr(rec, '@_title') || firstText(rec);
63
+ const category = attr(rec, '@_category') || inheritedCategory;
64
+ if (xmlUrl !== '') {
65
+ outlines.push({
66
+ name: attr(rec, '@_title') || text || xmlUrl,
67
+ url: xmlUrl,
68
+ category,
69
+ });
70
+ return;
71
+ }
72
+ const rawChildren = rec.outline;
73
+ const children = Array.isArray(rawChildren) ? rawChildren : (rawChildren === undefined || rawChildren === null ? [] : [rawChildren]);
74
+ const nextCategory = text !== '' ? text : inheritedCategory;
75
+ for (const child of children)
76
+ walk(child, nextCategory);
77
+ };
78
+ const rawBodyOutlines = body.outline;
79
+ const bodyOutlines = Array.isArray(rawBodyOutlines) ? rawBodyOutlines : (rawBodyOutlines === undefined || rawBodyOutlines === null ? [] : [rawBodyOutlines]);
80
+ for (const child of bodyOutlines)
81
+ walk(child, '');
82
+ return { title: firstText(head.title), outlines };
83
+ }
84
+ function escapeXml(value) {
85
+ return value
86
+ .replace(/&/g, '&amp;')
87
+ .replace(/</g, '&lt;')
88
+ .replace(/>/g, '&gt;')
89
+ .replace(/"/g, '&quot;')
90
+ .replace(/'/g, '&apos;');
91
+ }
92
+ /** 把订阅列表序列化为 OPML 2.0 文本。 */
93
+ export function buildOpml(feeds) {
94
+ const now = new Date().toISOString();
95
+ const rows = feeds.map((feed) => {
96
+ const text = feed.name !== '' ? feed.name : feed.url;
97
+ const attrs = [
98
+ 'text="' + escapeXml(text) + '"',
99
+ 'title="' + escapeXml(feed.name) + '"',
100
+ 'type="rss"',
101
+ 'xmlUrl="' + escapeXml(feed.url) + '"',
102
+ ];
103
+ if (feed.category !== '')
104
+ attrs.push('category="' + escapeXml(feed.category) + '"');
105
+ return ' <outline ' + attrs.join(' ') + ' />';
106
+ });
107
+ return [
108
+ '<?xml version="1.0" encoding="UTF-8"?>',
109
+ '<opml version="2.0">',
110
+ ' <head>',
111
+ ' <title>dsh-rss subscriptions</title>',
112
+ ' <dateCreated>' + now + '</dateCreated>',
113
+ ' </head>',
114
+ ' <body>',
115
+ ...rows,
116
+ ' </body>',
117
+ '</opml>',
118
+ '',
119
+ ].join('\n');
120
+ }
121
+ const HTTP_URL = /^https?:\/\//i;
122
+ /** 把 OPML 导入合并进现有订阅(按 url 去重),非法项跳过。 */
123
+ export function importOpmlFeeds(current, document) {
124
+ const feeds = [...current];
125
+ const added = [];
126
+ const skipped = [];
127
+ let existedCount = 0;
128
+ for (const outline of document.outlines) {
129
+ if (!HTTP_URL.test(outline.url)) {
130
+ skipped.push({ title: outline.name, url: outline.url, reason: 'URL 不是 http(s) 地址' });
131
+ continue;
132
+ }
133
+ const before = feeds.length;
134
+ const outcome = addFeed(feeds, outline.url, outline.name, outline.category);
135
+ feeds.splice(0, feeds.length, ...outcome.feeds);
136
+ if (outcome.existed) {
137
+ existedCount += 1;
138
+ }
139
+ else if (feeds.length > before) {
140
+ added.push(outcome.added);
141
+ }
142
+ }
143
+ return {
144
+ feeds,
145
+ added,
146
+ addedCount: added.length,
147
+ existedCount,
148
+ skippedCount: skipped.length,
149
+ skipped,
150
+ };
151
+ }
package/lib/parser.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface RssEntry {
14
14
  author: string;
15
15
  summary: string;
16
16
  categories: string[];
17
+ /** RSS content:encoded / Atom content 的正文(去标签,最多 20000 字符);缺失时省略。 */
18
+ content?: string;
17
19
  enclosure?: RssEnclosure;
18
20
  }
19
21
  /** 订阅源元信息。 */
package/lib/parser.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { XMLParser } from 'fast-xml-parser';
8
8
  const SUMMARY_MAX_CHARS = 500;
9
+ const CONTENT_MAX_CHARS = 20000;
9
10
  const xmlParser = new XMLParser({
10
11
  ignoreAttributes: false,
11
12
  attributeNamePrefix: '@_',
@@ -146,6 +147,7 @@ function normalizeRssItem(item, baseUrl) {
146
147
  link = resolveUrl(link, baseUrl);
147
148
  const pubDateRaw = firstText(item.pubDate) || firstText(item.date) || firstText(item.updated);
148
149
  const summaryRaw = firstText(item.description) || firstText(item.encoded) || firstText(item.summary);
150
+ const contentRaw = firstText(item.encoded) || firstText(item.content);
149
151
  const enclosure = pickRssEnclosure(item);
150
152
  const entry = {
151
153
  title,
@@ -157,6 +159,9 @@ function normalizeRssItem(item, baseUrl) {
157
159
  summary: truncate(stripHtml(summaryRaw), SUMMARY_MAX_CHARS),
158
160
  categories: pickCategories(item.category),
159
161
  };
162
+ const content = truncate(stripHtml(contentRaw), CONTENT_MAX_CHARS);
163
+ if (content !== '')
164
+ entry.content = content;
160
165
  if (enclosure !== undefined)
161
166
  entry.enclosure = enclosure;
162
167
  return entry;
@@ -170,6 +175,7 @@ function normalizeAtomEntry(entry, baseUrl) {
170
175
  link = resolveUrl(link, baseUrl);
171
176
  const pubDateRaw = firstText(entry.published) || firstText(entry.updated) || firstText(entry.issued);
172
177
  const summaryRaw = firstText(entry.summary) || firstText(entry.content);
178
+ const contentRaw = firstText(entry.content);
173
179
  let enclosure;
174
180
  const links = Array.isArray(entry.link) ? entry.link : (entry.link === undefined || entry.link === null ? [] : [entry.link]);
175
181
  for (const raw of links) {
@@ -195,6 +201,9 @@ function normalizeAtomEntry(entry, baseUrl) {
195
201
  summary: truncate(stripHtml(summaryRaw), SUMMARY_MAX_CHARS),
196
202
  categories: pickCategories(entry.category),
197
203
  };
204
+ const content = truncate(stripHtml(contentRaw), CONTENT_MAX_CHARS);
205
+ if (content !== '')
206
+ normalized.content = content;
198
207
  if (enclosure !== undefined)
199
208
  normalized.enclosure = enclosure;
200
209
  return normalized;
package/lib/settings.d.ts CHANGED
@@ -9,10 +9,13 @@ export declare const RSS_SETTINGS_NAMESPACE = "dsh-rss";
9
9
  /** settings 页形状:目前只有订阅列表(YAML 文本)。 */
10
10
  export declare const RssSettingsSchema: z<Schemastery.ObjectS<{
11
11
  feedsYaml: z<string, string>;
12
+ cursorsJson: z<string, string>;
12
13
  }>, Schemastery.ObjectT<{
13
14
  feedsYaml: z<string, string>;
15
+ cursorsJson: z<string, string>;
14
16
  }>>;
15
17
  /** settings 解析后的值。 */
16
18
  export interface RssSettingsValue {
17
19
  feedsYaml: string;
20
+ cursorsJson: string;
18
21
  }
package/lib/settings.js CHANGED
@@ -9,4 +9,5 @@ export const RSS_SETTINGS_NAMESPACE = 'dsh-rss';
9
9
  /** settings 页形状:目前只有订阅列表(YAML 文本)。 */
10
10
  export const RssSettingsSchema = z.object({
11
11
  feedsYaml: z.string().default(''),
12
+ cursorsJson: z.string().default(''),
12
13
  });
package/lib/tools.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 五个面向模型的 RSS 工具:list / add / remove / fetch / check。
2
+ * 九个面向模型的 RSS 工具:list / add / remove / fetch / check / opml_export / opml_import / search / health(fetch 支持增量)。
3
3
  * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
4
  *
5
5
  * @module dsh-rss/tools
@@ -37,8 +37,19 @@ export type FetchLike = (url: string, init?: {
37
37
  redirect?: string;
38
38
  signal?: AbortSignal;
39
39
  }) => Promise<Response>;
40
+ /** 单个订阅源的增量游标:已知条目 id 列表与更新时间。 */
41
+ export interface FeedCursor {
42
+ guids: string[];
43
+ updatedAt: string;
44
+ }
45
+ /** 解析 settings 里的增量游标 JSON,损坏时返回空对象(不阻断功能)。 */
46
+ export declare function parseCursors(json: string): Record<string, FeedCursor>;
47
+ /** 合并本次抓到的条目 id,截断到上限,返回新游标。 */
48
+ export declare function advanceCursor(prev: FeedCursor | undefined, entries: unknown[], now: string): FeedCursor;
49
+ /** 用游标过滤出未见过的条目(无游标时全部视为新)。 */
50
+ export declare function filterNewEntries(cursor: FeedCursor | undefined, entries: unknown[]): unknown[];
40
51
  /**
41
- * 构建五个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
52
+ * 构建七个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
42
53
  * @param config - 已解析配置。
43
54
  * @param settingsScope - dsh-rss settings scope(get/update)。
44
55
  * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
package/lib/tools.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * 五个面向模型的 RSS 工具:list / add / remove / fetch / check。
2
+ * 九个面向模型的 RSS 工具:list / add / remove / fetch / check / opml_export / opml_import / search / health(fetch 支持增量)。
3
3
  * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
4
  *
5
5
  * @module dsh-rss/tools
6
6
  */
7
+ import { writeFile } from 'node:fs/promises';
7
8
  import { addFeed, findFeedsByName, parseFeedsYaml, removeFeed, serializeFeeds } from './feeds.js';
9
+ import { buildOpml, importOpmlFeeds, parseOpml } from './opml.js';
8
10
  import { parseFeed } from './parser.js';
9
11
  import { createProxyFetch } from './proxy-fetch.js';
10
12
  const TIMEOUT_MS = 30000;
@@ -122,6 +124,7 @@ const entrySchema = {
122
124
  pubDateRaw: { type: 'string' },
123
125
  author: { type: 'string' },
124
126
  summary: { type: 'string' },
127
+ content: { type: 'string' },
125
128
  categories: { type: 'array', items: { type: 'string' } },
126
129
  enclosure: {
127
130
  type: 'object',
@@ -199,6 +202,27 @@ const checkSchema = {
199
202
  },
200
203
  additionalProperties: true,
201
204
  };
205
+ const opmlExportSchema = {
206
+ type: 'object',
207
+ properties: {
208
+ opml: { type: 'string' },
209
+ path: { type: 'string' },
210
+ feedCount: { type: 'integer' },
211
+ },
212
+ additionalProperties: true,
213
+ };
214
+ const opmlImportSchema = {
215
+ type: 'object',
216
+ properties: {
217
+ addedCount: { type: 'integer' },
218
+ existedCount: { type: 'integer' },
219
+ skippedCount: { type: 'integer' },
220
+ totalCount: { type: 'integer' },
221
+ added: { type: 'array', items: feedItemSchema },
222
+ skipped: { type: 'array', items: { type: 'object', additionalProperties: true } },
223
+ },
224
+ additionalProperties: true,
225
+ };
202
226
  // ---------- render 帮助函数 ----------
203
227
  function feedLabel(feed) {
204
228
  const rec = asRecord(feed);
@@ -254,9 +278,173 @@ function renderCheck(_args, value) {
254
278
  const text = '订阅源可用:' + title + '(' + String(rec.feedType) + '),共 ' + String(rec.entryCount) + ' 条。';
255
279
  return [{ type: 'text', text }];
256
280
  }
281
+ function renderOpmlExport(_args, value) {
282
+ const rec = asRecord(value);
283
+ const path = typeof rec.path === 'string' && rec.path !== '' ? ',已写入 ' + rec.path : '';
284
+ return [{ type: 'text', text: '已导出 ' + String(rec.feedCount ?? 0) + ' 个订阅为 OPML' + path + '。' }];
285
+ }
286
+ function renderOpmlImport(_args, value) {
287
+ const rec = asRecord(value);
288
+ const lines = [
289
+ 'OPML 导入完成:新增 ' + String(rec.addedCount ?? 0) + ',已存在 ' + String(rec.existedCount ?? 0) + ',跳过 ' + String(rec.skippedCount ?? 0) + '。当前共 ' + String(rec.totalCount ?? 0) + ' 个订阅源。',
290
+ ];
291
+ const skipped = Array.isArray(rec.skipped) ? rec.skipped : [];
292
+ for (const item of skipped) {
293
+ const row = asRecord(item);
294
+ lines.push('- 跳过:' + String(row.title ?? '') + '(' + String(row.reason ?? '') + ')');
295
+ }
296
+ return [{ type: 'text', text: lines.join('\n') }];
297
+ }
298
+ const CURSOR_GUID_LIMIT = 200;
299
+ /** 条目唯一 id:优先 guid,退回 link,再退回 标题|发布时间。 */
300
+ function entryId(entry) {
301
+ const rec = asRecord(entry);
302
+ const guid = typeof rec.guid === 'string' && rec.guid !== '' ? rec.guid : '';
303
+ if (guid !== '')
304
+ return guid;
305
+ const link = typeof rec.link === 'string' && rec.link !== '' ? rec.link : '';
306
+ if (link !== '')
307
+ return link;
308
+ const title = typeof rec.title === 'string' ? rec.title : '';
309
+ const pubDate = typeof rec.pubDate === 'string' ? rec.pubDate : '';
310
+ return title + '|' + pubDate;
311
+ }
312
+ /** 解析 settings 里的增量游标 JSON,损坏时返回空对象(不阻断功能)。 */
313
+ export function parseCursors(json) {
314
+ if (json.trim() === '')
315
+ return {};
316
+ try {
317
+ const value = JSON.parse(json);
318
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
319
+ return {};
320
+ const out = {};
321
+ for (const [url, cur] of Object.entries(value)) {
322
+ const rec = typeof cur === 'object' && cur !== null ? cur : {};
323
+ const guids = Array.isArray(rec.guids) ? rec.guids.filter((g) => typeof g === 'string') : [];
324
+ out[url] = { guids, updatedAt: typeof rec.updatedAt === 'string' ? rec.updatedAt : '' };
325
+ }
326
+ return out;
327
+ }
328
+ catch {
329
+ return {};
330
+ }
331
+ }
332
+ /** 合并本次抓到的条目 id,截断到上限,返回新游标。 */
333
+ export function advanceCursor(prev, entries, now) {
334
+ const known = new Set(prev?.guids ?? []);
335
+ for (const entry of entries)
336
+ known.add(entryId(entry));
337
+ const guids = [...known];
338
+ return { guids: guids.slice(-CURSOR_GUID_LIMIT), updatedAt: now };
339
+ }
340
+ /** 用游标过滤出未见过的条目(无游标时全部视为新)。 */
341
+ export function filterNewEntries(cursor, entries) {
342
+ if (cursor === undefined || cursor.guids.length === 0)
343
+ return entries;
344
+ const known = new Set(cursor.guids);
345
+ return entries.filter((entry) => !known.has(entryId(entry)));
346
+ }
347
+ // ---------- 搜索辅助 ----------
348
+ /** 解析 since 参数为毫秒时间戳,非法时报错。 */
349
+ function parseSinceMs(input) {
350
+ if (input === undefined)
351
+ return null;
352
+ const text = input.trim();
353
+ const ms = /^\d{4}-\d{2}-\d{2}$/.test(text) ? Date.parse(text + 'T00:00:00Z') : Date.parse(text);
354
+ if (Number.isNaN(ms)) {
355
+ throw new Error('since 参数不是有效日期,请给形如 2026-08-01 或 2026-08-01T00:00:00Z 的值。');
356
+ }
357
+ return ms;
358
+ }
359
+ /** 关键词是否命中条目的标题/摘要/正文/作者/分类(不区分大小写)。 */
360
+ function entryMatches(entry, queryLower) {
361
+ const rec = asRecord(entry);
362
+ const fields = [];
363
+ for (const key of ['title', 'summary', 'content', 'author']) {
364
+ const value = rec[key];
365
+ if (typeof value === 'string')
366
+ fields.push(value);
367
+ }
368
+ const categories = rec.categories;
369
+ if (Array.isArray(categories)) {
370
+ for (const category of categories) {
371
+ if (typeof category === 'string')
372
+ fields.push(category);
373
+ }
374
+ }
375
+ return fields.some((value) => value.toLowerCase().includes(queryLower));
376
+ }
377
+ // ---------- 搜索与健康自检输出 Schema ----------
378
+ const searchHitSchema = {
379
+ type: 'object',
380
+ properties: {
381
+ feedName: { type: 'string' },
382
+ feedUrl: { type: 'string' },
383
+ title: { type: 'string' },
384
+ link: { type: 'string' },
385
+ pubDate: { type: 'string' },
386
+ summary: { type: 'string' },
387
+ },
388
+ additionalProperties: true,
389
+ };
390
+ const searchSchema = {
391
+ type: 'object',
392
+ properties: {
393
+ count: { type: 'integer' },
394
+ hits: { type: 'array', items: searchHitSchema },
395
+ failedFeeds: { type: 'array', items: { type: 'object', additionalProperties: true } },
396
+ },
397
+ additionalProperties: true,
398
+ };
399
+ const healthSchema = {
400
+ type: 'object',
401
+ properties: {
402
+ ok: { type: 'boolean' },
403
+ plugin: { type: 'string' },
404
+ feedCount: { type: 'integer' },
405
+ proxyConfigured: { type: 'boolean' },
406
+ cursorCount: { type: 'integer' },
407
+ parserSelfTest: { type: 'boolean' },
408
+ config: { type: 'object', additionalProperties: true },
409
+ },
410
+ additionalProperties: true,
411
+ };
412
+ /** rss_search 的渲染。 */
413
+ function renderSearch(_args, value) {
414
+ const rec = asRecord(value);
415
+ const hits = Array.isArray(rec.hits) ? rec.hits : [];
416
+ const failed = Array.isArray(rec.failedFeeds) ? rec.failedFeeds : [];
417
+ const lines = ['搜索命中 ' + hits.length + ' 条:'];
418
+ for (const hit of hits) {
419
+ const item = asRecord(hit);
420
+ const title = typeof item.title === 'string' && item.title !== '' ? item.title : '(无标题)';
421
+ const from = typeof item.feedName === 'string' && item.feedName !== '' ? '【' + item.feedName + '】' : '';
422
+ const pubDate = typeof item.pubDate === 'string' && item.pubDate !== '' ? '(' + item.pubDate + ')' : '';
423
+ const link = typeof item.link === 'string' && item.link !== '' ? ' ' + item.link : '';
424
+ lines.push('- ' + from + title + pubDate + link);
425
+ }
426
+ for (const item of failed) {
427
+ const row = asRecord(item);
428
+ lines.push('- 抓取失败 ' + String(row.name ?? row.url ?? '') + ':' + String(row.error ?? ''));
429
+ }
430
+ return [{ type: 'text', text: lines.join('\n') }];
431
+ }
432
+ /** rss_health 的渲染。 */
433
+ function renderHealth(_args, value) {
434
+ const rec = asRecord(value);
435
+ const ok = rec.ok === true;
436
+ const lines = [
437
+ 'dsh-rss 自检' + (ok ? ':正常。' : ':发现异常,请查看下方详情。'),
438
+ '- 订阅源数量:' + String(rec.feedCount ?? 0),
439
+ '- 特殊代理(梯子):' + (rec.proxyConfigured === true ? '已配置' : '未配置'),
440
+ '- 增量游标数量:' + String(rec.cursorCount ?? 0),
441
+ '- 解析器自检:' + (rec.parserSelfTest === true ? '通过' : '失败'),
442
+ ];
443
+ return [{ type: 'text', text: lines.join('\n') }];
444
+ }
257
445
  // ---------- 工具构建 ----------
258
446
  /**
259
- * 构建五个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
447
+ * 构建七个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
260
448
  * @param config - 已解析配置。
261
449
  * @param settingsScope - dsh-rss settings scope(get/update)。
262
450
  * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
@@ -336,13 +524,26 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
336
524
  },
337
525
  timeoutMs: TIMEOUT_MS,
338
526
  };
527
+ const readCursorsJson = () => {
528
+ const value = settingsScope.get();
529
+ return value !== null && typeof value === 'object' && typeof value.cursorsJson === 'string' ? value.cursorsJson : '';
530
+ };
531
+ const persistCursors = async (cursors) => {
532
+ try {
533
+ await settingsScope.update({ cursorsJson: JSON.stringify(cursors) });
534
+ }
535
+ catch (error) {
536
+ throw new Error('写入增量游标失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)));
537
+ }
538
+ };
339
539
  const rssFetch = {
340
540
  name: 'rss_fetch',
341
- description: '抓取并解析一个 RSS/Atom 订阅源,返回源信息与最新条目(标题、链接、guid、发布时间、作者、摘要、分类、附件)。url 与 name 至少给一个:name 查已订阅源(同名多个时需改用 url),url 直接抓取任意地址。limit 控制返回条数(1-100,默认 20),超过部分以 truncated=true 提示。',
541
+ description: '抓取并解析一个 RSS/Atom 订阅源,返回源信息与最新条目(标题、链接、guid、发布时间、作者、摘要、分类、附件)。url 与 name 至少给一个:name 查已订阅源(同名多个时需改用 url),url 直接抓取任意地址。limit 控制返回条数(1-100,默认 20),超过部分以 truncated=true 提示。incremental=true 时启用增量模式:只返回上次抓取以来的新条目(按条目 id 去重,游标持久化在 settings),适合定时巡检省 token。',
342
542
  parameters: compileParameters({
343
543
  url: { type: 'string', description: '订阅源地址(可选,与 name 至少给一个)。' },
344
544
  name: { type: 'string', description: '已订阅源的名字(可选,可用 rss_list 查看)。' },
345
545
  limit: { type: 'integer', description: '返回条数,1-100,默认 20。' },
546
+ incremental: { type: 'boolean', description: '增量模式:只返回上次抓取后的新条目(默认 false)。' },
346
547
  }),
347
548
  output: {
348
549
  schema: fetchSchema,
@@ -367,10 +568,19 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
367
568
  }
368
569
  assertHttpUrl(url);
369
570
  const limit = clampedInteger(args, 'limit', 20, 1, 100);
571
+ const incremental = args.incremental === true;
370
572
  const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
371
573
  const parsed = parseFeed(xml, finalUrl);
372
- const entries = parsed.entries.slice(0, limit);
373
- return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
574
+ if (!incremental) {
575
+ const entries = parsed.entries.slice(0, limit);
576
+ return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
577
+ }
578
+ const cursors = parseCursors(readCursorsJson());
579
+ const fresh = filterNewEntries(cursors[url], parsed.entries);
580
+ cursors[url] = advanceCursor(cursors[url], parsed.entries, new Date().toISOString());
581
+ await persistCursors(cursors);
582
+ const entries = fresh.slice(0, limit);
583
+ return { url: finalUrl, feed: parsed.feed, entries, truncated: fresh.length > limit, incremental: true, newCount: fresh.length };
374
584
  },
375
585
  timeoutMs: TIMEOUT_MS,
376
586
  };
@@ -401,5 +611,146 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
401
611
  },
402
612
  timeoutMs: TIMEOUT_MS,
403
613
  };
404
- return [rssList, rssAdd, rssRemove, rssFetch, rssCheck];
614
+ const rssOpmlExport = {
615
+ name: 'rss_opml_export',
616
+ description: '把当前订阅列表导出为 OPML 2.0 文本(可直接导入 Feedly / Inoreader / NetNewsWire 等阅读器)。path 可选:提供时同时写入工作区文件。',
617
+ parameters: compileParameters({
618
+ path: { type: 'string', description: '可选输出文件路径(相对当前工作区)。缺省时只返回 OPML 文本。' },
619
+ }),
620
+ output: {
621
+ schema: opmlExportSchema,
622
+ render: renderOpmlExport,
623
+ },
624
+ async execute(rawArgs) {
625
+ const args = asRecord(rawArgs);
626
+ const feeds = getFeeds();
627
+ const opml = buildOpml(feeds);
628
+ const target = optionalString(args, 'path');
629
+ if (target !== undefined) {
630
+ await writeFile(target, opml, 'utf8');
631
+ }
632
+ return { opml, path: target ?? '', feedCount: feeds.length };
633
+ },
634
+ timeoutMs: TIMEOUT_MS,
635
+ };
636
+ const rssOpmlImport = {
637
+ name: 'rss_opml_import',
638
+ description: '从 OPML 2.0 文本批量导入订阅源(与现有订阅按 url 去重,已存在的只更新 name/category;非法 URL 跳过并报告原因)。导入结果持久化到 settings。',
639
+ parameters: compileParameters({
640
+ opml: { type: 'string', required: true, description: 'OPML 2.0 XML 文本(必填,可直接粘贴导出文件的内容)。' },
641
+ }),
642
+ output: {
643
+ schema: opmlImportSchema,
644
+ render: renderOpmlImport,
645
+ },
646
+ async execute(rawArgs) {
647
+ const args = asRecord(rawArgs);
648
+ const opmlText = requiredString(args, 'opml', 'OPML 文本');
649
+ const document = parseOpml(opmlText);
650
+ const outcome = importOpmlFeeds(getFeeds(), document);
651
+ await persistFeeds(outcome.feeds);
652
+ return {
653
+ addedCount: outcome.addedCount,
654
+ existedCount: outcome.existedCount,
655
+ skippedCount: outcome.skippedCount,
656
+ totalCount: outcome.feeds.length,
657
+ added: outcome.added,
658
+ skipped: outcome.skipped,
659
+ };
660
+ },
661
+ timeoutMs: TIMEOUT_MS,
662
+ };
663
+ const rssSearch = {
664
+ name: 'rss_search',
665
+ description: '跨订阅源搜索条目:实时抓取已订阅源,对标题/摘要/正文/作者/分类做不区分大小写的关键词匹配。query 必填;name 或 url 可限定单个订阅;since 过滤发布日期(如 2026-08-01);limit 控制返回条数(1-50,默认 20)。个别源抓取失败会单独报告,不影响其他源的搜索。',
666
+ parameters: compileParameters({
667
+ query: { type: 'string', required: true, description: '搜索关键词(必填,不区分大小写)。' },
668
+ name: { type: 'string', description: '只搜索这个名字的订阅(可选)。' },
669
+ url: { type: 'string', description: '只搜索这个地址的订阅(可选)。' },
670
+ since: { type: 'string', description: '只要该日期之后发布的条目(可选,如 2026-08-01)。' },
671
+ limit: { type: 'integer', description: '返回条数,1-50,默认 20。' },
672
+ }),
673
+ output: {
674
+ schema: searchSchema,
675
+ render: renderSearch,
676
+ },
677
+ async execute(rawArgs) {
678
+ const args = asRecord(rawArgs);
679
+ const query = requiredString(args, 'query', '搜索关键词').toLowerCase();
680
+ const limit = clampedInteger(args, 'limit', 20, 1, 50);
681
+ const sinceMs = parseSinceMs(optionalString(args, 'since'));
682
+ const urlFilter = optionalString(args, 'url');
683
+ const nameFilter = optionalString(args, 'name');
684
+ let targets = getFeeds();
685
+ if (urlFilter !== undefined)
686
+ targets = targets.filter((feed) => feed.url.toLowerCase() === urlFilter.toLowerCase());
687
+ if (nameFilter !== undefined)
688
+ targets = findFeedsByName(targets, nameFilter);
689
+ if (targets.length === 0) {
690
+ if (urlFilter !== undefined || nameFilter !== undefined) {
691
+ throw new Error('没有找到匹配的订阅。可用 rss_list 查看订阅列表。');
692
+ }
693
+ throw new Error('当前还没有订阅。请先用 rss_add 添加订阅。');
694
+ }
695
+ const hits = [];
696
+ const failedFeeds = [];
697
+ for (const feed of targets) {
698
+ if (hits.length >= limit)
699
+ break;
700
+ try {
701
+ const { xml, finalUrl } = await fetchFeedXml(feed.url, cfg, fetchImpl);
702
+ const parsed = parseFeed(xml, finalUrl);
703
+ for (const entry of parsed.entries) {
704
+ const rec = asRecord(entry);
705
+ const at = Date.parse(typeof rec.pubDate === 'string' ? rec.pubDate : '');
706
+ if (sinceMs !== null && !Number.isNaN(at) && at < sinceMs)
707
+ continue;
708
+ if (entryMatches(entry, query)) {
709
+ hits.push({ feedName: feed.name, feedUrl: feed.url, ...rec });
710
+ if (hits.length >= limit)
711
+ break;
712
+ }
713
+ }
714
+ }
715
+ catch (error) {
716
+ failedFeeds.push({ url: feed.url, name: feed.name, error: error instanceof Error ? error.message : String(error) });
717
+ }
718
+ }
719
+ return { count: hits.length, hits, failedFeeds };
720
+ },
721
+ timeoutMs: Math.min(300000, Math.max(TIMEOUT_MS, cfg.timeoutMs * 10)),
722
+ };
723
+ const rssHealth = {
724
+ name: 'rss_health',
725
+ description: 'dsh-rss 自检:检查配置有效性、订阅数量、增量游标与解析器自检(不发任何网络请求)。遇到问题时先运行本工具定位。',
726
+ parameters: compileParameters({}),
727
+ output: {
728
+ schema: healthSchema,
729
+ render: renderHealth,
730
+ },
731
+ async execute() {
732
+ const feeds = getFeeds();
733
+ const settingsValue = settingsScope.get();
734
+ const cursorsJson = settingsValue !== null && typeof settingsValue === 'object' && typeof settingsValue.cursorsJson === 'string' ? settingsValue.cursorsJson : '';
735
+ let parserSelfTest = false;
736
+ try {
737
+ const sample = parseFeed('<?xml version="1.0"?><rss version="2.0"><channel><title>selftest</title><item><title>item</title></item></channel></rss>', 'https://selftest.local/');
738
+ parserSelfTest = sample.entries.length === 1 && sample.feed.feedType === 'rss';
739
+ }
740
+ catch {
741
+ parserSelfTest = false;
742
+ }
743
+ return {
744
+ ok: parserSelfTest,
745
+ plugin: 'dsh-rss',
746
+ feedCount: feeds.length,
747
+ proxyConfigured: cfg.proxyUrl !== '',
748
+ cursorCount: Object.keys(parseCursors(cursorsJson)).length,
749
+ parserSelfTest,
750
+ config: { timeoutMs: cfg.timeoutMs, maxBodyBytes: cfg.maxBodyBytes, userAgent: cfg.userAgent },
751
+ };
752
+ },
753
+ timeoutMs: TIMEOUT_MS,
754
+ };
755
+ return [rssList, rssAdd, rssRemove, rssFetch, rssCheck, rssOpmlExport, rssOpmlImport, rssSearch, rssHealth];
405
756
  }
package/package.json CHANGED
@@ -1,65 +1,65 @@
1
- {
2
- "name": "dsh-rss",
3
- "version": "0.1.0",
4
- "description": "DSH RSS 订阅工具插件:管理订阅源并抓取解析 RSS/Atom(rss_list / rss_add / rss_remove / rss_fetch / rss_check)",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "types": "lib/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./lib/index.d.ts",
11
- "default": "./lib/index.js"
12
- },
13
- "./cordis.patch.yml": "./cordis.patch.yml",
14
- "./package.json": "./package.json"
15
- },
16
- "files": [
17
- "lib",
18
- "cordis.patch.yml",
19
- "README.md"
20
- ],
21
- "scripts": {
22
- "build": "tsc -p tsconfig.json",
23
- "prepare": "tsc -p tsconfig.json",
24
- "typecheck": "tsc -p tsconfig.json --noEmit",
25
- "test": "pnpm run build && node --test \"test/*.test.mjs\"",
26
- "prepublishOnly": "pnpm run build"
27
- },
28
- "dsh": {
29
- "bundle": {
30
- "patch": "./cordis.patch.yml"
31
- }
32
- },
33
- "keywords": [
34
- "dsh",
35
- "deepseek-harness",
36
- "plugin",
37
- "rss",
38
- "atom",
39
- "feed"
40
- ],
41
- "license": "MIT",
42
- "engines": {
43
- "node": ">=20"
44
- },
45
- "dependencies": {
46
- "fast-xml-parser": "^5.0.0",
47
- "schemastery": "^3.18.0",
48
- "undici": "^8.10.0",
49
- "yaml": "^2.9.0"
50
- },
51
- "devDependencies": {
52
- "@types/node": "^24.0.0",
53
- "typescript": "^5.6.0"
54
- },
55
- "repository": {
56
- "type": "git",
57
- "url": "https://github.com/STARDUSTLC666/dsh-rss"
58
- },
59
- "bugs": {
60
- "url": "https://github.com/STARDUSTLC666/dsh-rss/issues"
61
- },
62
- "homepage": "https://github.com/STARDUSTLC666/dsh-rss#readme",
63
- "author": "stardustlc",
64
- "packageManager": "pnpm@11.7.0"
65
- }
1
+ {
2
+ "name": "dsh-rss",
3
+ "version": "0.3.0",
4
+ "description": "DSH RSS 订阅工具插件:管理订阅源、OPML 导入导出、抓取解析 RSS/Atom 并保留正文(rss_list / rss_add / rss_remove / rss_fetch / rss_check / rss_opml_export / rss_opml_import)",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "README.en.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit",
25
+ "test": "pnpm run build && node --test \"test/*.test.mjs\"",
26
+ "prepublishOnly": "pnpm run build"
27
+ },
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ }
32
+ },
33
+ "keywords": [
34
+ "dsh",
35
+ "deepseek-harness",
36
+ "plugin",
37
+ "rss",
38
+ "atom",
39
+ "feed"
40
+ ],
41
+ "license": "MIT",
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "dependencies": {
46
+ "fast-xml-parser": "^5.0.0",
47
+ "schemastery": "^3.18.0",
48
+ "undici": "^8.10.0",
49
+ "yaml": "^2.9.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^24.0.0",
53
+ "typescript": "^5.6.0"
54
+ },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/STARDUSTLC666/dsh-rss.git"
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/STARDUSTLC666/dsh-rss/issues"
61
+ },
62
+ "homepage": "https://github.com/STARDUSTLC666/dsh-rss#readme",
63
+ "author": "stardustlc",
64
+ "packageManager": "pnpm@11.7.0"
65
+ }