|
@@ -0,0 +1,316 @@
|
|
|
+// YouTube 视频信息提取器
|
|
|
+
|
|
|
+// 处理网络请求
|
|
|
+request = async (method, url, data = null, headers = {}, requestId, platform) => {
|
|
|
+ console.log(`request url:${url}`)
|
|
|
+ console.log(`request data:${data}`)
|
|
|
+ console.log(`request method:${method}`)
|
|
|
+ console.log(`request headers:${JSON.stringify((headers))}`);
|
|
|
+
|
|
|
+ if (platform === "WEB") {
|
|
|
+ const res = await fetch(url, {
|
|
|
+ 'mode': 'cors',
|
|
|
+ 'method': method,
|
|
|
+ 'headers': headers,
|
|
|
+ 'body': data
|
|
|
+ });
|
|
|
+ const resData = await res.text();
|
|
|
+ return Promise.resolve({
|
|
|
+ 'data': resData,
|
|
|
+ 'headers': res.headers
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ AF.request(url, method, data, headers, requestId, (data, headers, err) => {
|
|
|
+ if (err) {
|
|
|
+ console.log(`request error: ${err}`);
|
|
|
+ reject(err);
|
|
|
+ } else {
|
|
|
+ console.log(`response headers: ${headers}`);
|
|
|
+ resolve({
|
|
|
+ 'data': data,
|
|
|
+ 'headers': JSON.parse(headers)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 解析视频编码信息
|
|
|
+parseCodecs = (format) => {
|
|
|
+ const mimeType = format['mimeType']
|
|
|
+ if (!mimeType) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+ const regex = /(?<mimetype>[^/]+\/[^;]+)(?:;\s*codecs="?(?<codecs>[^"]+))?/;
|
|
|
+ const match = mimeType.match(regex);
|
|
|
+ if (!match) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+ const codecs = match.groups.codecs;
|
|
|
+ if (!codecs) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+
|
|
|
+ const splitCodecs = codecs.trim().replace(/,$/, '').split(',').map(str => str.trim()).filter(Boolean);
|
|
|
+ let vcodec = null;
|
|
|
+ let acodec = null;
|
|
|
+
|
|
|
+ for (const fullCodec of splitCodecs) {
|
|
|
+ const codec = fullCodec.split('.')[0];
|
|
|
+
|
|
|
+ if (['avc1', 'avc2', 'avc3', 'avc4', 'vp9', 'vp8', 'hev1', 'hev2', 'h263', 'h264', 'mp4v', 'hvc1', 'av01', 'theora'].includes(codec)) {
|
|
|
+ if (!vcodec) {
|
|
|
+ vcodec = fullCodec;
|
|
|
+ }
|
|
|
+ } else if (['mp4a', 'opus', 'vorbis', 'mp3', 'aac', 'ac-3', 'ec-3', 'eac3', 'dtsc', 'dtse', 'dtsh', 'dtsl'].includes(codec)) {
|
|
|
+ if (!acodec) {
|
|
|
+ acodec = fullCodec;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ console.log(`WARNING: Unknown codec ${fullCodec}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!vcodec && !acodec) {
|
|
|
+ if (splitCodecs.length === 2) {
|
|
|
+ return {
|
|
|
+ vcodec: splitCodecs[0],
|
|
|
+ acodec: splitCodecs[1]
|
|
|
+ };
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ return {
|
|
|
+ vcodec: vcodec,
|
|
|
+ acodec: acodec
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {};
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+// 从播放器JS中提取解密函数
|
|
|
+async function extractDecryptFunction(playerUrl, requestId, platform) {
|
|
|
+ // 函数内部缓存
|
|
|
+ const cache = extractDecryptFunction.cache || (extractDecryptFunction.cache = {});
|
|
|
+ const cacheKey = `jsFunction:${playerUrl}`;
|
|
|
+
|
|
|
+ if (cache[cacheKey]) {
|
|
|
+ console.log(`从缓存获取解密函数: ${playerUrl}`);
|
|
|
+ return cache[cacheKey];
|
|
|
+ }
|
|
|
+
|
|
|
+ const playerResp = await request('GET', playerUrl, null, {}, requestId, platform);
|
|
|
+ const playerJs = playerResp.data;
|
|
|
+
|
|
|
+ // 提取签名函数名
|
|
|
+ const signatureFunctionName = playerJs.match(/\bc\s*&&\s*d\.set\([^,]+\s*,\s*\([^)]*\)\s*=>\s*([a-zA-Z$_][a-zA-Z$_0-9]*)\(/)[1];
|
|
|
+
|
|
|
+ // 提取ncode函数名
|
|
|
+ const ncodeFunctionName = playerJs.match(/\bc\s*&&\s*d\.set\([^,]+\s*,\s*\([^)]*\)\s*=>\s*([a-zA-Z$_][a-zA-Z$_0-9]*)\(\))/)[1];
|
|
|
+
|
|
|
+ // 提取函数定义
|
|
|
+ const functionPattern = new RegExp(`${signatureFunctionName}=function\\(\\w+\\)\\{[^\\}]+\\}`);
|
|
|
+ const signatureFunction = playerJs.match(functionPattern)[0];
|
|
|
+
|
|
|
+ const ncodeFunctionPattern = new RegExp(`${ncodeFunctionName}=function\\(\\)\\{[^\\}]+\\}`);
|
|
|
+ const ncodeFunction = playerJs.match(ncodeFunctionPattern)[0];
|
|
|
+
|
|
|
+ // 存入函数内部缓存
|
|
|
+ const result = {
|
|
|
+ signatureFunction,
|
|
|
+ ncodeFunction
|
|
|
+ };
|
|
|
+ cache[cacheKey] = result;
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+// 解析并执行解密函数
|
|
|
+function executeDecryptFunction(code, input) {
|
|
|
+ const fn = new Function('a', code.replace(/^[^=]+=function/, 'return function'));
|
|
|
+ return fn(input);
|
|
|
+}
|
|
|
+
|
|
|
+// 解密签名
|
|
|
+decryptSignature = async (signatureEncrypted, playerUrl, requestId, platform) => {
|
|
|
+ try {
|
|
|
+ // 提取解密函数
|
|
|
+ const {signatureFunction, ncodeFunction} = await extractDecryptFunction(playerUrl, requestId, platform);
|
|
|
+
|
|
|
+ // 执行签名解密
|
|
|
+ const decryptedSignature = executeDecryptFunction(signatureFunction, signatureEncrypted);
|
|
|
+
|
|
|
+ // 执行ncode处理
|
|
|
+ const ncode = executeDecryptFunction(ncodeFunction, '');
|
|
|
+
|
|
|
+ return {
|
|
|
+ signature: decryptedSignature,
|
|
|
+ ncode: ncode
|
|
|
+ };
|
|
|
+ } catch (e) {
|
|
|
+ console.error('签名解密失败:', e);
|
|
|
+ return {
|
|
|
+ signature: signatureEncrypted,
|
|
|
+ ncode: ''
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取视频详情
|
|
|
+detail = async (url, requestId, platform) => {
|
|
|
+ try {
|
|
|
+ // 获取视频页面 HTML
|
|
|
+ const htmlResp = await request('GET', `${url}&bpctr=9999999999&has_verified=1`, null, {
|
|
|
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36',
|
|
|
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
|
+ 'Accept-Language': 'en-us,en;q=0.5',
|
|
|
+ 'Sec-Fetch-Mode': 'navigate',
|
|
|
+ 'Accept-Encoding': 'gzip, deflate, br',
|
|
|
+
|
|
|
+ }, requestId, platform);
|
|
|
+
|
|
|
+ let {data: html, headers: htmlHeaders} = htmlResp;
|
|
|
+
|
|
|
+ // 解析初始播放器响应
|
|
|
+ const playerMatch = html.match(/var ytInitialPlayerResponse\s*=\s*({.*?});/);
|
|
|
+ if (!playerMatch) {
|
|
|
+ throw new Error('无法找到播放器数据');
|
|
|
+ }
|
|
|
+
|
|
|
+ const ytInitialPlayerResponse = JSON.parse(playerMatch[1]);
|
|
|
+ const originVideoDetails = ytInitialPlayerResponse['videoDetails'];
|
|
|
+
|
|
|
+ // 获取推荐视频
|
|
|
+ const recommendInfo = [];
|
|
|
+ const ytInitialDataMatch = html.match(/var ytInitialData\s*=\s*({.*?});/);
|
|
|
+ if (ytInitialDataMatch) {
|
|
|
+ const ytInitialData = JSON.parse(ytInitialDataMatch[1]);
|
|
|
+ const recommendations = ytInitialData.contents?.twoColumnWatchNextResults?.secondaryResults?.secondaryResults?.results || [];
|
|
|
+
|
|
|
+ for (const item of recommendations) {
|
|
|
+ if (item.compactVideoRenderer) {
|
|
|
+ const video = item.compactVideoRenderer;
|
|
|
+ if (video.videoId) {
|
|
|
+ recommendInfo.push({
|
|
|
+ type: "gridVideoRenderer",
|
|
|
+ videoId: video.videoId,
|
|
|
+ title: video.title?.simpleText,
|
|
|
+ thumbnails: video.thumbnail?.thumbnails,
|
|
|
+ channelName: video.longBylineText?.runs?.[0]?.text,
|
|
|
+ publishedTimeText: video.publishedTimeText?.simpleText,
|
|
|
+ viewCountText: video.viewCountText?.simpleText,
|
|
|
+ shortViewCountText: video.shortViewCountText?.simpleText,
|
|
|
+ lengthText: video.lengthText?.simpleText
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取播放格式
|
|
|
+ const formats = [];
|
|
|
+ const qualities = [];
|
|
|
+
|
|
|
+ // 从 HTML 中获取格式
|
|
|
+ const streamingData = ytInitialPlayerResponse.streamingData;
|
|
|
+ const allFormats = [
|
|
|
+ ...(streamingData.formats || []),
|
|
|
+ ...(streamingData.adaptiveFormats || [])
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const format of allFormats) {
|
|
|
+ if (format.height && parseInt(format.height) >= 720) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (format && !qualities.includes(format.qualityLabel)) {
|
|
|
+ const {vcodec, acodec} = parseCodecs(format);
|
|
|
+
|
|
|
+ let finalUrl = format.url;
|
|
|
+ if (!finalUrl && format.signatureCipher) {
|
|
|
+ const urlParams = new URLSearchParams(format.signatureCipher);
|
|
|
+ const url = urlParams.get('url');
|
|
|
+ const s = urlParams.get('s');
|
|
|
+ if (url && s) {
|
|
|
+ const playerUrl = `https://www.youtube.com${html.match(/"(?:PLAYER_JS_URL|jsUrl)"\s*:\s*"([^"]+)"/)?.at(1)}`;
|
|
|
+ const {signature, ncode} = await decryptSignature(s, playerUrl, requestId, platform);
|
|
|
+ finalUrl = `${url}&sig=${signature}&n=${ncode}`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (finalUrl && vcodec && acodec) {
|
|
|
+ formats.push({
|
|
|
+ width: format.width + "",
|
|
|
+ height: format.height + "",
|
|
|
+ type: format.mimeType,
|
|
|
+ quality: format.qualityLabel,
|
|
|
+ itag: format.itag,
|
|
|
+ fps: format.fps + "",
|
|
|
+ bitrate: format.bitrate + "",
|
|
|
+ ext: "mp4",
|
|
|
+ vcodec: vcodec,
|
|
|
+ acodec: acodec,
|
|
|
+ vbr: "0",
|
|
|
+ abr: "0",
|
|
|
+ container: "mp4_dash",
|
|
|
+ from: "web",
|
|
|
+ url: format.url,
|
|
|
+ videoUrl: "",
|
|
|
+ audioUrl: ""
|
|
|
+ });
|
|
|
+ qualities.push(format.qualityLabel);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按高度排序
|
|
|
+ formats.sort((a, b) => parseInt(a.height) - parseInt(b.height));
|
|
|
+
|
|
|
+ // 构建缩略图列表
|
|
|
+ const thumbnails = originVideoDetails.thumbnail.thumbnails.map(item => ({
|
|
|
+ url: item.url,
|
|
|
+ width: item.width + "",
|
|
|
+ height: item.height + ""
|
|
|
+ }));
|
|
|
+
|
|
|
+ // 构建视频详情
|
|
|
+ const videoDetails = {
|
|
|
+ isLiveContent: originVideoDetails.isLiveContent,
|
|
|
+ title: originVideoDetails.title,
|
|
|
+ thumbnails: thumbnails,
|
|
|
+ description: originVideoDetails.shortDescription,
|
|
|
+ lengthSeconds: originVideoDetails.lengthSeconds,
|
|
|
+ viewCount: originVideoDetails.viewCount,
|
|
|
+ keywords: originVideoDetails.keywords,
|
|
|
+ author: originVideoDetails.author,
|
|
|
+ channelID: originVideoDetails.channelId,
|
|
|
+ recommendInfo: recommendInfo,
|
|
|
+ channelURL: `https://www.youtube.com/channel/${originVideoDetails.channelId}`,
|
|
|
+ videoId: url.replace('https://www.youtube.com/watch?v=', '')
|
|
|
+ };
|
|
|
+
|
|
|
+ return {
|
|
|
+ code: 200,
|
|
|
+ msg: "",
|
|
|
+ requestId: requestId,
|
|
|
+ data: {
|
|
|
+ videoDetails: videoDetails,
|
|
|
+ streamingData: {
|
|
|
+ formats: formats
|
|
|
+ }
|
|
|
+ },
|
|
|
+ id: "MusicDetailViewModel_detail_url"
|
|
|
+ };
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ console.error(e);
|
|
|
+ return {
|
|
|
+ code: -1,
|
|
|
+ msg: e.toString(),
|
|
|
+ requestId: requestId
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|