rc-plugin/apps/RCtools.js
2025-09-11 13:29:17 +08:00

1372 lines
46 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 {
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,
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,
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';
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',
},
],
});
// 配置文件
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 = '真寻';
}
/**
* 下载直播片段
* @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<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);
}
}
}