dsh-rss 0.2.0 → 0.3.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/tools.d.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
- * 七个面向模型的 RSS 工具:list / add / remove / fetch / check / opml_export / opml_import。
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
7
  import { type ResolvedRssConfig } from './config.js';
8
+ import type { RssToolExecution } from './execution.js';
9
+ import { type FetchLike, type DnsLookupLike } from './network.js';
10
+ export type { RssToolExecution } from './execution.js';
11
+ export { isBlockedNetworkAddress, type FetchLike, type DnsLookupLike } from './network.js';
8
12
  /** 模型可见的内容块。 */
9
13
  export interface ContentBlock {
10
14
  type: 'text';
@@ -23,7 +27,7 @@ export interface RssToolDefinition {
23
27
  schema: Record<string, unknown>;
24
28
  render(args: unknown, value: unknown): ContentBlock[];
25
29
  };
26
- execute(args: unknown, exec: unknown): Promise<unknown>;
30
+ execute(args: unknown, exec?: RssToolExecution): Promise<unknown>;
27
31
  timeoutMs?: number;
28
32
  }
29
33
  /** 工具所需的 settings scope 最小面。 */
@@ -31,16 +35,22 @@ export interface RssSettingsScope {
31
35
  get(): unknown;
32
36
  update(patch: Record<string, unknown>): Promise<void>;
33
37
  }
34
- /** 可注入的 fetch 实现(测试用假 fetch 替换真网络)。 */
35
- export type FetchLike = (url: string, init?: {
36
- headers?: Record<string, string>;
37
- redirect?: string;
38
- signal?: AbortSignal;
39
- }) => Promise<Response>;
38
+ /** 单个订阅源的增量游标:已知条目 id 列表与更新时间。 */
39
+ export interface FeedCursor {
40
+ guids: string[];
41
+ updatedAt: string;
42
+ }
43
+ /** 解析 settings 里的增量游标 JSON,损坏时返回空对象(不阻断功能)。 */
44
+ export declare function parseCursors(json: string): Record<string, FeedCursor>;
45
+ /** 合并本次抓到的条目 id,截断到上限,返回新游标。 */
46
+ export declare function advanceCursor(prev: FeedCursor | undefined, entries: unknown[], now: string): FeedCursor;
47
+ /** 用游标过滤出未见过的条目(无游标时全部视为新)。 */
48
+ export declare function filterNewEntries(cursor: FeedCursor | undefined, entries: unknown[]): unknown[];
40
49
  /**
41
- * 构建七个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
50
+ * 构建九个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
42
51
  * @param config - 已解析配置。
43
52
  * @param settingsScope - dsh-rss settings scope(get/update)。
44
53
  * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
54
+ * @param lookupImpl - 可选注入的 DNS 查询(测试用),缺省使用系统 DNS。
45
55
  */
46
- export declare function buildRssTools(config: ResolvedRssConfig, settingsScope: RssSettingsScope, fetchImpl?: FetchLike): RssToolDefinition[];
56
+ export declare function buildRssTools(config: ResolvedRssConfig, settingsScope: RssSettingsScope, fetchImpl?: FetchLike, lookupImpl?: DnsLookupLike): RssToolDefinition[];
package/lib/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 七个面向模型的 RSS 工具:list / add / remove / fetch / check / opml_export / opml_import。
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
@@ -8,9 +8,10 @@ import { writeFile } from 'node:fs/promises';
8
8
  import { addFeed, findFeedsByName, parseFeedsYaml, removeFeed, serializeFeeds } from './feeds.js';
9
9
  import { buildOpml, importOpmlFeeds, parseOpml } from './opml.js';
10
10
  import { parseFeed } from './parser.js';
11
- import { createProxyFetch } from './proxy-fetch.js';
11
+ import { assertHttpUrl, fetchFeedXml } from './network.js';
12
+ import { resolveOpmlOutputPath } from './workspace.js';
13
+ export { isBlockedNetworkAddress } from './network.js';
12
14
  const TIMEOUT_MS = 30000;
