import axios from 'axios'; import { exec } from 'child_process'; import { HttpsProxyAgent } from 'https-proxy-agent'; import _ from 'lodash'; import fetch from 'node-fetch'; import fs from 'node:fs'; import PQueue from 'p-queue'; import querystring from 'querystring'; import { genVerifyFp } from '../utils/tiktok.js'; import { BILI_CDN_SELECT_LIST, BILI_DEFAULT_INTRO_LEN_LIMIT, BILI_RESOLUTION_LIST, COMMON_USER_AGENT, DIVIDING_LINE, douyinTypeMap, DOWNLOAD_WAIT_DETECT_FILE_TIME, REDIS_YUNZAI_ISOVERSEA, } from '../constants/constant.js'; import { RESOLVE_CONTROLLER_NAME_ENUM } from '../constants/resolve.js'; import { ANIME_SERIES_SEARCH_LINK, ANIME_SERIES_SEARCH_LINK2, BILI_ARTICLE_INFO, BILI_EP_INFO, BILI_ONLINE, BILI_SSID_INFO, BILI_STREAM_FLV, BILI_STREAM_INFO, DY_LIVE_INFO, DY_LIVE_INFO_2, DY_INFO, BILI_SUMMARY, DY_TOUTIAO_INFO, MIYOUSHE_ARTICLE, } from '../constants/tools.js'; import config from '../model/config.js'; import { startBBDown } from '../utils/bbdown-util.js'; import { BILI_HEADER, downloadBFile, filterBiliDescLink, getBiliAudio, getDownloadUrl, getDynamic, getVideoInfo, m4sToMp3, mergeFileToMp4, } from '../utils/bilibili.js'; import { getWbi } from '../utils/biliWbi.js'; import { checkToolInCurEnv, formatBiliInfo, retryAxiosReq, secondsToTime, truncateString, urlTransformShortLink, } from '../utils/common.js'; import { convertFlvToMp4 } from '../utils/ffmpeg-util.js'; import { checkAndRemoveFile, mkdirIfNotExists } from '../utils/file.js'; import { getDS } from '../utils/mihoyo.js'; import { redisExistKey, redisGetKey, redisSetKey } from '../utils/redis-util.js'; import { textArrayToMakeForward } from '../utils/yunzai-util.js'; import GeneralLinkAdapter from '../utils/general-link-adapter.js'; import aBogus from '../utils/a-bogus.cjs'; export class RCtools extends plugin { constructor() { super({ name: 'R插件工具和学习类', dsc: 'R插件工具相关指令', event: 'message.group', priority: 300, rule: [ { reg: '(bilibili.com|b23.tv|bili2233.cn|m.bilibili.com|t.bilibili.com|^BV[1-9a-zA-Z]{10}$)', fnc: 'bili', }, { reg: '(chenzhongtech.com|kuaishou.com|ixigua.com|h5.pipix.com|h5.pipigx.com|s.xsj.qq.com|m.okjike.com)', fnc: 'general', }, { reg: '(miyoushe.com)', fnc: 'miyoushe', }, { reg: '(v.douyin.com|live.douyin.com)', fnc: 'douyin', }, ], }); // 配置文件 this.toolsConfig = config.getConfig('tools'); // 视频保存路径 this.defaultPath = this.toolsConfig.defaultPath; // 视频限制大小 this.videoSizeLimit = this.toolsConfig.videoSizeLimit; // 获取全局禁用的解析 this.globalBlackList = this.toolsConfig.globalBlackList; // 魔法接口 this.proxyAddr = this.toolsConfig.proxyAddr; this.proxyPort = this.toolsConfig.proxyPort; // 加载识别前缀 this.identifyPrefix = this.toolsConfig.identifyPrefix; // 加载直播录制时长 this.streamDuration = this.toolsConfig.streamDuration; // 加载直播是否开启兼容模式 this.streamCompatibility = this.toolsConfig.streamCompatibility; // 加载哔哩哔哩配置 this.biliSessData = this.toolsConfig.biliSessData; // 加载哔哩哔哩的限制时长 this.biliDuration = this.toolsConfig.biliDuration; // 加载是否显示哔哩哔哩的封面 this.biliDisplayCover = this.toolsConfig.biliDisplayCover; // 加载是否显示哔哩哔哩的视频信息 this.biliDisplayInfo = this.toolsConfig.biliDisplayInfo; // 加载是否显示哔哩哔哩的简介 this.biliDisplayIntro = this.toolsConfig.biliDisplayIntro; // 加载是否显示哔哩哔哩的在线人数 this.biliDisplayOnline = this.toolsConfig.biliDisplayOnline; // 加载是否显示哔哩哔哩的总结 this.biliDisplaySummary = this.toolsConfig.biliDisplaySummary; // 加载哔哩哔哩是否使用BBDown this.biliUseBBDown = this.toolsConfig.biliUseBBDown; // 加载 BBDown 的CDN配置 this.biliCDN = this.toolsConfig.biliCDN; // 加载网易云Cookie this.neteaseCookie = this.toolsConfig.neteaseCookie; // 加载是否转化群语音 this.isSendVocal = this.toolsConfig.isSendVocal; // 加载是否自建服务器 this.useLocalNeteaseAPI = this.toolsConfig.useLocalNeteaseAPI; // 加载自建服务器API this.neteaseCloudAPIServer = this.toolsConfig.neteaseCloudAPIServer; // 加载网易云解析最高音质 this.neteaseCloudAudioQuality = this.toolsConfig.neteaseCloudAudioQuality; // 加载哔哩哔哩是否使用Aria2 this.biliDownloadMethod = this.toolsConfig.biliDownloadMethod; // 加载哔哩哔哩最高分辨率 this.biliResolution = this.toolsConfig.biliResolution; // 加载youtube的截取时长 this.youtubeClipTime = this.toolsConfig.youtubeClipTime; // 加载youtube的解析时长 this.youtubeDuration = this.toolsConfig.youtubeDuration; // 加载油管下载画质选项 this.youtubeGraphicsOptions = this.toolsConfig.youtubeGraphicsOptions; // 加载youtube的Cookie this.youtubeCookiePath = this.toolsConfig.youtubeCookiePath; // 加载抖音Cookie this.douyinCookie = this.toolsConfig.douyinCookie; // 加载抖音是否压缩 this.douyinCompression = this.toolsConfig.douyinCompression; // 加载抖音是否开启评论 this.douyinComments = this.toolsConfig.douyinComments; // 加载小红书Cookie this.xiaohongshuCookie = this.toolsConfig.xiaohongshuCookie; // 并发队列 this.queue = new PQueue({ concurrency: Number(this.toolsConfig.queueConcurrency) }); // 视频下载的并发数量 this.videoDownloadConcurrency = this.toolsConfig.videoDownloadConcurrency; // ai接口 this.aiBaseURL = this.toolsConfig.aiBaseURL; // ai api key this.aiApiKey = this.toolsConfig.aiApiKey; // ai模型 this.aiModel = this.toolsConfig.aiModel; // 强制使用海外服务器 this.forceOverseasServer = this.toolsConfig.forceOverseasServer; // 解析图片是否合并转发 this.globalImageLimit = this.toolsConfig.globalImageLimit; //💩💩💩 this.nickName = '真寻'; } async douyin(e) { // 切面判断是否需要解析 if (!(await this.isEnableResolve(RESOLVE_CONTROLLER_NAME_ENUM.douyin))) { logger.info(`[R插件][全局解析控制] ${RESOLVE_CONTROLLER_NAME_ENUM.douyin} 已拦截`); return true; } const urlRex = /(http:\/\/|https:\/\/)(v|live).douyin.com\/[A-Za-z\d._?%&+\-=\/#]*/; // 检测无效链接,例如:v.douyin.com if (!urlRex.test(e.msg)) { e.reply(`看上去这不像一个正经的抖音链接呢,${this.nickName}就不帮你解析咯..`); return; } // 获取链接 let douUrl = urlRex.exec(e.msg.trim())[0]; let ttwid = ''; if (douUrl.includes('v.douyin.com')) { const { location, ttwidValue } = await this.douyinRequest(douUrl); ttwid = ttwidValue; douUrl = location; } // TODO 如果有新的好解决方案可以删除,如果遇到https://www.iesdouyin.com/share/slides,这类动图暂时交付给其他API解析,感谢群u:"Error: Cannot find id"提供的服务器 if (douUrl.includes('share/slides')) { const detailIdMatch = douUrl.match(/\/slides\/(\d+)/); const detailId = detailIdMatch[1]; const apiUrl = 'http://tk.xigua.wiki:5555/douyin/detail'; const postData = { cookie: '', proxy: '', source: false, detail_id: detailId, }; // 用于存储下载的文件路径 const downloadedFilePaths = []; try { const apiResponse = await axios.post(apiUrl, postData, { headers: { 'Content-Type': 'application/json', accept: 'application/json', }, timeout: 15000, }); if (apiResponse.status !== 200 || !apiResponse.data || !apiResponse.data.data) { logger.error( `[R插件][抖音解析] API返回异常状态码或数据结构错误: ${apiResponse.status}, ${JSON.stringify(apiResponse.data)}` ); e.reply( '解析抖音动图失败了,可能是小猫踹翻了路由器,或者是小老鼠咬断了网线,总之等等再试试看..' ); return true; } const apiData = apiResponse.data.data; const downloads = apiData.downloads; const desc = apiData.desc || '无简介'; const authorNickname = apiData.nickname || '未知作者'; const replyMessages = []; replyMessages.push( `${this.nickName}识别:抖音动图 \n 作者是:${authorNickname}\n 简介:${desc}` ); const messageSegments = []; const downloadPath = this.getCurDownloadPath(e); await mkdirIfNotExists(downloadPath); await e.reply(replyMessages.join('\n')); for (const [index, downloadUrl] of downloads.entries()) { let filePath; let fileName; try { if (downloadUrl.includes('.mp4') || downloadUrl.includes('video_id')) { fileName = `temp${index > 0 ? index : ''}.mp4`; filePath = `${downloadPath}/${fileName}`; logger.info(`[R插件][抖音动图] 下载视频: ${downloadUrl}`); const response = await axios({ method: 'get', url: downloadUrl, responseType: 'stream', }); const writer = fs.createWriteStream(filePath); response.data.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); logger.info(`[R插件][抖音动图] 视频下载完成: ${filePath}`); messageSegments.push({ message: segment.video(filePath), nickname: e.sender.card || e.user_id, user_id: e.user_id, }); downloadedFilePaths.push(filePath); } else { fileName = `temp${index > 0 ? index : ''}.png`; filePath = `${downloadPath}/${fileName}`; logger.info(`[R插件][抖音动图] 下载图片: ${downloadUrl}`); const response = await axios({ method: 'get', url: downloadUrl, responseType: 'stream', }); const writer = fs.createWriteStream(filePath); response.data.pipe(writer); await new Promise((resolve, reject) => { writer.on('finish', resolve); writer.on('error', reject); }); logger.info(`[R插件][抖音动图] 图片下载完成: ${filePath}`); messageSegments.push({ message: segment.image(filePath), nickname: e.sender.card || e.user_id, user_id: e.user_id, }); downloadedFilePaths.push(filePath); } } catch (downloadError) { logger.error( `[R插件][抖音动图] 下载文件失败: ${downloadUrl}, 错误: ${downloadError.message}` ); messageSegments.push({ message: { type: 'text', text: `下载文件失败: ${downloadUrl}` }, nickname: e.sender.card || e.user_id, user_id: e.user_id, }); } } if (messageSegments.length > 0) { const forwardMsg = await Bot.makeForwardMsg(messageSegments); await e.reply(forwardMsg); // 删除文件 for (const filePath of downloadedFilePaths) { await checkAndRemoveFile(filePath); } } const headers = { 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', 'User-Agent': COMMON_USER_AGENT, Referer: 'https://www.douyin.com/', cookie: this.douyinCookie, }; await this.douyinComment(e, detailId, headers); } catch (error) { logger.error(`[R插件][抖音动图] 调用API或处理下载时发生错误: ${error.message}`); } return true; } // 获取 ID const douId = /note\/(\d+)/g.exec(douUrl)?.[1] || /video\/(\d+)/g.exec(douUrl)?.[1] || /live.douyin.com\/(\d+)/.exec(douUrl)?.[1] || /live\/(\d+)/.exec(douUrl)?.[1] || /webcast.amemv.com\/douyin\/webcast\/reflow\/(\d+)/.exec(douUrl)?.[1]; // 当前版本需要填入cookie if (_.isEmpty(this.douyinCookie) || _.isEmpty(douId)) { e.reply(`${this.nickName}的主人还没有给我配置cookie,没办法帮你解析抖音啦`); return; } // 以下是更新了很多次的抖音API历史,且用且珍惜 // const url = `https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=${ douId }`; // const url = `https://www.iesdouyin.com/aweme/v1/web/aweme/detail/?aweme_id=${ douId }&aid=1128&version_name=23.5.0&device_platform=android&os_version=2333`; // 感谢 Evil0ctal(https://github.com/Evil0ctal)提供的header 和 B1gM8c(https://github.com/B1gM8c)的逆向算法X-Bogus const headers = { 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', 'User-Agent': COMMON_USER_AGENT, Referer: 'https://www.douyin.com/', cookie: this.douyinCookie, }; let dyApi; if (douUrl.includes('live.douyin.com')) { // 第一类直播类型 dyApi = DY_LIVE_INFO.replaceAll('{}', douId); } else if (douUrl.includes('webcast.amemv.com')) { // 第二类直播类型,这里必须使用客户端的 fetch 请求 dyApi = DY_LIVE_INFO_2.replace('{}', douId) + `&verifyFp=${genVerifyFp()}` + `&msToken=${ttwid}`; const webcastResp = await fetch(dyApi); const webcastData = await webcastResp.json(); const item = webcastData.data.room; const { title, cover, user_count, stream_url } = item; const dySendContent = `${this.nickName}发现了一个抖音直播,${title}`; e.reply([ segment.image(cover?.url_list?.[0]), dySendContent, `\n🏄共有${user_count}人正在观看`, ]); // 下载10s的直播流 await this.sendStreamSegment( e, stream_url?.flv_pull_url?.HD1 || stream_url?.flv_pull_url?.FULL_HD1 || stream_url?.flv_pull_url?.SD1 || stream_url?.flv_pull_url?.SD2 ); return; } else { // 普通类型 dyApi = DY_INFO.replace('{}', douId); } // a-bogus参数 logger.debug(dyApi); const abParam = aBogus.generate_a_bogus( new URLSearchParams(new URL(dyApi).search).toString(), headers['User-Agent'] ); // const param = resp.data.result[0].paramsencode; const resDyApi = `${dyApi}&a_bogus=${abParam}`; headers['Referer'] = `https://www.douyin.com/`; // 定义一个dy请求 const dyResponse = () => axios.get(resDyApi, { headers, }); // 如果失败进行3次重试 try { const data = await retryAxiosReq(dyResponse); // saveJsonToFile(data); // 直播数据逻辑 if (douUrl.includes('live')) { const item = await data.data.data?.[0]; const { title, cover, user_count_str, stream_url } = item; const dySendContent = `${this.nickName}发现了一个抖音直播,${title}`; e.reply([ segment.image(cover?.url_list?.[0]), dySendContent, `\n共有${user_count_str}人正在观看`, ]); // 下载10s的直播流 await this.sendStreamSegment( e, stream_url?.flv_pull_url?.HD1 || stream_url?.flv_pull_url?.FULL_HD1 || stream_url?.flv_pull_url?.SD1 || stream_url?.flv_pull_url?.SD2 ); return; } const item = await data.aweme_detail; // await saveJsonToFile(item); // 如果为null则退出 if (item == null) { e.reply(`${this.nickName}无法识别当前抖音内容了..换一个试试`); return; } const urlTypeCode = item.aweme_type; const urlType = douyinTypeMap[urlTypeCode]; // 核心内容 if (urlType === 'video') { // logger.info(item.video); // 多位面选择:play_addr、play_addr_265、play_addr_h264 const { play_addr: { uri: videoAddrURI }, duration, cover, } = item.video; // 进行时间判断,如果超过时间阈值就不发送 const dyDuration = Math.trunc(duration / 1000); const durationThreshold = this.biliDuration; // 一些共同发送内容 let dySendContent = `${this.nickName}猜这是一个抖音视频\n${item.author.nickname}\n简介:${item.desc}`; if (dyDuration >= durationThreshold) { // 超过阈值,不发送的情况 // 封面 const dyCover = cover.url_list?.pop(); // logger.info(cover.url_list); dySendContent += `\n ${DIVIDING_LINE.replace('{}', '这视频真带派')}\n当前视频时长约:${(dyDuration / 60).toFixed(2).replace(/\.00$/, '')} 分钟,\n大于${this.nickName}管理员设置的最大时长 ${(durationThreshold / 60).toFixed(2).replace(/\.00$/, '')} 分钟,还是到抖音里面看吧..`; e.reply([segment.image(dyCover), dySendContent]); // 如果开启评论的就调用 await this.douyinComment(e, douId, headers); return; } e.reply(`${dySendContent}`); // 分辨率判断是否压缩 const resolution = this.douyinCompression ? '720p' : '1080p'; // 使用今日头条 CDN 进一步加快解析速度 const resUrl = DY_TOUTIAO_INFO.replace('1080p', resolution).replace('{}', videoAddrURI); // ⚠️ 暂时废弃代码 /*if (this.douyinCompression) { // H.265压缩率更高、流量省一半. 相对于H.264 // 265 和 264 随机均衡负载 const videoAddrList = Math.random() > 0.5 ? play_addr_265.url_list : play_addr_h264.url_list; resUrl = videoAddrList[videoAddrList.length - 1] || videoAddrList[0]; } else { // 原始格式,ps. videoAddrList这里[0]、[1]是 http,[最后一个]是 https const videoAddrList = play_addr.url_list; resUrl = videoAddrList[videoAddrList.length - 1] || videoAddrList[0]; }*/ // logger.info(resUrl); const path = `${this.getCurDownloadPath(e)}/temp.mp4`; // 加入队列 await this.downloadVideo(resUrl).then(() => { this.sendVideoToUpload(e, path); }); } else if (urlType === 'image') { // 发送描述 e.reply(`${this.nickName}识别: 抖音, ${item.desc}`); // 无水印图片列表 let no_watermark_image_list = []; // 有水印图片列表 // let watermark_image_list = []; for (let i of item.images) { // 无水印图片列表 no_watermark_image_list.push({ message: segment.image(i.url_list[0]), nickname: this.e.sender.card || this.e.user_id, user_id: this.e.user_id, }); // 有水印图片列表 // watermark_image_list.push(i.download_url_list[0]); // e.reply(segment.image(i.url_list[0])); } // console.log(no_watermark_image_list) await e.reply(await Bot.makeForwardMsg(no_watermark_image_list)); } // 如果开启评论的就调用 await this.douyinComment(e, douId, headers); } catch (err) { logger.error(err); logger.mark( `Cookie 过期或者 Cookie 没有填写,请参考\n${HELP_DOC}\n尝试无效后可以到官方QQ群[575663150]提出 bug 等待解决` ); } return true; } /** * douyin 请求参数 * @param url * @returns {Promise} */ async douyinRequest(url) { const params = { headers: { 'User-Agent': COMMON_USER_AGENT, }, timeout: 10000, }; try { const resp = await axios.get(url, params); const location = resp.request.res.responseUrl; const setCookieHeaders = resp.headers['set-cookie']; let ttwidValue; if (setCookieHeaders) { setCookieHeaders.forEach((cookie) => { // 使用正则表达式提取 ttwid 的值 const ttwidMatch = cookie.match(/ttwid=([^;]+)/); if (ttwidMatch) { ttwidValue = ttwidMatch[1]; } }); } return new Promise((resolve, reject) => { if (location != null) { return resolve({ location: location, ttwidValue: ttwidValue, }); } else { return reject('获取失败'); } }); } catch (error) { logger.error(error); throw error; } } /** * 获取 DY 评论 * @param e * @param douId * @param headers */ async douyinComment(e, douId, headers) { if (!this.douyinComments) { return; } const dyCommentUrl = DY_COMMENT.replace('{}', douId); const abParam = aBogus.generate_a_bogus( new URLSearchParams(new URL(dyCommentUrl).search).toString(), headers['User-Agent'] ); const commentsResp = await axios.get(`${dyCommentUrl}&a_bogus=${abParam}`, { headers, }); // logger.info(headers) // saveJsonToFile(commentsResp.data, "data.json", _); const comments = commentsResp.data.comments; const replyComments = comments.map((item) => { return { message: item.text, nickname: this.e.sender.card || this.e.user_id, user_id: this.e.user_id, }; }); e.reply(await Bot.makeForwardMsg(replyComments)); } /** * 下载直播片段 * @param e * @param stream_url * @param second */ async sendStreamSegment(e, stream_url, second = this.streamDuration) { let outputFilePath = `${this.getCurDownloadPath(e)}/stream_${second}s.flv`; // 删除临时文件 if (this.streamCompatibility) { await checkAndRemoveFile(outputFilePath.replace('flv', 'mp4')); } else { await checkAndRemoveFile(outputFilePath); } // 创建一个取消令牌 const CancelToken = axios.CancelToken; const source = CancelToken.source(); try { const response = await axios.get(stream_url, { responseType: 'stream', cancelToken: source.token, }); logger.info('[R插件][发送直播流] 正在下载直播流...'); const file = fs.createWriteStream(outputFilePath); response.data.pipe(file); // 设置 streamDuration 秒后停止下载 setTimeout(async () => { logger.info(`[R插件][发送直播流] 直播下载 ${second} 秒钟到,停止下载!`); // 取消请求 source.cancel('[R插件][发送直播流] 下载时间到,停止请求'); response.data.unpipe(file); // 取消管道连接 file.end(); // 结束写入 // 这里判断是否开启兼容模式 if (this.streamCompatibility) { logger.info(`[R插件][发送直播流] 开启兼容模式,开始转换mp4格式...`); const resolvedOutputPath = await convertFlvToMp4( outputFilePath, outputFilePath.replace('.flv', '.mp4') ); fs.unlinkSync(outputFilePath); outputFilePath = resolvedOutputPath; logger.info(`[R插件][发送直播流] 转换完成,开始发送视频...`); } await this.sendVideoToUpload(e, outputFilePath); }, second * 1000); // 监听请求被取消的情况 response.data.on('error', (err) => { if (axios.isCancel(err)) { logger.info('请求已取消:', err.message); } else { logger.error('下载过程中发生错误:', err.message); } }); } catch (error) { if (axios.isCancel(error)) { logger.info('请求已取消:', error.message); } else { logger.error(`下载失败: ${error.message}`); } await fs.promises.unlink(outputFilePath); // 下载失败时删除文件 } } // B 站解析 async bili(e) { logger.info('[rc-plugin] bilibili'); // 切面判断是否需要解析 if (!(await this.isEnableResolve(RESOLVE_CONTROLLER_NAME_ENUM.bili))) { logger.info(`[R插件][全局解析控制] ${RESOLVE_CONTROLLER_NAME_ENUM.bili} 已拦截`); return true; } const urlRex = /(?:https?:\/\/)?www\.bilibili\.com\/[A-Za-z\d._?%&+\-=\/#]*/g; const bShortRex = /(http:|https:)\/\/(b23.tv|bili2233.cn)\/[A-Za-z\d._?%&+\-=\/#]*/g; let url = e.msg === undefined ? e.message.shift().data.replaceAll('\\', '') : e.msg.trim().replaceAll('\\', ''); // 直接发送BV号的处理 if (/^BV[1-9a-zA-Z]{10}$/.exec(url)?.[0]) { url = `https://www.bilibili.com/video/${url}`; logger.info(url); } // 短号处理 if (url.includes('b23.tv') || url.includes('bili2233.cn')) { const bShortUrl = bShortRex.exec(url)?.[0]; await fetch(bShortUrl, { method: 'HEAD', }).then((resp) => { url = resp.url; }); } else if (url.includes('www.bilibili.com')) { url = urlRex.exec(url)[0]; } // 补充https url = url.startsWith('https://') ? url : 'https://' + url; // 直播间分享 // logger.info(url) if (url.includes('live.bilibili.com')) { // 提取直播间id const idPattern = /\/(\d+)$/; const parsedUrl = new URL(url); const streamId = parsedUrl.pathname.match(idPattern)?.[1]; // logger.info(streamId) // 提取相关信息 const liveData = await this.getBiliStreamInfo(streamId); // saveJsonToFile(liveData.data); const { title, user_cover, keyframe, description, tags, live_time, parent_area_name, area_name, } = liveData.data.data; e.reply([ segment.image(user_cover), segment.image(keyframe), [ `哼哼~${this.nickName}发现了一个哔哩哔哩直播!,${title}`, `${description ? `简单描述一下: ${description.replace(`<p>`, '').replace(`</p>`, '')}` : ''}`, `${tags ? `标签是: ${tags}` : ''}`, `属于: ${parent_area_name ? `${parent_area_name}` : ''}${area_name ? `-${area_name}` : ''}`, `${live_time ? `直播时间是: ${live_time}` : ''}`, `想看的话可以戳这里看奥: https://www.bilibili.com/blackboard/live/live-activity-player.html?enterTheRoom=0&cid=${streamId}`, ] .filter((item) => item.trim() !== '') .join('\n'), ]); const streamData = await this.getBiliStream(streamId); const { url: streamUrl } = streamData.data.data.durl[0]; await this.sendStreamSegment(e, streamUrl); return true; } // 处理专栏 if ((e.msg !== undefined && url.includes('read\/cv')) || url.includes('read\/mobile')) { await this.biliArticle(e, url); return true; } // 动态处理 if ( url.includes('t.bilibili.com') || url.includes('bilibili.com\/opus') || url.includes('bilibili.com\/dynamic') ) { if (_.isEmpty(this.biliSessData)) { e.reply(`看起来${this.nickName}暂时没有biliSessData呢..没法解析动态了..`); return true; } url = this.biliDynamic(e, url, this.biliSessData); return true; } // 创建文件,如果不存在, const path = `${this.getCurDownloadPath(e)}/`; await mkdirIfNotExists(path); // 处理番剧 if (url.includes('play\/ep') || url.includes('play\/ss')) { const ep = await this.biliEpInfo(url, e); // 如果使用了BBDown && 没有填写session 就放开下载 if (this.biliUseBBDown) { // 下载文件 await this.biliDownloadStrategy(e, `https://www.bilibili.com/bangumi/play/ep${ep}`, path); } return true; } // 视频信息获取例子:http://api.bilibili.com/x/web-interface/view?bvid=BV1hY411m7cB // 请求视频信息 const videoInfo = await getVideoInfo(url); // 打印获取到的视频信息,用于调试时长问题 logger.debug( `[R插件][Bili Debug] Video Info for ${url}: duration=${videoInfo.duration}, pages=${JSON.stringify(videoInfo.pages)}` ); const { duration, bvid, cid, owner, pages } = videoInfo; let durationForCheck; let displayTitle = videoInfo.title; // 始终使用总标题 let partTitle = null; // 用于存储分P标题 let targetPageInfo = null; // 用于后续下载决策 const urlParts = url.split('?'); const queryParams = urlParts.length > 1 ? querystring.parse(urlParts[1]) : {}; const pParam = queryParams.p ? parseInt(queryParams.p, 10) : null; // 只有当分P数量大于1时才认为是多P,并处理分P标题 if (pages && pages.length > 1) { if (pParam && pages.length >= pParam && pParam > 0) { // 如果URL指定了有效的p参数 targetPageInfo = pages[pParam - 1]; durationForCheck = targetPageInfo.duration; partTitle = targetPageInfo.part; // 存储分P标题 logger.info( `[R插件][Bili Duration] 分析到合集 P${pParam} (分P标题: ${partTitle}), 时长: ${durationForCheck}s` ); } else { // 否则,默认检查第一个分P targetPageInfo = pages[0]; durationForCheck = targetPageInfo.duration; // 在多P情况下,即使用户没有指定p,也显示第一个分p的标题 partTitle = targetPageInfo.part; logger.info( `[R插件][Bili Duration] 分析到合集 P1 (分P标题: ${partTitle}), 时长: ${durationForCheck}s` ); } } else { // 单P或无分P信息 durationForCheck = duration; // 对于单P视频,我们不设置 partTitle,以避免混淆 logger.info( `[R插件][Bili Duration] Using total duration (Title: ${displayTitle}): ${durationForCheck}s` ); } const isLimitDuration = durationForCheck > this.biliDuration; // 动态构造哔哩哔哩信息 let biliInfo = await this.constructBiliInfo( videoInfo, displayTitle, partTitle, pParam || (pages && pages.length > 1 ? 1 : null) ); // 总结 if (this.biliDisplaySummary) { const summary = await this.getBiliSummary(bvid, cid, owner.mid); // 封装总结 summary && e.reply( await Bot.makeForwardMsg( textArrayToMakeForward(e, [`诺,${this.nickName}已经把内容给你整理好了噢:`, summary]) ) ); } // 限制视频解析 if (isLimitDuration) { const durationInMinutes = (durationForCheck / 60).toFixed(0); // 使用 durationForCheck biliInfo.push( `${DIVIDING_LINE.replace('{}', '这视频真代派')}\n当前视频时长约:${durationInMinutes}分钟,\n大于${this.nickName}的管理员设置的最大时长 ${(this.biliDuration / 60).toFixed(2).replace(/\.00$/, '')} 分钟噢..` ); e.reply(biliInfo); return true; } else { e.reply(biliInfo); } // 只提取音乐处理 if (e.msg !== undefined && e.msg.startsWith('音乐')) { return await this.biliMusic(e, url); } // 下载文件 await this.biliDownloadStrategy(e, url, path); return true; } /** * 提取哔哩哔哩专栏 * @param e * @param url * @returns {Promise} */ async biliArticle(e, url) { const cvid = url.match(/read\/cv(\d+)/)?.[1] || url.match(/read\/mobile\?id=(\d+)/)?.[1]; const articleResp = await fetch(BILI_ARTICLE_INFO.replace('{}', cvid), { headers: { ...BILI_HEADER, }, }); const articleData = (await articleResp.json()).data; const { title, author_name, origin_image_urls } = articleData; if (origin_image_urls) { const titleMsg = { message: { type: 'text', text: `标题:${title}\n作者:${author_name}` }, nickname: e.sender.card || e.user_id, user_id: e.user_id, }; await e.reply( Bot.makeForwardMsg( origin_image_urls .map((item) => { return { message: segment.image(item), nickname: e.sender.card || e.user_id, user_id: e.user_id, }; }) .concat(titleMsg) ) ); } } /** * 构造哔哩哔哩信息 * @param videoInfo * @param displayTitle * @param partTitle * @param pParam * @returns {Promise<(string|string|*)[]>} */ async constructBiliInfo(videoInfo, displayTitle, partTitle, pParam) { // 增加 partTitle 和 pParam 参数 const { desc, bvid, cid, pic } = videoInfo; // 视频信息 const { view, danmaku, reply, favorite, coin, share, like } = videoInfo.stat; // 格式化数据 let combineContent = ''; // 是否显示信息 if (this.biliDisplayInfo) { // 构造一个可扩展的Map const dataProcessMap = { 点赞: like, 硬币: coin, 收藏: favorite, 分享: share, 总播放量: view, 弹幕数量: danmaku, 评论: reply, }; combineContent += `\n${formatBiliInfo(dataProcessMap)}`; } // 是否显示简介 if (this.biliDisplayIntro) { // 过滤简介中的一些链接 const filteredDesc = await filterBiliDescLink(desc); combineContent += `\n简介:${truncateString(filteredDesc, this.toolsConfig.biliIntroLenLimit || BILI_DEFAULT_INTRO_LEN_LIMIT)}`; } // 是否显示在线人数 if (this.biliDisplayOnline) { // 拼接在线人数 const onlineTotal = await this.biliOnlineTotal(bvid, cid); combineContent += `\n吼吼吼~当前视频有 ${onlineTotal.total} 人在观看,其中 ${onlineTotal.count} 人在网页端观看!`; } let finalTitle = `哼哼~${this.nickName}发现了一个哔哩哔哩视频! 名字叫做${displayTitle}`; // 如果有多P标题,并且它和主标题不一样,则添加 if (partTitle && partTitle !== displayTitle) { finalTitle += `|${pParam}P: ${partTitle}`; } let biliInfo = [finalTitle, combineContent]; // 是否显示封面 if (this.biliDisplayCover) { // 加入图片 biliInfo.unshift(segment.image(pic)); } return biliInfo; } /** * 获取哔哩哔哩番剧信息 * @param url * @param e * @returns {Promise} */ async biliEpInfo(url, e) { let ep; // 处理ssid if (url.includes('play\/ss')) { const ssid = url.match(/\/ss(\d+)/)?.[1]; let resp = await ( await fetch(BILI_SSID_INFO.replace('{}', ssid), { headers: BILI_HEADER, }) ).json(); ep = resp.result.main_section.episodes[0].share_url.replace( 'https://www.bilibili.com/bangumi/play/ep', '' ); } // 处理普通情况,上述情况无法处理的 if (_.isEmpty(ep)) { ep = url.match(/\/ep(\d+)/)?.[1]; } const resp = await ( await fetch(BILI_EP_INFO.replace('{}', ep), { headers: BILI_HEADER, }) ).json(); const result = resp.result; const { views, danmakus, likes, coins, favorites, favorite } = result.stat; // 封装成可以format的数据 const dataProcessMap = { 播放: views, 弹幕: danmakus, 点赞: likes, 分享: coins, 追番: favorites, 收藏: favorite, }; // 截断标题,查看Redis中是否存在,避免频繁走网络连接 const title = result.title; e.reply( [ segment.image(resp.result.cover), `哼哼~${this.nickName}发现了哔哩哔哩的一部番剧!${title}\n评分竟然高达:${result?.rating?.score ?? '-'} / ${result?.rating?.count ?? '-'}\n ${result.new_ep.desc}, ${result.seasons[0].new_ep.index_show}\n`, `${formatBiliInfo(dataProcessMap)}`, `\n\n在线观看: ${await urlTransformShortLink(ANIME_SERIES_SEARCH_LINK + title)}`, `\n在线观看: ${await urlTransformShortLink(ANIME_SERIES_SEARCH_LINK2 + title)}`, ], true ); return ep; } /** * 哔哩哔哩下载策略 * @param e 事件 * @param url 链接 * @param path 保存路径 * @returns {Promise} */ async biliDownloadStrategy(e, url, path) { return this.queue.add(async () => { // =================以下是调用BBDown的逻辑===================== // 下载视频和音频 const tempPath = `${path}temp`; // 检测是否开启BBDown if (this.biliUseBBDown) { // 检测环境的 BBDown const isExistBBDown = await checkToolInCurEnv('BBDown'); // 存在 BBDown if (isExistBBDown) { // 删除之前的文件 await checkAndRemoveFile(`${tempPath}.mp4`); // 下载视频 await startBBDown(url, path, { biliSessData: this.biliSessData, biliUseAria2: this.biliDownloadMethod === 1, biliCDN: BILI_CDN_SELECT_LIST.find((item) => item.value === this.biliCDN)?.sign, biliResolution: this.biliResolution, }); // 发送视频 return this.sendVideoToUpload(e, `${tempPath}.mp4`); } } // =================默认下载方式===================== try { // 获取分辨率参数 QN,如果没有默认使用 480p --> 32 const qn = BILI_RESOLUTION_LIST.find((item) => item.value === this.biliResolution).qn || 32; // 获取下载链接 const data = await getDownloadUrl(url, this.biliSessData, qn); if (data.audioUrl != null) { await this.downBili(tempPath, data.videoUrl, data.audioUrl); } else { // 处理无音频的情况 await downloadBFile( data.videoUrl, `${tempPath}.mp4`, _.throttle( (value) => logger.mark('视频下载进度', { data: value, }), 1000 ) ); } // 上传视频 return this.sendVideoToUpload(e, `${tempPath}.mp4`); } catch (err) { // 错误处理 logger.error('[R插件][哔哩哔哩视频发送]下载错误,具体原因为:', err); e.reply('呜呜..解析失败了..请重试一下'); } }); } /** * 获取在线人数 * @param bvid * @param cid * @returns {Promise<{total: *, count: *}>} */ async biliOnlineTotal(bvid, cid) { const onlineResp = await axios.get(BILI_ONLINE.replace('{0}', bvid).replace('{1}', cid)); const online = onlineResp.data.data; return { total: online.total, count: online.count, }; } // 下载哔哩哔哩音乐 async biliMusic(e, url) { const videoId = /video\/[^?\/ ]+/.exec(url)[0].split('/')[1]; this.queue.add(() => { getBiliAudio(videoId, '').then(async (audioUrl) => { const path = this.getCurDownloadPath(e); const biliMusicPath = await m4sToMp3(audioUrl, path); // 发送语音 e.reply(segment.record(biliMusicPath)); // 上传群文件 await this.uploadGroupFile(e, biliMusicPath); }); }); return true; } // 发送哔哩哔哩动态的算法 biliDynamic(e, url, session) { // 去除多余参数 if (url.includes('?')) { url = url.substring(0, url.indexOf('?')); } const dynamicId = /[^/]+(?!.*\/)/.exec(url)[0]; getDynamic(dynamicId, session).then(async (resp) => { if (resp.dynamicSrc.length > 0 || resp.dynamicDesc) { // 先发送动态描述文本 if (resp.dynamicDesc) { e.reply(`${this.nickName}发现了一条哔哩哔哩动态!\n${resp.dynamicDesc}`); } // 处理图片消息 if (resp.dynamicSrc.length > 0) { if (resp.dynamicSrc.length > this.globalImageLimit) { let dynamicSrcMsg = []; resp.dynamicSrc.forEach((item) => { dynamicSrcMsg.push({ message: segment.image(item), nickname: e.sender.card || e.user_id, user_id: e.user_id, }); }); await e.reply(await Bot.makeForwardMsg(dynamicSrcMsg)); } else { const images = resp.dynamicSrc.map((item) => segment.image(item)); await e.reply(images); } } } else { await e.reply(`${this.nickName}发现了一条哔哩哔哩动态, 但是解析失败!`); } }); return url; } /** * 哔哩哔哩总结 * @author zhiyu1998 * @param bvid 稿件 * @param cid 视频 cid * @param up_mid UP主 mid * @return {Promise} */ async getBiliSummary(bvid, cid, up_mid) { // 这个有点用,但不多 let wbi = 'wts=1701546363&w_rid=1073871926b3ccd99bd790f0162af634'; if (!_.isEmpty(this.biliSessData)) { wbi = await getWbi({ bvid, cid, up_mid }, this.biliSessData); } // 构造API const summaryUrl = `${BILI_SUMMARY}?${wbi}`; logger.info(summaryUrl); // 构造结果:https://api.bilibili.com/x/web-interface/view/conclusion/get?bvid=BV1L94y1H7CV&cid=1335073288&up_mid=297242063&wts=1701546363&w_rid=1073871926b3ccd99bd790f0162af634 return axios .get(summaryUrl, { headers: { Cookie: `SESSDATA=${this.biliSessData}`, }, }) .then((resp) => { logger.debug(resp); const data = resp.data.data?.model_result; logger.debug(data); const summary = data?.summary; const outline = data?.outline; let resReply = ''; // 总体总结 if (summary) { resReply = `\n摘要:${summary}\n`; } // 分段总结 if (outline) { const specificTimeSummary = outline.map((item) => { const smallTitle = item.title; const keyPoint = item?.part_outline; // 时间点的总结 const specificContent = keyPoint .map((point) => { const { timestamp, content } = point; const specificTime = secondsToTime(timestamp); return `${specificTime} ${content}\n`; }) .join(''); return `- ${smallTitle}\n${specificContent}\n`; }); resReply += specificTimeSummary.join(''); } return resReply; }); } /** * 获取直播间信息 * @param liveId * @returns {Promise<*>} */ async getBiliStreamInfo(liveId) { return axios.get(`${BILI_STREAM_INFO}?room_id=${liveId}`, { headers: { 'User-Agent': COMMON_USER_AGENT, }, }); } /** * 获取直播流 * @param liveId * @returns {Promise<*>} */ async getBiliStream(liveId) { return axios.get(`${BILI_STREAM_FLV}?cid=${liveId}`, { headers: { 'User-Agent': COMMON_USER_AGENT, }, }); } /** * 通用解析 * @param e * @return {Promise} */ async general(e) { // 切面判断是否需要解析 if (!(await this.isEnableResolve(RESOLVE_CONTROLLER_NAME_ENUM.general))) { logger.info(`[R插件][全局解析控制] ${RESOLVE_CONTROLLER_NAME_ENUM.general} 已拦截`); return true; } try { const adapter = await GeneralLinkAdapter.create(e.msg); logger.debug( `[R插件][General Adapter Debug] Adapter object: ${JSON.stringify(adapter, null, 2)}` ); e.reply(`${this.nickName}识别: ${adapter.name}${adapter.desc ? `, ${adapter.desc}` : ''}`); logger.debug(adapter); logger.debug( `[R插件][General Adapter Debug] adapter.images: ${JSON.stringify(adapter.images)}` ); logger.debug(`[R插件][General Adapter Debug] adapter.video: ${adapter.video}`); if (adapter.video && adapter.video !== '') { logger.debug( `[R插件][General Adapter Debug] Entering video sending logic for ${adapter.name}. Video URL: ${adapter.video}` ); const url = adapter.video; this.downloadVideo(url).then((path) => { logger.debug(`[R插件][General Adapter Debug] Video downloaded to path: ${path}`); this.sendVideoToUpload(e, `${path}/temp.mp4`); }); } else if (adapter.images && adapter.images.length > 0) { logger.debug( `[R插件][General Adapter Debug] Entering image sending logic for ${adapter.name}` ); const images = adapter.images.map((item) => { return { message: segment.image(item), nickname: this.e.sender.card || this.e.user_id, user_id: this.e.user_id, }; }); e.reply(Bot.makeForwardMsg(images)); } else { logger.debug( `[R插件][General Adapter Debug] No images or video found for ${adapter.name}. Replying with failure message.` ); e.reply('解析失败..无法获取到资源'); } } catch (err) { logger.error('解析失败 ', err); return true; } return true; } // 米游社 async miyoushe(e) { // 切面判断是否需要解析 if (!(await this.isEnableResolve(RESOLVE_CONTROLLER_NAME_ENUM.miyoushe))) { logger.info(`[R插件][全局解析控制] ${RESOLVE_CONTROLLER_NAME_ENUM.miyoushe} 已拦截`); return true; } let url = e.msg === undefined ? e.message.shift().data.replaceAll('\\', '') : e.msg.trim(); let msg = /(?:https?:\/\/)?(m|www)\.miyoushe\.com\/[A-Za-z\d._?%&+\-=\/#]*/.exec(url)?.[0]; const id = /\/(\d+)$/.exec(msg)?.[0].replace('\/', ''); fetch(MIYOUSHE_ARTICLE.replace('{}', id), { headers: { 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-cn', Connection: 'keep-alive', 'x-rpc-app_version': '2.87.0', 'x-rpc-client_type': '4', Referer: 'https://www.miyoushe.com/', DS: getDS(), }, }).then(async (resp) => { const respJson = await resp.json(); // debug专用 // fs.writeFile('data.json', JSON.stringify(respJson), (err) => { // if (err) { // logger.error('Error writing file:', err); // } else { // console.log('JSON saved to file successfully.'); // } // }); // return; const data = respJson.data.post.post; // 分别获取:封面、主题、内容、图片 const { cover, subject, content, images } = data; let realContent; // safe JSON.parse try { realContent = JSON.parse(content); } catch (e) { realContent = content; } const normalMsg = `${this.nickName}发现了一条米游社! ${subject}\n${realContent?.describe || ''}`; const replyMsg = cover ? [segment.image(cover), normalMsg] : normalMsg; e.reply(replyMsg); // 图片 if (images) { if (images.length > this.globalImageLimit) { const replyImages = images.map((item) => { return { message: segment.image(item), nickname: this.e.sender.card || this.e.user_id, user_id: this.e.user_id, }; }); e.reply(Bot.makeForwardMsg(replyImages)); } else { const imageSegments = images.map((item) => segment.image(item)); e.reply(imageSegments); } } // 视频 let vod_list = respJson.data.post?.vod_list; if (vod_list.length > 0) { const resolutions = vod_list?.[0]?.resolutions; // 逐个遍历是否包含url for (let i = 0; i < resolutions.length; i++) { if (resolutions) { // 暂时选取分辨率较低的video进行解析 const videoUrl = resolutions[i].url; this.downloadVideo(videoUrl).then((path) => { this.sendVideoToUpload(e, `${path}/temp.mp4`); }); break; } } } }); } /** * 哔哩哔哩下载 * @param title * @param videoUrl * @param audioUrl * @returns {Promise} */ async downBili(title, videoUrl, audioUrl) { return Promise.all([ downloadBFile( videoUrl, title + '-video.m4s', _.throttle( (value) => logger.mark('视频下载进度', { data: value, }), 1000 ), this.biliDownloadMethod, this.videoDownloadConcurrency ), downloadBFile( audioUrl, title + '-audio.m4s', _.throttle( (value) => logger.mark('音频下载进度', { data: value, }), 1000 ), this.biliDownloadMethod, this.videoDownloadConcurrency ), ]).then((data) => { return mergeFileToMp4(data[0].fullFileName, data[1].fullFileName, `${title}.mp4`); }); } /** * 获取当前发送人/群的下载路径 * @param e Yunzai 机器人事件 * @returns {string} */ getCurDownloadPath(e) { return `${this.defaultPath}${e.group_id || e.user_id}`; } /** * 提取视频下载位置 * @returns {{groupPath: string, target: string}} */ getGroupPathAndTarget() { const groupPath = `${this.defaultPath}${this.e.group_id || this.e.user_id}`; const target = `${groupPath}/temp.mp4`; return { groupPath, target }; } /** * 工具:根据URL多线程下载视频 / 音频 * @param url * @param isProxy * @param headers * @param numThreads * @returns {Promise} */ async downloadVideo( url, isProxy = false, headers = null, numThreads = this.videoDownloadConcurrency ) { // 构造群信息参数 const { groupPath, target } = this.getGroupPathAndTarget.call(this); await mkdirIfNotExists(groupPath); // 构造header部分内容 const userAgent = 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Mobile Safari/537.36'; // 构造代理参数 const proxyOption = { ...(isProxy && { httpAgent: new HttpsProxyAgent(`http://${this.proxyAddr}:${this.proxyPort}`), }), }; /** * 构造下载视频参数 * 构造信息:链接、头信息、userAgent、代理信息、下载位置、返回的路径 * @type {{headers: null, userAgent: string, groupPath: string, url, proxyOption: {}, target: string}} */ const downloadVideoParams = { url, headers, userAgent, proxyOption, target, groupPath, }; logger.info(`[R插件][视频下载]:当前队列长度为 ${this.queue.size + 1}`); return await this.queue.add(async () => { // 如果是用户设置了单线程,则不分片下载 if (numThreads === 1) { return this.downloadVideoWithSingleThread(downloadVideoParams); } else if (numThreads !== 1 && this.biliDownloadMethod === 1) { return this.downloadVideoWithAria2(downloadVideoParams, numThreads); } else if (numThreads !== 1 && this.biliDownloadMethod === 2) { return this.downloadVideoUseAxel(downloadVideoParams, numThreads); } else { return this.downloadVideoWithMultiThread(downloadVideoParams, numThreads); } }); } /** * 多线程下载视频 * @link {downloadVideo} * @param downloadVideoParams * @param numThreads * @returns {Promise<*>} */ async downloadVideoWithMultiThread(downloadVideoParams, numThreads) { const { url, headers, userAgent, proxyOption, target, groupPath } = downloadVideoParams; try { // Step 1: 请求视频资源获取 Content-Length const headRes = await axios.head(url, { headers: headers || { 'User-Agent': userAgent }, ...proxyOption, }); const contentLength = headRes.headers['content-length']; if (!contentLength) { throw new Error('无法获取视频大小'); } // Step 2: 计算每个线程应该下载的文件部分 const partSize = Math.ceil(contentLength / numThreads); let promises = []; for (let i = 0; i < numThreads; i++) { const start = i * partSize; let end = start + partSize - 1; if (i === numThreads - 1) { end = contentLength - 1; // 确保最后一部分可以下载完整 } // Step 3: 并发下载文件的不同部分 const partAxiosConfig = { headers: { 'User-Agent': userAgent, Range: `bytes=${start}-${end}`, }, responseType: 'stream', ...proxyOption, }; promises.push( axios.get(url, partAxiosConfig).then((res) => { return new Promise((resolve, reject) => { const partPath = `${target}.part${i}`; logger.mark(`[R插件][视频下载引擎] 正在下载 part${i}`); const writer = fs.createWriteStream(partPath); res.data.pipe(writer); writer.on('finish', () => { logger.mark(`[R插件][视频下载引擎] part${i + 1} 下载完成`); // 记录线程下载完成 resolve(partPath); }); writer.on('error', reject); }); }) ); } // 等待所有部分都下载完毕 const parts = await Promise.all(promises); // Step 4: 合并下载的文件部分 await checkAndRemoveFile(target); // 确保目标文件不存在 const writer = fs.createWriteStream(target, { flags: 'a' }); for (const partPath of parts) { await new Promise((resolve, reject) => { const reader = fs.createReadStream(partPath); reader.pipe(writer, { end: false }); reader.on('end', () => { fs.unlinkSync(partPath); // 删除部分文件 resolve(); }); reader.on('error', reject); }); } writer.close(); return groupPath; } catch (err) { logger.error(`下载视频发生错误!\ninfo:${err}`); } } /** * 使用Aria2进行多线程下载 * @param downloadVideoParams * @param numThreads * @returns {Promise} */ async downloadVideoWithAria2(downloadVideoParams, numThreads) { const { url, headers, userAgent, proxyOption, target, groupPath } = downloadVideoParams; // 构造aria2c命令参数 const aria2cArgs = [ `"${url}"`, `--out="temp.mp4"`, `--dir="${groupPath}"`, `--user-agent="${userAgent}"`, `--max-connection-per-server=${numThreads}`, // 每个服务器的最大连接数 `--split=${numThreads}`, // 分成 6 个部分进行下载 ]; // 如果有自定义头信息 if (headers) { for (const [key, value] of Object.entries(headers)) { aria2cArgs.push(`--header="${key}: ${value}"`); } } // 如果使用代理 if (proxyOption && proxyOption.httpAgent) { const proxyUrl = proxyOption.httpAgent.proxy.href; aria2cArgs.push(`--all-proxy="${proxyUrl}"`); } try { await checkAndRemoveFile(target); logger.mark(`开始下载: ${url}`); // 执行aria2c命令 const command = `aria2c ${aria2cArgs.join(' ')}`; exec(command, (error, stdout, stderr) => { if (error) { logger.error(`下载视频发生错误!\ninfo:${stderr}`); throw error; } else { logger.mark(`下载完成: ${url}`); } }); // 监听文件生成完成 let count = 0; return new Promise((resolve, reject) => { const checkInterval = setInterval(() => { logger.info(logger.red(`[R插件][Aria2] 没有检测到文件!重试第${count + 1}次`)); count += 1; if (fs.existsSync(target)) { logger.info('[R插件][Aria2] 检测到文件!'); clearInterval(checkInterval); resolve(groupPath); } if (count === 6) { logger.error(`[R插件][Aria2] 下载视频发生错误!`); clearInterval(checkInterval); reject(); } }, DOWNLOAD_WAIT_DETECT_FILE_TIME); }); } catch (err) { logger.error(`下载视频发生错误!\ninfo:${err}`); throw err; } } /** * 使用Axel进行多线程下载 * @param downloadVideoParams * @param numThreads * @returns {Promise} */ async downloadVideoUseAxel(downloadVideoParams, numThreads) { const { url, headers, userAgent, proxyOption, target, groupPath } = downloadVideoParams; // 构造axel命令参数 const axelArgs = [`-n ${numThreads}`, `-o "${target}"`, `-U "${userAgent}"`, url]; // 如果有自定义头信息 if (headers) { for (const [key, value] of Object.entries(headers)) { axelArgs.push(`-H "${key}: ${value}"`); } } // 如果使用代理 if (proxyOption && proxyOption.httpAgent) { const proxyUrl = proxyOption.httpAgent.proxy.href; axelArgs.push(`--proxy="${proxyUrl}"`); } try { await checkAndRemoveFile(target); logger.mark(`开始下载: ${url}`); // 执行axel命令 const command = `axel ${axelArgs.join(' ')}`; exec(command, (error, stdout, stderr) => { if (error) { logger.error(`下载视频发生错误!\ninfo:${stderr}`); throw error; } else { logger.mark(`下载完成: ${url}`); } }); let count = 0; // 监听文件生成完成 return new Promise((resolve, reject) => { const checkInterval = setInterval(() => { logger.info(logger.red(`[R插件][Aria2] 没有检测到文件!重试第${count + 1}次`)); count += 1; if (fs.existsSync(target)) { logger.info('[R插件][Axel] 检测到文件!'); clearInterval(checkInterval); logger.info(`[R插件][Axel] 下载到${groupPath}`); resolve(groupPath); } if (count === 6) { logger.error(`[R插件][Axel] 下载视频发生错误!`); clearInterval(checkInterval); reject(); } }, DOWNLOAD_WAIT_DETECT_FILE_TIME); }); } catch (err) { logger.error(`下载视频发生错误!\ninfo:${err}`); throw err; } } /** * 单线程下载视频 * @link {downloadVideo} * @returns {Promise} * @param downloadVideoParams */ async downloadVideoWithSingleThread(downloadVideoParams) { const { url, headers, userAgent, proxyOption, target, groupPath } = downloadVideoParams; const axiosConfig = { headers: headers || { 'User-Agent': userAgent }, responseType: 'stream', ...proxyOption, }; try { await checkAndRemoveFile(target); const res = await axios.get(url, axiosConfig); logger.mark(`开始下载: ${url}`); const writer = fs.createWriteStream(target); res.data.pipe(writer); return new Promise((resolve, reject) => { writer.on('finish', () => resolve(groupPath)); writer.on('error', reject); }); } catch (err) { logger.error(`下载视频发生错误!\ninfo:${err}`); } } /** * 判断是否启用解析 * @param resolveName * @returns {Promise} */ async isEnableResolve(resolveName) { const controller = this.globalBlackList; // 如果不存在,那么直接放行 if (controller == null) { return true; } // 找到禁用列表中是否包含 `resolveName` const foundItem = controller.find((item) => item === resolveName); // 如果 undefined 说明不在禁用列表就放行 return foundItem === undefined; } /** * 判断是否是海外服务器 * @return {Promise} */ async isOverseasServer() { // 如果配置了强制使用海外服务器,则返回true if (this.forceOverseasServer) { return true; } // 如果第一次使用没有值就设置 if (!(await redisExistKey(REDIS_YUNZAI_ISOVERSEA))) { await redisSetKey(REDIS_YUNZAI_ISOVERSEA, { os: false, // 默认不使用海外服务器 }); return false; } // 如果有就取出来 return (await redisGetKey(REDIS_YUNZAI_ISOVERSEA)).os; } /** * 发送转上传视频 * @param e 交互事件 * @param path 视频所在路径 * @param videoSizeLimit 发送转上传视频的大小限制,默认70MB */ async sendVideoToUpload(e, path, videoSizeLimit = this.videoSizeLimit) { try { // 判断文件是否存在 if (!fs.existsSync(path)) { return e.reply('视频不存在'); } const stats = fs.statSync(path); const videoSize = Math.floor(stats.size / (1024 * 1024)); // 正常发送视频 if (videoSize > videoSizeLimit) { e.reply( `当前视频大小:${videoSize}MB,\n大于${this.nickName}管理员设置的最大限制${videoSizeLimit}MB..\n改为上传群文件` ); await this.uploadGroupFile(e, path); // uploadGroupFile 内部会处理删除 } else { await e.reply(segment.video(path)); await checkAndRemoveFile(path); // 发送成功后删除 } } catch (err) { logger.error(`[R插件][发送视频判断是否需要上传] 发生错误:\n ${err}`); // 如果发送失败,也尝试删除,避免残留 await checkAndRemoveFile(path); } } /** * 上传到群文件 * @param e 交互事件 * @param path 上传的文件所在路径 * @return {Promise} */ async uploadGroupFile(e, path) { // 判断是否是ICQQ if (e.bot?.sendUni) { await e.group.fs.upload(path); } else { await e.group.sendFile(path); } } }