dsh-rss 0.1.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/lib/parser.js ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * RSS / Atom 解析归一化:用 fast-xml-parser 把 RSS 0.9x/1.0/2.0、RDF、Atom 统一解析成
3
+ * { feed, entries }。不解析 DTD/外部实体;内容只做文本抽取,不执行任何脚本。
4
+ *
5
+ * @module dsh-rss/parser
6
+ */
7
+ import { XMLParser } from 'fast-xml-parser';
8
+ const SUMMARY_MAX_CHARS = 500;
9
+ const xmlParser = new XMLParser({
10
+ ignoreAttributes: false,
11
+ attributeNamePrefix: '@_',
12
+ removeNSPrefix: true,
13
+ processEntities: true,
14
+ htmlEntities: true,
15
+ parseTagValue: false,
16
+ trimValues: true,
17
+ isArray: (name) => name === 'item' || name === 'entry' || name === 'category' || name === 'link' || name === 'author' || name === 'contributor',
18
+ });
19
+ function asRecord(value) {
20
+ return typeof value === 'object' && value !== null ? value : {};
21
+ }
22
+ /** 取节点文本:字符串直接取;对象取 #text;数组取第一个。 */
23
+ function firstText(value) {
24
+ if (typeof value === 'string')
25
+ return value.trim();
26
+ if (Array.isArray(value))
27
+ return firstText(value[0]);
28
+ if (typeof value === 'object' && value !== null) {
29
+ const text = value['#text'];
30
+ if (typeof text === 'string')
31
+ return text.trim();
32
+ }
33
+ return '';
34
+ }
35
+ /** 去 HTML 标签并折叠空白(只用于摘要抽取,不做完整 HTML 渲染)。 */
36
+ function stripHtml(html) {
37
+ if (!html)
38
+ return '';
39
+ return html
40
+ .replace(/<[^>]*>/g, ' ')
41
+ .replace(/&nbsp;/gi, ' ')
42
+ .replace(/&amp;/gi, '&')
43
+ .replace(/&lt;/gi, '<')
44
+ .replace(/&gt;/gi, '>')
45
+ .replace(/&quot;/gi, '"')
46
+ .replace(/&apos;/gi, "'")
47
+ .replace(/&#39;/g, "'")
48
+ .replace(/\s+/g, ' ')
49
+ .trim();
50
+ }
51
+ /** 截断并加省略号。 */
52
+ function truncate(text, max) {
53
+ if (text.length <= max)
54
+ return text;
55
+ return text.slice(0, max) + '…';
56
+ }
57
+ /** 各种日期格式(RFC822 / ISO 8601)统一转 ISO 8601 UTC;无法解析时返回空串。 */
58
+ function toIsoDate(raw) {
59
+ const text = firstText(raw);
60
+ if (text === '')
61
+ return '';
62
+ const parsed = new Date(text);
63
+ if (Number.isNaN(parsed.getTime()))
64
+ return '';
65
+ return parsed.toISOString();
66
+ }
67
+ /** 相对链接基于订阅源地址解析成绝对链接;失败时原样返回。 */
68
+ function resolveUrl(link, baseUrl) {
69
+ if (link === '')
70
+ return '';
71
+ try {
72
+ return new URL(link, baseUrl).href;
73
+ }
74
+ catch {
75
+ return link;
76
+ }
77
+ }
78
+ /** 取 link:优先 rel=alternate 或未标 rel 的 href,最后兜底取纯文本。 */
79
+ function pickLink(linkValue) {
80
+ const values = Array.isArray(linkValue) ? linkValue : (linkValue === undefined || linkValue === null ? [] : [linkValue]);
81
+ let fallback = '';
82
+ for (const value of values) {
83
+ const rec = asRecord(value);
84
+ const href = typeof rec['@_href'] === 'string' ? rec['@_href'].trim() : '';
85
+ const rel = typeof rec['@_rel'] === 'string' ? rec['@_rel'].trim().toLowerCase() : '';
86
+ if (href === '')
87
+ continue;
88
+ if (rel === '' || rel === 'alternate')
89
+ return href;
90
+ if (fallback === '')
91
+ fallback = href;
92
+ }
93
+ if (fallback !== '')
94
+ return fallback;
95
+ return firstText(linkValue);
96
+ }
97
+ /** 取作者:字符串直接取;对象取 name/email;数组拼接。 */
98
+ function pickAuthor(value) {
99
+ if (Array.isArray(value)) {
100
+ return value.map((item) => pickAuthor(item)).filter((text) => text !== '').join(', ');
101
+ }
102
+ if (typeof value === 'string')
103
+ return value.trim();
104
+ const rec = asRecord(value);
105
+ return firstText(rec.name) || firstText(rec.email) || firstText(rec);
106
+ }
107
+ /** 取分类:字符串或对象(Atom 的 term 属性)统一成字符串数组。 */
108
+ function pickCategories(value) {
109
+ if (value === undefined || value === null)
110
+ return [];
111
+ const values = Array.isArray(value) ? value : [value];
112
+ const result = [];
113
+ for (const item of values) {
114
+ if (typeof item === 'string') {
115
+ const text = item.trim();
116
+ if (text !== '')
117
+ result.push(text);
118
+ continue;
119
+ }
120
+ const rec = asRecord(item);
121
+ const term = firstText(rec['@_term']) || firstText(rec);
122
+ if (term !== '')
123
+ result.push(term);
124
+ }
125
+ return result;
126
+ }
127
+ /** 取附件(RSS 的 enclosure 元素)。 */
128
+ function pickRssEnclosure(item) {
129
+ const enc = asRecord(item.enclosure);
130
+ const url = typeof enc['@_url'] === 'string' ? enc['@_url'].trim() : '';
131
+ if (url === '')
132
+ return undefined;
133
+ return {
134
+ url,
135
+ type: typeof enc['@_type'] === 'string' ? enc['@_type'].trim() : '',
136
+ length: typeof enc['@_length'] === 'string' ? enc['@_length'].trim() : '',
137
+ };
138
+ }
139
+ /** RSS2 / RSS1 条目归一化。 */
140
+ function normalizeRssItem(item, baseUrl) {
141
+ const title = firstText(item.title);
142
+ const guid = firstText(item.guid);
143
+ let link = firstText(item.link);
144
+ if (link === '' && /^https?:\/\//i.test(guid))
145
+ link = guid;
146
+ link = resolveUrl(link, baseUrl);
147
+ const pubDateRaw = firstText(item.pubDate) || firstText(item.date) || firstText(item.updated);
148
+ const summaryRaw = firstText(item.description) || firstText(item.encoded) || firstText(item.summary);
149
+ const enclosure = pickRssEnclosure(item);
150
+ const entry = {
151
+ title,
152
+ link,
153
+ guid,
154
+ pubDate: toIsoDate(pubDateRaw),
155
+ pubDateRaw,
156
+ author: pickAuthor(item.author) || pickAuthor(item.creator),
157
+ summary: truncate(stripHtml(summaryRaw), SUMMARY_MAX_CHARS),
158
+ categories: pickCategories(item.category),
159
+ };
160
+ if (enclosure !== undefined)
161
+ entry.enclosure = enclosure;
162
+ return entry;
163
+ }
164
+ /** Atom 条目归一化。 */
165
+ function normalizeAtomEntry(entry, baseUrl) {
166
+ const title = firstText(entry.title);
167
+ const guid = firstText(entry.id);
168
+ const linkRaw = pickLink(entry.link);
169
+ let link = linkRaw !== '' ? linkRaw : (/^https?:\/\//i.test(guid) ? guid : '');
170
+ link = resolveUrl(link, baseUrl);
171
+ const pubDateRaw = firstText(entry.published) || firstText(entry.updated) || firstText(entry.issued);
172
+ const summaryRaw = firstText(entry.summary) || firstText(entry.content);
173
+ let enclosure;
174
+ const links = Array.isArray(entry.link) ? entry.link : (entry.link === undefined || entry.link === null ? [] : [entry.link]);
175
+ for (const raw of links) {
176
+ const rec = asRecord(raw);
177
+ const rel = typeof rec['@_rel'] === 'string' ? rec['@_rel'].trim().toLowerCase() : '';
178
+ const href = typeof rec['@_href'] === 'string' ? rec['@_href'].trim() : '';
179
+ if (rel === 'enclosure' && href !== '') {
180
+ enclosure = {
181
+ url: href,
182
+ type: typeof rec['@_type'] === 'string' ? rec['@_type'].trim() : '',
183
+ length: typeof rec['@_length'] === 'string' ? rec['@_length'].trim() : '',
184
+ };
185
+ break;
186
+ }
187
+ }
188
+ const normalized = {
189
+ title,
190
+ link,
191
+ guid,
192
+ pubDate: toIsoDate(pubDateRaw),
193
+ pubDateRaw,
194
+ author: pickAuthor(entry.author) || pickAuthor(entry.contributor),
195
+ summary: truncate(stripHtml(summaryRaw), SUMMARY_MAX_CHARS),
196
+ categories: pickCategories(entry.category),
197
+ };
198
+ if (enclosure !== undefined)
199
+ normalized.enclosure = enclosure;
200
+ return normalized;
201
+ }
202
+ /** 过滤掉既无标题又无链接的无效条目。 */
203
+ function keepUsable(entry) {
204
+ return entry.title !== '' || entry.link !== '';
205
+ }
206
+ /** RSS2 / RSS1 频道元信息。 */
207
+ function buildRssFeedMeta(url, channel, feedType) {
208
+ return {
209
+ url,
210
+ title: firstText(channel.title),
211
+ link: resolveUrl(firstText(channel.link), url),
212
+ description: truncate(stripHtml(firstText(channel.description)), SUMMARY_MAX_CHARS),
213
+ language: firstText(channel.language),
214
+ lastBuildDate: toIsoDate(firstText(channel.lastBuildDate) || firstText(channel.pubDate) || firstText(channel.date)),
215
+ generator: firstText(channel.generator),
216
+ feedType,
217
+ entryCount: 0,
218
+ };
219
+ }
220
+ /** Atom feed 元信息。 */
221
+ function buildAtomFeedMeta(url, feedRec) {
222
+ const langAttr = feedRec['@_lang'];
223
+ const generatorValue = asRecord(feedRec.generator);
224
+ return {
225
+ url,
226
+ title: firstText(feedRec.title),
227
+ link: resolveUrl(pickLink(feedRec.link), url),
228
+ description: truncate(stripHtml(firstText(feedRec.subtitle)), SUMMARY_MAX_CHARS),
229
+ language: firstText(feedRec.lang) || (typeof langAttr === 'string' ? langAttr.trim() : ''),
230
+ lastBuildDate: toIsoDate(firstText(feedRec.updated) || firstText(feedRec.published)),
231
+ generator: firstText(generatorValue.value) || firstText(feedRec.generator),
232
+ feedType: 'atom',
233
+ entryCount: 0,
234
+ };
235
+ }
236
+ /** 检测截断:文档声明了根元素却缺少对应闭合标签(fast-xml-parser 对未闭合 XML 很宽容,需自行兜底)。 */
237
+ function detectTruncated(xmlText) {
238
+ const head = xmlText.slice(0, 512).toLowerCase();
239
+ if (/<feed[\s>]/.test(head) && !/<\/feed\s*>/i.test(xmlText))
240
+ return 'feed';
241
+ if (/<rdf:rdf[\s>]/.test(head) && !/<\/rdf:rdf\s*>/i.test(xmlText))
242
+ return 'rdf:RDF';
243
+ if (/<rss[\s>]/.test(head) && !/<\/rss\s*>/i.test(xmlText))
244
+ return 'rss';
245
+ return null;
246
+ }
247
+ /**
248
+ * 解析 XML 文本为归一化结构。
249
+ * @param xmlText - 订阅源 XML 原文。
250
+ * @param url - 订阅源地址(用于解析相对链接)。
251
+ * @throws 内容为空 / XML 非法 / 缺少 feed 根元素时抛中文错误。
252
+ */
253
+ export function parseFeed(xmlText, url) {
254
+ if (typeof xmlText !== 'string' || xmlText.trim() === '') {
255
+ throw new Error('抓取到的内容为空,无法解析为订阅源。');
256
+ }
257
+ const missingRoot = detectTruncated(xmlText);
258
+ if (missingRoot !== null) {
259
+ throw new Error('XML 不完整(可能被截断):缺少 </' + missingRoot + '> 闭合标签。');
260
+ }
261
+ let doc;
262
+ try {
263
+ doc = xmlParser.parse(xmlText);
264
+ }
265
+ catch (error) {
266
+ throw new Error('XML 解析失败:' + (error instanceof Error ? error.message : String(error)));
267
+ }
268
+ const root = asRecord(doc);
269
+ const baseUrl = url || '';
270
+ if (root.feed !== undefined) {
271
+ const feedRec = asRecord(root.feed);
272
+ const rawEntries = Array.isArray(feedRec.entry) ? feedRec.entry : (feedRec.entry === undefined || feedRec.entry === null ? [] : [feedRec.entry]);
273
+ const entries = rawEntries.map((item) => normalizeAtomEntry(asRecord(item), baseUrl)).filter(keepUsable);
274
+ const meta = buildAtomFeedMeta(baseUrl, feedRec);
275
+ return { feed: { ...meta, entryCount: entries.length }, entries };
276
+ }
277
+ if (root.RDF !== undefined) {
278
+ const rdf = asRecord(root.RDF);
279
+ const channel = asRecord(rdf.channel);
280
+ const rawEntries = Array.isArray(rdf.item) ? rdf.item : (rdf.item === undefined || rdf.item === null ? [] : [rdf.item]);
281
+ const entries = rawEntries.map((item) => normalizeRssItem(asRecord(item), baseUrl)).filter(keepUsable);
282
+ const meta = buildRssFeedMeta(baseUrl, channel, 'rdf');
283
+ return { feed: { ...meta, entryCount: entries.length }, entries };
284
+ }
285
+ if (root.rss !== undefined) {
286
+ const rss = asRecord(root.rss);
287
+ const channel = asRecord(rss.channel);
288
+ const rawEntries = Array.isArray(channel.item) ? channel.item : (channel.item === undefined || channel.item === null ? [] : [channel.item]);
289
+ const entries = rawEntries.map((item) => normalizeRssItem(asRecord(item), baseUrl)).filter(keepUsable);
290
+ const meta = buildRssFeedMeta(baseUrl, channel, 'rss');
291
+ return { feed: { ...meta, entryCount: entries.length }, entries };
292
+ }
293
+ throw new Error('内容不是 RSS/Atom 订阅源(缺少 <rss>、<rdf:RDF> 或 <feed> 根元素)。');
294
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 构造一个把所有请求路由到 proxyUrl 的 fetch。
3
+ * @param proxyUrl - 如 http://127.0.0.1:7890
4
+ */
5
+ export declare function createProxyFetch(proxyUrl: string): typeof globalThis.fetch;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 插件级 HTTP 代理:给 RSS 抓取请求一个走指定代理的 fetch,不影响同进程其他插件。
3
+ */
4
+ import { ProxyAgent, fetch as undiciFetch } from 'undici';
5
+ /**
6
+ * 构造一个把所有请求路由到 proxyUrl 的 fetch。
7
+ * @param proxyUrl - 如 http://127.0.0.1:7890
8
+ */
9
+ export function createProxyFetch(proxyUrl) {
10
+ const agent = new ProxyAgent(proxyUrl);
11
+ // undici 自带类型与全局 fetch 类型不完全一致,桥接处用 any 避免类型摩擦;
12
+ // 对外契约仍是 typeof globalThis.fetch。
13
+ return ((input, init) => undiciFetch(input, { ...init, dispatcher: agent }));
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * settings 命名空间:订阅列表(feedsYaml)存在这里,供设置页与工具共同读写。
3
+ *
4
+ * @module dsh-rss/settings
5
+ */
6
+ import z from 'schemastery';
7
+ /** 本插件拥有的 settings 文档命名空间。 */
8
+ export declare const RSS_SETTINGS_NAMESPACE = "dsh-rss";
9
+ /** settings 页形状:目前只有订阅列表(YAML 文本)。 */
10
+ export declare const RssSettingsSchema: z<Schemastery.ObjectS<{
11
+ feedsYaml: z<string, string>;
12
+ }>, Schemastery.ObjectT<{
13
+ feedsYaml: z<string, string>;
14
+ }>>;
15
+ /** settings 解析后的值。 */
16
+ export interface RssSettingsValue {
17
+ feedsYaml: string;
18
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * settings 命名空间:订阅列表(feedsYaml)存在这里,供设置页与工具共同读写。
3
+ *
4
+ * @module dsh-rss/settings
5
+ */
6
+ import z from 'schemastery';
7
+ /** 本插件拥有的 settings 文档命名空间。 */
8
+ export const RSS_SETTINGS_NAMESPACE = 'dsh-rss';
9
+ /** settings 页形状:目前只有订阅列表(YAML 文本)。 */
10
+ export const RssSettingsSchema = z.object({
11
+ feedsYaml: z.string().default(''),
12
+ });
package/lib/tools.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 五个面向模型的 RSS 工具:list / add / remove / fetch / check。
3
+ * 直接调用 ctx.tools.register 注册【编译好的 JSON Schema】参数与 canonical 输出。
4
+ *
5
+ * @module dsh-rss/tools
6
+ */
7
+ import { type ResolvedRssConfig } from './config.js';
8
+ /** 模型可见的内容块。 */
9
+ export interface ContentBlock {
10
+ type: 'text';
11
+ text: string;
12
+ }
13
+ /** 注册给 ctx.tools.register 的原始工具定义(parameters 为编译好的 JSON Schema)。 */
14
+ export interface RssToolDefinition {
15
+ name: string;
16
+ description: string;
17
+ parameters: {
18
+ type: 'object';
19
+ properties: Record<string, unknown>;
20
+ required?: string[];
21
+ };
22
+ output: {
23
+ schema: Record<string, unknown>;
24
+ render(args: unknown, value: unknown): ContentBlock[];
25
+ };
26
+ execute(args: unknown, exec: unknown): Promise<unknown>;
27
+ timeoutMs?: number;
28
+ }
29
+ /** 工具所需的 settings scope 最小面。 */
30
+ export interface RssSettingsScope {
31
+ get(): unknown;
32
+ update(patch: Record<string, unknown>): Promise<void>;
33
+ }
34
+ /** 可注入的 fetch 实现(测试用假 fetch 替换真网络)。 */
35
+ export type FetchLike = (url: string, init?: {
36
+ headers?: Record<string, string>;
37
+ redirect?: string;
38
+ signal?: AbortSignal;
39
+ }) => Promise<Response>;
40
+ /**
41
+ * 构建五个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
42
+ * @param config - 已解析配置。
43
+ * @param settingsScope - dsh-rss settings scope(get/update)。
44
+ * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
45
+ */
46
+ export declare function buildRssTools(config: ResolvedRssConfig, settingsScope: RssSettingsScope, fetchImpl?: FetchLike): RssToolDefinition[];