13
- const PROXY_HINT = '。若该订阅源需要特殊代理(梯子)才能访问,请在 cordis.patch.yml 里给 dsh-rss 配置 proxyUrl(如 http://127.0.0.1:7890)后重启。';
14
15
  /**
15
16
  * 编译作者 DSL 为原始 JSON Schema(正是 defineTool 存为 definition.parameters 的值)。
16
17
  * 原生线路会原样下发该值,原始 DSL 会被模型 API 拒绝(schema must be a JSON Schema)。
@@ -56,54 +57,6 @@ function clampedInteger(args, key, fallback, min, max) {
56
57
  return fallback;
57
58
  return Math.min(max, Math.max(min, value));
58
59
  }
59
- function assertHttpUrl(url) {
60
- if (!/^https?:\/\//i.test(url)) {
61
- throw new Error('订阅源地址必须是 http(s):// 开头的完整地址,例如 https://example.com/feed.xml。');
62
- }
63
- }
64
- /** 构造默认 fetch:配置了 proxyUrl 时走插件级代理。 */
65
- function makeFetch(cfg) {
66
- if (cfg.proxyUrl === '')
67
- return globalThis.fetch;
68
- return createProxyFetch(cfg.proxyUrl);
69
- }
70
- /**
71
- * 抓取订阅源 XML:校验地址、限时、限制体积、剥 BOM。
72
- */
73
- async function fetchFeedXml(url, cfg, fetchImpl) {
74
- assertHttpUrl(url);
75
- const fetcher = fetchImpl ?? makeFetch(cfg);
76
- let response;
77
- try {
78
- response = await fetcher(url, {
79
- headers: {
80
- 'user-agent': cfg.userAgent,
81
- accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*',
82
- },
83
- redirect: 'follow',
84
- signal: AbortSignal.timeout(cfg.timeoutMs),
85
- });
86
- }
87
- catch (error) {
88
- throw new Error('抓取失败:' + (error instanceof Error ? error.message : String(error)) + PROXY_HINT);
89
- }
90
- if (!response.ok) {
91
- throw new Error('抓取失败:服务器返回 HTTP ' + response.status + '。');
92
- }
93
- let bytes;
94
- try {
95
- bytes = new Uint8Array(await response.arrayBuffer());
96
- }
97
- catch (error) {
98
- throw new Error('读取订阅源内容失败:' + (error instanceof Error ? error.message : String(error)));
99
- }
100
- if (bytes.length > cfg.maxBodyBytes) {
101
- throw new Error('订阅源内容超过 ' + cfg.maxBodyBytes + ' 字节上限,已停止读取。');
102
- }
103
- let xml = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
104
- xml = xml.replace(/^\uFEFF/, '');
105
- return { xml, finalUrl: response.url || url };
106
- }
107
60
  // ---------- 输出 JSON Schema ----------
108
61
  const feedItemSchema = {
109
62
  type: 'object',
@@ -295,14 +248,162 @@ function renderOpmlImport(_args, value) {
295
248
  }
296
249
  return [{ type: 'text', text: lines.join('\n') }];
297
250
  }
