rc-plugin/apps/RCtools.js
2025-09-23 11:42:43 +08:00

1786 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
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,
BILI_SUMMARY,
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';
import * as DY_INFO from 'es-toolkit/compat';
import * as DY_TOUTIAO_INFO from 'es-toolkit/compat';
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`;
// 感谢 Evil0ctalhttps://github.com/Evil0ctal提供的header 和 B1gM8chttps://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参数
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<string>}
*/
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(`&lt;p&gt;`, '').replace(`&lt;/p&gt;`, '')}` : ''}`,
`${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<void>}
*/
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<void>}
*/
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<void>}
*/
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<string>}
*/
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<void>}
*/
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<unknown>}
*/
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<string>}
*/
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<unknown>}
*/
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<unknown>}
*/
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<unknown>}
* @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<boolean>}
*/
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<Boolean>}
*/
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<void>}
*/
async uploadGroupFile(e, path) {
// 判断是否是ICQQ
if (e.bot?.sendUni) {
await e.group.fs.upload(path);
} else {
await e.group.sendFile(path);
}
}
}