diff --git a/README.md b/README.md index ea7de9b..da08e01 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ utils -- 工具类 git clone https://gitee.com/kyrzy0416/rconsole-plugin.git ./plugins/rconsole-plugin/ ``` -2.【必要】在`Yunzai-Bot`目录下安装axios(0.27.2)、魔法工具(tunnel)、哔哩哔哩总结(chatgpt-api)依赖 +2.【必要】在`Yunzai-Bot`目录下安装axios(0.27.2)、魔法工具(tunnel) ```shell @@ -53,6 +53,19 @@ sudo apt-get install ffmpeg # 其他linux参考(群友推荐):https://gitee.com/baihu433/ffmpeg # Windows 参考:https://www.jianshu.com/p/5015a477de3c ```` +油管解析需要 yt-dlp 的依赖才能完成解析(三选一): +```shell +# 三选一 +# ubuntu (国内 or 国外,且安装了snap) +snap install yt-dlp +# debian 海外 +curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o ~/.local/bin/yt-dlp +chmod a+rx ~/.local/bin/yt-dlp +# debian 国内 +curl -L https://ghproxy.net/https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o ~/.local/bin/yt-dlp +chmod a+rx ~/.local/bin/yt-dlp +``` + 4. 【可选】小程序解析适配了: * 喵崽:[Yoimiya / Miao-Yunzai](https://gitee.com/yoimiya-kokomi/Miao-Yunzai) * TRSS:[时雨◎星空 / Yunzai](https://gitee.com/TimeRainStarSky/Yunzai) @@ -118,7 +131,7 @@ sudo apt-get install ffmpeg ![help](./img/help.webp) ## 📝 计划功能 -- [ ] YouTube解析(这个可能要🕊一久) +- [x] YouTube解析(这个可能要🕊一久) - [ ] Instagram解析修复 - [ ] 单张图片解析 - [ ] 视频解析 @@ -142,6 +155,9 @@ sudo apt-get install ffmpeg * [一杯凉](https://gitee.com/yibeiliang) 提供小程序解析冲突解决方案 * [x0rz4](https://gitee.com/x0rz4) 提供依赖掉包解决方案 +感谢以下框架的开源: +YouTube解析参考了:[yt-dlp:A youtube-dl fork with additional features and fixes](https://github.com/yt-dlp/yt-dlp) + ## ☕ 请我喝一杯瑞幸咖啡 如果你觉得插件能帮助到你增进好友关系,那么你可以在有条件的情况下[请我喝一杯瑞幸咖啡](https://afdian.net/a/zhiyu1998),这是我开源这个插件的最大动力! 感谢以下朋友的支持!(排名不分多少) diff --git a/apps/tools.js b/apps/tools.js index 3ca9963..27cf532 100644 --- a/apps/tools.js +++ b/apps/tools.js @@ -12,9 +12,10 @@ import { parseUrl, parseM3u8, downloadM3u8Videos, mergeAcFileToMp4 } from "../ut import { transMap, douyinTypeMap, - RESTRICTION_DESCRIPTION, XHS_NO_WATERMARK_HEADER, + DIVIDING_LINE, + XHS_NO_WATERMARK_HEADER, REDIS_YUNZAI_ISOVERSEA, } from "../constants/constant.js"; -import { formatBiliInfo, getIdVideo, secondsToTime } from "../utils/common.js"; +import {containsChinese, formatBiliInfo, getIdVideo, secondsToTime} from "../utils/common.js"; import config from "../model/index.js"; import Translate from "../utils/trans-strategy.js"; import * as xBogus from "../utils/x-bogus.cjs"; @@ -26,6 +27,8 @@ import TokenBucket from "../utils/token-bucket.js"; import { getWbi } from "../utils/biliWbi.js"; import { BILI_SUMMARY } from "../constants/bili.js"; import { XHS_VIDEO } from "../constants/xhs.js"; +import child_process from 'node:child_process' +import { getAudio, getVideo } from "../utils/y2b.js"; export class tools extends plugin { constructor() { @@ -80,6 +83,11 @@ export class tools extends plugin { fnc: "clearTrash", permission: "master", }, + { + reg: "^#设置海外解析$", + fnc: "setOversea", + permission: "master", + }, { reg: "(h5app.kuwo.cn)", fnc: "bodianMusic", @@ -88,6 +96,10 @@ export class tools extends plugin { reg: "(kuaishou.com)", fnc: "kuaishou", }, + { + reg: "(youtube.com)", + fnc: "y2b" + } ], }); // 配置文件 @@ -104,6 +116,12 @@ export class tools extends plugin { this.biliDuration = this.toolsConfig.biliDuration; // 加载抖音Cookie this.douyinCookie = this.toolsConfig.douyinCookie; + // 翻译引擎 + this.translateEngine = new Translate({ + translateAppId: this.toolsConfig.translateAppId, + translateSecret: this.toolsConfig.translateSecret, + proxy: this.myProxy, + }); } // 翻译插件 @@ -118,13 +136,8 @@ export class tools extends plugin { return; } const place = msg.slice(1 + language[1].length) - const translateEngine = new Translate({ - translateAppId: this.toolsConfig.translateAppId, - translateSecret: this.toolsConfig.translateSecret, - proxy: this.myProxy, - }); // 如果没有百度那就Google - const translateResult = await translateEngine.translate(place, language[1]); + const translateResult = await this.translateEngine.translate(place, language[1]); e.reply(translateResult.trim(), true); return true; } @@ -216,6 +229,8 @@ export class tools extends plugin { const urlShortRex = /(http:|https:)\/\/vt.tiktok.com\/[A-Za-z\d._?%&+\-=\/#]*/g; const urlShortRex2 = /(http:|https:)\/\/vm.tiktok.com\/[A-Za-z\d._?%&+\-=\/#]*/g; let url = e.msg.trim(); + // 判断是否是海外服务器 + const isOversea = await this.isOverseasServer(); // 短号处理 if (url.includes("vt.tiktok")) { const temp_url = urlShortRex.exec(url)[0]; @@ -223,7 +238,7 @@ export class tools extends plugin { redirect: "follow", follow: 10, timeout: 10000, - agent: new HttpProxyAgent(this.myProxy), + agent: isOversea ? '' : new HttpProxyAgent(this.myProxy), }).then(resp => { url = resp.url; }); @@ -234,7 +249,7 @@ export class tools extends plugin { redirect: "follow", follow: 10, timeout: 10000, - agent: new HttpProxyAgent(this.myProxy), + agent: isOversea ? '' : new HttpProxyAgent(this.myProxy), }).then(resp => { url = resp.url; }); @@ -255,13 +270,13 @@ export class tools extends plugin { // redirect: "follow", follow: 10, timeout: 10000, - agent: new HttpsProxyAgent(this.myProxy), + agent: isOversea ? '' : new HttpProxyAgent(this.myProxy), }) .then(async resp => { const respJson = await resp.json(); const data = respJson.aweme_list[0]; e.reply(`识别:tiktok, ${ data.desc }`); - this.downloadVideo(data.video.play_addr.url_list[0], true).then(video => { + this.downloadVideo(data.video.play_addr.url_list[0], !isOversea).then(video => { e.reply( segment.video( `${ this.defaultPath }${ this.e.group_id || this.e.user_id }/temp.mp4`, @@ -343,7 +358,7 @@ export class tools extends plugin { biliInfo.unshift(segment.image(pic)) // 限制视频解析 const durationInMinutes = (curDuration / 60).toFixed(0); - biliInfo.push(`${ RESTRICTION_DESCRIPTION }\n当前视频时长约:${ durationInMinutes }分钟,\n大于管理员设置的最大时长 ${ this.biliDuration / 60 } 分钟!`) + biliInfo.push(`${ DIVIDING_LINE.replace('{}', '限制说明') }\n当前视频时长约:${ durationInMinutes }分钟,\n大于管理员设置的最大时长 ${ this.biliDuration / 60 } 分钟!`) summary && biliInfo.push(`\n${ summary }`); e.reply(biliInfo); return true; @@ -889,6 +904,102 @@ export class tools extends plugin { }); } + /** + * youtube解析 + * @param e + * @returns {Promise} + */ + async y2b(e) { + const urlRex = /(?:https?:\/\/)?www\.youtube\.com\/[A-Za-z\d._?%&+\-=\/#]*/g; + let url = urlRex.exec(e.msg)[0]; + // 获取url查询参数 + const query = querystring.parse(url.split("?")[1]); + let p = query?.p || '0'; + let v = query?.v; + // 判断是否是海外服务器,默认为false + const isProxy = !(await this.isOverseasServer()); + + let audios = [], videos = []; + let bestAudio = {}, bestVideo = {}; + + let rs = { title: '', thumbnail: '', formats: [] }; + try { + let cmd = `yt-dlp --print-json --skip-download ${this.y2bCk !== undefined ? `--cookies ${this.y2bCk}` : ''} '${url}' ${isProxy ? '--proxy http://127.0.0.1:7890' : ''} 2> /dev/null` + console.log('解析视频, 命令:', cmd); + rs = child_process.execSync(cmd).toString(); + try { + rs = JSON.parse(rs); + } catch (error) { + let cmd = `yt-dlp --print-json --skip-download ${this.y2bCk !== undefined ? `--cookies ${this.y2bCk}` : ''} '${url}?p=1' ${isProxy ? '--proxy http://127.0.0.1:7890' : ''} 2> /dev/null`; + logger.mark('尝试分P, 命令:', cmd); + rs = child_process.execSync(cmd).toString(); + rs = JSON.parse(rs); + p = '1'; + // url = `${msg.url}?p=1`; + } + if (!containsChinese(rs.title)) { + // 启用翻译引擎翻译不是中文的标题 + const transedTitle = await this.translateEngine.translate(rs.title, '中'); + // const transedDescription = await this.translateEngine.translate(rs.description, '中'); + e.reply(`识别:油管, + ${rs.title.trim()}\n + ${DIVIDING_LINE.replace("{}", "R插件翻译引擎服务")}\n + ${transedTitle}\n + ${rs.description} + `); + } else { + e.reply(`识别:油管,${rs.title}`); + } + } catch (error) { + logger.error(error.toString()); + e.reply("解析失败") + return; + } + + // 格式化 + rs.formats.forEach(it => { + let length = (it.filesize_approx ? '≈' : '') + ((it.filesize || it.filesize_approx || 0) / 1024 / 1024).toFixed(2); + if (it.audio_ext != 'none') { + audios.push(getAudio(it.format_id, it.ext, (it.abr || 0).toFixed(0), it.format_note || it.format || '', length)); + } else if (it.video_ext != 'none') { + videos.push(getVideo(it.format_id, it.ext, it.resolution, it.height, (it.vbr || 0).toFixed(0), it.format_note || it.format || '', length)); + } + }); + + // 寻找最佳的分辨率 + // bestAudio = Array.from(audios).sort((a, b) => a.rate - b.rate)[audios.length - 1]; + // bestVideo = Array.from(videos).sort((a, b) => a.rate - b.rate)[videos.length - 1]; + + // 较为有性能的分辨率 + logger.info(videos) + bestVideo = Array.from(videos).filter(item => item.scale.includes("720"))[0]; + bestAudio = Array.from(audios).filter(item => item.format === 'm4a')[0]; + logger.info({ + bestVideo, + bestAudio + }) + + // 格式化yt-dlp的请求 + const format = `${bestVideo.id}x${bestAudio.id}` + // 下载地址格式化 + const path = `${v}${ p ? `/p${p}` : '' }`; + const fullpath = `${ this.defaultPath }${ this.e.group_id || this.e.user_id }/${path}`; + // yt-dlp下载 + let cmd = //`cd '${__dirname}' && (cd tmp > /dev/null || (mkdir tmp && cd tmp)) &&` + + `yt-dlp ${this.y2bCk !== undefined ? `--cookies ${this.y2bCk}` : ''} https://youtu.be/${v} -f ${format.replace('x', '+')} ` + + `-o '${fullpath}/${v}.%(ext)s' ${isProxy ? '--proxy http://127.0.0.1:7890' : ''} -k --write-info-json`; + try { + await child_process.execSync(cmd); + e.reply(segment.video(`${fullpath}/${v}.mp4`)) + // 清理文件 + await deleteFolderRecursive(fullpath); + } catch (error) { + logger.error(error.toString()); + e.reply("y2b下载失败"); + return; + } + } + /** * 哔哩哔哩下载 * @param title @@ -1054,15 +1165,33 @@ export class tools extends plugin { } } + async setOversea(e) { + // 查看当前设置 + let os; + if ((await redis.exists(REDIS_YUNZAI_ISOVERSEA))) { + os = JSON.parse(await redis.get(REDIS_YUNZAI_ISOVERSEA)).os; + } + // 设置 + os = ~os + await redis.set( + REDIS_YUNZAI_ISOVERSEA, + JSON.stringify({ + os: os, + }), + ); + e.reply(`当前服务器:${os ? '海外服务器' : '国内服务器'}`) + return true; + } + /** * 判断是否是海外服务器 * @return {Promise} */ async isOverseasServer() { - const isOS = "Yz:rconsole:tools:oversea"; // 如果第一次使用没有值就设置 - if (!(await redis.exists(isOS))) { + if (!(await redis.exists(REDIS_YUNZAI_ISOVERSEA))) { await redis.set( + REDIS_YUNZAI_ISOVERSEA, JSON.stringify({ os: false, }), @@ -1070,7 +1199,7 @@ export class tools extends plugin { return true; } // 如果有就取出来 - return JSON.parse(redis.get(isOS)).os; + return JSON.parse((await redis.get(REDIS_YUNZAI_ISOVERSEA))).os; } /** diff --git a/config/help.yaml b/config/help.yaml index d674494..a9774b7 100644 --- a/config/help.yaml +++ b/config/help.yaml @@ -41,6 +41,9 @@ - icon: bilimusic title: "bili音乐+链接" desc: 哔哩哔哩音乐分享实时下载 + - icon: youtube + title: "youtube.com" + desc: 油管学习版分享实时下载 - icon: 推特 title: "小蓝鸟" desc: 推特学习版分享实时下载 diff --git a/config/version.yaml b/config/version.yaml index ad77098..2d5d298 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,10 +1,10 @@ - { - version: 1.2.3, + version: 1.3.0, data: [ + 新增油管解析功能, 新增小红书无水印下载功能, 新增哔哩哔哩官方AI总结功能, - 新增哔哩哔哩音乐提取功能, 新增快手解析功能, 支持锅巴插件,方便查看和修改配置, 添加#R帮助获取插件帮助, diff --git a/constants/constant.js b/constants/constant.js index 94b85b3..5967f19 100644 --- a/constants/constant.js +++ b/constants/constant.js @@ -59,4 +59,10 @@ export const XHS_NO_WATERMARK_HEADER = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 UBrowser/6.2.4098.3 Safari/537.36', } -export const RESTRICTION_DESCRIPTION = "\n-----------------------限制说明-----------------------" \ No newline at end of file +/** + * 分割线 + * @type {string} + */ +export const DIVIDING_LINE = "\n-----------------------{}-----------------------" + +export const REDIS_YUNZAI_ISOVERSEA = "Yz:rconsole:tools:oversea"; \ No newline at end of file diff --git a/resources/img/icon/youtube.png b/resources/img/icon/youtube.png new file mode 100644 index 0000000..0c71d0c Binary files /dev/null and b/resources/img/icon/youtube.png differ diff --git a/utils/common.js b/utils/common.js index 36822ca..1324aae 100644 --- a/utils/common.js +++ b/utils/common.js @@ -9,7 +9,7 @@ import {TEN_THOUSAND} from "../constants/constant.js"; /** * 请求模板 */ -class jFeatch { +export class jFetch { async get(url) { const r = await fetch(url); return await r.json(); @@ -26,7 +26,7 @@ class jFeatch { * @param time cron * @param isAutoPush 是否推送(开关) */ -function autoTask(func, time, groupList, isAutoPush = false) { +export function autoTask(func, time, groupList, isAutoPush = false) { if (isAutoPush) { schedule.scheduleJob(time, () => { // 正常传输 @@ -55,7 +55,7 @@ function autoTask(func, time, groupList, isAutoPush = false) { * @param delay * @returns {Promise} */ -function retry(func, maxRetries = 3, delay = 1000) { +export function retry(func, maxRetries = 3, delay = 1000) { return new Promise((resolve, reject) => { const attempt = (remainingTries) => { func() @@ -79,7 +79,7 @@ function retry(func, maxRetries = 3, delay = 1000) { * @param filename * @returns {Promise} */ -function downloadPDF (url, filename) { +export function downloadPDF (url, filename) { return axios({ url: url, responseType: "stream", @@ -102,7 +102,7 @@ function downloadPDF (url, filename) { * @param url * @returns {Promise} */ -async function getIdVideo(url) { +export async function getIdVideo(url) { const matching = url.includes("/video/"); if (!matching) { this.e.reply("没找到,正在获取随机视频!"); @@ -112,7 +112,7 @@ async function getIdVideo(url) { return idVideo.length > 19 ? idVideo.substring(0, idVideo.indexOf("?")) : idVideo; } -function generateRandomStr(randomlength = 16){ +export function generateRandomStr(randomlength = 16){ const base_str = 'ABCDEFGHIGKLMNOPQRSTUVWXYZabcdefghigklmnopqrstuvwxyz0123456789=' let random_str = '' for (let i = 0; i < randomlength; i++) { @@ -128,7 +128,7 @@ function generateRandomStr(randomlength = 16){ * @param redirect 是否要重定向 * @returns {Promise} */ -async function downloadMp3(mp3Url, path, redirect = "manual") { +export async function downloadMp3(mp3Url, path, redirect = "manual") { return fetch(mp3Url, { headers: { "User-Agent": @@ -178,7 +178,7 @@ const dataProcessing = data => { * @param data * @return {string} */ -function formatBiliInfo(data) { +export function formatBiliInfo(data) { return Object.keys(data).map(key => `${key}:${dataProcessing(data[key])}`).join(' | '); } @@ -187,7 +187,7 @@ function formatBiliInfo(data) { * @param seconds * @return {string} */ -function secondsToTime(seconds) { +export function secondsToTime(seconds) { const pad = (num, size) => num.toString().padStart(size, '0'); let hours = Math.floor(seconds / 3600); @@ -201,4 +201,29 @@ function secondsToTime(seconds) { return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(secs, 2)}`; } -export { jFeatch, autoTask, retry, getIdVideo, generateRandomStr, downloadMp3, dataProcessing, formatBiliInfo, secondsToTime }; +/** + * 判断字符串是否是中文(全局判断) + * @param str + * @returns {boolean} + */ +export function isChinese(str) { + return /^[\u4e00-\u9fff]+$/.test(str); +} + +/** + * 判断字符串是否包含中文 + * @param str + * @returns {boolean} + */ +export function containsChinese(str) { + return /[\u4e00-\u9fff]/.test(str); +} + +/** + * 判断字符串是否包含中文 && 检测标点符号 + * @param str + * @returns {boolean} + */ +export function containsChineseOrPunctuation(str) { + return /[\u4e00-\u9fff\uff00-\uffef]/.test(str); +} diff --git a/utils/y2b.js b/utils/y2b.js new file mode 100644 index 0000000..b4c1e60 --- /dev/null +++ b/utils/y2b.js @@ -0,0 +1,27 @@ +/** + * y2b 音频信息 + * @param id + * @param format + * @param rate + * @param info + * @param size + * @returns {{size: (string|*), rate: (string|*), format, id, info}} + */ +export function getAudio(id, format, rate, info, size) { + return { id, format, rate: rate == 0 ? '未知' : rate, info, size: size == 0 ? '未知' : size }; +} + +/** + * y2b 视频信息 + * @param id + * @param format + * @param scale + * @param frame + * @param rate + * @param info + * @param size + * @returns {{size: (string|*), rate: (string|*), format, scale, id, frame, info}} + */ +export function getVideo(id, format, scale, frame, rate, info, size) { + return { id, format, scale, frame, rate: rate == 0 ? '未知' : rate, info, size: size == 0 ? '未知' : size }; +} \ No newline at end of file