251
+ const CURSOR_GUID_LIMIT = 200;
252
+ /** 条目唯一 id:优先 guid,退回 link,再退回 标题|发布时间。 */
253
+ function entryId(entry) {
254
+ const rec = asRecord(entry);
255
+ const guid = typeof rec.guid === 'string' && rec.guid !== '' ? rec.guid : '';
256
+ if (guid !== '')
257
+ return guid;
258
+ const link = typeof rec.link === 'string' && rec.link !== '' ? rec.link : '';
259
+ if (link !== '')
260
+ return link;
261
+ const title = typeof rec.title === 'string' ? rec.title : '';
262
+ const pubDate = typeof rec.pubDate === 'string' ? rec.pubDate : '';
263
+ return title + '|' + pubDate;
264
+ }
265
+ /** 解析 settings 里的增量游标 JSON,损坏时返回空对象(不阻断功能)。 */
266
+ export function parseCursors(json) {
267
+ if (json.trim() === '')
268
+ return {};
269
+ try {
270
+ const value = JSON.parse(json);
271
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
272
+ return {};
273
+ const out = {};
274
+ for (const [url, cur] of Object.entries(value)) {
275
+ const rec = typeof cur === 'object' && cur !== null ? cur : {};
276
+ const guids = Array.isArray(rec.guids) ? rec.guids.filter((g) => typeof g === 'string') : [];
277
+ out[url] = { guids, updatedAt: typeof rec.updatedAt === 'string' ? rec.updatedAt : '' };
278
+ }
279
+ return out;
280
+ }
281
+ catch {
282
+ return {};
283
+ }
284
+ }
285
+ /** 合并本次抓到的条目 id,截断到上限,返回新游标。 */
286
+ export function advanceCursor(prev, entries, now) {
287
+ const known = new Set(prev?.guids ?? []);
288
+ for (const entry of entries)
289
+ known.add(entryId(entry));
290
+ const guids = [...known];
291
+ return { guids: guids.slice(-CURSOR_GUID_LIMIT), updatedAt: now };
292
+ }
293
+ /** 用游标过滤出未见过的条目(无游标时全部视为新)。 */
294
+ export function filterNewEntries(cursor, entries) {
295
+ if (cursor === undefined || cursor.guids.length === 0)
296
+ return entries;
297
+ const known = new Set(cursor.guids);
298
+ return entries.filter((entry) => !known.has(entryId(entry)));
299
+ }
300
+ // ---------- 搜索辅助 ----------
301
+ /** 解析 since 参数为毫秒时间戳,非法时报错。 */
302
+ function parseSinceMs(input) {
303
+ if (input === undefined)
304
+ return null;
305
+ const text = input.trim();
306
+ const ms = /^\d{4}-\d{2}-\d{2}$/.test(text) ? Date.parse(text + 'T00:00:00Z') : Date.parse(text);
307
+ if (Number.isNaN(ms)) {
308
+ throw new Error('since 参数不是有效日期,请给形如 2026-08-01 或 2026-08-01T00:00:00Z 的值。');
309
+ }
310
+ return ms;
311
+ }
312
+ /** 关键词是否命中条目的标题/摘要/正文/作者/分类(不区分大小写)。 */
313
+ function entryMatches(entry, queryLower) {
314
+ const rec = asRecord(entry);
315
+ const fields = [];
316
+ for (const key of ['title', 'summary', 'content', 'author']) {
317
+ const value = rec[key];
318
+ if (typeof value === 'string')
319
+ fields.push(value);
320
+ }
321
+ const categories = rec.categories;
322
+ if (Array.isArray(categories)) {
323
+ for (const category of categories) {
324
+ if (typeof category === 'string')
325
+ fields.push(category);
326
+ }
327
+ }
328
+ return fields.some((value) => value.toLowerCase().includes(queryLower));
329
+ }
330
+ // ---------- 搜索与健康自检输出 Schema ----------
331
+ const searchHitSchema = {
332
+ type: 'object',
333
+ properties: {
334
+ feedName: { type: 'string' },
335
+ feedUrl: { type: 'string' },
336
+ title: { type: 'string' },
337
+ link: { type: 'string' },
338
+ pubDate: { type: 'string' },
339
+ summary: { type: 'string' },
340
+ },
341
+ additionalProperties: true,
342
+ };
343
+ const searchSchema = {
344
+ type: 'object',
345
+ properties: {
346
+ count: { type: 'integer' },
347
+ hits: { type: 'array', items: searchHitSchema },
348
+ failedFeeds: { type: 'array', items: { type: 'object', additionalProperties: true } },
349
+ },
350
+ additionalProperties: true,
351
+ };
352
+ const healthSchema = {
353
+ type: 'object',
354
+ properties: {
355
+ ok: { type: 'boolean' },
356
+ plugin: { type: 'string' },
357
+ feedCount: { type: 'integer' },
358
+ proxyConfigured: { type: 'boolean' },
359
+ cursorCount: { type: 'integer' },
360
+ parserSelfTest: { type: 'boolean' },
361
+ config: { type: 'object', additionalProperties: true },
362
+ },
363
+ additionalProperties: true,
364
+ };
365
+ /** rss_search 的渲染。 */
366
+ function renderSearch(_args, value) {
367
+ const rec = asRecord(value);
368
+ const hits = Array.isArray(rec.hits) ? rec.hits : [];
369
+ const failed = Array.isArray(rec.failedFeeds) ? rec.failedFeeds : [];
370
+ const lines = ['搜索命中 ' + hits.length + ' 条:'];
371
+ for (const hit of hits) {
372
+ const item = asRecord(hit);
373
+ const title = typeof item.title === 'string' && item.title !== '' ? item.title : '(无标题)';
374
+ const from = typeof item.feedName === 'string' && item.feedName !== '' ? '【' + item.feedName + '】' : '';
375
+ const pubDate = typeof item.pubDate === 'string' && item.pubDate !== '' ? '(' + item.pubDate + ')' : '';
376
+ const link = typeof item.link === 'string' && item.link !== '' ? ' ' + item.link : '';
377
+ lines.push('- ' + from + title + pubDate + link);
378
+ }
379
+ for (const item of failed) {
380
+ const row = asRecord(item);
381
+ lines.push('- 抓取失败 ' + String(row.name ?? row.url ?? '') + ':' + String(row.error ?? ''));
382
+ }
383
+ return [{ type: 'text', text: lines.join('\n') }];
384
+ }
385
+ /** rss_health 的渲染。 */
386
+ function renderHealth(_args, value) {
387
+ const rec = asRecord(value);
388
+ const ok = rec.ok === true;
389
+ const lines = [
390
+ 'dsh-rss 自检' + (ok ? ':正常。' : ':发现异常,请查看下方详情。'),
391
+ '- 订阅源数量:' + String(rec.feedCount ?? 0),
392
+ '- 特殊代理(梯子):' + (rec.proxyConfigured === true ? '已配置' : '未配置'),
393
+ '- 增量游标数量:' + String(rec.cursorCount ?? 0),
394
+ '- 解析器自检:' + (rec.parserSelfTest === true ? '通过' : '失败'),
395
+ ];
396
+ return [{ type: 'text', text: lines.join('\n') }];
397
+ }
298
398
  // ---------- 工具构建 ----------
