@anyul/koishi-plugin-rss 5.3.2 → 5.3.10
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/commands/index.js +1 -3
- package/lib/commands/subscription-create.d.ts +2 -0
- package/lib/commands/subscription-create.js +2 -2
- package/lib/commands/subscription-edit.d.ts +2 -0
- package/lib/commands/subscription-edit.js +8 -12
- package/lib/commands/subscription-management.d.ts +2 -0
- package/lib/commands/subscription-management.js +10 -12
- package/lib/commands/utils.d.ts +0 -5
- package/lib/commands/utils.js +0 -16
- package/lib/commands/web-monitor.d.ts +2 -0
- package/lib/commands/web-monitor.js +2 -2
- package/lib/config.js +4 -10
- package/lib/core/feeder-arg.js +3 -38
- package/lib/core/feeder-runtime.js +0 -5
- package/lib/core/feeder.js +53 -12
- package/lib/core/item-processor-runtime.d.ts +4 -2
- package/lib/core/item-processor-runtime.js +4 -12
- package/lib/core/notification-queue-sender.js +0 -5
- package/lib/core/notification-queue-store.d.ts +7 -0
- package/lib/core/notification-queue-store.js +28 -6
- package/lib/core/notification-queue.d.ts +10 -0
- package/lib/core/notification-queue.js +18 -7
- package/lib/core/parser.js +5 -5
- package/lib/core/renderer.js +7 -7
- package/lib/core/search-format.d.ts +1 -1
- package/lib/core/search-format.js +1 -1
- package/lib/core/subscription-store.d.ts +44 -0
- package/lib/core/subscription-store.js +60 -0
- package/lib/core/telegram-video-restore.js +3 -8
- package/lib/index.d.ts +1 -4
- package/lib/index.js +20 -15
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types.d.ts +51 -32
- package/lib/utils/common.d.ts +6 -0
- package/lib/utils/common.js +20 -30
- package/lib/utils/error-handler.d.ts +49 -0
- package/lib/utils/error-handler.js +111 -1
- package/lib/utils/fetcher.js +12 -38
- package/lib/utils/legacy-config.js +9 -3
- package/lib/utils/logger.d.ts +6 -0
- package/lib/utils/logger.js +82 -14
- package/lib/utils/media.js +21 -21
- package/lib/utils/message-cache.d.ts +8 -0
- package/lib/utils/message-cache.js +12 -0
- package/lib/utils/proxy.d.ts +37 -0
- package/lib/utils/proxy.js +60 -0
- package/package.json +16 -3
- package/lib/commands/error-handler.d.ts +0 -53
- package/lib/commands/error-handler.js +0 -120
- package/lib/commands/rss-subscribe.d.ts +0 -11
- package/lib/commands/rss-subscribe.js +0 -323
- package/lib/core/feeder-old.d.ts +0 -15
- package/lib/core/feeder-old.js +0 -403
- package/lib/utils/error-tracker.d.ts +0 -127
- package/lib/utils/error-tracker.js +0 -352
- package/lib/utils/metrics.d.ts +0 -184
- package/lib/utils/metrics.js +0 -401
- package/lib/utils/structured-logger.d.ts +0 -135
- package/lib/utils/structured-logger.js +0 -248
package/lib/commands/index.js
CHANGED
|
@@ -11,7 +11,6 @@ exports.handleCacheClear = handleCacheClear;
|
|
|
11
11
|
exports.handleCacheCleanup = handleCacheCleanup;
|
|
12
12
|
exports.handleCachePull = handleCachePull;
|
|
13
13
|
const error_handler_1 = require("../utils/error-handler");
|
|
14
|
-
const error_tracker_1 = require("../utils/error-tracker");
|
|
15
14
|
const logger_1 = require("../utils/logger");
|
|
16
15
|
const message_cache_1 = require("../utils/message-cache");
|
|
17
16
|
const utils_1 = require("./utils");
|
|
@@ -288,7 +287,7 @@ async function handleCacheList(cmdCtx, args, config) {
|
|
|
288
287
|
}
|
|
289
288
|
}
|
|
290
289
|
async function handleCacheMessage(cmdCtx, args, config) {
|
|
291
|
-
const {
|
|
290
|
+
const { logContext } = cmdCtx;
|
|
292
291
|
const realId = parseInt(args[0]);
|
|
293
292
|
if (!realId || realId < 1) {
|
|
294
293
|
return '请提供真实 ID\n用法: rsso.cache message <ID>\n示例: rsso.cache message 42\n💡 提示:使用 "rsso.cache list" 查看方括号中的真实 ID';
|
|
@@ -525,7 +524,6 @@ async function resolveCacheMessageById(cmdCtx, realId) {
|
|
|
525
524
|
function logCacheError(config, error, scope, context) {
|
|
526
525
|
const normalizedError = (0, error_handler_1.normalizeError)(error);
|
|
527
526
|
(0, logger_1.debug)(config, normalizedError, scope, 'error', context);
|
|
528
|
-
(0, error_tracker_1.trackError)(normalizedError, context);
|
|
529
527
|
}
|
|
530
528
|
var subscription_management_1 = require("./subscription-management");
|
|
531
529
|
Object.defineProperty(exports, "registerSubscriptionManagementCommands", { enumerable: true, get: function () { return subscription_management_1.registerSubscriptionManagementCommands; } });
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
+
import { SubscriptionStore } from '../core/subscription-store';
|
|
2
3
|
import type { Config } from '../types';
|
|
3
4
|
export interface QuickListItem {
|
|
4
5
|
prefix: string;
|
|
@@ -20,6 +21,7 @@ export declare function resolveQuickItem(input: string, quickList: QuickListItem
|
|
|
20
21
|
export interface SubscriptionCreateCommandDeps {
|
|
21
22
|
ctx: Context;
|
|
22
23
|
config: Config;
|
|
24
|
+
store: SubscriptionStore;
|
|
23
25
|
usage: string;
|
|
24
26
|
quickList: QuickListItem[];
|
|
25
27
|
parseQuickUrl: (url: string) => string;
|
|
@@ -71,7 +71,7 @@ function registerSubscriptionCreateCommand(deps) {
|
|
|
71
71
|
if (!(0, utils_1.isValidUrl)((0, common_1.ensureUrlProtocol)(url))) {
|
|
72
72
|
return '❌ URL 格式不正确,请以 http:// 或 https:// 开头';
|
|
73
73
|
}
|
|
74
|
-
const rssList = await deps.
|
|
74
|
+
const rssList = await deps.store.findByGuild(platform, guildId);
|
|
75
75
|
if (rssList.find(item => item.url === url))
|
|
76
76
|
return '❌ 该订阅已存在';
|
|
77
77
|
const rawArg = deps.formatArg(options);
|
|
@@ -137,7 +137,7 @@ function registerSubscriptionCreateCommand(deps) {
|
|
|
137
137
|
else if (deps.config.basic.urlDeduplication && rssList.find(item => item.rssId === rssItem.rssId)) {
|
|
138
138
|
return `❌ 订阅已存在: ${rssItem.rssId}`;
|
|
139
139
|
}
|
|
140
|
-
await deps.
|
|
140
|
+
await deps.store.create(rssItem);
|
|
141
141
|
if (deps.config.basic.firstLoad && arg.firstLoad !== false && rssItemList.length > 0) {
|
|
142
142
|
let itemArray = rssItemList.sort((a, b) => deps.parsePubDate(b.pubDate).getTime() - deps.parsePubDate(a.pubDate).getTime());
|
|
143
143
|
if (arg.reverse)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
+
import { SubscriptionStore } from '../core/subscription-store';
|
|
2
3
|
import type { Config } from '../types';
|
|
3
4
|
export interface SubscriptionEditCommandDeps {
|
|
4
5
|
ctx: Context;
|
|
5
6
|
config: Config;
|
|
7
|
+
store: SubscriptionStore;
|
|
6
8
|
}
|
|
7
9
|
export declare function registerSubscriptionEditCommand(deps: SubscriptionEditCommandDeps): void;
|
|
@@ -37,7 +37,7 @@ function registerSubscriptionEditCommand(deps) {
|
|
|
37
37
|
.example('rsso.edit 1 -t "新标题"')
|
|
38
38
|
.action(async ({ session, options }, id) => {
|
|
39
39
|
const { guildId, platform, authority } = (0, utils_1.extractSessionInfo)(session);
|
|
40
|
-
const rssList = await deps.
|
|
40
|
+
const rssList = await deps.store.findByGuild(platform, guildId);
|
|
41
41
|
const listIndex = id - 1;
|
|
42
42
|
if (listIndex < 0 || listIndex >= rssList.length) {
|
|
43
43
|
return `❌ 序号 ${id} 不存在\n当前共有 ${rssList.length} 个订阅\n\n使用 rsso.list 查看完整列表`;
|
|
@@ -62,7 +62,7 @@ function registerSubscriptionEditCommand(deps) {
|
|
|
62
62
|
if (options.test) {
|
|
63
63
|
return buildEditPreview(rssItem, id, options, parseResult.targets);
|
|
64
64
|
}
|
|
65
|
-
return saveSubscriptionChanges(deps.
|
|
65
|
+
return saveSubscriptionChanges(deps.store, rssItem, id, options, parseResult.targets);
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
function buildEditPreview(rssItem, id, options, parsedTargets) {
|
|
@@ -90,7 +90,7 @@ function buildEditPreview(rssItem, id, options, parsedTargets) {
|
|
|
90
90
|
testOutput += '\n⚠️ 测试模式:不会保存修改\n去掉 --test 选项保存更改';
|
|
91
91
|
return testOutput;
|
|
92
92
|
}
|
|
93
|
-
async function saveSubscriptionChanges(
|
|
93
|
+
async function saveSubscriptionChanges(store, rssItem, id, options, parsedTargets) {
|
|
94
94
|
const updates = {};
|
|
95
95
|
if (options.title)
|
|
96
96
|
updates.title = options.title;
|
|
@@ -114,7 +114,7 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
|
|
|
114
114
|
const [newPlatform, newGuildId] = parsedTargets[0].split(/[::]/);
|
|
115
115
|
updates.platform = newPlatform;
|
|
116
116
|
updates.guildId = newGuildId;
|
|
117
|
-
await
|
|
117
|
+
await store.update(rssItem.id, updates);
|
|
118
118
|
let result = `✅ 订阅已更新 [序号:${id} | ID:${rssItem.id}]\n\n`;
|
|
119
119
|
if (options.title)
|
|
120
120
|
result += `标题: ${rssItem.title} → ${options.title}\n`;
|
|
@@ -131,14 +131,10 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
|
|
|
131
131
|
let result = `✅ 订阅已更新 [序号:${id} | ID:${rssItem.id}]\n\n`;
|
|
132
132
|
result += `已创建 ${parsedTargets.length} 个推送目标:\n\n`;
|
|
133
133
|
result += `1. 原订阅 (本群): ${originalTarget}\n`;
|
|
134
|
-
await
|
|
134
|
+
await store.update(rssItem.id, updates);
|
|
135
135
|
for (let i = 0; i < parsedTargets.length; i++) {
|
|
136
136
|
const [newPlatform, newGuildId] = parsedTargets[i].split(/[::]/);
|
|
137
|
-
const existing = await
|
|
138
|
-
platform: newPlatform,
|
|
139
|
-
guildId: newGuildId,
|
|
140
|
-
url: rssItem.url
|
|
141
|
-
});
|
|
137
|
+
const existing = await store.findByGuildAndUrl(newPlatform, newGuildId, rssItem.url);
|
|
142
138
|
if (existing.length > 0) {
|
|
143
139
|
result += `${i + 2}. ⚠️ ${newPlatform}:${newGuildId} (订阅已存在,已跳过)\n`;
|
|
144
140
|
continue;
|
|
@@ -149,7 +145,7 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
|
|
|
149
145
|
platform: newPlatform,
|
|
150
146
|
guildId: newGuildId,
|
|
151
147
|
};
|
|
152
|
-
const created = await
|
|
148
|
+
const created = await store.create(newSubscription);
|
|
153
149
|
result += `${i + 2}. ✅ ${newPlatform}:${newGuildId} (新订阅ID: ${created.id})\n`;
|
|
154
150
|
}
|
|
155
151
|
if (options.title)
|
|
@@ -159,7 +155,7 @@ async function saveSubscriptionChanges(ctx, rssItem, id, options, parsedTargets)
|
|
|
159
155
|
result += '\n💡 提示: 可以使用 rsso.list 查看所有订阅';
|
|
160
156
|
return result.trim();
|
|
161
157
|
}
|
|
162
|
-
await
|
|
158
|
+
await store.update(rssItem.id, updates);
|
|
163
159
|
let result = `✅ 订阅已更新 [序号:${id} | ID:${rssItem.id}]\n\n`;
|
|
164
160
|
if (options.title)
|
|
165
161
|
result += `标题: ${rssItem.title} → ${options.title}\n`;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
+
import { SubscriptionStore } from '../core/subscription-store';
|
|
2
3
|
import type { Config } from '../types';
|
|
3
4
|
export interface SubscriptionCommandDeps {
|
|
4
5
|
ctx: Context;
|
|
5
6
|
config: Config;
|
|
7
|
+
store: SubscriptionStore;
|
|
6
8
|
parsePubDate: (pubDate: any) => Date;
|
|
7
9
|
parseQuickUrl: (url: string) => string;
|
|
8
10
|
getRssData: (url: string, arg: any) => Promise<any[]>;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerSubscriptionManagementCommands = registerSubscriptionManagementCommands;
|
|
4
4
|
const error_handler_1 = require("../utils/error-handler");
|
|
5
|
-
const error_tracker_1 = require("../utils/error-tracker");
|
|
6
5
|
const logger_1 = require("../utils/logger");
|
|
7
6
|
const utils_1 = require("./utils");
|
|
8
7
|
function registerSubscriptionManagementCommands(deps) {
|
|
@@ -24,7 +23,7 @@ function registerListCommand(deps) {
|
|
|
24
23
|
.example('rsso.list')
|
|
25
24
|
.action(async ({ session }, id) => {
|
|
26
25
|
const { guildId, platform } = (0, utils_1.extractSessionInfo)(session);
|
|
27
|
-
const rssList = await getGuildSubscriptions(deps.
|
|
26
|
+
const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
|
|
28
27
|
if (id === undefined) {
|
|
29
28
|
if (rssList.length === 0)
|
|
30
29
|
return '当前没有任何订阅';
|
|
@@ -70,14 +69,14 @@ function registerRemoveCommand(deps) {
|
|
|
70
69
|
const authorityCheck = (0, utils_1.checkAuthority)(authority, deps.config.basic.authority, `权限不足!当前权限: ${authority},需要权限: ${deps.config.basic.authority} 或以上`);
|
|
71
70
|
if (!authorityCheck.success)
|
|
72
71
|
return authorityCheck.message;
|
|
73
|
-
await deps.
|
|
72
|
+
await deps.store.removeAllByGuild(platform, guildId);
|
|
74
73
|
return '✅ 已删除全部订阅';
|
|
75
74
|
}
|
|
76
|
-
const rssList = await getGuildSubscriptions(deps.
|
|
75
|
+
const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
|
|
77
76
|
const rssItem = getSubscriptionByIndex(rssList, id);
|
|
78
77
|
if (!rssItem)
|
|
79
78
|
return getSubscriptionNotFoundMessage(id, rssList.length);
|
|
80
|
-
await deps.
|
|
79
|
+
await deps.store.remove(rssItem.id);
|
|
81
80
|
return `✅ 已删除订阅: ${rssItem.title}`;
|
|
82
81
|
});
|
|
83
82
|
}
|
|
@@ -93,7 +92,7 @@ function registerPullCommand(deps) {
|
|
|
93
92
|
.example('rsso.pull 1')
|
|
94
93
|
.action(async ({ session }, id) => {
|
|
95
94
|
const { guildId, platform } = (0, utils_1.extractSessionInfo)(session);
|
|
96
|
-
const rssList = await getGuildSubscriptions(deps.
|
|
95
|
+
const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
|
|
97
96
|
const rssItem = getSubscriptionByIndex(rssList, id);
|
|
98
97
|
if (!rssItem)
|
|
99
98
|
return getSubscriptionNotFoundMessage(id, rssList.length);
|
|
@@ -121,7 +120,6 @@ function registerPullCommand(deps) {
|
|
|
121
120
|
catch (error) {
|
|
122
121
|
const normalizedError = (0, error_handler_1.normalizeError)(error);
|
|
123
122
|
(0, logger_1.debug)(deps.config, normalizedError, 'pull error', 'error', logContext);
|
|
124
|
-
(0, error_tracker_1.trackError)(normalizedError, logContext);
|
|
125
123
|
return `拉取失败: ${(0, error_handler_1.getFriendlyErrorMessage)(error, '获取订阅数据')}`;
|
|
126
124
|
}
|
|
127
125
|
});
|
|
@@ -140,7 +138,7 @@ function registerFollowCommand(deps) {
|
|
|
140
138
|
.example('rsso.follow 1')
|
|
141
139
|
.action(async ({ session, options }, id) => {
|
|
142
140
|
const { guildId, platform, authorId, authority } = (0, utils_1.extractSessionInfo)(session);
|
|
143
|
-
const rssList = await getGuildSubscriptions(deps.
|
|
141
|
+
const rssList = await getGuildSubscriptions(deps.store, platform, guildId);
|
|
144
142
|
const rssItem = getSubscriptionByIndex(rssList, id);
|
|
145
143
|
if (!rssItem)
|
|
146
144
|
return getSubscriptionNotFoundMessage(id, rssList.length);
|
|
@@ -152,18 +150,18 @@ function registerFollowCommand(deps) {
|
|
|
152
150
|
if (followers.includes('all'))
|
|
153
151
|
return '💡 已经设置全员提醒';
|
|
154
152
|
followers.push('all');
|
|
155
|
-
await deps.
|
|
153
|
+
await deps.store.update(rssItem.id, { followers });
|
|
156
154
|
return '✅ 已设置全员提醒';
|
|
157
155
|
}
|
|
158
156
|
if (followers.includes(authorId))
|
|
159
157
|
return '💡 已经关注过了';
|
|
160
158
|
followers.push(authorId);
|
|
161
|
-
await deps.
|
|
159
|
+
await deps.store.update(rssItem.id, { followers });
|
|
162
160
|
return '✅ 关注成功';
|
|
163
161
|
});
|
|
164
162
|
}
|
|
165
|
-
async function getGuildSubscriptions(
|
|
166
|
-
return
|
|
163
|
+
async function getGuildSubscriptions(store, platform, guildId) {
|
|
164
|
+
return store.findByGuild(platform, guildId);
|
|
167
165
|
}
|
|
168
166
|
function getSubscriptionByIndex(rssList, id) {
|
|
169
167
|
const listIndex = id - 1;
|
package/lib/commands/utils.d.ts
CHANGED
|
@@ -33,11 +33,6 @@ export declare function extractSessionInfo(session: Session): SessionInfo;
|
|
|
33
33
|
* 构建命令日志上下文
|
|
34
34
|
*/
|
|
35
35
|
export declare function buildCommandLogContext(session: Session, command?: string, operation?: string): Record<string, any>;
|
|
36
|
-
/**
|
|
37
|
-
* 命令错误处理包装器
|
|
38
|
-
* 统一处理命令执行中的错误
|
|
39
|
-
*/
|
|
40
|
-
export declare function withCommandErrorHandling(config: Config, operation: string, handler: () => Promise<string>, context?: Record<string, any>): Promise<string>;
|
|
41
36
|
/**
|
|
42
37
|
* 权限检查辅助函数
|
|
43
38
|
*/
|
package/lib/commands/utils.js
CHANGED
|
@@ -6,14 +6,10 @@
|
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.extractSessionInfo = extractSessionInfo;
|
|
8
8
|
exports.buildCommandLogContext = buildCommandLogContext;
|
|
9
|
-
exports.withCommandErrorHandling = withCommandErrorHandling;
|
|
10
9
|
exports.checkAuthority = checkAuthority;
|
|
11
10
|
exports.parseTarget = parseTarget;
|
|
12
11
|
exports.parseTargets = parseTargets;
|
|
13
12
|
exports.isValidUrl = isValidUrl;
|
|
14
|
-
const error_handler_1 = require("../utils/error-handler");
|
|
15
|
-
const error_tracker_1 = require("../utils/error-tracker");
|
|
16
|
-
const logger_1 = require("../utils/logger");
|
|
17
13
|
/**
|
|
18
14
|
* 从会话中提取信息
|
|
19
15
|
*/
|
|
@@ -39,18 +35,6 @@ function buildCommandLogContext(session, command, operation) {
|
|
|
39
35
|
context.operation = operation;
|
|
40
36
|
return context;
|
|
41
37
|
}
|
|
42
|
-
/**
|
|
43
|
-
* 命令错误处理包装器
|
|
44
|
-
* 统一处理命令执行中的错误
|
|
45
|
-
*/
|
|
46
|
-
function withCommandErrorHandling(config, operation, handler, context) {
|
|
47
|
-
return handler().catch((error) => {
|
|
48
|
-
const normalizedError = (0, error_handler_1.normalizeError)(error);
|
|
49
|
-
(0, logger_1.debug)(config, normalizedError, `${operation} error`, 'error', context);
|
|
50
|
-
(0, error_tracker_1.trackError)(normalizedError, context);
|
|
51
|
-
return Promise.resolve(`${operation}失败: ${(0, error_handler_1.getFriendlyErrorMessage)(error, operation)}`);
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
38
|
/**
|
|
55
39
|
* 权限检查辅助函数
|
|
56
40
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Context } from 'koishi';
|
|
2
|
+
import { SubscriptionStore } from '../core/subscription-store';
|
|
2
3
|
import type { Config, TemplateType, rssArg } from '../types';
|
|
3
4
|
type DebugType = 'disable' | 'error' | 'info' | 'details';
|
|
4
5
|
type HtmlMonitorArg = rssArg & {
|
|
@@ -21,6 +22,7 @@ interface WatchCommandOptions {
|
|
|
21
22
|
export interface WebMonitorCommandDeps {
|
|
22
23
|
ctx: Context;
|
|
23
24
|
config: Config;
|
|
25
|
+
store: SubscriptionStore;
|
|
24
26
|
debug: (message: any, name?: string, type?: DebugType, context?: Record<string, any>) => void;
|
|
25
27
|
mixinArg: (arg: Record<string, any>) => rssArg;
|
|
26
28
|
getRssData: (url: string, arg: Record<string, any>) => Promise<any[]>;
|
|
@@ -55,7 +55,7 @@ HTML 网页监控功能,使用 CSS 选择器提取内容
|
|
|
55
55
|
return '❌ 未找到符合选择器的元素';
|
|
56
56
|
return buildItemsPreview(items, `找到 ${items.length} 个元素:`, true);
|
|
57
57
|
}
|
|
58
|
-
const rssList = await deps.
|
|
58
|
+
const rssList = await deps.store.findByGuild(platform, guildId);
|
|
59
59
|
if (rssList.find(item => item.url === normalizedUrl))
|
|
60
60
|
return '❌ 该订阅已存在';
|
|
61
61
|
const htmlItems = await deps.getRssData(normalizedUrl, arg);
|
|
@@ -77,7 +77,7 @@ HTML 网页监控功能,使用 CSS 选择器提取内容
|
|
|
77
77
|
if (deps.config.basic.urlDeduplication && rssList.find(item => item.rssId === rssItem.rssId)) {
|
|
78
78
|
return `❌ 订阅已存在: ${rssItem.rssId}`;
|
|
79
79
|
}
|
|
80
|
-
await deps.
|
|
80
|
+
await deps.store.create(rssItem);
|
|
81
81
|
if (deps.config.basic.firstLoad && arg.firstLoad !== false && htmlItems.length > 0) {
|
|
82
82
|
await broadcastInitialItems(deps, `${platform}:${guildId}`, htmlItems, rssItem);
|
|
83
83
|
}
|
package/lib/config.js
CHANGED
|
@@ -54,10 +54,12 @@ exports.Config = koishi_1.Schema.object({
|
|
|
54
54
|
maxRssItem: koishi_1.Schema.number().description('限制更新时的最大推送数量上限,超出上限时较早的更新会被忽略').default(10),
|
|
55
55
|
firstLoad: koishi_1.Schema.boolean().description('首次订阅时是否发送最后的更新').default(true),
|
|
56
56
|
urlDeduplication: koishi_1.Schema.boolean().description('同群组中不允许重复添加相同订阅').default(true),
|
|
57
|
-
|
|
57
|
+
resendUpdatedContent: koishi_1.Schema.union(['disable', 'latest', 'all']).description('当内容更新时再次发送').default('disable'),
|
|
58
|
+
resendUpdataContent: koishi_1.Schema.union(['disable', 'latest', 'all']).description('(已弃用,请使用 resendUpdatedContent)').hidden().deprecated(),
|
|
58
59
|
imageMode: koishi_1.Schema.union(['base64', 'File', 'assets']).description('图片发送模式<br>\`base64\` Base64格式(兼容性好但容易超长)<br>\`File\` 本地文件(不支持沙盒环境)<br>\`assets\` Assets服务(推荐,需安装assets-xxx插件并配置)').default('base64'),
|
|
59
60
|
videoMode: koishi_1.Schema.union(['filter', 'href', 'base64', 'File', 'assets']).description('视频发送模式(iframe标签内的视频无法处理)<br>\`filter\` 过滤视频,含有视频的推送将不会被发送<br>\`href\` 使用视频网络地址直接发送<br>\`base64\` 下载后以base64格式发送<br>\`File\` 下载后以文件发送<br>\`assets\` 上传到assets服务(需安装assets-xxx插件并配置)').default('href'),
|
|
60
|
-
|
|
61
|
+
mergeVideo: koishi_1.Schema.boolean().default(false).description('以合并消息发送视频'),
|
|
62
|
+
margeVideo: koishi_1.Schema.boolean().description('(已弃用,请使用 mergeVideo)').hidden().deprecated(),
|
|
61
63
|
usePoster: koishi_1.Schema.boolean().default(false).description('加载视频封面'),
|
|
62
64
|
autoSplitImage: koishi_1.Schema.boolean().description('垂直拆分大尺寸图片,解决部分适配器发不出长图的问题').default(true),
|
|
63
65
|
cacheDir: koishi_1.Schema.string().description('File模式时使用的缓存路径').default('data/cache/rssOwl'),
|
|
@@ -191,14 +193,6 @@ exports.Config = koishi_1.Schema.object({
|
|
|
191
193
|
contextFields: koishi_1.Schema.array(koishi_1.Schema.string()).description('要包含的上下文字段(如 guildId, platform 等)').default([]),
|
|
192
194
|
sanitizeLogs: koishi_1.Schema.boolean().description('自动脱敏日志中的敏感信息').default(true),
|
|
193
195
|
}).description('日志设置'),
|
|
194
|
-
errorTracking: koishi_1.Schema.object({
|
|
195
|
-
enabled: koishi_1.Schema.boolean().description('启用错误追踪').default(false),
|
|
196
|
-
dsn: koishi_1.Schema.string().role('secret').description('Sentry DSN').default(''),
|
|
197
|
-
environment: koishi_1.Schema.string().description('错误追踪环境').default('production'),
|
|
198
|
-
release: koishi_1.Schema.string().description('错误追踪版本号').default('5.2.48'),
|
|
199
|
-
tracesSampleRate: koishi_1.Schema.number().min(0).max(1).description('性能追踪采样率').default(0.1),
|
|
200
|
-
profilesSampleRate: koishi_1.Schema.number().min(0).max(1).description('性能分析采样率').default(0.1),
|
|
201
|
-
}).description('错误追踪设置'),
|
|
202
196
|
tdl: koishi_1.Schema.object({
|
|
203
197
|
enabled: koishi_1.Schema.boolean().description('启用 Telegram 大视频 tdl 兜底下载<br>当 RSSHub 返回 "Video is too big" 占位时,调用外部 tdl 直接从 Telegram 拉取原始视频。<br><b>需宿主机自行安装</b>: `go install github.com/iyear/tdl@latest` 或下载 [Release](https://github.com/iyear/tdl/releases),并执行 `tdl login` 完成登录。<br>插件运行时探测 PATH,缺失则跳过、不阻塞主流程。').default(false),
|
|
204
198
|
binPath: koishi_1.Schema.string().description('tdl 二进制路径,默认从 PATH 探测<br>Docker/容器场景下若 tdl 装在持久卷(如 /koishi/bin/tdl),在此指定可避免依赖容器 PATH').default(''),
|
package/lib/core/feeder-arg.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.formatArg = formatArg;
|
|
4
4
|
exports.mixinArg = mixinArg;
|
|
5
5
|
const legacy_config_1 = require("../utils/legacy-config");
|
|
6
|
+
const proxy_1 = require("../utils/proxy");
|
|
6
7
|
const logger_1 = require("../utils/logger");
|
|
7
8
|
const ARG_ENTRY_KEYS = [
|
|
8
9
|
'forceLength',
|
|
@@ -102,45 +103,9 @@ function parseProxyAgentArg(value, auth) {
|
|
|
102
103
|
return undefined;
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
(0, logger_1.debug)(config, `合并代理配置 - argProxy: ${JSON.stringify(argProxy)}, configProxy.enabled: ${configProxy?.enabled}`, 'proxy merge debug', 'details');
|
|
107
|
-
if (argProxy?.enabled === false) {
|
|
108
|
-
(0, logger_1.debug)(config, '订阅明确禁用代理', 'proxy merge', 'details');
|
|
109
|
-
return { enabled: false };
|
|
110
|
-
}
|
|
111
|
-
if (argProxy?.enabled === true && argProxy?.host) {
|
|
112
|
-
(0, logger_1.debug)(config, '使用订阅的代理配置', 'proxy merge', 'details');
|
|
113
|
-
return argProxy;
|
|
114
|
-
}
|
|
115
|
-
const shouldUseConfigProxy = !argProxy || Object.keys(argProxy || {}).length === 0 || argProxy?.enabled === undefined || argProxy?.enabled === null;
|
|
116
|
-
if (shouldUseConfigProxy) {
|
|
117
|
-
if (configProxy?.enabled) {
|
|
118
|
-
const result = {
|
|
119
|
-
enabled: true,
|
|
120
|
-
protocol: configProxy.protocol,
|
|
121
|
-
host: configProxy.host,
|
|
122
|
-
port: configProxy.port,
|
|
123
|
-
auth: configProxy.auth?.enabled ? configProxy.auth : undefined,
|
|
124
|
-
};
|
|
125
|
-
(0, logger_1.debug)(config, `使用全局代理: ${result.protocol}://${result.host}:${result.port}`, 'proxy merge', 'info');
|
|
126
|
-
return result;
|
|
127
|
-
}
|
|
128
|
-
(0, logger_1.debug)(config, '全局代理未启用', 'proxy merge', 'details');
|
|
129
|
-
}
|
|
130
|
-
if (argProxy?.enabled === true && !argProxy?.host) {
|
|
131
|
-
const result = {
|
|
132
|
-
...configProxy,
|
|
133
|
-
...argProxy,
|
|
134
|
-
auth: configProxy?.auth?.enabled ? configProxy.auth : undefined,
|
|
135
|
-
};
|
|
136
|
-
(0, logger_1.debug)(config, '订阅代理配置不完整,补充全局配置', 'proxy merge', 'details');
|
|
137
|
-
return result;
|
|
138
|
-
}
|
|
139
|
-
(0, logger_1.debug)(config, '代理未配置,使用默认(禁用)', 'proxy merge', 'details');
|
|
140
|
-
return { enabled: false };
|
|
141
|
-
}
|
|
106
|
+
// mergeProxyAgent 已提升到 utils/proxy.ts,统一代理 helper 归属
|
|
142
107
|
function mergeProxyAgentWithLog(argProxy, configProxy, config) {
|
|
143
|
-
const result = mergeProxyAgent(argProxy, configProxy, config);
|
|
108
|
+
const result = (0, proxy_1.mergeProxyAgent)(argProxy, configProxy, config);
|
|
144
109
|
(0, logger_1.debug)(config, `[DEBUG_PROXY] mergeProxyAgent input: arg=${JSON.stringify(argProxy)} conf=${JSON.stringify(configProxy)} output=${JSON.stringify(result)}`, 'proxy merge', 'details');
|
|
145
110
|
return result;
|
|
146
111
|
}
|
|
@@ -13,7 +13,6 @@ const koishi_1 = require("koishi");
|
|
|
13
13
|
const constants_1 = require("../constants");
|
|
14
14
|
const common_1 = require("../utils/common");
|
|
15
15
|
const error_handler_1 = require("../utils/error-handler");
|
|
16
|
-
const error_tracker_1 = require("../utils/error-tracker");
|
|
17
16
|
const legacy_config_1 = require("../utils/legacy-config");
|
|
18
17
|
const logger_1 = require("../utils/logger");
|
|
19
18
|
const parser_1 = require("./parser");
|
|
@@ -101,10 +100,6 @@ async function fetchRssItems(ctx, config, $http, rssItem, arg, feedDebug) {
|
|
|
101
100
|
feedDebug(`Fetch failed for ${rssItem.title}: ${normalizedError.message}`, 'feeder', 'error', {
|
|
102
101
|
stage: 'fetch',
|
|
103
102
|
});
|
|
104
|
-
(0, error_tracker_1.trackError)(normalizedError, {
|
|
105
|
-
...buildFeedLogContext(rssItem),
|
|
106
|
-
stage: 'fetch',
|
|
107
|
-
});
|
|
108
103
|
return [];
|
|
109
104
|
}
|
|
110
105
|
}
|
package/lib/core/feeder.js
CHANGED
|
@@ -40,7 +40,6 @@ exports.stopFeeder = stopFeeder;
|
|
|
40
40
|
const koishi_1 = require("koishi");
|
|
41
41
|
const common_1 = require("../utils/common");
|
|
42
42
|
const error_handler_1 = require("../utils/error-handler");
|
|
43
|
-
const error_tracker_1 = require("../utils/error-tracker");
|
|
44
43
|
const legacy_config_1 = require("../utils/legacy-config");
|
|
45
44
|
const logger_1 = require("../utils/logger");
|
|
46
45
|
const feeder_arg_1 = require("./feeder-arg");
|
|
@@ -54,6 +53,12 @@ Object.defineProperty(exports, "findRssItem", { enumerable: true, get: function
|
|
|
54
53
|
Object.defineProperty(exports, "getLastContent", { enumerable: true, get: function () { return feeder_runtime_2.getLastContent; } });
|
|
55
54
|
let interval = null;
|
|
56
55
|
let queueInterval = null;
|
|
56
|
+
let cleanupInterval = null;
|
|
57
|
+
// 重入栅栏:feeder() 单轮可能跑很久(订阅多 + 网络/tdl 慢),
|
|
58
|
+
// 若耗时超过 refreshInterval,下一次 interval tick 会并发启动第二个 feeder(),
|
|
59
|
+
// 导致同一消息被重复入队(addTask 的读-写去重非原子)→ 重复推送。
|
|
60
|
+
// 仿照 NotificationQueueManager.processQueue 的 processing 标志,单轮串行。
|
|
61
|
+
let feederRunning = false;
|
|
57
62
|
function shouldSkipByInterval(rssItem, arg, originalArg) {
|
|
58
63
|
if (!rssItem.arg.interval)
|
|
59
64
|
return false;
|
|
@@ -171,24 +176,44 @@ async function feeder(deps, processor) {
|
|
|
171
176
|
const normalizedError = (0, error_handler_1.normalizeError)(err);
|
|
172
177
|
const feedContext = (0, feeder_runtime_1.buildFeedLogContext)(rssItem);
|
|
173
178
|
(0, logger_1.debug)(config, `Feeder error for ${rssItem.url}: ${normalizedError.message}`, 'feeder', 'error', feedContext);
|
|
174
|
-
(0, error_tracker_1.trackError)(normalizedError, feedContext);
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
function startFeeder(ctx, config, $http, processor, queueManager) {
|
|
183
|
+
// 幂等清理:若上一实例的 interval 尚未清理(未走 dispose 就再次 startFeeder),
|
|
184
|
+
// 先清掉旧句柄,避免 ghost 定时器泄漏。
|
|
185
|
+
if (interval) {
|
|
186
|
+
clearInterval(interval);
|
|
187
|
+
interval = null;
|
|
188
|
+
}
|
|
189
|
+
if (queueInterval) {
|
|
190
|
+
clearInterval(queueInterval);
|
|
191
|
+
queueInterval = null;
|
|
192
|
+
}
|
|
193
|
+
if (cleanupInterval) {
|
|
194
|
+
clearInterval(cleanupInterval);
|
|
195
|
+
cleanupInterval = null;
|
|
196
|
+
}
|
|
179
197
|
const deps = { ctx, config, $http, queueManager };
|
|
180
198
|
const lifecycleDebug = (0, logger_1.createDebugWithContext)(config, { lifecycle: 'feeder' });
|
|
181
199
|
const queueRuntimeConfig = (0, notification_queue_retry_1.getQueueRuntimeConfig)(config);
|
|
200
|
+
// 受重入栅栏保护的 feeder 运行:避免上一轮未跑完时下一轮并发启动
|
|
201
|
+
const runFeederGuarded = () => {
|
|
202
|
+
if (feederRunning) {
|
|
203
|
+
(0, logger_1.debug)(config, 'feeder 上一轮仍在运行,跳过本次 tick', 'feeder', 'details', { skipped: true });
|
|
204
|
+
return Promise.resolve();
|
|
205
|
+
}
|
|
206
|
+
feederRunning = true;
|
|
207
|
+
return feeder(deps, processor).finally(() => {
|
|
208
|
+
feederRunning = false;
|
|
209
|
+
});
|
|
210
|
+
};
|
|
182
211
|
// Initial run
|
|
183
|
-
|
|
212
|
+
runFeederGuarded().catch(err => {
|
|
184
213
|
const normalizedError = (0, error_handler_1.normalizeError)(err);
|
|
185
214
|
lifecycleDebug(`Initial feeder run failed: ${normalizedError.message}`, 'feeder', 'error', {
|
|
186
215
|
operation: 'initial-feeder-run',
|
|
187
216
|
});
|
|
188
|
-
(0, error_tracker_1.trackError)(normalizedError, {
|
|
189
|
-
lifecycle: 'feeder',
|
|
190
|
-
operation: 'initial-feeder-run',
|
|
191
|
-
});
|
|
192
217
|
});
|
|
193
218
|
// 启动生产者定时器(抓取 RSS)
|
|
194
219
|
const refreshInterval = (config.basic?.refresh || 600) * 1000;
|
|
@@ -197,7 +222,7 @@ function startFeeder(ctx, config, $http, processor, queueManager) {
|
|
|
197
222
|
const { delCache } = await Promise.resolve().then(() => __importStar(require('../utils/media')));
|
|
198
223
|
await delCache(config);
|
|
199
224
|
}
|
|
200
|
-
await
|
|
225
|
+
await runFeederGuarded();
|
|
201
226
|
}, refreshInterval);
|
|
202
227
|
// 启动消费者定时器(处理发送队列)
|
|
203
228
|
// 频率更高,确保消息快速发送
|
|
@@ -205,16 +230,26 @@ function startFeeder(ctx, config, $http, processor, queueManager) {
|
|
|
205
230
|
queueInterval = setInterval(async () => {
|
|
206
231
|
await queueManager.processQueue();
|
|
207
232
|
}, queueProcessInterval);
|
|
233
|
+
// 启动清理定时器:周期性删除过期的 SUCCESS/FAILED 任务,
|
|
234
|
+
// 防止 rss_notification_queue 表无限膨胀拖慢查询(此前只手动命令清理)。
|
|
235
|
+
// 每小时一次,cleanupHours 控制具体保留时长。
|
|
236
|
+
cleanupInterval = setInterval(async () => {
|
|
237
|
+
try {
|
|
238
|
+
await queueManager.runAutomaticCleanup();
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const normalizedError = (0, error_handler_1.normalizeError)(err);
|
|
242
|
+
lifecycleDebug(`自动清理失败: ${normalizedError.message}`, 'queue', 'error', {
|
|
243
|
+
operation: 'automatic-cleanup',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}, 60 * 60 * 1000);
|
|
208
247
|
// 立即处理一次队列(启动时)
|
|
209
248
|
queueManager.processQueue().catch(err => {
|
|
210
249
|
const normalizedError = (0, error_handler_1.normalizeError)(err);
|
|
211
250
|
lifecycleDebug(`Initial queue processing failed: ${normalizedError.message}`, 'queue', 'error', {
|
|
212
251
|
operation: 'initial-queue-processing',
|
|
213
252
|
});
|
|
214
|
-
(0, error_tracker_1.trackError)(normalizedError, {
|
|
215
|
-
lifecycle: 'feeder',
|
|
216
|
-
operation: 'initial-queue-processing',
|
|
217
|
-
});
|
|
218
253
|
});
|
|
219
254
|
lifecycleDebug('Feeder started', 'feeder', 'info', {
|
|
220
255
|
refreshInterval,
|
|
@@ -230,6 +265,12 @@ function stopFeeder(config) {
|
|
|
230
265
|
clearInterval(queueInterval);
|
|
231
266
|
queueInterval = null;
|
|
232
267
|
}
|
|
268
|
+
if (cleanupInterval) {
|
|
269
|
+
clearInterval(cleanupInterval);
|
|
270
|
+
cleanupInterval = null;
|
|
271
|
+
}
|
|
272
|
+
// 复位栅栏:避免上一轮未跑完就 stopFeeder 后,下次 startFeeder 时栅栏卡死
|
|
273
|
+
feederRunning = false;
|
|
233
274
|
if (config) {
|
|
234
275
|
const lifecycleDebug = (0, logger_1.createDebugWithContext)(config, { lifecycle: 'feeder' });
|
|
235
276
|
lifecycleDebug('Feeder stopped', 'feeder', 'info');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as cheerio from 'cheerio';
|
|
2
2
|
import { Context } from 'koishi';
|
|
3
3
|
import { Config, rssArg } from '../types';
|
|
4
|
+
import { normalizeText } from '../utils/common';
|
|
4
5
|
export interface ItemProcessorRuntimeDeps {
|
|
5
6
|
ctx: Context;
|
|
6
7
|
config: Config;
|
|
@@ -13,8 +14,10 @@ interface RenderDescriptionOptions {
|
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
16
|
* 标准化 RSS 字段内容,统一数组 / 空值 / 非字符串输入。
|
|
17
|
+
*
|
|
18
|
+
* 实现已收敛到 `utils/common.ts`,此处 re-export 以维持现有 import 路径不变。
|
|
16
19
|
*/
|
|
17
|
-
export
|
|
20
|
+
export { normalizeText };
|
|
18
21
|
/**
|
|
19
22
|
* 解析并去重 HTML 中的图片资源,返回原图到最终地址的映射表。
|
|
20
23
|
*/
|
|
@@ -48,4 +51,3 @@ export declare function formatVideoList(videoList: Array<[string, string]>): str
|
|
|
48
51
|
* 统一执行图片渲染,兼容 base64 / File / assets / HTML 回退。
|
|
49
52
|
*/
|
|
50
53
|
export declare function renderImage(htmlContent: string, deps: ItemProcessorRuntimeDeps, arg: rssArg): Promise<string>;
|
|
51
|
-
export {};
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.normalizeText =
|
|
36
|
+
exports.normalizeText = void 0;
|
|
37
37
|
exports.buildResolvedImageMap = buildResolvedImageMap;
|
|
38
38
|
exports.renderImageListFromHtml = renderImageListFromHtml;
|
|
39
39
|
exports.renderLoadedHtml = renderLoadedHtml;
|
|
@@ -44,6 +44,8 @@ exports.renderImage = renderImage;
|
|
|
44
44
|
const cheerio = __importStar(require("cheerio"));
|
|
45
45
|
const koishi_1 = require("koishi");
|
|
46
46
|
const marked_1 = require("marked");
|
|
47
|
+
const common_1 = require("../utils/common");
|
|
48
|
+
Object.defineProperty(exports, "normalizeText", { enumerable: true, get: function () { return common_1.normalizeText; } });
|
|
47
49
|
const logger_1 = require("../utils/logger");
|
|
48
50
|
const media_1 = require("../utils/media");
|
|
49
51
|
const renderer_1 = require("./renderer");
|
|
@@ -62,7 +64,7 @@ function collectUniqueImageSources(html) {
|
|
|
62
64
|
return [...new Set(imageSources)];
|
|
63
65
|
}
|
|
64
66
|
async function prependAiSummarySection(config, htmlContent, item, options) {
|
|
65
|
-
const aiSummary = normalizeText(item?.aiSummary).trim();
|
|
67
|
+
const aiSummary = (0, common_1.normalizeText)(item?.aiSummary).trim();
|
|
66
68
|
if (!aiSummary || !isImageRenderEnabled(config))
|
|
67
69
|
return htmlContent;
|
|
68
70
|
const aiSummaryHtml = await (0, marked_1.marked)(aiSummary);
|
|
@@ -99,16 +101,6 @@ async function prepareHtmlForRender(deps, html, arg, useXml = false) {
|
|
|
99
101
|
html('img').attr('style', 'object-fit:scale-down;max-width:100%;height:auto;');
|
|
100
102
|
return useXml ? html.xml() : html.html();
|
|
101
103
|
}
|
|
102
|
-
/**
|
|
103
|
-
* 标准化 RSS 字段内容,统一数组 / 空值 / 非字符串输入。
|
|
104
|
-
*/
|
|
105
|
-
function normalizeText(value) {
|
|
106
|
-
if (Array.isArray(value))
|
|
107
|
-
return value.join('');
|
|
108
|
-
if (value === undefined || value === null)
|
|
109
|
-
return '';
|
|
110
|
-
return String(value);
|
|
111
|
-
}
|
|
112
104
|
/**
|
|
113
105
|
* 解析并去重 HTML 中的图片资源,返回原图到最终地址的映射表。
|
|
114
106
|
*/
|
|
@@ -36,7 +36,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.NotificationQueueSender = void 0;
|
|
37
37
|
exports.downgradeQueueMessage = downgradeQueueMessage;
|
|
38
38
|
const error_handler_1 = require("../utils/error-handler");
|
|
39
|
-
const error_tracker_1 = require("../utils/error-tracker");
|
|
40
39
|
const notification_queue_retry_1 = require("./notification-queue-retry");
|
|
41
40
|
function downgradeQueueMessage(content) {
|
|
42
41
|
if (content.isDowngraded) {
|
|
@@ -108,10 +107,6 @@ class NotificationQueueSender {
|
|
|
108
107
|
catch (err) {
|
|
109
108
|
const normalizedError = (0, error_handler_1.normalizeError)(err);
|
|
110
109
|
taskDebug(`缓存消息失败: ${normalizedError.message}`, 'cache', 'info');
|
|
111
|
-
(0, error_tracker_1.trackError)(normalizedError, {
|
|
112
|
-
...this.deps.buildTaskLogContext(task),
|
|
113
|
-
operation: 'cacheMessage',
|
|
114
|
-
});
|
|
115
110
|
}
|
|
116
111
|
}
|
|
117
112
|
}
|