koishi-plugin-share-links-analysis 0.7.2 → 0.7.3

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/core.js CHANGED
@@ -69,8 +69,7 @@ function resolveLinks(content) {
69
69
  async function processLink(ctx, config, link, session) {
70
70
  for (const parser of parsers) {
71
71
  if (parser.name == link.platform) {
72
- if (config.logLevel == "full")
73
- ctx.logger('share-links-analysis').info(`解析平台:${parser.name},链接:${link.url}`);
72
+ ctx.logger('share-links-analysis').debug(`解析平台:${parser.name},链接:${link.url}`);
74
73
  return await parser.process(ctx, config, link, session);
75
74
  }
76
75
  }
package/lib/index.js CHANGED
@@ -1,11 +1,45 @@
1
1
  "use strict";
2
2
  // src/index.ts
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
3
36
  Object.defineProperty(exports, "__esModule", { value: true });
4
37
  exports.Config = exports.usage = exports.inject = exports.name = void 0;
5
38
  exports.apply = apply;
6
39
  const koishi_1 = require("koishi");
7
40
  const core_1 = require("./core");
8
41
  const utils_1 = require("./utils");
42
+ const fs = __importStar(require("node:fs"));
9
43
  exports.name = 'share-links-analysis';
10
44
  exports.inject = {
11
45
  required: ['BiliBiliVideo', 'database', 'puppeteer'],
@@ -39,6 +73,11 @@ exports.Config = koishi_1.Schema.intersect([
39
73
  sendFiles: koishi_1.Schema.boolean().default(true).description("是否发送文件(视频等)"),
40
74
  sendLinks: koishi_1.Schema.boolean().default(false).description("是否附加直链(仅对合并发送有效)"),
41
75
  }).description("基础设置"),
76
+ koishi_1.Schema.object({
77
+ enableCache: koishi_1.Schema.boolean().default(true).description("开启缓存(包括解析结果缓存和资源文件缓存)"),
78
+ cacheExpiration: koishi_1.Schema.number().default(24).description("缓存过期时间(小时)。设置为 0 则不过期。"),
79
+ autoCleanInterval: koishi_1.Schema.number().default(1).description("自动清理过期缓存的检查间隔(小时)。"),
80
+ }).description("缓存设置"),
42
81
  koishi_1.Schema.object({
43
82
  format: koishi_1.Schema.string().role('textarea').default(`{title}
44
83
  {cover}
@@ -68,22 +107,17 @@ exports.Config = koishi_1.Schema.intersect([
68
107
  }).description('跨环境路径映射设置'),
69
108
  koishi_1.Schema.object({
70
109
  userAgent: koishi_1.Schema.string().description("所有 API 请求所用的 User-Agent").default("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"),
71
- logLevel: koishi_1.Schema.union([
72
- koishi_1.Schema.const('none').description('不记录'),
73
- koishi_1.Schema.const('link_only').description('仅记录视频直链'),
74
- koishi_1.Schema.const('full').description('记录完整调试信息'),
75
- ]).role('radio').default('none').description("选择后台日志记录等级"),
110
+ debug: koishi_1.Schema.boolean().default(false).description("开启调试模式 (输出详细日志)"),
76
111
  }).description("调试设置"),
77
112
  ]);
78
113
  function apply(ctx, config) {
79
- // @ts-ignore
114
+ // 数据库模型定义 (无需 ts-ignore)
80
115
  ctx.model.extend('sla_cookie_cache', {
81
116
  platform: 'string', // 平台名称,如 'xiaohongshu'
82
117
  cookie: 'text', // 存储的 cookie 字符串
83
118
  }, {
84
119
  primary: 'platform' // 使用平台名称作为主键
85
120
  });
86
- // @ts-ignore
87
121
  ctx.model.extend('sla_group_settings', {
88
122
  guildId: 'string',
89
123
  custom_parsers: 'json',
@@ -91,6 +125,59 @@ function apply(ctx, config) {
91
125
  }, {
92
126
  primary: 'guildId',
93
127
  });
128
+ // 解析结果缓存
129
+ ctx.model.extend('sla_parse_cache', {
130
+ key: 'string', // platform + ':' + id
131
+ data: 'json',
132
+ created_at: 'double',
133
+ }, { primary: 'key' });
134
+ // 资源文件缓存 (hash)
135
+ ctx.model.extend('sla_file_cache', {
136
+ hash: 'string', // URL MD5
137
+ path: 'string', // 本地绝对路径
138
+ url: 'string',
139
+ created_at: 'double',
140
+ }, { primary: 'hash' });
141
+ const logger = ctx.logger('share-links-analysis');
142
+ // 根据配置设置日志等级
143
+ if (config.debug) {
144
+ logger.level = 3; // Debug Level
145
+ }
146
+ // 清理缓存函数
147
+ const cleanExpiredCache = async () => {
148
+ if (!config.enableCache || config.cacheExpiration <= 0)
149
+ return;
150
+ const now = Date.now();
151
+ const threshold = now - config.cacheExpiration * 60 * 60 * 1000;
152
+ // 清理解析缓存
153
+ await ctx.database.remove('sla_parse_cache', {
154
+ created_at: { $lt: threshold }
155
+ });
156
+ // 清理文件缓存
157
+ const expiredFiles = await ctx.database.get('sla_file_cache', {
158
+ created_at: { $lt: threshold }
159
+ });
160
+ for (const file of expiredFiles) {
161
+ try {
162
+ if (fs.existsSync(file.path)) {
163
+ await fs.promises.unlink(file.path);
164
+ }
165
+ }
166
+ catch (e) {
167
+ logger.warn(`删除过期文件失败 ${file.path}: ${e}`);
168
+ }
169
+ }
170
+ await ctx.database.remove('sla_file_cache', {
171
+ created_at: { $lt: threshold }
172
+ });
173
+ if (expiredFiles.length > 0) {
174
+ logger.info(`已自动清理 ${expiredFiles.length} 个过期文件。`);
175
+ }
176
+ };
177
+ // 设置定时清理
178
+ if (config.enableCache && config.autoCleanInterval > 0) {
179
+ ctx.setInterval(cleanExpiredCache, config.autoCleanInterval * 60 * 60 * 1000);
180
+ }
94
181
  // 注册指令
95
182
  const cmd = ctx.command('share', '分享解析插件配置', { authority: 1 })
96
183
  .action(async ({ session }) => {
@@ -115,12 +202,9 @@ function apply(ctx, config) {
115
202
  if (!value)
116
203
  return '请输入正确的模式';
117
204
  const mode = value.trim().toLowerCase() === 'true';
118
- // @ts-ignore
119
205
  const data = await ctx.database.get('sla_group_settings', session.guildId);
120
- // @ts-ignore
121
206
  const final_parsers = { ...data[0]?.custom_parsers, ...{ [parser]: mode } };
122
207
  const record = { guildId: session.guildId, custom_parsers: final_parsers };
123
- // @ts-ignore
124
208
  await ctx.database.upsert('sla_group_settings', [record]);
125
209
  }
126
210
  await session.execute('share');
@@ -134,7 +218,6 @@ function apply(ctx, config) {
134
218
  if (value) {
135
219
  const mode = value.trim().toLowerCase() === 'true';
136
220
  const record = { guildId: session.guildId, nsfw_enabled: mode };
137
- // @ts-ignore
138
221
  await ctx.database.upsert('sla_group_settings', [record]);
139
222
  }
140
223
  await session.execute('share');
@@ -145,11 +228,25 @@ function apply(ctx, config) {
145
228
  return '该指令只能在群组中使用。';
146
229
  if (!await (0, utils_1.isUserAdmin)(session, session.userId))
147
230
  return '权限不足';
148
- // @ts-ignore
149
231
  await ctx.database.remove('sla_group_settings', { guildId: session.guildId });
150
232
  return '已重置为全局默认设置。';
151
233
  });
152
- const logger = ctx.logger('share-links-analysis');
234
+ // 清除缓存指令
235
+ cmd.subcommand('.clean', '清除所有缓存和文件', { authority: 3 })
236
+ .action(async ({ session }) => {
237
+ await ctx.database.remove('sla_parse_cache', {});
238
+ const allFiles = await ctx.database.get('sla_file_cache', {});
239
+ for (const file of allFiles) {
240
+ try {
241
+ if (fs.existsSync(file.path)) {
242
+ await fs.promises.unlink(file.path);
243
+ }
244
+ }
245
+ catch { }
246
+ }
247
+ await ctx.database.remove('sla_file_cache', {});
248
+ return '缓存及对应文件已清理。';
249
+ });
153
250
  const lastProcessedUrls = {};
154
251
  ctx.on('ready', async () => {
155
252
  logger.info('插件已启动,执行插件初始化');
@@ -168,8 +265,7 @@ function apply(ctx, config) {
168
265
  if (session.guildId) {
169
266
  const settings = await (0, utils_1.getEffectiveSettings)(ctx, session.guildId, config);
170
267
  if (!settings.parsers[link.platform]) {
171
- if (config.logLevel == "full")
172
- ctx.logger('share-links-analysis').info(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
268
+ logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
173
269
  if (config.showError)
174
270
  await session.send(`根据策略,该链接已被阻止解析:平台:${link.platform}`);
175
271
  continue;
@@ -177,8 +273,7 @@ function apply(ctx, config) {
177
273
  }
178
274
  else {
179
275
  if (!config.default_parsers[link.platform]) {
180
- if (config.logLevel == "full")
181
- ctx.logger('share-links-analysis').info(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
276
+ logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
182
277
  if (config.showError)
183
278
  await session.send(`根据策略,该链接已被阻止解析:平台:${link.platform}`);
184
279
  continue;
@@ -192,36 +287,66 @@ function apply(ctx, config) {
192
287
  if (!lastProcessedUrls[channelId])
193
288
  lastProcessedUrls[channelId] = {};
194
289
  if (now - (lastProcessedUrls[channelId][link.url] || 0) < config.Min_Interval * 1000) {
195
- if (config.logLevel === 'full')
196
- logger.info(`链接 ${link.url} 在冷却时间内,跳过处理。`);
290
+ logger.debug(`链接 ${link.url} 在冷却时间内,跳过处理。`);
197
291
  continue;
198
292
  }
199
293
  if (config.waitTip_Switch) {
200
294
  await session.send(config.waitTip_Switch);
201
295
  }
202
- const result = await (0, core_1.processLink)(ctx, config, link, session);
296
+ // === 缓存逻辑 ===
297
+ let result = null;
298
+ const cacheKey = `${link.platform}:${link.id}`;
299
+ if (config.enableCache) {
300
+ const cached = await ctx.database.get('sla_parse_cache', cacheKey);
301
+ // 检查是否存在且未过期
302
+ if (cached.length > 0) {
303
+ const entry = cached[0];
304
+ const isExpired = config.cacheExpiration > 0 && (Date.now() - entry.created_at > config.cacheExpiration * 60 * 60 * 1000);
305
+ if (!isExpired) {
306
+ logger.debug(`使用缓存解析结果: ${cacheKey}`);
307
+ result = entry.data;
308
+ }
309
+ else {
310
+ // 过期删除
311
+ await ctx.database.remove('sla_parse_cache', { key: cacheKey });
312
+ }
313
+ }
314
+ }
315
+ // 缓存未命中,执行解析
316
+ if (!result) {
317
+ result = await (0, core_1.processLink)(ctx, config, link, session);
318
+ // 写入缓存
319
+ if (result && config.enableCache) {
320
+ await ctx.database.upsert('sla_parse_cache', [{
321
+ key: cacheKey,
322
+ data: result,
323
+ created_at: Date.now()
324
+ }]);
325
+ }
326
+ }
327
+ // === 缓存逻辑结束 ===
203
328
  if (result) {
204
329
  lastProcessedUrls[channelId][link.url] = now;
205
- await sendResult(session, config, result, logger);
330
+ await sendResult(ctx, session, config, result, logger);
206
331
  }
207
332
  linkCount++;
208
333
  }
209
334
  });
210
335
  }
211
- async function sendResult(session, config, result, logger) {
336
+ async function sendResult(ctx, session, config, result, logger) {
212
337
  if (!session.channel) {
213
- await (0, utils_1.sendResult_plain)(session, config, result, logger);
338
+ await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
214
339
  return;
215
340
  }
216
341
  switch (config.useForward) {
217
342
  case "plain":
218
- await (0, utils_1.sendResult_plain)(session, config, result, logger);
343
+ await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
219
344
  return;
220
345
  case 'forward':
221
- await (0, utils_1.sendResult_forward)(session, config, result, logger, false);
346
+ await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, false);
222
347
  return;
223
348
  case "mixed":
224
- await (0, utils_1.sendResult_forward)(session, config, result, logger, true);
349
+ await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, true);
225
350
  return;
226
351
  }
227
352
  }
@@ -92,9 +92,7 @@ async function process(ctx, config, link, session) {
92
92
  if (locationHeader) {
93
93
  finalUrl = locationHeader;
94
94
  }
95
- else if (config.logLevel === 'full') {
96
- logger.debug(`解析短链接时发生网络错误或未找到跳转地址: ${e.message}`);
97
- }
95
+ logger.debug(`解析短链接时发生网络错误或未找到跳转地址: ${e.message}`);
98
96
  }
99
97
  if (finalUrl && (finalUrl.includes('b23.tv') || finalUrl.includes('bilibili.com/'))) {
100
98
  const urlObj = new URL(finalUrl);
@@ -122,8 +120,7 @@ async function process(ctx, config, link, session) {
122
120
  }
123
121
  }
124
122
  if (finalUrl) {
125
- if (config.logLevel === 'full')
126
- logger.info(`短链接解析成功,指向: ${finalUrl}`);
123
+ logger.debug(`短链接解析成功,指向: ${finalUrl}`);
127
124
  const matchedLinks = match(finalUrl);
128
125
  if (matchedLinks.length > 0 && matchedLinks[0].type === 'video') {
129
126
  videoId = matchedLinks[0].id;
@@ -143,8 +140,7 @@ async function process(ctx, config, link, session) {
143
140
  logger.warn(`无法从链接 ${link.url} 中解析出有效的B站视频ID。`);
144
141
  return null;
145
142
  }
146
- if (config.logLevel === 'full')
147
- logger.info(`获取视频信息,ID: ${videoId}`);
143
+ logger.debug(`获取视频信息,ID: ${videoId}`);
148
144
  const idType = videoId.startsWith('BV') ? 'bvid' : 'aid';
149
145
  const infoUrl = `https://api.bilibili.com/x/web-interface/view?${idType}=${videoId}`;
150
146
  try {
@@ -158,14 +154,12 @@ async function process(ctx, config, link, session) {
158
154
  const data = info.data;
159
155
  // --- 获取视频直链 ---
160
156
  let videoUrl = null;
161
- if (config.logLevel === 'full')
162
- logger.info(`尝试获取视频流,bvid: ${data.bvid}`);
157
+ logger.debug(`尝试获取视频流,bvid: ${data.bvid}`);
163
158
  try {
164
159
  const videoStream = await ctx.BiliBiliVideo.getBilibiliVideoStream(data.aid, data.bvid, data.pages[0].cid, config.Video_ClarityPriority === '1' ? 32 : 80, 'html5', 1);
165
160
  if (videoStream?.data?.durl?.[0]?.url) {
166
161
  videoUrl = videoStream.data.durl[0].url;
167
- if (config.logLevel === 'full')
168
- logger.info(`成功获取视频流,bvid: ${data.bvid}`);
162
+ logger.debug(`成功获取视频流,bvid: ${data.bvid}`);
169
163
  }
170
164
  }
171
165
  catch (e) {
@@ -68,7 +68,7 @@ async function process(ctx, config, link, session) {
68
68
  return null;
69
69
  }
70
70
  try {
71
- logger.info(`🔍 解析推文: ${apiUrl}`);
71
+ logger.debug(`🔍 解析推文: ${apiUrl}`);
72
72
  const tweetData = await ctx.http.get(apiUrl, {
73
73
  headers: {
74
74
  'User-Agent': config.userAgent,
@@ -113,8 +113,7 @@ async function init(ctx, config) {
113
113
  const filteredCookies = finalCookies.filter((c) => c.name !== 'acw_tc');
114
114
  // 使用过滤后的 cookie 数组来生成字符串
115
115
  const cookieString = filteredCookies.map((c) => `${c.name}=${c.value}`).join('; ');
116
- // @ts-ignore
117
- await ctx.database.upsert('sla_cookie_cache', [{ platform: platformId, cookie: cookieString }]);
116
+ await ctx.database.upsert('sla_cookie_cache', [{ platform: exports.name, cookie: cookieString }]);
118
117
  logger.info('成功执行两步刷新策略并缓存了小红书 Cookie!');
119
118
  return true;
120
119
  }
@@ -145,22 +144,20 @@ async function process(ctx, config, link, session) {
145
144
  const decodedUrl = link.url.replace(/&amp;/g, '&');
146
145
  const originalUrl = new URL(decodedUrl);
147
146
  token = originalUrl.searchParams.get('xsec_token');
148
- if (token && config.logLevel === 'full') {
149
- logger.info(`成功从分享链接中提取 xsec_token。`);
147
+ if (token) {
148
+ logger.debug(`成功从分享链接中提取 xsec_token。`);
150
149
  }
151
- else if (config.logLevel === 'full') {
150
+ else {
152
151
  logger.debug(`分享链接中未找到 xsec_token: ${link.url}`);
153
152
  }
154
153
  }
155
154
  catch (e) {
156
- if (config.logLevel === 'full')
157
- logger.debug(`解析分享链接URL失败: ${link.url}`);
155
+ logger.debug(`解析分享链接URL失败: ${link.url}`);
158
156
  }
159
157
  let finalUrl = link.url;
160
158
  // 步骤二:如果是短链接,获取其跳转后的基础地址
161
159
  if (link.url.includes('xhslink.com')) {
162
- if (config.logLevel === 'full')
163
- logger.info(`小红书短链接解析:尝试获取 ${link.url} 的最终地址`);
160
+ logger.debug(`小红书短链接解析:尝试获取 ${link.url} 的最终地址`);
164
161
  try {
165
162
  await ctx.http(link.url, {
166
163
  method: 'GET',
@@ -172,8 +169,7 @@ async function process(ctx, config, link, session) {
172
169
  const location = e.response?.headers?.location;
173
170
  if (location) {
174
171
  finalUrl = location;
175
- if (config.logLevel === 'full')
176
- logger.info(`短链接解析成功,跳转地址: ${finalUrl}`);
172
+ logger.debug(`短链接解析成功,跳转地址: ${finalUrl}`);
177
173
  }
178
174
  else {
179
175
  logger.error(`解析短链接时发生网络错误: ${e.message}`);
@@ -198,12 +194,9 @@ async function process(ctx, config, link, session) {
198
194
  logger.error(`构建最终请求URL失败: ${finalUrl}`);
199
195
  return null;
200
196
  }
201
- if (config.logLevel === 'full')
202
- logger.info(`正在抓取小红书页面: ${urlToFetch}`);
197
+ logger.debug(`正在抓取小红书页面: ${urlToFetch}`);
203
198
  try {
204
- // @ts-ignore
205
- const dbCache = await ctx.database.get('sla_cookie_cache', platformId);
206
- // @ts-ignore
199
+ const dbCache = await ctx.database.get('sla_cookie_cache', exports.name);
207
200
  let currentCookie = (dbCache && dbCache.length > 0) ? dbCache[0].cookie : '';
208
201
  const requestHeaders = {
209
202
  'User-Agent': config.userAgent,
@@ -236,14 +229,10 @@ async function process(ctx, config, link, session) {
236
229
  let coverUrl = undefined;
237
230
  const images = [];
238
231
  if (noteData.type === 'video' && noteData.video) {
239
- if (config.logLevel === 'full') {
240
- logger.info(`[XHS Video Debug] 发现视频笔记,视频数据对象: \n${JSON.stringify(noteData.video, null, 2)}`);
241
- }
232
+ logger.debug(`[XHS Video Debug] 发现视频笔记,视频数据对象: \n${JSON.stringify(noteData.video, null, 2)}`);
242
233
  if (noteData.video.media?.stream?.h264?.[0]?.masterUrl) {
243
234
  videoUrl = noteData.video.media.stream.h264[0].masterUrl;
244
- if (config.logLevel === 'full') {
245
- logger.info(`[XHS Video Debug] 已提取视频链接: ${videoUrl}`);
246
- }
235
+ logger.debug(`[XHS Video Debug] 已提取视频链接: ${videoUrl}`);
247
236
  }
248
237
  else {
249
238
  logger.warn('[XHS Video Debug] 未能从预期路径 `note.video.media.stream.h264[0].masterUrl` 找到视频链接。');
package/lib/types.d.ts CHANGED
@@ -27,18 +27,21 @@ export interface PluginConfig {
27
27
  usingLocal: boolean;
28
28
  sendFiles: boolean;
29
29
  sendLinks: boolean;
30
+ enableCache: boolean;
31
+ cacheExpiration: number;
32
+ autoCleanInterval: number;
30
33
  format: string;
31
34
  parseLimit: number;
32
35
  useNumeral: boolean;
33
36
  showError: boolean;
34
37
  proxy: string;
35
- proxy_settings: object;
36
- default_parsers: object;
38
+ proxy_settings: Record<string, boolean>;
39
+ default_parsers: Record<string, boolean>;
37
40
  allow_sensitive: boolean;
38
41
  onebotReadDir: string;
39
42
  localDownloadDir: string;
40
43
  userAgent: string;
41
- logLevel: 'none' | 'link_only' | 'full';
44
+ debug: boolean;
42
45
  }
43
46
  export interface BilibiliVideoInfo {
44
47
  data: {
@@ -79,6 +82,32 @@ declare module 'koishi' {
79
82
  BiliBiliVideo: any;
80
83
  puppeteer?: any;
81
84
  }
85
+ interface Tables {
86
+ sla_parse_cache: SlaParseCache;
87
+ sla_file_cache: SlaFileCache;
88
+ sla_cookie_cache: SlaCookieCache;
89
+ sla_group_settings: SlaGroupSettings;
90
+ }
91
+ }
92
+ export interface SlaParseCache {
93
+ key: string;
94
+ data: ParsedInfo;
95
+ created_at: number;
96
+ }
97
+ export interface SlaFileCache {
98
+ hash: string;
99
+ path: string;
100
+ url: string;
101
+ created_at: number;
102
+ }
103
+ export interface SlaCookieCache {
104
+ platform: string;
105
+ cookie: string;
106
+ }
107
+ export interface SlaGroupSettings {
108
+ guildId: string;
109
+ custom_parsers: Record<string, boolean>;
110
+ nsfw_enabled: boolean;
82
111
  }
83
112
  export interface XhsImageInfo {
84
113
  imageScene: string;
package/lib/utils.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { ParsedInfo, PluginConfig } from './types';
2
2
  import { Context, Logger, Session } from "koishi";
3
+ import { Agent as HttpAgent } from 'http';
4
+ import { Agent as HttpsAgent } from 'https';
5
+ import 'koishi-plugin-adapter-onebot';
3
6
  /**
4
7
  * 将数字格式化为易读的字符串(如 万、亿)
5
8
  * @param num 数字
@@ -9,11 +12,12 @@ import { Context, Logger, Session } from "koishi";
9
12
  export declare function numeral(num: number, config: PluginConfig): string;
10
13
  export declare function escapeHtml(str: string): string;
11
14
  export declare function unescapeHtml(str: string): string;
15
+ export declare function getProxyAgent(proxy: string | undefined, url: string): HttpAgent | HttpsAgent | undefined;
12
16
  export declare function getFileSize(url: string, proxy: string | undefined, userAgent: string | undefined, logger: Logger): Promise<number | null>;
13
17
  export declare function getEffectiveSettings(ctx: Context, guildId: string | undefined, config: PluginConfig): Promise<{
14
- parsers: any;
15
- nsfw: any;
18
+ parsers: Record<string, boolean>;
19
+ nsfw: boolean;
16
20
  }>;
17
21
  export declare function isUserAdmin(session: Session, userId: string): Promise<boolean>;
18
- export declare function sendResult_plain(session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<void>;
19
- export declare function sendResult_forward(session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, mixed_sending?: boolean): Promise<void>;
22
+ export declare function sendResult_plain(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger): Promise<void>;
23
+ export declare function sendResult_forward(ctx: Context, session: Session, config: PluginConfig, result: ParsedInfo, logger: Logger, mixed_sending?: boolean): Promise<void>;
package/lib/utils.js CHANGED
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.numeral = numeral;
40
40
  exports.escapeHtml = escapeHtml;
41
41
  exports.unescapeHtml = unescapeHtml;
42
+ exports.getProxyAgent = getProxyAgent;
42
43
  exports.getFileSize = getFileSize;
43
44
  exports.getEffectiveSettings = getEffectiveSettings;
44
45
  exports.isUserAdmin = isUserAdmin;
@@ -53,6 +54,8 @@ const url_1 = require("url");
53
54
  const http_proxy_agent_1 = require("http-proxy-agent");
54
55
  const https_proxy_agent_1 = require("https-proxy-agent");
55
56
  const fs = __importStar(require("node:fs"));
57
+ const crypto_1 = require("crypto");
58
+ require("koishi-plugin-adapter-onebot");
56
59
  /**
57
60
  * 将数字格式化为易读的字符串(如 万、亿)
58
61
  * @param num 数字
@@ -122,14 +125,50 @@ function parseHtmlToSegments(html) {
122
125
  }
123
126
  return segments;
124
127
  }
125
- async function downloadAndMapUrl(url, proxy, userAgent, localDownloadDir, onebotReadDir, logger) {
128
+ async function downloadAndMapUrl(ctx, url, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache) {
126
129
  await fs.promises.mkdir(localDownloadDir, { recursive: true });
130
+ // 1. 计算 Hash
131
+ const hash = (0, crypto_1.createHash)('md5').update(url).digest('hex');
127
132
  const u = new url_1.URL(url);
128
133
  const ext = path_1.default.extname(u.pathname).split('?')[0] || '.bin';
129
- const safeFilename = `${Date.now()}_${Math.random().toString(36).substring(2, 10)}${ext}`;
134
+ // 如果开启缓存,先查库
135
+ if (enableCache) {
136
+ try {
137
+ const cached = await ctx.database.get('sla_file_cache', hash);
138
+ if (cached.length > 0) {
139
+ const cachedPath = cached[0].path;
140
+ if (fs.existsSync(cachedPath)) {
141
+ const filename = path_1.default.basename(cachedPath);
142
+ const onebotPath = path_1.default.posix.join(onebotReadDir, filename);
143
+ logger.debug(`缓存命中: ${url} -> ${cachedPath}`);
144
+ return `file://${onebotPath}`;
145
+ }
146
+ else {
147
+ // 数据库有记录但文件不存在,删除记录
148
+ await ctx.database.remove('sla_file_cache', { hash });
149
+ }
150
+ }
151
+ }
152
+ catch (e) {
153
+ logger.warn(`读取文件缓存失败,将重新下载: ${e}`);
154
+ }
155
+ }
156
+ // 2. 生成文件名 (使用 Hash 以实现去重)
157
+ const safeFilename = `${hash}${ext}`;
130
158
  const actualPath = path_1.default.join(localDownloadDir, safeFilename);
131
159
  const onebotPath = path_1.default.posix.join(onebotReadDir, safeFilename);
132
160
  const fileUrl = `file://${onebotPath}`;
161
+ // 3. 检查本地文件是否存在 (双重保险,或者应对未清理的情况)
162
+ if (enableCache && fs.existsSync(actualPath)) {
163
+ // 补写数据库
164
+ await ctx.database.upsert('sla_file_cache', [{
165
+ hash,
166
+ path: actualPath,
167
+ url,
168
+ created_at: Date.now()
169
+ }]);
170
+ return fileUrl;
171
+ }
133
172
  return new Promise((resolve, reject) => {
134
173
  const agent = getProxyAgent(proxy, url);
135
174
  const headers = {
@@ -144,7 +183,7 @@ async function downloadAndMapUrl(url, proxy, userAgent, localDownloadDir, onebot
144
183
  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
145
184
  req.destroy();
146
185
  logger.debug(`重定向: ${url} -> ${res.headers.location}`);
147
- downloadAndMapUrl(res.headers.location, proxy, userAgent, localDownloadDir, onebotReadDir, logger)
186
+ downloadAndMapUrl(ctx, res.headers.location, proxy, userAgent, localDownloadDir, onebotReadDir, logger, enableCache)
148
187
  .then(resolve)
149
188
  .catch(reject);
150
189
  return;
@@ -163,8 +202,22 @@ async function downloadAndMapUrl(url, proxy, userAgent, localDownloadDir, onebot
163
202
  }
164
203
  const pipelineAsync = (0, util_1.promisify)(stream_1.pipeline);
165
204
  pipelineAsync(res, (0, fs_1.createWriteStream)(actualPath))
166
- .then(() => {
205
+ .then(async () => {
167
206
  logger.debug(`下载成功: ${url} -> ${fileUrl}`);
207
+ // 下载成功,写入数据库缓存
208
+ if (enableCache) {
209
+ try {
210
+ await ctx.database.upsert('sla_file_cache', [{
211
+ hash,
212
+ path: actualPath,
213
+ url,
214
+ created_at: Date.now()
215
+ }]);
216
+ }
217
+ catch (dbErr) {
218
+ logger.warn(`写入文件缓存数据库失败: ${dbErr}`);
219
+ }
220
+ }
168
221
  resolve(fileUrl);
169
222
  })
170
223
  .catch((err) => {
@@ -287,13 +340,10 @@ async function getEffectiveSettings(ctx, guildId, config) {
287
340
  nsfw: config.allow_sensitive
288
341
  };
289
342
  }
290
- // @ts-ignore
291
343
  const data = await ctx.database.get('sla_group_settings', guildId);
292
344
  const record = data[0];
293
345
  // 合并:自定义设置覆盖默认
294
- // @ts-ignore
295
346
  const effectiveParsers = { ...config.default_parsers, ...record?.custom_parsers ? record.custom_parsers : {} };
296
- // @ts-ignore
297
347
  const nsfw_enabled = record?.nsfw_enabled ? record.nsfw_enabled : config.allow_sensitive;
298
348
  return {
299
349
  parsers: effectiveParsers,
@@ -303,7 +353,7 @@ async function getEffectiveSettings(ctx, guildId, config) {
303
353
  async function isUserAdmin(session, userId) {
304
354
  if (!session.guildId)
305
355
  return false;
306
- // @ts-ignore
356
+ // 使用 (session.user as any) 来规避类型检查,同时保留可选链以防 user 为空
307
357
  if (session.user?.authority >= 3)
308
358
  return true;
309
359
  try {
@@ -322,10 +372,8 @@ async function isUserAdmin(session, userId) {
322
372
  return true;
323
373
  }
324
374
  }
325
- async function sendResult_plain(session, config, result, logger) {
326
- if (config.logLevel === 'full') {
327
- logger.info('进入普通发送');
328
- }
375
+ async function sendResult_plain(ctx, session, config, result, logger) {
376
+ logger.debug('进入普通发送');
329
377
  const localDownloadDir = config.localDownloadDir;
330
378
  const onebotReadDir = config.onebotReadDir;
331
379
  let mediaCoverUrl = result.coverUrl;
@@ -333,15 +381,14 @@ async function sendResult_plain(session, config, result, logger) {
333
381
  let proxy = undefined;
334
382
  if (config.proxy_settings[result.platform]) {
335
383
  proxy = config.proxy;
336
- logger.info("正在使用代理");
384
+ logger.debug("正在使用代理");
337
385
  }
338
386
  // --- 下载封面 ---
339
387
  if (result.coverUrl) {
340
388
  if (config.usingLocal) {
341
389
  try {
342
- mediaCoverUrl = await downloadAndMapUrl(result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
343
- if (config.logLevel === 'full')
344
- logger.info(`封面已下载: ${mediaCoverUrl}`);
390
+ mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
391
+ logger.debug(`封面已下载: ${mediaCoverUrl}`);
345
392
  }
346
393
  catch (e) {
347
394
  logger.warn(`封面下载失败: ${result.coverUrl}`, e);
@@ -360,10 +407,9 @@ async function sendResult_plain(session, config, result, logger) {
360
407
  const remoteUrl = match[1];
361
408
  if (config.usingLocal) {
362
409
  try {
363
- const localUrl = await downloadAndMapUrl(remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
410
+ const localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
364
411
  urlMap[remoteUrl] = localUrl;
365
- if (config.logLevel === 'full')
366
- logger.info(`正文图片已下载: ${localUrl}`);
412
+ logger.debug(`正文图片已下载: ${localUrl}`);
367
413
  }
368
414
  catch (e) {
369
415
  logger.warn(`正文图片下载失败: ${remoteUrl}`, e);
@@ -389,9 +435,7 @@ async function sendResult_plain(session, config, result, logger) {
389
435
  message = message.replace(/{stats}/g, escapeHtml(result.stats || ''));
390
436
  // 清理空行
391
437
  const cleanMessage = message.split('\n').filter(line => line.trim() !== '' || line.includes('<')).join('\n');
392
- if (config.logLevel === 'full') {
393
- logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
394
- }
438
+ logger.debug(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
395
439
  const sendPromises = [];
396
440
  // 发送主消息
397
441
  if (cleanMessage) {
@@ -409,19 +453,17 @@ async function sendResult_plain(session, config, result, logger) {
409
453
  const maxBytes = config.Max_size * 1024 * 1024;
410
454
  if (sizeBytes !== null && sizeBytes > maxBytes) {
411
455
  shouldSend = false;
412
- if (config.logLevel !== 'none') {
413
- const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
414
- const maxMB = config.Max_size.toFixed(2);
415
- sendPromises.push(session.send(`文件大小超限 (${sizeMB} MB > ${maxMB} MB)`));
416
- logger.info(`文件大小超限 (${sizeMB} MB > ${maxMB} MB),跳过: ${remoteUrl}`);
417
- }
456
+ const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
457
+ const maxMB = config.Max_size.toFixed(2);
458
+ sendPromises.push(session.send(`文件大小超限 (${sizeMB} MB > ${maxMB} MB)`));
459
+ logger.info(`文件大小超限 (${sizeMB} MB > ${maxMB} MB),跳过: ${remoteUrl}`);
418
460
  }
419
461
  }
420
462
  if (shouldSend) {
421
463
  try {
422
464
  let localUrl = remoteUrl;
423
465
  if (config.usingLocal)
424
- localUrl = await downloadAndMapUrl(remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
466
+ localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
425
467
  if (!localUrl)
426
468
  continue;
427
469
  let element = null;
@@ -438,14 +480,10 @@ async function sendResult_plain(session, config, result, logger) {
438
480
  }
439
481
  if (element) {
440
482
  sendPromises.push(session.send(element));
441
- if (config.logLevel === 'link_only') {
442
- logger.info(`${type} 直链 (${result.platform}): ${remoteUrl}`);
443
- }
444
- if (config.logLevel === 'full') {
445
- const size = await getFileSize(remoteUrl, proxy, config.userAgent, logger);
446
- const sizeMB = size ? (size / (1024 * 1024)).toFixed(2) : 'unknown';
447
- logger.info(`${type} 已发送 (${sizeMB} MB): ${localUrl}`);
448
- }
483
+ logger.debug(`${type} 直链 (${result.platform}): ${remoteUrl}`);
484
+ const size = await getFileSize(remoteUrl, proxy, config.userAgent, logger);
485
+ const sizeMB = size ? (size / (1024 * 1024)).toFixed(2) : 'unknown';
486
+ logger.debug(`${type} 已发送 (${sizeMB} MB): ${localUrl}`);
449
487
  }
450
488
  }
451
489
  catch (e) {
@@ -456,10 +494,8 @@ async function sendResult_plain(session, config, result, logger) {
456
494
  }
457
495
  await Promise.all(sendPromises);
458
496
  }
459
- async function sendResult_forward(session, config, result, logger, mixed_sending = false) {
460
- if (config.logLevel === 'full') {
461
- logger.info(mixed_sending ? '进入混合发送' : '进入合并发送');
462
- }
497
+ async function sendResult_forward(ctx, session, config, result, logger, mixed_sending = false) {
498
+ logger.debug(mixed_sending ? '进入混合发送' : '进入合并发送');
463
499
  const localDownloadDir = config.localDownloadDir;
464
500
  const onebotReadDir = config.onebotReadDir;
465
501
  let mediaCoverUrl = result.coverUrl;
@@ -473,7 +509,7 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
473
509
  if (result.coverUrl) {
474
510
  if (config.usingLocal) {
475
511
  try {
476
- mediaCoverUrl = await downloadAndMapUrl(result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
512
+ mediaCoverUrl = await downloadAndMapUrl(ctx, result.coverUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
477
513
  }
478
514
  catch (e) {
479
515
  logger.warn('封面下载失败', e);
@@ -491,7 +527,7 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
491
527
  await Promise.all(imgUrls.map(async (url) => {
492
528
  if (config.usingLocal) {
493
529
  try {
494
- urlMap[url] = await downloadAndMapUrl(url, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
530
+ urlMap[url] = await downloadAndMapUrl(ctx, url, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
495
531
  }
496
532
  catch (e) {
497
533
  logger.warn(`正文图片下载失败: ${url}`, e);
@@ -507,7 +543,7 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
507
543
  mediaMainbody = mediaMainbody.replace(new RegExp(escaped, 'g'), local);
508
544
  }
509
545
  }
510
- // === 主消息(不含媒体文件)===
546
+ // === 主消息 ===
511
547
  let message = config.format;
512
548
  message = message.replace(/{title}/g, result.title || '');
513
549
  message = message.replace(/{authorName}/g, result.authorName || '');
@@ -555,7 +591,7 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
555
591
  }
556
592
  });
557
593
  }
558
- // --- 处理 files 中的所有媒体 ---
594
+ // --- 处理 files ---
559
595
  const extraSendPromises = [];
560
596
  if (config.sendFiles && Array.isArray(result.files)) {
561
597
  for (const file of result.files) {
@@ -568,30 +604,28 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
568
604
  const maxBytes = config.Max_size * 1024 * 1024;
569
605
  if (sizeBytes !== null && sizeBytes > maxBytes) {
570
606
  shouldInclude = false;
571
- if (config.logLevel !== 'none') {
572
- const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
573
- const maxMB = config.Max_size.toFixed(2);
574
- forwardNodes.push({
575
- type: 'node',
576
- data: {
577
- user_id: session.selfId,
578
- nickname: '分享助手',
579
- content: {
580
- type: 'text', data: {
581
- text: `文件大小超限 (${sizeMB} MB > ${maxMB} MB)`
582
- }
607
+ const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(2);
608
+ const maxMB = config.Max_size.toFixed(2);
609
+ forwardNodes.push({
610
+ type: 'node',
611
+ data: {
612
+ user_id: session.selfId,
613
+ nickname: '分享助手',
614
+ content: {
615
+ type: 'text', data: {
616
+ text: `文件大小超限 (${sizeMB} MB > ${maxMB} MB)`
583
617
  }
584
618
  }
585
- });
586
- logger.info(`文件大小超限 (${sizeMB} MB > ${maxMB} MB),跳过: ${remoteUrl}`);
587
- }
619
+ }
620
+ });
621
+ logger.info(`文件大小超限 (${sizeMB} MB > ${maxMB} MB),跳过: ${remoteUrl}`);
588
622
  }
589
623
  }
590
624
  if (shouldInclude) {
591
625
  try {
592
626
  let localUrl = remoteUrl;
593
627
  if (config.usingLocal)
594
- localUrl = await downloadAndMapUrl(remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger);
628
+ localUrl = await downloadAndMapUrl(ctx, remoteUrl, proxy, config.userAgent, localDownloadDir, onebotReadDir, logger, config.enableCache);
595
629
  if (!localUrl)
596
630
  continue;
597
631
  if (!mixed_sending) {
@@ -631,9 +665,7 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
631
665
  extraSendPromises.push(session.send(element));
632
666
  }
633
667
  }
634
- if (config.logLevel === 'link_only') {
635
- logger.info(`${type} 直链 (${result.platform}): ${remoteUrl}`);
636
- }
668
+ logger.debug(`${type} 直链 (${result.platform}): ${remoteUrl}`);
637
669
  }
638
670
  catch (e) {
639
671
  logger.warn(`${type} 下载失败: ${remoteUrl}`, e);
@@ -656,9 +688,7 @@ async function sendResult_forward(session, config, result, logger, mixed_sending
656
688
  }
657
689
  if (forwardNodes.length === 0 && extraSendPromises.length === 0)
658
690
  return;
659
- if (config.logLevel === 'full') {
660
- logger.info(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
661
- }
691
+ logger.debug(`解析结果: \n ${JSON.stringify(result, null, 2)}`);
662
692
  if (!(session.onebot && session.onebot._request))
663
693
  throw new Error("Onebot is not defined");
664
694
  const promises = [];
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "koishi-plugin-share-links-analysis",
3
3
  "description": "自用插件",
4
4
  "license": "MIT",
5
- "version": "0.7.2",
5
+ "version": "0.7.3",
6
6
  "main": "lib/index.js",
7
7
  "typings": "lib/index.d.ts",
8
8
  "files": [