299
399
  /**
300
- * 构建七个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
400
+ * 构建九个工具定义。每个 execute 惰性读取配置与订阅,错误时抛出中文指引。
301
401
  * @param config - 已解析配置。
302
402
  * @param settingsScope - dsh-rss settings scope(get/update)。
303
403
  * @param fetchImpl - 可选注入的 fetch(测试用),缺省按 proxyUrl 构造。
404
+ * @param lookupImpl - 可选注入的 DNS 查询(测试用),缺省使用系统 DNS。
304
405
  */
305
- export function buildRssTools(config, settingsScope, fetchImpl) {
406
+ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
306
407
  const cfg = config;
307
408
  const getFeeds = () => {
308
409
  const value = settingsScope.get();
@@ -325,7 +426,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
325
426
  schema: listSchema,
326
427
  render: renderList,
327
428
  },
328
- async execute(rawArgs) {
429
+ async execute() {
329
430
  const feeds = getFeeds();
330
431
  return { count: feeds.length, feeds };
331
432
  },
@@ -343,20 +444,20 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
343
444
  schema: addSchema,
344
445
  render: renderAdd,
345
446
  },
346
- async execute(rawArgs) {
447
+ async execute(rawArgs, exec) {
347
448
  const args = asRecord(rawArgs);
348
449
  const url = requiredString(args, 'url', '订阅源地址');
349
450
  assertHttpUrl(url);
350
451
  const name = optionalString(args, 'name') ?? '';
351
452
  const category = optionalString(args, 'category') ?? '';
352
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
453
+ const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
353
454
  const parsed = parseFeed(xml, finalUrl);
354
455
  const effectiveName = name !== '' ? name : parsed.feed.title;
355
456
  const { feeds, added, existed } = addFeed(getFeeds(), url, effectiveName, category);
356
457
  await persistFeeds(feeds);
357
458
  return { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType };
358
459
  },
359
- timeoutMs: TIMEOUT_MS,
460
+ timeoutMs: cfg.timeoutMs,
360
461
  };
361
462
  const rssRemove = {
362
463
  name: 'rss_remove',
@@ -377,19 +478,32 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
377
478
  },
378
479
  timeoutMs: TIMEOUT_MS,
379
480
  };
