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

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.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context, Session } from 'koishi';
2
2
  import { Link, PluginConfig, ParsedInfo } from './types';
3
- export declare const parsers_str: ("bilibili" | "xiaohongshu" | "twitter" | "xiaoheihe")[];
3
+ export declare const parsers_str: ("bilibili" | "xiaohongshu" | "twitter" | "xiaoheihe" | "youtube")[];
4
4
  /**
5
5
  * 从文本中解析出所有支持的链接
6
6
  * @param content 消息内容
package/lib/core.js CHANGED
@@ -42,8 +42,9 @@ const Bilibili = __importStar(require("./parsers/bilibili"));
42
42
  const Xiaohongshu = __importStar(require("./parsers/xiaohongshu"));
43
43
  const Twitter = __importStar(require("./parsers/twitter"));
44
44
  const Xiaoheihe = __importStar(require("./parsers/xiaoheihe"));
45
+ const Youtube = __importStar(require("./parsers/youtube"));
45
46
  // 定义所有支持的解析器
46
- const parsers = [Bilibili, Xiaohongshu, Twitter, Xiaoheihe];
47
+ const parsers = [Bilibili, Xiaohongshu, Twitter, Xiaoheihe, Youtube];
47
48
  exports.parsers_str = parsers.map(p => p.name);
48
49
  /**
49
50
  * 从文本中解析出所有支持的链接
@@ -69,8 +70,7 @@ function resolveLinks(content) {
69
70
  async function processLink(ctx, config, link, session) {
70
71
  for (const parser of parsers) {
71
72
  if (parser.name == link.platform) {
72
- if (config.logLevel == "full")
73
- ctx.logger('share-links-analysis').info(`解析平台:${parser.name},链接:${link.url}`);
73
+ ctx.logger('share-links-analysis').debug(`解析平台:${parser.name},链接:${link.url}`);
74
74
  return await parser.process(ctx, config, link, session);
75
75
  }
76
76
  }
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}
@@ -54,6 +93,9 @@ exports.Config = koishi_1.Schema.intersect([
54
93
  useNumeral: koishi_1.Schema.boolean().default(true).description("使用格式化数字 (如 10000 -> 1万)"),
55
94
  showError: koishi_1.Schema.boolean().default(false).description("当链接被阻止时提醒发送者"),
56
95
  }).description("高级解析设置"),
96
+ koishi_1.Schema.object({
97
+ youtubeCookie: koishi_1.Schema.string().role('textarea').description("[YouTube 专用] 手动填入 Cookie 字符串。使用已登录账号的 Cookie 可大幅降低被拦截概率。<br>获取方式:浏览器F12 -> 网络 -> 刷新YouTube首页 -> 查看请求头中的 `cookie` 字段。"),
98
+ }).description("YouTube 设置"),
57
99
  koishi_1.Schema.object({
58
100
  proxy: koishi_1.Schema.string().description("代理设置"),
59
101
  proxy_settings: koishi_1.Schema.object(Object.fromEntries(core_1.parsers_str.map(parser => [parser, koishi_1.Schema.boolean().default(false).description(`对${parser}使用代理`)]))),
@@ -68,22 +110,17 @@ exports.Config = koishi_1.Schema.intersect([
68
110
  }).description('跨环境路径映射设置'),
69
111
  koishi_1.Schema.object({
70
112
  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("选择后台日志记录等级"),
113
+ debug: koishi_1.Schema.boolean().default(false).description("开启调试模式 (输出详细日志)"),
76
114
  }).description("调试设置"),
77
115
  ]);
78
116
  function apply(ctx, config) {
79
- // @ts-ignore
117
+ // 数据库模型定义
80
118
  ctx.model.extend('sla_cookie_cache', {
81
119
  platform: 'string', // 平台名称,如 'xiaohongshu'
82
120
  cookie: 'text', // 存储的 cookie 字符串
83
121
  }, {
84
122
  primary: 'platform' // 使用平台名称作为主键
85
123
  });
86
- // @ts-ignore
87
124
  ctx.model.extend('sla_group_settings', {
88
125
  guildId: 'string',
89
126
  custom_parsers: 'json',
@@ -91,6 +128,59 @@ function apply(ctx, config) {
91
128
  }, {
92
129
  primary: 'guildId',
93
130
  });
131
+ // 解析结果缓存
132
+ ctx.model.extend('sla_parse_cache', {
133
+ key: 'string', // platform + ':' + id
134
+ data: 'json',
135
+ created_at: 'double',
136
+ }, { primary: 'key' });
137
+ // 资源文件缓存 (hash)
138
+ ctx.model.extend('sla_file_cache', {
139
+ hash: 'string', // URL MD5
140
+ path: 'string', // 本地绝对路径
141
+ url: 'string',
142
+ created_at: 'double',
143
+ }, { primary: 'hash' });
144
+ const logger = ctx.logger('share-links-analysis');
145
+ // 根据配置设置日志等级
146
+ if (config.debug) {
147
+ logger.level = 3; // Debug Level
148
+ }
149
+ // 清理缓存函数
150
+ const cleanExpiredCache = async () => {
151
+ if (!config.enableCache || config.cacheExpiration <= 0)
152
+ return;
153
+ const now = Date.now();
154
+ const threshold = now - config.cacheExpiration * 60 * 60 * 1000;
155
+ // 清理解析缓存
156
+ await ctx.database.remove('sla_parse_cache', {
157
+ created_at: { $lt: threshold }
158
+ });
159
+ // 清理文件缓存
160
+ const expiredFiles = await ctx.database.get('sla_file_cache', {
161
+ created_at: { $lt: threshold }
162
+ });
163
+ for (const file of expiredFiles) {
164
+ try {
165
+ if (fs.existsSync(file.path)) {
166
+ await fs.promises.unlink(file.path);
167
+ }
168
+ }
169
+ catch (e) {
170
+ logger.warn(`删除过期文件失败 ${file.path}: ${e}`);
171
+ }
172
+ }
173
+ await ctx.database.remove('sla_file_cache', {
174
+ created_at: { $lt: threshold }
175
+ });
176
+ if (expiredFiles.length > 0) {
177
+ logger.info(`已自动清理 ${expiredFiles.length} 个过期文件。`);
178
+ }
179
+ };
180
+ // 设置定时清理
181
+ if (config.enableCache && config.autoCleanInterval > 0) {
182
+ ctx.setInterval(cleanExpiredCache, config.autoCleanInterval * 60 * 60 * 1000);
183
+ }
94
184
  // 注册指令
95
185
  const cmd = ctx.command('share', '分享解析插件配置', { authority: 1 })
96
186
  .action(async ({ session }) => {
@@ -115,12 +205,9 @@ function apply(ctx, config) {
115
205
  if (!value)
116
206
  return '请输入正确的模式';
117
207
  const mode = value.trim().toLowerCase() === 'true';
118
- // @ts-ignore
119
208
  const data = await ctx.database.get('sla_group_settings', session.guildId);
120
- // @ts-ignore
121
209
  const final_parsers = { ...data[0]?.custom_parsers, ...{ [parser]: mode } };
122
210
  const record = { guildId: session.guildId, custom_parsers: final_parsers };
123
- // @ts-ignore
124
211
  await ctx.database.upsert('sla_group_settings', [record]);
125
212
  }
126
213
  await session.execute('share');
@@ -134,7 +221,6 @@ function apply(ctx, config) {
134
221
  if (value) {
135
222
  const mode = value.trim().toLowerCase() === 'true';
136
223
  const record = { guildId: session.guildId, nsfw_enabled: mode };
137
- // @ts-ignore
138
224
  await ctx.database.upsert('sla_group_settings', [record]);
139
225
  }
140
226
  await session.execute('share');
@@ -145,11 +231,25 @@ function apply(ctx, config) {
145
231
  return '该指令只能在群组中使用。';
146
232
  if (!await (0, utils_1.isUserAdmin)(session, session.userId))
147
233
  return '权限不足';
148
- // @ts-ignore
149
234
  await ctx.database.remove('sla_group_settings', { guildId: session.guildId });
150
235
  return '已重置为全局默认设置。';
151
236
  });
152
- const logger = ctx.logger('share-links-analysis');
237
+ // 清除缓存指令
238
+ cmd.subcommand('.clean', '清除所有缓存和文件', { authority: 3 })
239
+ .action(async ({ session }) => {
240
+ await ctx.database.remove('sla_parse_cache', {});
241
+ const allFiles = await ctx.database.get('sla_file_cache', {});
242
+ for (const file of allFiles) {
243
+ try {
244
+ if (fs.existsSync(file.path)) {
245
+ await fs.promises.unlink(file.path);
246
+ }
247
+ }
248
+ catch { }
249
+ }
250
+ await ctx.database.remove('sla_file_cache', {});
251
+ return '缓存及对应文件已清理。';
252
+ });
153
253
  const lastProcessedUrls = {};
154
254
  ctx.on('ready', async () => {
155
255
  logger.info('插件已启动,执行插件初始化');
@@ -168,8 +268,7 @@ function apply(ctx, config) {
168
268
  if (session.guildId) {
169
269
  const settings = await (0, utils_1.getEffectiveSettings)(ctx, session.guildId, config);
170
270
  if (!settings.parsers[link.platform]) {
171
- if (config.logLevel == "full")
172
- ctx.logger('share-links-analysis').info(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
271
+ logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
173
272
  if (config.showError)
174
273
  await session.send(`根据策略,该链接已被阻止解析:平台:${link.platform}`);
175
274
  continue;
@@ -177,8 +276,7 @@ function apply(ctx, config) {
177
276
  }
178
277
  else {
179
278
  if (!config.default_parsers[link.platform]) {
180
- if (config.logLevel == "full")
181
- ctx.logger('share-links-analysis').info(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
279
+ logger.debug(`根据策略,该链接已被阻止解析:平台:${link.platform},链接:${link.url}`);
182
280
  if (config.showError)
183
281
  await session.send(`根据策略,该链接已被阻止解析:平台:${link.platform}`);
184
282
  continue;
@@ -192,36 +290,66 @@ function apply(ctx, config) {
192
290
  if (!lastProcessedUrls[channelId])
193
291
  lastProcessedUrls[channelId] = {};
194
292
  if (now - (lastProcessedUrls[channelId][link.url] || 0) < config.Min_Interval * 1000) {
195
- if (config.logLevel === 'full')
196
- logger.info(`链接 ${link.url} 在冷却时间内,跳过处理。`);
293
+ logger.debug(`链接 ${link.url} 在冷却时间内,跳过处理。`);
197
294
  continue;
198
295
  }
199
296
  if (config.waitTip_Switch) {
200
297
  await session.send(config.waitTip_Switch);
201
298
  }
202
- const result = await (0, core_1.processLink)(ctx, config, link, session);
299
+ // === 缓存逻辑 ===
300
+ let result = null;
301
+ const cacheKey = `${link.platform}:${link.id}`;
302
+ if (config.enableCache) {
303
+ const cached = await ctx.database.get('sla_parse_cache', cacheKey);
304
+ // 检查是否存在且未过期
305
+ if (cached.length > 0) {
306
+ const entry = cached[0];
307
+ const isExpired = config.cacheExpiration > 0 && (Date.now() - entry.created_at > config.cacheExpiration * 60 * 60 * 1000);
308
+ if (!isExpired) {
309
+ logger.debug(`使用缓存解析结果: ${cacheKey}`);
310
+ result = entry.data;
311
+ }
312
+ else {
313
+ // 过期删除
314
+ await ctx.database.remove('sla_parse_cache', { key: cacheKey });
315
+ }
316
+ }
317
+ }
318
+ // 缓存未命中,执行解析
319
+ if (!result) {
320
+ result = await (0, core_1.processLink)(ctx, config, link, session);
321
+ // 写入缓存
322
+ if (result && config.enableCache) {
323
+ await ctx.database.upsert('sla_parse_cache', [{
324
+ key: cacheKey,
325
+ data: result,
326
+ created_at: Date.now()
327
+ }]);
328
+ }
329
+ }
330
+ // === 缓存逻辑结束 ===
203
331
  if (result) {
204
332
  lastProcessedUrls[channelId][link.url] = now;
205
- await sendResult(session, config, result, logger);
333
+ await sendResult(ctx, session, config, result, logger);
206
334
  }
207
335
  linkCount++;
208
336
  }
209
337
  });
210
338
  }
211
- async function sendResult(session, config, result, logger) {
339
+ async function sendResult(ctx, session, config, result, logger) {
212
340
  if (!session.channel) {
213
- await (0, utils_1.sendResult_plain)(session, config, result, logger);
341
+ await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
214
342
  return;
215
343
  }
216
344
  switch (config.useForward) {
217
345
  case "plain":
218
- await (0, utils_1.sendResult_plain)(session, config, result, logger);
346
+ await (0, utils_1.sendResult_plain)(ctx, session, config, result, logger);
219
347
  return;
220
348
  case 'forward':
221
- await (0, utils_1.sendResult_forward)(session, config, result, logger, false);
349
+ await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, false);
222
350
  return;
223
351
  case "mixed":
224
- await (0, utils_1.sendResult_forward)(session, config, result, logger, true);
352
+ await (0, utils_1.sendResult_forward)(ctx, session, config, result, logger, true);
225
353
  return;
226
354
  }
227
355
  }
@@ -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` 找到视频链接。');
@@ -0,0 +1,10 @@
1
+ import { Context, Session } from 'koishi';
2
+ import { Link, ParsedInfo, PluginConfig } from '../types';
3
+ export declare const name = "youtube";
4
+ export declare function match(content: string): Link[];
5
+ /**
6
+ * 初始化:尝试获取 Cookie
7
+ * 策略:优先 Puppeteer (最稳),失败则回退到 HTTP 请求 (利用插件自身的代理设置)
8
+ */
9
+ export declare function init(ctx: Context, config: PluginConfig): Promise<boolean>;
10
+ export declare function process(ctx: Context, config: PluginConfig, link: Link, session: Session): Promise<ParsedInfo | null>;
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+ // src/parsers/youtube.ts
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.name = void 0;
8
+ exports.match = match;
9
+ exports.init = init;
10
+ exports.process = process;
11
+ const utils_1 = require("../utils");
12
+ const ytdl_core_1 = __importDefault(require("@distube/ytdl-core"));
13
+ exports.name = "youtube";
14
+ // 匹配规则:支持普通视频、短链接 (youtu.be)、Shorts 以及 Embed 链接
15
+ const linkRules = [
16
+ {
17
+ pattern: /https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?v=([\w-]{11})/gi,
18
+ type: "video",
19
+ },
20
+ {
21
+ pattern: /https?:\/\/youtu\.be\/([\w-]{11})/gi,
22
+ type: "video",
23
+ },
24
+ {
25
+ pattern: /https?:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([\w-]{11})/gi,
26
+ type: "shorts",
27
+ },
28
+ {
29
+ pattern: /https?:\/\/(?:www\.|m\.)?youtube\.com\/(?:v|embed)\/([\w-]{11})/gi,
30
+ type: "video",
31
+ }
32
+ ];
33
+ function match(content) {
34
+ const results = [];
35
+ const seen = new Set();
36
+ for (const rule of linkRules) {
37
+ let match;
38
+ while ((match = rule.pattern.exec(content)) !== null) {
39
+ const id = match[1];
40
+ const url = `https://www.youtube.com/watch?v=${id}`;
41
+ if (seen.has(url))
42
+ continue;
43
+ seen.add(url);
44
+ results.push({
45
+ platform: exports.name,
46
+ type: rule.type,
47
+ id,
48
+ url,
49
+ });
50
+ }
51
+ }
52
+ return results;
53
+ }
54
+ /**
55
+ * 初始化:尝试获取 Cookie
56
+ * 策略:优先 Puppeteer (最稳),失败则回退到 HTTP 请求 (利用插件自身的代理设置)
57
+ */
58
+ async function init(ctx, config) {
59
+ const logger = ctx.logger('share-links-analysis:youtube');
60
+ // 1. 检查是否已配置手动 Cookie
61
+ if (config.youtubeCookie && config.youtubeCookie.trim().length > 0) {
62
+ logger.info('检测到配置文件中已填写手动 Cookie,将优先使用该 Cookie,跳过自动刷新。');
63
+ // 可选:将配置的 Cookie 同步到数据库,或者在 process 中直接读取配置
64
+ // 这里我们选择不写入数据库,而是每次 process 时直接读配置,方便用户随时修改
65
+ return true;
66
+ }
67
+ // 2. 自动获取逻辑 (仅当未配置手动 Cookie 时执行)
68
+ let cookieString = '';
69
+ // --- 策略 A: 尝试 Puppeteer (受全局 puppeteer 配置影响) ---
70
+ if (ctx.puppeteer) {
71
+ logger.info('尝试通过 Puppeteer 获取 Cookie...');
72
+ let page = null;
73
+ try {
74
+ page = await ctx.puppeteer.page();
75
+ await page.setUserAgent(config.userAgent);
76
+ // Puppeteer 只能走全局代理或 Chrome 参数代理,无法在这里动态设置
77
+ await page.goto('https://www.youtube.com', {
78
+ waitUntil: 'domcontentloaded',
79
+ timeout: 15000
80
+ });
81
+ // 尝试点击同意 (针对欧盟IP)
82
+ try {
83
+ const consentButton = await page.$('button[aria-label*="Accept"]');
84
+ if (consentButton)
85
+ await consentButton.click();
86
+ }
87
+ catch (e) { }
88
+ const cookies = await page.cookies();
89
+ if (cookies.length > 0) {
90
+ cookieString = cookies.map((c) => `${c.name}=${c.value}`).join('; ');
91
+ logger.info(`Puppeteer 成功: 获取到 ${cookies.length} 个 Cookie`);
92
+ }
93
+ }
94
+ catch (error) {
95
+ logger.warn(`Puppeteer 获取失败: ${error.message}`);
96
+ }
97
+ finally {
98
+ if (page)
99
+ await page.close();
100
+ }
101
+ }
102
+ // --- 策略 B: 如果 Puppeteer 失败,尝试 HTTP 回退 (使用插件独立代理) ---
103
+ if (!cookieString) {
104
+ logger.info('尝试通过 HTTP 请求回退获取 Cookie...');
105
+ try {
106
+ // 构造请求头
107
+ const headers = {
108
+ 'User-Agent': config.userAgent,
109
+ 'Accept-Language': 'en-US,en;q=0.9',
110
+ };
111
+ // 创建一个专用的 http 实例,显式应用插件配置的 proxy
112
+ // 这样才能确保 request 走你在插件里填写的代理,而不是 Koishi 全局代理
113
+ const http = config.proxy
114
+ ? ctx.http.extend({ proxy: config.proxy })
115
+ : ctx.http;
116
+ const res = await http('https://www.youtube.com', {
117
+ method: 'HEAD',
118
+ headers: headers,
119
+ redirect: 'manual'
120
+ });
121
+ let setCookie = [];
122
+ // 兼容处理 Headers: 标准 API (Node 18+) 或 Polyfill
123
+ if (res.headers && typeof res.headers.getSetCookie === 'function') {
124
+ setCookie = res.headers.getSetCookie();
125
+ }
126
+ else if (res.headers && typeof res.headers.get === 'function') {
127
+ // 降级:部分环境可能没有 getSetCookie
128
+ const raw = res.headers.get('set-cookie');
129
+ if (raw)
130
+ setCookie = [raw];
131
+ }
132
+ else if (res.headers && Array.isArray(res.headers['set-cookie'])) {
133
+ // 兼容旧版 axios 风格返回
134
+ setCookie = res.headers['set-cookie'];
135
+ }
136
+ if (setCookie && setCookie.length > 0) {
137
+ cookieString = setCookie.map(str => str.split(';')[0]).join('; ');
138
+ logger.info(`HTTP 回退成功: 获取到 Cookie 字符串`);
139
+ }
140
+ else {
141
+ logger.warn('HTTP 请求未返回 Set-Cookie 头');
142
+ }
143
+ }
144
+ catch (error) {
145
+ logger.warn(`HTTP 回退失败: ${error.message}`);
146
+ }
147
+ }
148
+ // --- 写入数据库 ---
149
+ if (cookieString) {
150
+ await ctx.database.upsert('sla_cookie_cache', [{ platform: exports.name, cookie: cookieString }]);
151
+ return true;
152
+ }
153
+ else {
154
+ logger.error('所有策略均失败,无法获取 YouTube Cookie。');
155
+ return false;
156
+ }
157
+ }
158
+ // 辅助函数:解析 Cookie 字符串
159
+ function parseCookieString(cookieString) {
160
+ if (!cookieString)
161
+ return [];
162
+ return cookieString.split(';').map(pair => {
163
+ const parts = pair.split('=');
164
+ // 处理 value 中可能包含 = 的情况
165
+ const name = parts.shift();
166
+ const value = parts.join('=');
167
+ if (name && value)
168
+ return { name: name.trim(), value: value.trim() };
169
+ return null;
170
+ }).filter((c) => c !== null);
171
+ }
172
+ async function process(ctx, config, link, session) {
173
+ const logger = ctx.logger(`share-links-analysis:${exports.name}`);
174
+ const videoUrl = `https://www.youtube.com/watch?v=${link.id}`;
175
+ // 修复 2: 提升变量作用域,确保 catch 块能访问
176
+ let cookieString = '';
177
+ try {
178
+ // 1. 准备 Cookie
179
+ // 优先级:配置的手动 Cookie > 数据库缓存的自动 Cookie
180
+ if (config.youtubeCookie && config.youtubeCookie.trim().length > 0) {
181
+ cookieString = config.youtubeCookie.trim();
182
+ logger.debug('使用配置文件中的手动 Cookie。');
183
+ }
184
+ else {
185
+ const dbCache = await ctx.database.get('sla_cookie_cache', exports.name);
186
+ cookieString = (dbCache && dbCache.length > 0) ? dbCache[0].cookie : '';
187
+ if (cookieString)
188
+ logger.debug('使用数据库缓存的自动 Cookie。');
189
+ }
190
+ if (!cookieString) {
191
+ logger.debug('无 Cookie,将尝试裸连解析(风险较高)。');
192
+ }
193
+ else {
194
+ logger.debug('已加载 Cookie,准备解析。');
195
+ }
196
+ const cookies = parseCookieString(cookieString);
197
+ // 2. 创建 Agent (ytdl 专用)
198
+ // 无论 Puppeteer 是否有代理,这里必须使用插件配置的代理来请求视频流
199
+ let agent;
200
+ if (config.proxy) {
201
+ // 使用 createProxyAgent 同时处理 代理 和 Cookie
202
+ // 注意:@distube/ytdl-core 4.x+ 支持此方法
203
+ if (typeof ytdl_core_1.default.createProxyAgent === 'function') {
204
+ logger.debug(`使用代理: ${config.proxy}`);
205
+ agent = ytdl_core_1.default.createProxyAgent({ uri: config.proxy }, cookies);
206
+ }
207
+ else {
208
+ // 如果版本较旧没有 createProxyAgent,回退到普通 Agent (代理会失效)
209
+ logger.warn('ytdl.createProxyAgent 不存在,将忽略代理设置。请更新 @distube/ytdl-core');
210
+ agent = ytdl_core_1.default.createAgent(cookies);
211
+ }
212
+ }
213
+ else {
214
+ // 无代理,直接使用 createAgent
215
+ agent = ytdl_core_1.default.createAgent(cookies);
216
+ }
217
+ // 3. 获取视频信息
218
+ logger.debug(`正在解析 YouTube 视频: ${link.id}`);
219
+ // 尝试获取信息
220
+ // 为了提高成功率,我们允许 ytdl 尝试使用不同的内置客户端 (如 Android, iOS)
221
+ // 注意:@distube/ytdl-core 会自动处理客户端回退,但我们可以显式传参
222
+ const info = await ytdl_core_1.default.getInfo(videoUrl, {
223
+ agent, // 将正确的 Agent 传递给顶层选项
224
+ requestOptions: {
225
+ headers: {
226
+ 'User-Agent': config.userAgent,
227
+ // 确保 headers 里也带上 Cookie,增加成功率
228
+ 'Cookie': cookieString
229
+ }
230
+ }
231
+ });
232
+ const details = info.videoDetails;
233
+ // 4. 提取元数据
234
+ const title = details.title;
235
+ const authorName = details.author.name;
236
+ const description = details.description || '';
237
+ // 获取最高分辨率的封面
238
+ const thumbnails = details.thumbnails;
239
+ const coverUrl = thumbnails.length > 0 ? thumbnails[thumbnails.length - 1].url : undefined;
240
+ const views = (0, utils_1.numeral)(parseInt(details.viewCount), config);
241
+ const likes = details.likes ? (0, utils_1.numeral)(details.likes, config) : '未知';
242
+ const statsString = `观看: ${views} | 点赞: ${likes}`;
243
+ // 5. 选择最佳视频流
244
+ // 优先寻找 MP4 封装且包含音视频的格式 (兼容性最好)
245
+ let format = ytdl_core_1.default.chooseFormat(info.formats, {
246
+ quality: 'highest',
247
+ filter: (f) => f.container === 'mp4' && f.hasAudio && f.hasVideo
248
+ });
249
+ // 如果找不到 MP4 合流,尝试任意音视频合流
250
+ if (!format) {
251
+ logger.debug('未找到 MP4 合流格式,尝试查找任意音视频合流...');
252
+ try {
253
+ format = ytdl_core_1.default.chooseFormat(info.formats, {
254
+ quality: 'highest',
255
+ filter: 'audioandvideo'
256
+ });
257
+ }
258
+ catch (e) { }
259
+ }
260
+ // 6. 构建文件列表
261
+ const files = [];
262
+ if (format && format.url) {
263
+ // 只有当存在有效链接时才添加
264
+ files.push({ type: 'video', url: format.url });
265
+ }
266
+ else {
267
+ logger.warn('未找到合适的视频流格式。');
268
+ }
269
+ // 7. 构建正文
270
+ const mainbody = (0, utils_1.escapeHtml)(description);
271
+ return {
272
+ platform: exports.name,
273
+ title: title,
274
+ authorName: authorName,
275
+ mainbody: mainbody,
276
+ coverUrl: coverUrl,
277
+ files: files,
278
+ sourceUrl: videoUrl,
279
+ stats: statsString,
280
+ };
281
+ }
282
+ catch (error) {
283
+ if (error.message.includes('Sign in') || error.message.includes('429')) {
284
+ const tip = cookieString
285
+ ? '当前 Cookie 可能已失效,请更新配置中的 Cookie。'
286
+ : '请在插件配置中填入已登录账号的 YouTube Cookie。';
287
+ await session.send(`YouTube 解析被拦截:${tip}`);
288
+ logger.warn(`解析拦截: ${error.message}`);
289
+ }
290
+ else if (error.message.includes('Private video')) {
291
+ await session.send('解析失败:私享视频。');
292
+ }
293
+ else {
294
+ logger.error(`解析异常: ${error.message}`);
295
+ }
296
+ return null;
297
+ }
298
+ }
package/lib/types.d.ts CHANGED
@@ -27,18 +27,22 @@ 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;
37
+ youtubeCookie?: string;
34
38
  proxy: string;
