diff --git a/apps/RCtools.js b/apps/RCtools.js index 65f755d..05d133b 100644 --- a/apps/RCtools.js +++ b/apps/RCtools.js @@ -6,6 +6,7 @@ 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, @@ -25,6 +26,8 @@ import { BILI_SSID_INFO, BILI_STREAM_FLV, BILI_STREAM_INFO, + DY_LIVE_INFO, + DY_LIVE_INFO_2, BILI_SUMMARY, MIYOUSHE_ARTICLE, } from '../constants/tools.js'; @@ -45,6 +48,7 @@ import { getWbi } from '../utils/biliWbi.js'; import { checkToolInCurEnv, formatBiliInfo, + retryAxiosReq, secondsToTime, truncateString, urlTransformShortLink, @@ -55,6 +59,9 @@ 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() { @@ -76,6 +83,10 @@ export class RCtools extends plugin { reg: '(miyoushe.com)', fnc: 'miyoushe', }, + { + reg: '(v.douyin.com|live.douyin.com)', + fnc: 'douyin', + }, ], }); // 配置文件 @@ -161,6 +172,409 @@ export class RCtools extends plugin { 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参数 + 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 diff --git a/constants/tools.js b/constants/tools.js index bb2e718..34f8154 100644 --- a/constants/tools.js +++ b/constants/tools.js @@ -13,6 +13,20 @@ export const BILI_SUMMARY = 'https://api.bilibili.com/x/web-interface/view/concl export const BILI_PLAY_STREAM = 'https://api.bilibili.com/x/player/wbi/playurl?cid={cid}&bvid={bvid}&qn={qn}&fnval=16'; +/** + * DY 直播信息 + * @type {string} + */ +export const DY_LIVE_INFO = + 'https://live.douyin.com/webcast/room/web/enter/?device_platform=webapp&aid=6383&channel=channel_pc_web&pc_client_type=1&version_code=190500&version_name=19.5.0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=Win32&browser_name=Firefox&browser_version=124.0&browser_online=true&engine_name=Gecko&engine_version=122.0.0.0&os_name=Windows&os_version=10&cpu_core_num=12&device_memory=8&platform=PC&web_rid={}&room_id_str={}'; + +/** + * DY 直播信息 二类型 + * @type {string} + */ +export const DY_LIVE_INFO_2 = + 'https://webcast.amemv.com/webcast/room/reflow/info/?type_id=0&live_id=1&sec_user_id=&version_code=99.99.99&app_id=1128&room_id={}'; + /** * 动态信息 * https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/dynamic/get_dynamic_detail.md diff --git a/utils/common.js b/utils/common.js index 89fae80..56badb8 100644 --- a/utils/common.js +++ b/utils/common.js @@ -23,6 +23,31 @@ export function formatBiliInfo(data) { .join(' | '); } +/** + * 重试 axios 请求 + * @param requestFunction + * @param retries + * @param delay + * @returns {*} + */ +export async function retryAxiosReq(requestFunction, retries = 3, delay = 1000) { + try { + const response = await requestFunction(); + if (!response.data) { + throw new Error('请求空数据'); + } + return response.data; + } catch (error) { + if (retries > 0) { + logger.mark(`[R插件][重试模块]重试中... (${3 - retries + 1}/3) 次`); + await new Promise((resolve) => setTimeout(resolve, delay)); + return retryAxiosReq(requestFunction, retries - 1, delay); + } else { + throw error; + } + } +} + /** * 数字转换成具体时间 * @param seconds diff --git a/utils/tiktok.js b/utils/tiktok.js new file mode 100644 index 0000000..7f86e51 --- /dev/null +++ b/utils/tiktok.js @@ -0,0 +1,36 @@ +export function genVerifyFp() { + const baseStr = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const t = baseStr.length; + let milliseconds = Date.now(); // 获取当前的时间戳(毫秒) + let base36 = ""; + + // 将时间戳转换为base36 + while (milliseconds > 0) { + let remainder = milliseconds % 36; + if (remainder < 10) { + base36 = remainder.toString() + base36; + } else { + base36 = String.fromCharCode('a'.charCodeAt(0) + remainder - 10) + base36; + } + milliseconds = Math.floor(milliseconds / 36); + } + + const r = base36; + let o = new Array(36).fill(""); + o[8] = o[13] = o[18] = o[23] = "_"; + o[14] = "4"; + + // 生成随机字符 + for (let i = 0; i < 36; i++) { + if (!o[i]) { + let n = Math.floor(Math.random() * t); + if (i === 19) { + n = (3 & n) | 8; + } + o[i] = baseStr[n]; + } + } + + return "verify_" + r + "_" + o.join(""); +} +