481
+ const readCursorsJson = () => {
482
+ const value = settingsScope.get();
483
+ return value !== null && typeof value === 'object' && typeof value.cursorsJson === 'string' ? value.cursorsJson : '';
484
+ };
485
+ const persistCursors = async (cursors) => {
486
+ try {
487
+ await settingsScope.update({ cursorsJson: JSON.stringify(cursors) });
488
+ }
489
+ catch (error) {
490
+ throw new Error('写入增量游标失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)));
491
+ }
492
+ };
380
493
  const rssFetch = {
381
494
  name: 'rss_fetch',
382
- description: '抓取并解析一个 RSS/Atom 订阅源,返回源信息与最新条目(标题、链接、guid、发布时间、作者、摘要、分类、附件)。url 与 name 至少给一个:name 查已订阅源(同名多个时需改用 url),url 直接抓取任意地址。limit 控制返回条数(1-100,默认 20),超过部分以 truncated=true 提示。',
495
+ description: '抓取并解析一个 RSS/Atom 订阅源,返回源信息与最新条目(标题、链接、guid、发布时间、作者、摘要、分类、附件)。url 与 name 至少给一个:name 查已订阅源(同名多个时需改用 url),url 直接抓取任意地址。limit 控制返回条数(1-100,默认 20),超过部分以 truncated=true 提示。incremental=true 时启用增量模式:只返回上次抓取以来的新条目(按条目 id 去重,游标持久化在 settings),适合定时巡检省 token。',
383
496
  parameters: compileParameters({
384
497
  url: { type: 'string', description: '订阅源地址(可选,与 name 至少给一个)。' },
385
498
  name: { type: 'string', description: '已订阅源的名字(可选,可用 rss_list 查看)。' },
386
499
  limit: { type: 'integer', description: '返回条数,1-100,默认 20。' },
500
+ incremental: { type: 'boolean', description: '增量模式:只返回上次抓取后的新条目(默认 false)。' },
387
501
  }),
388
502
  output: {
389
503
  schema: fetchSchema,
390
504
  render: renderFetch,
391
505
  },
392
- async execute(rawArgs) {
506
+ async execute(rawArgs, exec) {
393
507
  const args = asRecord(rawArgs);
394
508
  let url = optionalString(args, 'url');
395
509
  const name = optionalString(args, 'name');
@@ -408,12 +522,21 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
408
522
  }
409
523
  assertHttpUrl(url);
410
524
  const limit = clampedInteger(args, 'limit', 20, 1, 100);
411
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
525
+ const incremental = args.incremental === true;
526
+ const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
412
527
  const parsed = parseFeed(xml, finalUrl);
413
- const entries = parsed.entries.slice(0, limit);
414
- return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
528
+ if (!incremental) {
529
+ const entries = parsed.entries.slice(0, limit);
530
+ return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
531
+ }
532
+ const cursors = parseCursors(readCursorsJson());
533
+ const fresh = filterNewEntries(cursors[url], parsed.entries);
534
+ cursors[url] = advanceCursor(cursors[url], parsed.entries, new Date().toISOString());
535
+ await persistCursors(cursors);
536
+ const entries = fresh.slice(0, limit);
537
+ return { url: finalUrl, feed: parsed.feed, entries, truncated: fresh.length > limit, incremental: true, newCount: fresh.length };
415
538
  },
416
- timeoutMs: TIMEOUT_MS,
539
+ timeoutMs: cfg.timeoutMs,
417
540
  };
418
541
  const rssCheck = {
419
542
  name: 'rss_check',
@@ -425,11 +548,11 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
425
548
  schema: checkSchema,
426
549
  render: renderCheck,
427
550
  },
428
- async execute(rawArgs) {
551
+ async execute(rawArgs, exec) {
429
552
  const args = asRecord(rawArgs);
430
553
  const url = requiredString(args, 'url', '订阅源地址');
431
554
  assertHttpUrl(url);
432
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, fetchImpl);
555
+ const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
433
556
  const parsed = parseFeed(xml, finalUrl);
434
557
  return {
435
558
  ok: true,
@@ -440,7 +563,7 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
440
563
  description: parsed.feed.description,
441
564
  };
442
565
  },
443
- timeoutMs: TIMEOUT_MS,
566
+ timeoutMs: cfg.timeoutMs,
444
567
  };
445
568
  const rssOpmlExport = {
446
569
  name: 'rss_opml_export',
@@ -452,15 +575,19 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
452
575
  schema: opmlExportSchema,
453
576
  render: renderOpmlExport,
454
577
  },
455
- async execute(rawArgs) {
578
+ async execute(rawArgs, exec) {
456
579
  const args = asRecord(rawArgs);
457
580
  const feeds = getFeeds();
458
581
  const opml = buildOpml(feeds);
459
582
  const target = optionalString(args, 'path');
583
+ let outputPath = '';
460
584
  if (target !== undefined) {
461
- await writeFile(target, opml, 'utf8');
585
+ exec?.signal.throwIfAborted();
586
+ outputPath = await resolveOpmlOutputPath(target, exec);
587
+ exec?.signal.throwIfAborted();
588
+ await writeFile(outputPath, opml, { encoding: 'utf8', signal: exec?.signal });
462
589
  }
463
- return { opml, path: target ?? '', feedCount: feeds.length };
590
+ return { opml, path: outputPath, feedCount: feeds.length };
464
591
  },
465
592
  timeoutMs: TIMEOUT_MS,
466
593
  };