35
- proxy_settings: object;
36
- default_parsers: object;
39
+ proxy_settings: Record<string, boolean>;
40
+ default_parsers: Record<string, boolean>;
37
41
  allow_sensitive: boolean;
38
42
  onebotReadDir: string;
39
43
  localDownloadDir: string;
40
44
  userAgent: string;
41
- logLevel: 'none' | 'link_only' | 'full';
45
+ debug: boolean;
42
46
  }
43
47
  export interface BilibiliVideoInfo {
44
48
  data: {
@@ -79,6 +83,32 @@ declare module 'koishi' {
79
83
  BiliBiliVideo: any;
80
84
  puppeteer?: any;
81
85
  }
86
+ interface Tables {
87
+ sla_parse_cache: SlaParseCache;
88
+ sla_file_cache: SlaFileCache;
89
+ sla_cookie_cache: SlaCookieCache;
90
+ sla_group_settings: SlaGroupSettings;
91
+ }
92
+ }
93
+ export interface SlaParseCache {
94
+ key: string;
95
+ data: ParsedInfo;
96
+ created_at: number;
97
+ }
98
+ export interface SlaFileCache {
99
+ hash: string;
100
+ path: string;
101
+ url: string;
102
+ created_at: number;
103
+ }
104
+ export interface SlaCookieCache {
105
+ platform: string;
106
+ cookie: string;
107
+ }
108
+ export interface SlaGroupSettings {
109
+ guildId: string;
110
+ custom_parsers: Record<string, boolean>;
111
+ nsfw_enabled: boolean;
82
112
  }
83
113
  export interface XhsImageInfo {
84
114
  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.8.0",
6
6
  "main": "lib/index.js",
7
7
  "typings": "lib/index.d.ts",
8
8
  "files": [