@zhin.js/plugin-rss 1.1.18 → 1.1.20

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.
@@ -1,5 +1,5 @@
1
1
  // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
- import { definePage, createDeclarativePage } from '@zhin.js/console-contract';
2
+ import { definePage, definePageBlueprint } from '@zhin.js/console-contract';
3
3
  export const meta = definePage({
4
4
  title: 'RSS 订阅',
5
5
  titleI18n: { en: 'RSS Subscriptions' },
@@ -7,7 +7,7 @@ export const meta = definePage({
7
7
  order: 52,
8
8
  requiredPermissions: ['rss:read'],
9
9
  });
10
- export default createDeclarativePage({
10
+ export default definePageBlueprint({
11
11
  title: { zh: 'RSS 订阅', en: 'RSS Subscriptions' },
12
12
  description: {
13
13
  zh: '管理 RSS/Atom 订阅源、轮询频率与已读缓存',
@@ -32,19 +32,73 @@ export default createDeclarativePage({
32
32
  ],
33
33
  actions: [
34
34
  {
35
- name: 'poll-now',
36
- label: { zh: '立即轮询', en: 'Poll now' },
35
+ name: 'addSubscription',
36
+ label: { zh: '添加订阅', en: 'Add Subscription' },
37
37
  variant: 'primary',
38
+ fields: [
39
+ {
40
+ key: 'url',
41
+ label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
42
+ type: 'text',
43
+ required: true,
44
+ },
45
+ {
46
+ key: 'title',
47
+ label: { zh: '订阅名称', en: 'Feed Title' },
48
+ type: 'text',
49
+ },
50
+ {
51
+ key: 'adapter',
52
+ label: { zh: '推送平台', en: 'Adapter' },
53
+ type: 'text',
54
+ defaultValue: 'sandbox',
55
+ },
56
+ {
57
+ key: 'targetType',
58
+ label: { zh: '目标类型', en: 'Target Type' },
59
+ type: 'select',
60
+ defaultValue: 'group',
61
+ options: [
62
+ { value: 'group', label: { zh: '群聊 (group)', en: 'Group' } },
63
+ { value: 'private', label: { zh: '私聊 (private)', en: 'Private' } },
64
+ ],
65
+ },
66
+ {
67
+ key: 'targetId',
68
+ label: { zh: '推送目标 ID', en: 'Target ID' },
69
+ type: 'text',
70
+ defaultValue: 'console',
71
+ },
72
+ ],
73
+ },
74
+ {
75
+ name: 'removeSubscription',
76
+ label: { zh: '移除订阅', en: 'Remove Subscription' },
77
+ variant: 'danger',
78
+ fields: [
79
+ {
80
+ key: 'url',
81
+ label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
82
+ type: 'text',
83
+ required: true,
84
+ },
85
+ ],
86
+ },
87
+ {
88
+ name: 'pollNow',
89
+ label: { zh: '立即轮询', en: 'Poll Now' },
90
+ variant: 'secondary',
38
91
  },
39
92
  {
40
93
  name: 'clean-seen',
41
94
  label: { zh: '清理已读缓存', en: 'Clean seen cache' },
42
- variant: 'secondary',
95
+ variant: 'ghost',
43
96
  },
44
97
  ],
45
98
  sections: [
46
99
  {
47
100
  id: 'subscriptions',
101
+ layout: 'table',
48
102
  title: { zh: '当前订阅列表', en: 'Active Subscriptions' },
49
103
  emptyText: { zh: '暂无 RSS 订阅', en: 'No RSS subscriptions' },
50
104
  items: 'subscriptions',
@@ -1,4 +1,4 @@
1
- import { definePage, createDeclarativePage } from '@zhin.js/console-contract';
1
+ import { definePage, definePageBlueprint } from '@zhin.js/console-contract';
2
2
 
3
3
  export interface RssSubscriptionItem {
4
4
  readonly id: string;
@@ -29,7 +29,7 @@ export const meta = definePage({
29
29
  requiredPermissions: ['rss:read'],
30
30
  });
31
31
 
32
- export default createDeclarativePage<RssConsoleStatus>({
32
+ export default definePageBlueprint<RssConsoleStatus>({
33
33
  title: { zh: 'RSS 订阅', en: 'RSS Subscriptions' },
34
34
  description: {
35
35
  zh: '管理 RSS/Atom 订阅源、轮询频率与已读缓存',
@@ -54,19 +54,73 @@ export default createDeclarativePage<RssConsoleStatus>({
54
54
  ],
55
55
  actions: [
56
56
  {
57
- name: 'poll-now',
58
- label: { zh: '立即轮询', en: 'Poll now' },
57
+ name: 'addSubscription',
58
+ label: { zh: '添加订阅', en: 'Add Subscription' },
59
59
  variant: 'primary',
60
+ fields: [
61
+ {
62
+ key: 'url',
63
+ label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
64
+ type: 'text',
65
+ required: true,
66
+ },
67
+ {
68
+ key: 'title',
69
+ label: { zh: '订阅名称', en: 'Feed Title' },
70
+ type: 'text',
71
+ },
72
+ {
73
+ key: 'adapter',
74
+ label: { zh: '推送平台', en: 'Adapter' },
75
+ type: 'text',
76
+ defaultValue: 'sandbox',
77
+ },
78
+ {
79
+ key: 'targetType',
80
+ label: { zh: '目标类型', en: 'Target Type' },
81
+ type: 'select',
82
+ defaultValue: 'group',
83
+ options: [
84
+ { value: 'group', label: { zh: '群聊 (group)', en: 'Group' } },
85
+ { value: 'private', label: { zh: '私聊 (private)', en: 'Private' } },
86
+ ],
87
+ },
88
+ {
89
+ key: 'targetId',
90
+ label: { zh: '推送目标 ID', en: 'Target ID' },
91
+ type: 'text',
92
+ defaultValue: 'console',
93
+ },
94
+ ],
95
+ },
96
+ {
97
+ name: 'removeSubscription',
98
+ label: { zh: '移除订阅', en: 'Remove Subscription' },
99
+ variant: 'danger',
100
+ fields: [
101
+ {
102
+ key: 'url',
103
+ label: { zh: 'RSS 订阅源 URL', en: 'RSS Feed URL' },
104
+ type: 'text',
105
+ required: true,
106
+ },
107
+ ],
108
+ },
109
+ {
110
+ name: 'pollNow',
111
+ label: { zh: '立即轮询', en: 'Poll Now' },
112
+ variant: 'secondary',
60
113
  },
61
114
  {
62
115
  name: 'clean-seen',
63
116
  label: { zh: '清理已读缓存', en: 'Clean seen cache' },
64
- variant: 'secondary',
117
+ variant: 'ghost',
65
118
  },
66
119
  ],
67
120
  sections: [
68
121
  {
69
122
  id: 'subscriptions',
123
+ layout: 'table',
70
124
  title: { zh: '当前订阅列表', en: 'Active Subscriptions' },
71
125
  emptyText: { zh: '暂无 RSS 订阅', en: 'No RSS subscriptions' },
72
126
  items: 'subscriptions',
package/plugin.js CHANGED
@@ -78,6 +78,9 @@ export default definePlugin({
78
78
  throw new DataMigrationError('unavailable', 'Historical RSS subscription awaits exact-target binding');
79
79
  if (!outbound.sendTarget)
80
80
  throw new Error('Canonical subscription delivery requires an exact-target Outbound Host');
81
+ // ADR 0050 / 0055: pushes are not messages, so the plugin checks its own switch per target conversation.
82
+ if (!context.isEnabledFor({ conversation: input.target }))
83
+ return;
81
84
  const run = async () => { await outbound.sendTarget(input.target, input.content, { locale: input.locale }); };
82
85
  if (migrationScopes)
83
86
  await migrationScopes.run({ scope: subscriptionMigrationScope(input), run });
@@ -112,7 +115,7 @@ export default definePlugin({
112
115
  }
113
116
  catch { /* No persistent state port: no administrator provider. */ }
114
117
  if (state) {
115
- administrator = new AdministratorRssTasks(createAdministratorRssState(database), outbound, config, undefined, runtime.locale);
118
+ administrator = new AdministratorRssTasks(createAdministratorRssState(database), outbound, config, undefined, runtime.locale, target => context.isEnabledFor({ conversation: target }));
116
119
  context.lifecycle.add(() => administrator?.stop());
117
120
  }
118
121
  }
package/schema.json CHANGED
@@ -2,6 +2,14 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
+ "x-zhin-config": {
6
+ "levels": [
7
+ "global",
8
+ "adapter",
9
+ "endpoint",
10
+ "conversation"
11
+ ]
12
+ },
5
13
  "properties": {
6
14
  "pollCron": {
7
15
  "type": "string",
@@ -11,25 +19,60 @@
11
19
  "en": {
12
20
  "description": "Polling schedule (6-field cron expression)."
13
21
  }
22
+ },
23
+ "x-zhin-config": {
24
+ "levels": [
25
+ "global"
26
+ ]
14
27
  }
15
28
  },
16
29
  "maxPerGroup": {
17
30
  "type": "number",
18
31
  "default": 30,
19
32
  "minimum": 1,
20
- "maximum": 200
33
+ "maximum": 200,
34
+ "x-zhin-config": {
35
+ "levels": [
36
+ "global"
37
+ ]
38
+ }
21
39
  },
22
40
  "maxItems": {
23
41
  "type": "number",
24
42
  "default": 5,
25
43
  "minimum": 1,
26
- "maximum": 20
44
+ "maximum": 20,
45
+ "x-zhin-config": {
46
+ "levels": [
47
+ "global"
48
+ ]
49
+ }
27
50
  },
28
51
  "timeout": {
29
52
  "type": "number",
30
53
  "default": 15000,
31
54
  "minimum": 1000,
32
- "maximum": 60000
55
+ "maximum": 60000,
56
+ "x-zhin-config": {
57
+ "levels": [
58
+ "global"
59
+ ]
60
+ }
61
+ },
62
+ "allowPrivateNetwork": {
63
+ "type": "boolean",
64
+ "default": false,
65
+ "description": "允许订阅回环/内网地址上的 feed(如自建 RSSHub)。默认拒绝,因为订阅地址来自聊天用户。",
66
+ "x-i18n": {
67
+ "en": {
68
+ "description": "Allow feeds on loopback/LAN addresses (for example a self-hosted RSSHub). Refused by default because feed URLs come from chat users."
69
+ }
70
+ },
71
+ "x-zhin-config": {
72
+ "levels": [
73
+ "global"
74
+ ]
75
+ }
33
76
  }
34
77
  }
35
78
  }
@@ -12,7 +12,7 @@ export default defineAgentTool({
12
12
  return t(context.locale, 'urlRequired');
13
13
  try {
14
14
  const runtime = context.use(rssRuntimeToken);
15
- const { title, items } = await fetchFeed(url, runtime.config.timeout);
15
+ const { title, items } = await fetchFeed(url, runtime.config);
16
16
  if (items.length === 0)
17
17
  return t(context.locale, 'toolPreviewEmpty', { title });
18
18
  return items
@@ -11,7 +11,7 @@ export default defineAgentTool<{ url: string }>({
11
11
  if (!url) return t(context.locale, 'urlRequired');
12
12
  try {
13
13
  const runtime = context.use(rssRuntimeToken);
14
- const { title, items } = await fetchFeed(url, runtime.config.timeout);
14
+ const { title, items } = await fetchFeed(url, runtime.config);
15
15
  if (items.length === 0) return t(context.locale, 'toolPreviewEmpty', { title });
16
16
  return items
17
17
  .slice(0, 5)
package/src/admin-i18n.ts CHANGED
@@ -146,6 +146,10 @@ const catalog = {
146
146
  "zh-CN": "平台未确认投递,请核实目标会话后取消并另建订阅;不会自动重试",
147
147
  "en": "The platform did not confirm delivery. Check the target conversation, then cancel and create another subscription; no automatic retry."
148
148
  },
149
+ "featureDisabled": {
150
+ "zh-CN": "RSS 已在目标会话停用(功能开关),本轮未投递;重新启用后从后续新文章开始推送",
151
+ "en": "RSS is switched off for the target conversation (feature switch); nothing was delivered this poll. Future articles are delivered once it is re-enabled."
152
+ },
149
153
  "delivered": {
150
154
  "zh-CN": "已确认投递;等待后续新文章",
151
155
  "en": "Delivery confirmed; waiting for future articles."
@@ -70,15 +70,22 @@ export class AdministratorRssTasks implements PluginTaskProvider {
70
70
  readonly #outbound: OutboundHost;
71
71
  readonly #config: Readonly<RssConfig>;
72
72
  readonly #load: FeedLoader;
73
+ readonly #enabledFor: (target: DeliveryTarget) => boolean;
73
74
  readonly projectLocale: Locale;
74
75
  readonly #admission = createGenerationAdmissionGate();
75
76
  #stopped = false;
76
77
  #working = false;
77
78
  get [generationAdmissionSource]() { return [this.#admission]; }
78
79
 
79
- constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed, projectLocale: Locale = 'zh-CN') {
80
+ /**
81
+ * `enabledFor` is the ADR 0050 / 0055 feature switch of the target conversation (pushes are not
82
+ * messages, so dispatch gating never sees them); a switched-off target is skipped, not sent.
83
+ */
84
+ constructor(state: PluginStateStore, outbound: OutboundHost, config: Readonly<RssConfig>, load: FeedLoader = fetchFeed, projectLocale: Locale = 'zh-CN',
85
+ enabledFor: (target: DeliveryTarget) => boolean = () => true) {
80
86
  if (!outbound.sendTarget) throw unavailable('exactTarget');
81
87
  this.projectLocale = projectLocale; this.#state = state; this.#outbound = outbound; this.#config = config; this.#load = load;
88
+ this.#enabledFor = enabledFor;
82
89
  }
83
90
  stop(): void { this.#stopped = true; }
84
91
 
@@ -169,7 +176,7 @@ export class AdministratorRssTasks implements PluginTaskProvider {
169
176
  if (this.#stopped) break;
170
177
  if (task.state !== 'scheduled' || flushed.has(task.id)) continue;
171
178
  let feed: Awaited<ReturnType<FeedLoader>>;
172
- try { feed = await this.#load(task.url, this.#config.timeout); }
179
+ try { feed = await this.#load(task.url, this.#config); }
173
180
  catch { await this.#recordCheck(task, 'fetchFailed'); continue; }
174
181
  if (this.#stopped) break;
175
182
  // Keep only bounded identifiers, never arbitrary feed bodies, in durable state.
@@ -211,7 +218,10 @@ export class AdministratorRssTasks implements PluginTaskProvider {
211
218
  async #deliver(claimed: Task, content: string): Promise<void> {
212
219
  if (this.#stopped) return;
213
220
  let state: Task['state'] = 'unknown', detail = taskDetail('unknown');
214
- try {
221
+ let enabled: boolean;
222
+ try { enabled = this.#enabledFor(claimed.target); } catch { enabled = true; }
223
+ if (!enabled) { state = 'scheduled'; detail = taskDetail('featureDisabled'); }
224
+ else try {
215
225
  const id = await this.#outbound.sendTarget!(claimed.target, content, { locale: resolveLocale(claimed.locale) });
216
226
  if (id) { state = 'scheduled'; detail = taskDetail('delivered'); }
217
227
  } catch (error) {
package/src/console.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  type PluginConsoleRpcInput,
8
8
  type PluginConsoleRpcJson,
9
9
  } from 'zhin.js';
10
- import { listAllSubscriptions } from './db-store.js';
10
+ import { getRssSubs, listAllSubscriptions } from './db-store.js';
11
11
  import { cleanOldSeen, pollAllFeeds } from './poll.js';
12
12
  import { rssRuntimeToken, type RssRuntime } from './runtime.js';
13
13
 
@@ -39,6 +39,8 @@ function resolveRssRuntime(call: PluginConsoleRpcCall, fallback?: RssRuntime): R
39
39
 
40
40
  export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleRpc {
41
41
  const rpc = definePluginConsoleRpc({
42
+ // Day-to-day edits belong to operators; `:write` would otherwise infer `manage` (ADR 0049).
43
+ permissions: [{ id: 'rss:write', level: 'operate', description: '编辑订阅' }],
42
44
  status: {
43
45
  page: 'index',
44
46
  requiredPermissions: ['rss:read'],
@@ -58,6 +60,86 @@ export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleR
58
60
  },
59
61
  },
60
62
  actions: {
63
+ 'add-subscription': {
64
+ page: 'index',
65
+ requiredPermissions: ['rss:write'],
66
+ async handler(input: PluginConsoleRpcInput, call: PluginConsoleRpcCall): Promise<PluginConsoleRpcJson> {
67
+ const runtime = resolveRssRuntime(call, defaultRuntime);
68
+ const url = typeof input.url === 'string' ? input.url.trim() : '';
69
+ let parsedUrl: URL;
70
+ try {
71
+ parsedUrl = new URL(url);
72
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
73
+ throw new Error('invalid protocol');
74
+ }
75
+ } catch {
76
+ throw new PluginConsoleRpcError('invalid_input', '请提供有效的 HTTP/HTTPS RSS 订阅源 URL');
77
+ }
78
+ const title =
79
+ typeof input.title === 'string' && input.title.trim()
80
+ ? input.title.trim()
81
+ : parsedUrl.hostname;
82
+ const adapter =
83
+ typeof input.adapter === 'string' && input.adapter.trim()
84
+ ? input.adapter.trim()
85
+ : 'sandbox';
86
+ const targetType = input.targetType === 'private' ? 'private' : 'group';
87
+ const targetId =
88
+ typeof input.targetId === 'string' && input.targetId.trim()
89
+ ? input.targetId.trim()
90
+ : 'console';
91
+ const Subs = getRssSubs(runtime.db);
92
+ if (!Subs) {
93
+ throw new PluginConsoleRpcError('unavailable', 'RSS 订阅表尚未初始化');
94
+ }
95
+ await Subs.insert({
96
+ url,
97
+ feed_title: title,
98
+ adapter_name: adapter,
99
+ endpoint_id: adapter,
100
+ channel_type: targetType,
101
+ channel_id: targetId,
102
+ creator_id: 'console',
103
+ creator_name: 'Console',
104
+ created_at: new Date().toISOString(),
105
+ });
106
+ return {
107
+ ok: true,
108
+ url,
109
+ title,
110
+ adapter,
111
+ targetType,
112
+ targetId,
113
+ message: `已添加 RSS 订阅:${title} (${url})`,
114
+ };
115
+ },
116
+ },
117
+ 'remove-subscription': {
118
+ page: 'index',
119
+ requiredPermissions: ['rss:write'],
120
+ async handler(input: PluginConsoleRpcInput, call: PluginConsoleRpcCall): Promise<PluginConsoleRpcJson> {
121
+ const runtime = resolveRssRuntime(call, defaultRuntime);
122
+ const url = typeof input.url === 'string' ? input.url.trim() : '';
123
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
124
+ if (!url && !id) {
125
+ throw new PluginConsoleRpcError('invalid_input', '请提供要移除的 RSS 订阅 URL 或 ID');
126
+ }
127
+ const Subs = getRssSubs(runtime.db);
128
+ if (!Subs) {
129
+ throw new PluginConsoleRpcError('unavailable', 'RSS 订阅表尚未初始化');
130
+ }
131
+ if (url) {
132
+ await Subs.delete().where({ url });
133
+ } else {
134
+ await Subs.delete().where({ id });
135
+ }
136
+ return {
137
+ ok: true,
138
+ url: url || id,
139
+ message: `已移除 RSS 订阅:${url || id}`,
140
+ };
141
+ },
142
+ },
61
143
  'poll-now': {
62
144
  page: 'index',
63
145
  requiredPermissions: ['rss:write'],
@@ -83,7 +165,15 @@ export function createRssConsoleRpc(defaultRuntime?: RssRuntime): PluginConsoleR
83
165
  const origGet = methods.get.bind(methods);
84
166
  const origHas = methods.has.bind(methods);
85
167
  const normalizeKey = (key: string) =>
86
- key === 'pollNow' ? 'poll-now' : key === 'cleanSeen' ? 'clean-seen' : key;
168
+ key === 'pollNow'
169
+ ? 'poll-now'
170
+ : key === 'cleanSeen'
171
+ ? 'clean-seen'
172
+ : key === 'addSubscription'
173
+ ? 'add-subscription'
174
+ : key === 'removeSubscription'
175
+ ? 'remove-subscription'
176
+ : key;
87
177
  methods.get = (key: string) => origGet(normalizeKey(key));
88
178
  methods.has = (key: string) => origHas(normalizeKey(key));
89
179
  return rpc;
package/src/feed.ts CHANGED
@@ -4,6 +4,10 @@ import { rssMessages as t } from './i18n.js';
4
4
  * RSS feed fetch/preview helpers (no Plugin host).
5
5
  */
6
6
  import Parser from 'rss-parser';
7
+ import { fetchPublic, readCappedBody, type HostLookup } from './public-fetch.js';
8
+
9
+ /** Feeds larger than this are refused instead of buffered. */
10
+ const MAX_FEED_BYTES = 5 * 1024 * 1024;
7
11
 
8
12
  export interface FeedItem {
9
13
  title: string;
@@ -20,6 +24,8 @@ export interface RssConfig {
20
24
  readonly timeout: number;
21
25
  readonly maxItemsPerPoll?: number;
22
26
  readonly fetchTimeoutMs?: number;
27
+ /** Let feeds live on loopback/LAN addresses (self-hosted RSSHub and the like). Default false. */
28
+ readonly allowPrivateNetwork?: boolean;
23
29
  }
24
30
 
25
31
  export const DEFAULT_RSS_CONFIG: RssConfig = {
@@ -79,18 +85,35 @@ export function stripHtml(html: string): string {
79
85
  return out;
80
86
  }
81
87
 
88
+ /**
89
+ * Fetch and parse a feed. The URL comes from chat users, so it is requested through the
90
+ * public-address policy (every redirect hop re-checked), with one timeout for the whole
91
+ * exchange and a body cap.
92
+ */
82
93
  export async function fetchFeed(
83
94
  url: string,
84
- timeout = DEFAULT_RSS_CONFIG.timeout,
95
+ options: number | Pick<RssConfig, 'timeout' | 'allowPrivateNetwork'> = DEFAULT_RSS_CONFIG,
96
+ lookup?: HostLookup,
85
97
  ): Promise<{ title: string; items: FeedItem[] }> {
86
- const parser = new Parser({
87
- timeout,
88
- headers: {
89
- 'User-Agent': 'ZhinBot-RSS/1.0',
90
- Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
98
+ const { timeout, allowPrivateNetwork = false } = typeof options === 'number' ? { timeout: options } : options;
99
+ const { response } = await fetchPublic(url, {
100
+ timeoutMs: timeout,
101
+ lookup,
102
+ allowPrivateNetwork,
103
+ init: {
104
+ headers: {
105
+ 'User-Agent': 'ZhinBot-RSS/1.0',
106
+ Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml',
107
+ },
91
108
  },
92
109
  });
93
- const feed = await parser.parseURL(url);
110
+ if (!response.ok) {
111
+ await response.body?.cancel();
112
+ throw new Error(`Status code ${response.status}`);
113
+ }
114
+ const body = await readCappedBody(response, MAX_FEED_BYTES);
115
+ if (!body) throw new Error(`Feed is larger than ${MAX_FEED_BYTES} bytes`);
116
+ const feed = await new Parser().parseString(body.toString('utf8'));
94
117
  const items: FeedItem[] = (feed.items || []).slice(0, 20).map((item) => ({
95
118
  title: (item.title || '').trim(),
96
119
  link: (item.link || '').trim(),
package/src/poll.ts CHANGED
@@ -8,10 +8,22 @@ import { fetchFeed, type FeedItem } from './feed.js';
8
8
  import { getRssSeen, getRssSubs } from './db-store.js';
9
9
  import type { RssRuntime } from './runtime.js';
10
10
 
11
+ /** Seen rows are kept this long after the item was last present in its feed. */
12
+ export const SEEN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
13
+ /** Seen rows of items still in the feed are re-stamped at most this often. */
14
+ const SEEN_REFRESH_MS = 24 * 60 * 60 * 1000;
15
+
11
16
  function ts(): string {
12
17
  return new Date().toISOString();
13
18
  }
14
19
 
20
+ /** True for an item whose own publish date is past the retention window. Undated items are never stale. */
21
+ function isStale(item: FeedItem, cutoffMs: number): boolean {
22
+ if (!item.date) return false;
23
+ const published = Date.parse(item.date);
24
+ return Number.isFinite(published) && published < cutoffMs;
25
+ }
26
+
15
27
  export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: number, locale: Locale = 'zh-CN'): string {
16
28
  const lines = [t(locale, 'newItems', { title: feedTitle, count: items.length })];
17
29
  for (const item of items.slice(0, maxItems)) {
@@ -26,19 +38,28 @@ export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: n
26
38
  return lines.join('\n');
27
39
  }
28
40
 
29
- export async function markItemsSeen(runtime: RssRuntime, feedUrl: string, items: FeedItem[]): Promise<void> {
41
+ export async function markItemsSeen(
42
+ runtime: RssRuntime,
43
+ feedUrl: string,
44
+ items: FeedItem[],
45
+ /** Guids already stored for this feed; loaded once when the caller does not have them. */
46
+ knownGuids?: ReadonlySet<string>,
47
+ ): Promise<void> {
30
48
  const Seen = getRssSeen(runtime.db);
31
- if (!Seen) return;
49
+ if (!Seen || items.length === 0) return;
50
+ const known = new Set(knownGuids
51
+ ?? (await Seen.select().where({ feed_url: feedUrl })).map((row) => String(row.item_guid ?? '')));
52
+ // One timestamp per batch so cleanup can drop the whole batch with a single delete.
53
+ const seenAt = ts();
32
54
  for (const item of items) {
33
- const dup = await Seen.select().where({ feed_url: feedUrl, item_guid: item.guid });
34
- if (dup.length === 0) {
35
- await Seen.insert({
36
- feed_url: feedUrl,
37
- item_guid: item.guid,
38
- item_title: item.title,
39
- seen_at: ts(),
40
- });
41
- }
55
+ if (known.has(item.guid)) continue;
56
+ known.add(item.guid);
57
+ await Seen.insert({
58
+ feed_url: feedUrl,
59
+ item_guid: item.guid,
60
+ item_title: item.title,
61
+ seen_at: seenAt,
62
+ });
42
63
  }
43
64
  }
44
65
 
@@ -110,15 +131,28 @@ export async function checkSubscriptions(runtime: RssRuntime, options: {
110
131
 
111
132
  for (const url of options.urls) {
112
133
  try {
113
- const { title, items } = await fetchFeed(url, runtime.config.timeout);
134
+ const { title, items } = await fetchFeed(url, runtime.config);
114
135
  const seenRows = await Seen.select().where({ feed_url: url });
115
- const seenGuids = new Set(seenRows.map((r) => String(r.item_guid ?? '')));
116
- const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid));
136
+ const seenAt = new Map(seenRows.map((r) => [String(r.item_guid ?? ''), String(r.seen_at ?? '')]));
137
+ const seenGuids = new Set(seenAt.keys());
138
+ const now = Date.now();
139
+ // Keep the seen row of an item that is still listed, so it is not re-announced after cleanup.
140
+ const refreshBefore = new Date(now - SEEN_REFRESH_MS).toISOString();
141
+ const refreshed = ts();
142
+ for (const item of items) {
143
+ const stamp = seenAt.get(item.guid);
144
+ if (item.guid && stamp !== undefined && stamp < refreshBefore) {
145
+ await Seen.update({ seen_at: refreshed }).where({ feed_url: url, item_guid: item.guid });
146
+ }
147
+ }
148
+ // An item older than the retention window can no longer have a seen row; never treat it as new.
149
+ const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid)
150
+ && !isStale(item, now - SEEN_RETENTION_MS));
117
151
  if (newItems.length === 0) {
118
152
  parts.push(t(locale, 'noNew', { title: title || url }));
119
153
  continue;
120
154
  }
121
- await markItemsSeen(runtime, url, newItems);
155
+ await markItemsSeen(runtime, url, newItems, seenGuids);
122
156
  totalNew += newItems.length;
123
157
  const body = formatNewItems(title || url, newItems, runtime.config.maxItems, locale);
124
158
  parts.push(body);
@@ -139,21 +173,22 @@ export async function checkSubscriptions(runtime: RssRuntime, options: {
139
173
  }
140
174
 
141
175
  /**
142
- * Periodically clean seen records older than 7 days.
176
+ * Periodically clean seen records older than 7 days. Rows of items still listed in their
177
+ * feed are re-stamped by checkSubscriptions and therefore survive.
143
178
  */
144
179
  export async function cleanOldSeen(runtime: RssRuntime): Promise<void> {
145
180
  const Seen = getRssSeen(runtime.db);
146
181
  if (!Seen) return;
147
182
  try {
148
- const cutoff = new Date();
149
- cutoff.setDate(cutoff.getDate() - 7);
150
- const cutoffStr = cutoff.toISOString();
183
+ const cutoffStr = new Date(Date.now() - SEEN_RETENTION_MS).toISOString();
151
184
  const all = await Seen.select();
152
- const expired = all.filter((r) => typeof r.seen_at === 'string' && r.seen_at < cutoffStr);
153
- for (const row of expired) {
154
- // 表结构没有 id 列,按业务键 (feed_url, item_guid) 删除。
155
- await Seen.delete().where({ feed_url: row.feed_url, item_guid: row.item_guid });
185
+ // The table has no id column. Rows of one batch share (feed_url, seen_at), so one delete per batch.
186
+ const batches = new Map<string, { feed_url: unknown; seen_at: string }>();
187
+ for (const row of all) {
188
+ if (typeof row.seen_at !== 'string' || row.seen_at >= cutoffStr) continue;
189
+ batches.set(`${String(row.feed_url)}\0${row.seen_at}`, { feed_url: row.feed_url, seen_at: row.seen_at });
156
190
  }
191
+ for (const batch of batches.values()) await Seen.delete().where(batch);
157
192
  } catch (e) {
158
193
  // Cleanup failure must not break the main flow, but it must be visible.
159
194
  console.warn(`[rss] cleanOldSeen failed: ${e instanceof Error ? e.message : String(e)}`);