@@ -491,5 +618,98 @@ export function buildRssTools(config, settingsScope, fetchImpl) {
491
618
  },
492
619
  timeoutMs: TIMEOUT_MS,
493
620
  };
494
- return [rssList, rssAdd, rssRemove, rssFetch, rssCheck, rssOpmlExport, rssOpmlImport];
621
+ const rssSearch = {
622
+ name: 'rss_search',
623
+ description: '跨订阅源搜索条目:实时抓取已订阅源,对标题/摘要/正文/作者/分类做不区分大小写的关键词匹配。query 必填;name 或 url 可限定单个订阅;since 过滤发布日期(如 2026-08-01);limit 控制返回条数(1-50,默认 20)。个别源抓取失败会单独报告,不影响其他源的搜索。',
624
+ parameters: compileParameters({
625
+ query: { type: 'string', required: true, description: '搜索关键词(必填,不区分大小写)。' },
626
+ name: { type: 'string', description: '只搜索这个名字的订阅(可选)。' },
627
+ url: { type: 'string', description: '只搜索这个地址的订阅(可选)。' },
628
+ since: { type: 'string', description: '只要该日期之后发布的条目(可选,如 2026-08-01)。' },
629
+ limit: { type: 'integer', description: '返回条数,1-50,默认 20。' },
630
+ }),
631
+ output: {
632
+ schema: searchSchema,
633
+ render: renderSearch,
634
+ },
635
+ async execute(rawArgs, exec) {
636
+ const args = asRecord(rawArgs);
637
+ const query = requiredString(args, 'query', '搜索关键词').toLowerCase();
638
+ const limit = clampedInteger(args, 'limit', 20, 1, 50);
639
+ const sinceMs = parseSinceMs(optionalString(args, 'since'));
640
+ const urlFilter = optionalString(args, 'url');
641
+ const nameFilter = optionalString(args, 'name');
642
+ let targets = getFeeds();
643
+ if (urlFilter !== undefined)
644
+ targets = targets.filter((feed) => feed.url.toLowerCase() === urlFilter.toLowerCase());
645
+ if (nameFilter !== undefined)
646
+ targets = findFeedsByName(targets, nameFilter);
647
+ if (targets.length === 0) {
648
+ if (urlFilter !== undefined || nameFilter !== undefined) {
649
+ throw new Error('没有找到匹配的订阅。可用 rss_list 查看订阅列表。');
650
+ }
651
+ throw new Error('当前还没有订阅。请先用 rss_add 添加订阅。');
652
+ }
653
+ const hits = [];
654
+ const failedFeeds = [];
655
+ for (const feed of targets) {
656
+ if (hits.length >= limit)
657
+ break;
658
+ try {
659
+ const { xml, finalUrl } = await fetchFeedXml(feed.url, cfg, exec, fetchImpl, lookupImpl);
660
+ const parsed = parseFeed(xml, finalUrl);
661
+ for (const entry of parsed.entries) {
662
+ const rec = asRecord(entry);
663
+ const at = Date.parse(typeof rec.pubDate === 'string' ? rec.pubDate : '');
664
+ if (sinceMs !== null && !Number.isNaN(at) && at < sinceMs)
665
+ continue;
666
+ if (entryMatches(entry, query)) {
667
+ hits.push({ feedName: feed.name, feedUrl: feed.url, ...rec });
668
+ if (hits.length >= limit)
669
+ break;
670
+ }
671
+ }
672
+ }
673
+ catch (error) {
674
+ exec?.signal.throwIfAborted();
675
+ failedFeeds.push({ url: feed.url, name: feed.name, error: error instanceof Error ? error.message : String(error) });
676
+ }
677
+ }
678
+ return { count: hits.length, hits, failedFeeds };
679
+ },
680
+ timeoutMs: Math.min(300000, Math.max(TIMEOUT_MS, cfg.timeoutMs * 10)),
681
+ };
682
+ const rssHealth = {
683
+ name: 'rss_health',
684
+ description: 'dsh-rss 自检:检查配置有效性、订阅数量、增量游标与解析器自检(不发任何网络请求)。遇到问题时先运行本工具定位。',
685
+ parameters: compileParameters({}),
686
+ output: {
687
+ schema: healthSchema,
688
+ render: renderHealth,
689
+ },
690
+ async execute() {
691
+ const feeds = getFeeds();
692
+ const settingsValue = settingsScope.get();
693
+ const cursorsJson = settingsValue !== null && typeof settingsValue === 'object' && typeof settingsValue.cursorsJson === 'string' ? settingsValue.cursorsJson : '';
694
+ let parserSelfTest = false;
695
+ try {
696
+ 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/');
697
+ parserSelfTest = sample.entries.length === 1 && sample.feed.feedType === 'rss';
698
+ }
699
+ catch {
700
+ parserSelfTest = false;
701
+ }
702
+ return {
703
+ ok: parserSelfTest,
704
+ plugin: 'dsh-rss',
705
+ feedCount: feeds.length,
706
+ proxyConfigured: cfg.proxyUrl !== '',
707
+ cursorCount: Object.keys(parseCursors(cursorsJson)).length,
708
+ parserSelfTest,
709
+ config: { timeoutMs: cfg.timeoutMs, maxBodyBytes: cfg.maxBodyBytes, userAgent: cfg.userAgent },
710
+ };
711
+ },
712
+ timeoutMs: TIMEOUT_MS,
713
+ };
714
+ return [rssList, rssAdd, rssRemove, rssFetch, rssCheck, rssOpmlExport, rssOpmlImport, rssSearch, rssHealth];
495
715
  }
