@zhin.js/plugin-rss 1.0.1 → 1.1.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/commands/rss-add/[url]/index.js +61 -0
- package/commands/rss-add/[url]/index.ts +63 -0
- package/commands/rss-check/[[url]]/index.js +43 -0
- package/commands/rss-check/[[url]]/index.ts +42 -0
- package/commands/rss-list/index.js +29 -0
- package/commands/rss-list/index.ts +32 -0
- package/commands/rss-preview/[url]/index.js +22 -0
- package/commands/rss-preview/[url]/index.ts +19 -0
- package/commands/rss-remove/[url]/index.js +35 -0
- package/commands/rss-remove/[url]/index.ts +37 -0
- package/lib/channel.d.ts +16 -0
- package/lib/channel.d.ts.map +1 -0
- package/lib/channel.js +42 -0
- package/lib/channel.js.map +1 -0
- package/lib/db-store.d.ts +5 -0
- package/lib/db-store.d.ts.map +1 -0
- package/lib/db-store.js +9 -0
- package/lib/db-store.js.map +1 -0
- package/lib/feed.d.ts +29 -0
- package/lib/feed.d.ts.map +1 -0
- package/lib/feed.js +89 -0
- package/lib/feed.js.map +1 -0
- package/lib/index.d.ts +7 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -492
- package/lib/index.js.map +1 -1
- package/lib/memory-store.d.ts +30 -0
- package/lib/memory-store.d.ts.map +1 -0
- package/lib/memory-store.js +61 -0
- package/lib/memory-store.js.map +1 -0
- package/lib/poll.d.ts +28 -0
- package/lib/poll.d.ts.map +1 -0
- package/lib/poll.js +147 -0
- package/lib/poll.js.map +1 -0
- package/lib/runtime.d.ts +16 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/runtime.js +3 -0
- package/lib/runtime.js.map +1 -0
- package/package.json +52 -13
- package/plugin.js +88 -0
- package/schema.json +30 -0
- package/skills/rss/tools/rss_list_subscriptions/index.js +32 -0
- package/skills/rss/tools/rss_list_subscriptions/index.ts +38 -0
- package/skills/rss/tools/rss_preview_feed/index.js +26 -0
- package/skills/rss/tools/rss_preview_feed/index.ts +23 -0
- package/src/channel.ts +69 -0
- package/src/db-store.ts +22 -0
- package/src/feed.ts +106 -0
- package/src/index.ts +13 -556
- package/src/memory-store.ts +88 -0
- package/src/poll.ts +153 -0
- package/src/runtime.ts +22 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory RSS models used when no DatabaseHost is available.
|
|
3
|
+
*
|
|
4
|
+
* 模型表面与 `@zhin.js/plugin-runtime` 的 DatabaseHostModel 结构兼容:
|
|
5
|
+
* select 的 where 返回 PromiseLike(host 返回链式 Selection,memory 返回 Promise),
|
|
6
|
+
* 这样 plugin.ts 可以把 PluginDatabaseHost 直接当 RssMemoryDb 用。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type RssRow = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export interface RssModel {
|
|
12
|
+
select: (...fields: string[]) => {
|
|
13
|
+
where: (query: Record<string, unknown>) => PromiseLike<RssRow[]>;
|
|
14
|
+
then: <TResult1 = RssRow[], TResult2 = never>(
|
|
15
|
+
onfulfilled?: ((value: RssRow[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
16
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
17
|
+
) => Promise<TResult1 | TResult2>;
|
|
18
|
+
};
|
|
19
|
+
insert: (row: Record<string, unknown>) => Promise<unknown>;
|
|
20
|
+
delete: () => { where: (query: Record<string, unknown>) => Promise<unknown> };
|
|
21
|
+
update: (patch: Record<string, unknown>) => {
|
|
22
|
+
where: (query: Record<string, unknown>) => Promise<unknown>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createMemoryModel(): RssModel {
|
|
27
|
+
const rows: RssRow[] = [];
|
|
28
|
+
let nextId = 1;
|
|
29
|
+
|
|
30
|
+
function matches(row: Record<string, unknown>, query: Record<string, unknown>): boolean {
|
|
31
|
+
return Object.entries(query).every(([key, value]) => row[key] === value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function all(): RssRow[] {
|
|
35
|
+
return rows.map((row) => ({ ...row }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
select: () => {
|
|
40
|
+
const promise = Promise.resolve(all());
|
|
41
|
+
return {
|
|
42
|
+
where: async (query: Record<string, unknown>) =>
|
|
43
|
+
rows.filter((row) => matches(row, query)).map((row) => ({ ...row })),
|
|
44
|
+
then: (onfulfilled, onrejected) => promise.then(onfulfilled, onrejected),
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
insert: async (row: Record<string, unknown>) => {
|
|
48
|
+
const withId: RssRow = { id: String(nextId++), ...row };
|
|
49
|
+
rows.push(withId);
|
|
50
|
+
return { ...withId };
|
|
51
|
+
},
|
|
52
|
+
delete: () => ({
|
|
53
|
+
where: async (query: Record<string, unknown>) => {
|
|
54
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
55
|
+
if (matches(rows[i]!, query)) rows.splice(i, 1);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
update: (patch: Record<string, unknown>) => ({
|
|
60
|
+
where: async (query: Record<string, unknown>) => {
|
|
61
|
+
for (const row of rows) {
|
|
62
|
+
if (matches(row, query)) Object.assign(row, patch);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RssMemoryDb {
|
|
70
|
+
models: {
|
|
71
|
+
get: (name: string) => RssModel | undefined;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const RSS_SUBS_TABLE = 'rss_subscriptions';
|
|
76
|
+
export const RSS_SEEN_TABLE = 'rss_seen_items';
|
|
77
|
+
|
|
78
|
+
export function createInMemoryRssDb(): RssMemoryDb {
|
|
79
|
+
const models = new Map<string, RssModel>([
|
|
80
|
+
[RSS_SUBS_TABLE, createMemoryModel()],
|
|
81
|
+
[RSS_SEEN_TABLE, createMemoryModel()],
|
|
82
|
+
]);
|
|
83
|
+
return {
|
|
84
|
+
models: {
|
|
85
|
+
get: (name: string) => models.get(name),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/poll.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed poll helpers: detect new items; optional OutboundHost push to subscribers.
|
|
3
|
+
*/
|
|
4
|
+
import { fetchFeed, type FeedItem } from './feed.js';
|
|
5
|
+
import { getRssSeen, getRssSubs } from './db-store.js';
|
|
6
|
+
import type { RssRuntime } from './runtime.js';
|
|
7
|
+
|
|
8
|
+
function ts(): string {
|
|
9
|
+
return new Date().toISOString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatNewItems(feedTitle: string, items: FeedItem[], maxItems: number): string {
|
|
13
|
+
const lines = [`${feedTitle} 有 ${items.length} 条新内容:`];
|
|
14
|
+
for (const item of items.slice(0, maxItems)) {
|
|
15
|
+
lines.push('');
|
|
16
|
+
lines.push(`${item.title}`);
|
|
17
|
+
if (item.summary) lines.push(item.summary);
|
|
18
|
+
if (item.link) lines.push(item.link);
|
|
19
|
+
}
|
|
20
|
+
if (items.length > maxItems) {
|
|
21
|
+
lines.push(`\n...还有 ${items.length - maxItems} 条,请查看源站`);
|
|
22
|
+
}
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function markItemsSeen(runtime: RssRuntime, feedUrl: string, items: FeedItem[]): Promise<void> {
|
|
27
|
+
const Seen = getRssSeen(runtime.db);
|
|
28
|
+
if (!Seen) return;
|
|
29
|
+
for (const item of items) {
|
|
30
|
+
const dup = await Seen.select().where({ feed_url: feedUrl, item_guid: item.guid });
|
|
31
|
+
if (dup.length === 0) {
|
|
32
|
+
await Seen.insert({
|
|
33
|
+
feed_url: feedUrl,
|
|
34
|
+
item_guid: item.guid,
|
|
35
|
+
item_title: item.title,
|
|
36
|
+
seen_at: ts(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CheckResult {
|
|
43
|
+
readonly text: string;
|
|
44
|
+
readonly totalNew: number;
|
|
45
|
+
readonly pushed: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function pushToSubscribers(runtime: RssRuntime, feedUrl: string, content: string): Promise<number> {
|
|
49
|
+
const push = runtime.outbound;
|
|
50
|
+
const Subs = getRssSubs(runtime.db);
|
|
51
|
+
if (!push || !Subs) return 0;
|
|
52
|
+
const subscribers = await Subs.select().where({ url: feedUrl });
|
|
53
|
+
let pushed = 0;
|
|
54
|
+
for (const sub of subscribers) {
|
|
55
|
+
const adapterName = String(sub.adapter_name ?? '');
|
|
56
|
+
const channelId = String(sub.channel_id ?? '');
|
|
57
|
+
if (!adapterName || !channelId) continue;
|
|
58
|
+
try {
|
|
59
|
+
await push({
|
|
60
|
+
adapterName,
|
|
61
|
+
endpointKey: String(sub.endpoint_id ?? adapterName),
|
|
62
|
+
channelType: String(sub.channel_type ?? 'private'),
|
|
63
|
+
channelId,
|
|
64
|
+
content,
|
|
65
|
+
});
|
|
66
|
+
pushed += 1;
|
|
67
|
+
} catch {
|
|
68
|
+
// OutboundHost already logs; continue other subscribers.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return pushed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check subscribed feeds for new items and mark them seen.
|
|
76
|
+
* When OutboundHost is wired, pushes formatted text to each subscriber channel.
|
|
77
|
+
*/
|
|
78
|
+
export async function checkSubscriptions(runtime: RssRuntime, options: {
|
|
79
|
+
urls: readonly string[];
|
|
80
|
+
}): Promise<CheckResult> {
|
|
81
|
+
const Seen = getRssSeen(runtime.db);
|
|
82
|
+
const Subs = getRssSubs(runtime.db);
|
|
83
|
+
if (!Seen || !Subs) {
|
|
84
|
+
return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
let totalNew = 0;
|
|
89
|
+
let pushed = 0;
|
|
90
|
+
|
|
91
|
+
for (const url of options.urls) {
|
|
92
|
+
try {
|
|
93
|
+
const { title, items } = await fetchFeed(url, runtime.config.timeout);
|
|
94
|
+
const seenRows = await Seen.select().where({ feed_url: url });
|
|
95
|
+
const seenGuids = new Set(seenRows.map((r) => String(r.item_guid ?? '')));
|
|
96
|
+
const newItems = items.filter((item) => item.guid && !seenGuids.has(item.guid));
|
|
97
|
+
if (newItems.length === 0) {
|
|
98
|
+
parts.push(`${title || url}: 无新内容`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
await markItemsSeen(runtime, url, newItems);
|
|
102
|
+
totalNew += newItems.length;
|
|
103
|
+
const body = formatNewItems(title || url, newItems, runtime.config.maxItems);
|
|
104
|
+
parts.push(body);
|
|
105
|
+
pushed += await pushToSubscribers(runtime, url, body);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
parts.push(`${url}: 检查失败 — ${(e as Error).message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (parts.length === 0) return { text: '没有可检查的订阅', totalNew: 0, pushed: 0 };
|
|
112
|
+
const pushNote = runtime.outbound
|
|
113
|
+
? (pushed > 0 ? `,已推送 ${pushed} 个会话` : ',无出站推送')
|
|
114
|
+
: '(未装配 OutboundHost,仅本地标记)';
|
|
115
|
+
const header = totalNew > 0
|
|
116
|
+
? `检查完成,共 ${totalNew} 条新内容${pushNote}`
|
|
117
|
+
: '检查完成,暂无新内容';
|
|
118
|
+
return { text: `${header}\n\n${parts.join('\n\n')}`, totalNew, pushed };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Periodically clean seen records older than 7 days.
|
|
123
|
+
*/
|
|
124
|
+
export async function cleanOldSeen(runtime: RssRuntime): Promise<void> {
|
|
125
|
+
const Seen = getRssSeen(runtime.db);
|
|
126
|
+
if (!Seen) return;
|
|
127
|
+
try {
|
|
128
|
+
const cutoff = new Date();
|
|
129
|
+
cutoff.setDate(cutoff.getDate() - 7);
|
|
130
|
+
const cutoffStr = cutoff.toISOString();
|
|
131
|
+
const all = await Seen.select();
|
|
132
|
+
const expired = all.filter((r) => typeof r.seen_at === 'string' && r.seen_at < cutoffStr);
|
|
133
|
+
for (const row of expired) {
|
|
134
|
+
// 表结构没有 id 列,按业务键 (feed_url, item_guid) 删除。
|
|
135
|
+
await Seen.delete().where({ feed_url: row.feed_url, item_guid: row.item_guid });
|
|
136
|
+
}
|
|
137
|
+
} catch (e) {
|
|
138
|
+
// Cleanup failure must not break the main flow, but it must be visible.
|
|
139
|
+
console.warn(`[rss] cleanOldSeen failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Poll all unique feed URLs across subscriptions.
|
|
145
|
+
*/
|
|
146
|
+
export async function pollAllFeeds(runtime: RssRuntime): Promise<CheckResult> {
|
|
147
|
+
const Subs = getRssSubs(runtime.db);
|
|
148
|
+
if (!Subs) return { text: 'RSS 数据库尚未就绪', totalNew: 0, pushed: 0 };
|
|
149
|
+
const all = await Subs.select();
|
|
150
|
+
const urls = [...new Set(all.map((s) => String(s.url ?? '')).filter(Boolean))];
|
|
151
|
+
if (urls.length === 0) return { text: '暂无任何 RSS 订阅', totalNew: 0, pushed: 0 };
|
|
152
|
+
return checkSubscriptions(runtime, { urls });
|
|
153
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createToken } from 'zhin.js';
|
|
2
|
+
import type { RssConfig } from './feed.js';
|
|
3
|
+
import type { RssMemoryDb } from './memory-store.js';
|
|
4
|
+
|
|
5
|
+
export type RssOutboundPush = (input: {
|
|
6
|
+
readonly adapterName: string;
|
|
7
|
+
readonly endpointKey: string;
|
|
8
|
+
readonly channelType: string;
|
|
9
|
+
readonly channelId: string;
|
|
10
|
+
readonly content: string;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
export interface RssRuntime {
|
|
14
|
+
readonly db: RssMemoryDb;
|
|
15
|
+
readonly config: Readonly<RssConfig>;
|
|
16
|
+
readonly outbound: RssOutboundPush | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const rssRuntimeToken = createToken<RssRuntime>(
|
|
20
|
+
'zhin.rss.runtime',
|
|
21
|
+
'Owner-scoped RSS runtime',
|
|
22
|
+
);
|