koishi-plugin-video-parser-all 0.1.7 → 0.1.8

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.
Files changed (2) hide show
  1. package/lib/index.js +263 -156
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ const crypto_1 = __importDefault(require("crypto"));
11
11
  const fs_1 = __importDefault(require("fs"));
12
12
  const path_1 = __importDefault(require("path"));
13
13
  const promises_1 = require("stream/promises");
14
+ const worker_threads_1 = require("worker_threads");
14
15
  exports.name = 'video-parser-all';
15
16
  exports.Config = koishi_1.Schema.object({
16
17
  enable: koishi_1.Schema.boolean().default(true).description('是否启用插件'),
@@ -62,9 +63,33 @@ exports.Config = koishi_1.Schema.object({
62
63
  customApi: koishi_1.Schema.string().description('B站自定义API'),
63
64
  }).description('B站配置'),
64
65
  });
66
+ // 工作线程处理下载逻辑
67
+ if (!worker_threads_1.isMainThread) {
68
+ const { url, filePath } = worker_threads_1.workerData;
69
+ (async () => {
70
+ try {
71
+ const response = await (0, axios_1.default)({
72
+ url,
73
+ method: 'GET',
74
+ responseType: 'stream',
75
+ timeout: 60000,
76
+ headers: {
77
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
78
+ }
79
+ });
80
+ await (0, promises_1.pipeline)(response.data, fs_1.default.createWriteStream(filePath));
81
+ worker_threads_1.parentPort?.postMessage({ success: true, filePath });
82
+ }
83
+ catch (error) {
84
+ worker_threads_1.parentPort?.postMessage({
85
+ success: false,
86
+ error: error.message
87
+ });
88
+ }
89
+ })();
90
+ }
65
91
  const processed = new Map();
66
92
  const linkBuffer = new Map();