@@ -0,0 +1,6 @@
1
+ import type { RssToolExecution } from './execution.js';
2
+ /**
3
+ * 把模型给出的 OPML 相对路径约束在本次调用的 session cwd 内。
4
+ * 同时解析父目录的真实路径并拒绝目标符号链接,避免通过链接越界。
5
+ */
6
+ export declare function resolveOpmlOutputPath(requestedPath: string, exec: RssToolExecution | undefined): Promise<string>;
@@ -0,0 +1,42 @@
1
+ /** 将 OPML 文件输出约束在工具调用所属的会话工作区。 */
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep, win32 } from 'node:path';
4
+ function isWithin(root, target) {
5
+ const rel = relative(root, target);
6
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
7
+ }
8
+ /**
9
+ * 把模型给出的 OPML 相对路径约束在本次调用的 session cwd 内。
10
+ * 同时解析父目录的真实路径并拒绝目标符号链接,避免通过链接越界。
11
+ */
12
+ export async function resolveOpmlOutputPath(requestedPath, exec) {
13
+ if (isAbsolute(requestedPath) || win32.isAbsolute(requestedPath)) {
14
+ throw new Error('OPML 输出 path 必须是相对当前会话工作区的路径,不能使用绝对路径。');
15
+ }
16
+ const sessionCwd = exec?.agent?.session.header.cwd;
17
+ if (typeof sessionCwd !== 'string' || sessionCwd.trim() === '') {
18
+ throw new Error('无法确定本次调用的 session cwd,已拒绝写入 OPML 文件。');
19
+ }
20
+ const lexicalRoot = resolve(sessionCwd);
21
+ const lexicalTarget = resolve(lexicalRoot, requestedPath);
22
+ if (!isWithin(lexicalRoot, lexicalTarget)) {
23
+ throw new Error('OPML 输出 path 不能越过当前会话工作区。');
24
+ }
25
+ const canonicalRoot = await realpath(lexicalRoot);
26
+ const canonicalParent = await realpath(dirname(lexicalTarget));
27
+ const canonicalTarget = resolve(canonicalParent, basename(lexicalTarget));
28
+ if (!isWithin(canonicalRoot, canonicalTarget)) {
29
+ throw new Error('OPML 输出 path 通过符号链接越过了当前会话工作区。');
30
+ }
31
+ try {
32
+ const stat = await lstat(lexicalTarget);
33
+ if (stat.isSymbolicLink())
34
+ throw new Error('OPML 输出目标不能是符号链接。');
35
+ }
36
+ catch (error) {
37
+ const code = typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
38
+ if (code !== 'ENOENT')
39
+ throw error;
40
+ }
41
+ return canonicalTarget;
42
+ }