67
- const parseCostHistory = new Map();
68
93
  const PLATFORM_KEYWORDS = {
69
94
  bilibili: ['bilibili', 'b23', 'B站'],
70
95
  kuaishou: ['kuaishou', '快手'],
@@ -91,200 +116,282 @@ function getPlatformType(url) {
91
116
  return 'bilibili';
92
117
  return null;
93
118
  }
94
- async function downloadVideo(url, filename) {
95
- const dir = path_1.default.join(process.cwd(), 'temp_videos');
96
- if (!fs_1.default.existsSync(dir))
97
- fs_1.default.mkdirSync(dir, { recursive: true });
98
- const file = path_1.default.join(dir, `${filename}.mp4`);
99
- const res = await (0, axios_1.default)({ url, method: 'GET', responseType: 'stream', timeout: 30000 });
100
- await (0, promises_1.pipeline)(res.data, fs_1.default.createWriteStream(file));
101
- return file;
119
+ // 多线程下载视频
120
+ async function downloadVideoWithThreads(url, filename) {
121
+ return new Promise((resolve, reject) => {
122
+ const dir = path_1.default.join(process.cwd(), 'temp_videos');
123
+ if (!fs_1.default.existsSync(dir))
124
+ fs_1.default.mkdirSync(dir, { recursive: true });
125
+ const filePath = path_1.default.join(dir, `${filename}.mp4`);
126
+ const worker = new worker_threads_1.Worker(__filename, {
127
+ workerData: { url, filePath }
128
+ });
129
+ worker.on('message', (result) => {
130
+ if (result.success) {
131
+ resolve(result.filePath);
132
+ }
133
+ else {
134
+ reject(new Error(result.error));
135
+ }
136
+ });
137
+ worker.on('error', (error) => {
138
+ reject(error);
139
+ });
140
+ worker.on('exit', (code) => {
141
+ if (code !== 0) {
142
+ reject(new Error(`下载线程退出,代码: ${code}`));
143
+ }
144
+ });
145
+ });
102
146
  }
103
- function parseData(data, platform) {
104
- if (platform === 'bilibili') {
105
- return {
106
- title: data.title || '无标题',
107
- author: data.user?.name || data.author || '未知UP主',
108
- desc: data.desc || data.description || data.title || '无简介',
109
- digg: data.like || data.digg || 0,
110
- coin: data.coin || 0,
111
- collect: data.collect || data.favorite || 0,
112
- share: data.share || 0,
113
- play: data.play || data.view || 0,
114
- danmaku: data.danmaku || data.comment || 0,
115
- cover: data.cover || data.imgurl || data.pic || '',
116
- video: data.url || data.video_url || (data.videos?.[0]?.url || '')
117
- };
147
+ function parseData(data, platform, maxDescLength) {
148
+ let title = data.title || '无标题';
149
+ let author = data.author?.name || data.user?.name || data.author || '未知作者';
150
+ let desc = data.desc || data.description || title || '无简介';
151
+ desc = desc.slice(0, maxDescLength);
152
+ let digg = data.like || data.digg || 0;
153
+ let coin = data.coin || 0;
154
+ let collect = data.collect || data.favorite || 0;
155
+ let share = data.share || 0;
156
+ let play = data.play || data.view || 0;
157
+ let danmaku = data.danmaku || data.comment || 0;
158
+ let cover = data.cover || data.imgurl || data.pic || '';
159
+ let video = '';
160
+ if (data.url)
161
+ video = data.url;
162
+ else if (data.video_url)
163
+ video = data.video_url;
164
+ else if (data.videos && Array.isArray(data.videos) && data.videos.length > 0) {
165
+ video = data.videos.reduce((prev, curr) => (prev.size || 0) > (curr.size || 0) ? prev : curr).url || '';
118
166
  }
119
- if (platform === 'douyin') {
120
- return {
121
- title: data.title || '无标题',
122
- author: data.author?.name || '未知作者',
123
- desc: data.desc || data.title || '无简介',
124
- digg: data.like || data.digg || 0,
125
- coin: 0,
126
- collect: 0,
127
- share: 0,
128
- play: 0,
129
- danmaku: 0,
130
- cover: data.cover || '',
131
- video: data.url || (data.live_photo?.[0]?.video || '')
132
- };
133
- }
134
- return {
135
- title: data.title || '无标题',
136
- author: data.author || '未知作者',
137
- desc: data.desc || data.title || '无简介',
138
- digg: data.like || 0,
139
- coin: 0,
140
- collect: 0,
141
- share: 0,
142
- play: 0,
143
- danmaku: 0,
144
- cover: data.cover || '',
145
- video: data.url || ''
146
- };
167
+ else if (data.play)
168
+ video = data.play;
169
+ else if (data.hd_url)
170
+ video = data.hd_url;
171
+ return { title, author, desc, digg, coin, collect, share, play, danmaku, cover, video };
147
172
  }
148
173
  function apply(ctx, config) {
149
- const http = axios_1.default.create({ timeout: config.timeout });
174
+ if (!worker_threads_1.isMainThread)
175
+ return;
176
+ const http = axios_1.default.create({
177
+ timeout: config.timeout,
178
+ headers: {
179
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
180
+ }
181
+ });
150
182
  function getApi(platform) {
151
183
  const conf = config[platform];
152
- if (!conf)
153
- return config.commonApi;
154
184
  if (conf.mode === 'custom' && conf.customApi)
155
185
  return conf.customApi;
156
186
  if (conf.mode === 'own')
157
187
  return conf.ownApi;
158
188
  return config.commonApi;
159
189
  }
160
- async function calculateRealEstimatedTime(urls, session) {
161
- let totalTime = 0;
162
- for (const url of urls) {
163
- const platform = getPlatformType(url);
164
- if (!platform)
165
- continue;
166
- const history = parseCostHistory.get(platform) || [];
167
- let avgCost = 3000;
168
- if (history.length > 0) {
169
- avgCost = history.reduce((a, b) => a + b, 0) / history.length;
170
- }
171
- if (config.downloadVideoBeforeSend && platform === 'bilibili') {
172
- avgCost += 2000;
173
- }
174
- totalTime += avgCost;
175
- }
176
- totalTime += config.messageBufferDelay * 1000;
177
- return Math.ceil(totalTime / 1000);
178
- }
179
190
  async function parse(url) {
180
- const start = Date.now();
181
191
  const platform = getPlatformType(url);
182
192
  if (!platform)
183
- return { data: null, cost: Date.now() - start };
193
+ return { data: null, platform: null };
184
194
  const api = getApi(platform);
185
195
  if (!api)
186
- return { data: null, cost: Date.now() - start };
196
+ return { data: null, platform: null };
187
197
  try {
188
198
  const res = await http.get(api, { params: { url } });
189
199
  if (res.data.code === 200 && res.data.data) {
190
- const cost = Date.now() - start;
191
- const history = parseCostHistory.get(platform) || [];
192
- if (history.length > 10)
193
- history.shift();
194
- history.push(cost);
195
- parseCostHistory.set(platform, history);
196
- return { data: parseData(res.data.data, platform), cost };
200
+ return {
201
+ data: parseData(res.data.data, platform, config.maxDescLength),
202
+ platform
203
+ };
204
+ }
205
+ }
206
+ catch (e) {
207
+ ctx.logger.error(`解析失败: ${e.message}`);
208
+ }
209
+ return { data: null, platform: null };
210
+ }
211
+ async function processSingleUrl(session, url) {
212
+ const hash = crypto_1.default.createHash('md5').update(url).digest('hex');
213
+ const now = Date.now();
214
+ if (processed.get(hash) && now - processed.get(hash) < config.sameLinkInterval * 1000) {
215
+ return null;
216
+ }
217
+ processed.set(hash, now);
218
+ const parseResult = await parse(url);
219
+ if (!parseResult.data)
220
+ return null;
221
+ let text = config.imageParseFormat
222
+ .replace(/\${标题}/g, parseResult.data.title)
223
+ .replace(/\${UP主}/g, parseResult.data.author)
224
+ .replace(/\${简介}/g, parseResult.data.desc)
225
+ .replace(/\${点赞}/g, String(parseResult.data.digg))
226
+ .replace(/\${投币}/g, String(parseResult.data.coin))
227
+ .replace(/\${收藏}/g, String(parseResult.data.collect))
228
+ .replace(/\${转发}/g, String(parseResult.data.share))
229
+ .replace(/\${观看}/g, String(parseResult.data.play))
230
+ .replace(/\${弹幕}/g, String(parseResult.data.danmaku))
231
+ .replace(/\${tab}/g, '\t')
232
+ .replace(/\${~~~}/g, '\n');
233
+ const contentParts = [];
234
+ if (config.returnContent.showImageText) {
235
+ const [beforeCover, afterCover] = text.split('${封面}');
236
+ if (beforeCover && beforeCover.trim())
237
+ contentParts.push(beforeCover.trim());
238
+ if (parseResult.data.cover)
239
+ contentParts.push(koishi_1.h.image(parseResult.data.cover));
240
+ if (afterCover && afterCover.trim())
241
+ contentParts.push(afterCover.trim());
242
+ }
243
+ let videoContent = '';
244
+ if (config.returnContent.showVideoFile && parseResult.data.video) {
245
+ if (config.downloadVideoBeforeSend && session.platform === 'onebot') {
246
+ try {
247
+ const filename = crypto_1.default.createHash('md5').update(parseResult.data.video).digest('hex');
248
+ const filePath = await downloadVideoWithThreads(parseResult.data.video, filename);
249
+ videoContent = koishi_1.h.video(`file://${filePath}`);
250
+ }
251
+ catch (e) {
252
+ ctx.logger.error(`下载视频失败: ${e.message}`);
253
+ videoContent = koishi_1.h.video(parseResult.data.video);
254
+ }
255
+ }
256
+ else {
257
+ videoContent = koishi_1.h.video(parseResult.data.video);
197
258
  }
198
259
  }
199
- catch (e) { }
200
- return { data: null, cost: Date.now() - start };
260
+ if (config.returnContent.showVideoUrl && parseResult.data.video) {
261
+ contentParts.push(`🔗 无水印链接:${parseResult.data.video}`);
262
+ }
263
+ return {
264
+ textContent: contentParts.join('\n'),
265
+ videoContent,
266
+ rawData: parseResult.data
267
+ };
201
268
  }
202
- async function revokeTip(session, key) {
203
- if (!config.revokeWaitingTip)
269
+ async function revokeWaitingTip(session, sessionKey) {
270
+ if (!config.revokeWaitingTip || session.platform !== 'onebot')
204
271
  return;
205
- const buf = linkBuffer.get(key);
206
- if (buf?.tipMsgId && session.platform === 'onebot') {
207
- await session.bot.deleteMessage(session.channelId, buf.tipMsgId).catch(() => { });
272
+ const bufferData = linkBuffer.get(sessionKey);
273
+ if (!bufferData?.tipMsgId)
274
+ return;
275
+ try {
276
+ await session.bot.internal.deleteMessage(session.channelId, bufferData.tipMsgId);
277
+ }
278
+ catch (e) {
279
+ ctx.logger.debug(`撤回等待提示失败: ${e.message}`);
208
280
  }
209
281
  }
210
- async function flush(session) {
211
- const key = `${session.platform}:${session.userId}:${session.channelId}`;
212
- const buf = linkBuffer.get(key);
213
- if (!buf)
282
+ async function flushBuffer(session) {
283
+ const sessionKey = `${session.platform}:${session.userId}:${session.channelId}`;
284
+ const bufferData = linkBuffer.get(sessionKey);
285
+ if (!bufferData)
214
286
  return;
215
- clearTimeout(buf.timer);
216
- linkBuffer.delete(key);
217
- await revokeTip(session, key);
218
- const urls = buf.urls;
219
- const estimated = await calculateRealEstimatedTime(urls, session);
220
- let tipText = config.waitingTipText;
221
- if (estimated > 0)
222
- tipText += ` 预计${estimated}秒后完成`;
287
+ clearTimeout(bufferData.timer);
288
+ linkBuffer.delete(sessionKey);
289
+ await revokeWaitingTip(session, sessionKey);
223
290
  const results = [];
224
- for (const url of urls) {
225
- const hash = crypto_1.default.createHash('md5').update(url).digest('hex');
226
- if (processed.get(hash) && Date.now() - processed.get(hash) < config.sameLinkInterval * 1000)
227
- continue;
228
- processed.set(hash, Date.now());
229
- const { data, cost } = await parse(url);
230
- if (data)
231
- results.push({ data, cost });
291
+ for (const url of bufferData.urls) {
292
+ const result = await processSingleUrl(session, url);
293
+ if (result)
294
+ results.push(result);
232
295
  }
233
- if (config.enableForward && session.platform === 'onebot' && results.length > 0) {
234
- const nodes = results.map(r => ({
235
- user_id: session.selfId,
236
- time: Math.floor(Date.now() / 1000),
237
- content: [r.data.title, r.data.cover ? koishi_1.h.image(r.data.cover) : null, r.data.video ? koishi_1.h.video(r.data.video) : null].filter(Boolean).join('\n')
238
- }));
239
- await session.send((0, koishi_1.h)('forward', {
240
- content: '群聊的聊天记录',
241
- brief: '[聊天记录]',
242
- data: nodes
243
- })).catch(() => {
244
- results.forEach(r => session.send(r.data.title).then(() => session.send(r.data.video)));
296
+ if (results.length === 0)
297
+ return;
298
+ if (config.enableForward && session.platform === 'onebot') {
299
+ const forwardNodes = results.map(result => {
300
+ const nodeContent = [];
301
+ if (result.textContent)
302
+ nodeContent.push(result.textContent);
303
+ if (result.videoContent)
304
+ nodeContent.push(result.videoContent);
305
+ return {
306
+ type: 'node',
307
+ data: {
308
+ name: '视频解析机器人',
309
+ uin: session.selfId,
310
+ content: nodeContent
311
+ }
312
+ };
245
313
  });
314
+ try {
315
+ await session.send((0, koishi_1.h)('message', {
316
+ type: 'forward',
317
+ data: {
318
+ messages: forwardNodes
319
+ }
320
+ }));
321
+ return;
322
+ }
323
+ catch (e) {
324
+ ctx.logger.error(`合并转发失败: ${e.message}`);
325
+ }
246
326
  }
247
- else {
248
- results.forEach(r => {
249
- session.send(r.data.title);
250
- if (r.data.video)
251
- session.send(r.data.video);
252
- });
327
+ for (const result of results) {
328
+ try {
329
+ if (result.textContent)
330
+ await session.send(result.textContent);
331
+ if (result.videoContent)
332
+ await session.send(result.videoContent);
333
+ }
334
+ catch (e) {
335
+ if (!config.ignoreSendError) {
336
+ ctx.logger.warn(`发送消息失败: ${e.message}`);
337
+ }
338
+ }
253
339
  }
254
340
  }
255
341
  ctx.on('message', async (session) => {
256
- if (!config.enable || !hasPlatformKeyword(session.content))
257
- return;
258
- const urls = extractUrl(session.content);
259
- if (!urls.length)
260
- return;
261
- const key = `${session.platform}:${session.userId}:${session.channelId}`;
262
- if (linkBuffer.has(key)) {
263
- linkBuffer.get(key).urls.push(...urls);
264
- clearTimeout(linkBuffer.get(key).timer);
265
- }
266
- else {
267
- const estimated = await calculateRealEstimatedTime(urls, session);
268
- let tipText = config.waitingTipText;
269
- if (estimated > 0)
270
- tipText += ` 预计${estimated}秒后完成`;
271
- const tipMsg = await session.send(tipText).catch(() => null);
272
- linkBuffer.set(key, {
342
+ try {
343
+ if (!config.enable)
344
+ return;
345
+ const content = session.content.trim();
346
+ if (!hasPlatformKeyword(content))
347
+ return;
348
+ const urls = extractUrl(content);
349
+ if (urls.length === 0)
350
+ return;
351
+ const sessionKey = `${session.platform}:${session.userId}:${session.channelId}`;
352
+ if (linkBuffer.has(sessionKey)) {
353
+ const existing = linkBuffer.get(sessionKey);
354
+ existing.urls.push(...urls);
355
+ clearTimeout(existing.timer);
356
+ existing.timer = setTimeout(() => flushBuffer(session), config.messageBufferDelay * 1000);
357
+ linkBuffer.set(sessionKey, existing);
358
+ return;
359
+ }
360
+ let tipMsgId;
361
+ if (config.showWaitingTip) {
362
+ const tipText = config.waitingTipText;
363
+ const tipMsg = await session.send(tipText).catch(e => {
364
+ if (!config.ignoreSendError)
365
+ ctx.logger.warn(`发送等待提示失败: ${e.message}`);
366
+ return null;
367
+ });
368
+ tipMsgId = tipMsg?.messageId || tipMsg?.id;
369
+ }
370
+ linkBuffer.set(sessionKey, {
273
371
  urls,
274
- timer: setTimeout(() => flush(session), config.messageBufferDelay * 1000),
275
- tipMsgId: tipMsg?.messageId || tipMsg?.id
372
+ timer: setTimeout(() => flushBuffer(session), config.messageBufferDelay * 1000),
373
+ tipMsgId
276
374
  });
277
375
  }
278
- linkBuffer.get(key).timer = setTimeout(() => flush(session), config.messageBufferDelay * 1000);
376
+ catch (e) {
377
+ ctx.logger.error(`消息处理异常: ${e.message}`);
378
+ }
279
379
  });
280
380
  setInterval(() => {
281
- processed.forEach((t, k) => Date.now() - t > 86400000 && processed.delete(k));
282
- const dir = path_1.default.join(process.cwd(), 'temp_videos');
283
- if (fs_1.default.existsSync(dir)) {
284
- fs_1.default.readdirSync(dir).forEach(f => {
285
- const p = path_1.default.join(dir, f);
286
- if (Date.now() - fs_1.default.statSync(p).ctimeMs > 3600000)
287
- fs_1.default.unlinkSync(p);
381
+ const now = Date.now();
382
+ processed.forEach((timestamp, hash) => {
383
+ if (now - timestamp > 86400000) {
384
+ processed.delete(hash);
385
+ }
386
+ });
387
+ const tempDir = path_1.default.join(process.cwd(), 'temp_videos');
388
+ if (fs_1.default.existsSync(tempDir)) {
389
+ fs_1.default.readdirSync(tempDir).forEach(file => {
390
+ const filePath = path_1.default.join(tempDir, file);
391
+ const stat = fs_1.default.statSync(filePath);
392
+ if (now - stat.ctimeMs > 3600000) {
393
+ fs_1.default.unlinkSync(filePath);
394
+ }
288
395
  });
289
396
  }
290
397
  }, 3600000);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-video-parser-all",
3
3
  "description": "Koishi 视频解析插件,支持抖音/快手/B站链接解析,可自定义API和解析规则",
4
- "version": "0.1.7",
4
+ "version": "0.1.8",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [