CCTV客户端视频解析

将CCTV视频解析成HLS地址.

// ==UserScript==
// @name:en-US         CCTV-HLS-Client
// @name               CCTV客户端视频解析
// @description:en-US  parse cctv video to hls url.
// @description        将CCTV视频解析成HLS地址.
// @namespace          https://greasyfork.org/users/135090
// @version            1.5.3
// @author             [ZWB](https://greasyfork.org/zh-CN/users/863179)
// @license            CC
// @grant              none
// @run-at             document-end
// @match              *://*.cctv.com/*/*/*/V*.shtml*
// @match              *://*.cctv.cn/*/*/*/V*.shtml*
// @match              *://*.cctv.com/*/*/*/A*.shtml*
// @match              *://*.cctv.cn/*/*/*/A*.shtml*
// @match              *://vdn.apps.cntv.cn/api/getHttpVideoInfo*
// @icon               https://tv.cctv.cn/favicon.ico
// ==/UserScript==

(async () => {
    async function linkbutton(guid, n) {
        let base = "https://vdn.apps.cntv.cn";
        let pathname = "/api/getHttpVideoInfo.do";
        let apihref = base + pathname + `?client=flash&im=0&pid=${guid}`;
        let bts = n * 40 + 20;
        let btn = document.createElement("a");
        btn.href = apihref;
        btn.id = "btn" + n;
        btn.type = "button";
        btn.target = "_blank";
        btn.textContent = "点击跳转到下载页"+(n+1);
        btn.style = `
        position: fixed;
        z-index: 999;
        bottom: ${bts}px;
        right: 20px;
        background-color: #f86336;
        color: white;
        padding: 5px;
        border: none;
        cursor: pointer;
        font-size: 16px;
        `;

        document.body.appendChild(btn);
    }
    if (location.hostname.indexOf(".cctv.com") > 0 || location.hostname.indexOf(".cctv.cn") > 0) {
        setTimeout(async () => {
            if ( window?.guid?.length >0){
                let base = "https://vdn.apps.cntv.cn";
                let pathname = "/api/getHttpVideoInfo.do";
                let apihref = base + pathname + `?client=flash&im=0&pid=${guid}`;
                location.href = (apihref); //直接跳转需要取消注释
            }
            if (window?.playerParas?.vdn?.vdnUrl != undefined ){
                let apihref = window?.playerParas?.vdn?.vdnUrl;
                location.href = (apihref); //直接跳转需要取消注释
            }
            
            if (window?.vodh5player?.playerList?.length > 1) {
                window?.vodh5player?.playerList?.forEach((i, n) => {
                    let guid = i?.options_?.paras?.videoId;
                    linkbutton(guid, n);
                });
                return;
            } else if (window?.vodh5player?.playerList?.length == 1){
                let guid = window?.vodh5player?.playerList[0]?.options_?.paras?.videoId;
                let base = "https://vdn.apps.cntv.cn";
                let pathname = "/api/getHttpVideoInfo.do";
                let apihref = base + pathname + `?client=flash&im=0&pid=${guid}`;
                location.href = (apihref); //直接跳转需要取消注释
            }
        }, 1250);
    }

    if (location.hostname.indexOf("vdn.apps.cntv.cn") > -1) {
        let data = await JSON.parse(document?.body?.textContent); //解析成JSON对象
        let title = data?.title?.replaceAll(" ", ""); //视频标题
        let normal = data?.manifest?.hls_enc2_url; //普通加密视频
        let is4K = data?.play_channel?.indexOf("4K") > 0 ? true : false;
        const brarry = ["450", "850", "1200", "2000", "4000"];
        document.title = title; //网页标题由视频标题提供
        let hlsUrl = data?.hls_url?.replaceAll("main", brarry[4]);
        if (is4K) {
            console.info("4K频道只有一种清晰度可选,直接使用此清晰度")
        } else {
            let brcount = await parseM3u8(normal);
            console.log(brcount + "个");
            let bri = brcount - 1;
            if (brcount < 3) {
                hlsUrl = data?.hls_url?.replaceAll("main", brarry[bri]); //360P及以下未加密
            } else {
                hlsUrl = normal?.replaceAll("main", brarry[bri]); //高码率普通视频
            }

            let tsnlen = hlsUrl.split("/").length - 2; //在api页获取guid值在播放链接中的索引
            let tsn = hlsUrl.split("/")[tsnlen]; //根据索引得到guid值,其实也可以用let tsn = location.search.split('&')[2].slice(4);
            console.info("guid:" + tsn);
            title = title.length > 0 ? title : tsn + "_guid.ts"; //标题空格用_代替
        }
        let hlsfull =  hlsUrl.split("&")[0]; //防止复制到命令行的地址产生歧义
        document.body.innerHTML = `<h2><a href="${hlsfull}" alt="">${hlsfull}</a></h2><hr>`;
        // 创建实时更新的进度-代码块开始>
        let txtctt = document.createElement("h2");
        txtctt.textContent = title;
        document.body.appendChild(txtctt);
        await downloadM3U8Video(hlsUrl, title.concat(".ts"), {
            onProgress: (current, total) => {
                let cotp = `${Math.round((current / total) * 100)}`;
                txtctt.textContent = title + "---下载进程" + cotp + "%";
                console.info(`进度: ${current}/${total} (${cotp}%)`);
            }
        });
        // 创建实时更新的进度-代码块结束<
    }
    // 解析M3U8文件函数
    async function parseM3u8(nm) {
        try {
            console.log(`开始解析: ${nm}`);

            const response = await fetch(nm);

            if (!response.ok) {
                console.log(`HTTP错误: ${response.status}`);
                return 1;
            }

            const content = await response.text();
            let count = content.split('\n')
                .filter(line => {
                    const trimmed = line.trim();
                    return trimmed && !trimmed.startsWith('#') && trimmed.endsWith('.m3u8');
                }).length;
            console.log(`找到 ${count} 个嵌套M3U8地址`);
            return +count;
        } catch (error) {
            console.log(`解析失败: ${error.message}`, true);
            return 1;
        }
    }

    async function downloadM3U8Video(m3u8Url, outputFilename = 'video.ts', options = {}) {
        try {
            // 1. 获取并解析M3U8文件
            let response = await fetch(m3u8Url);
            if (!response.ok) {
                console.log("访问失败")
            }
            const m3u8Content = await response.text();
            const lines = m3u8Content.split('\n');
            const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf("/") + 1);

            // 🐱猫抓扩展直接调用cbox时需要填写的参数设置>> cbox:"${url}" "%PUBLIC%\Downloads\cctv_${now}.MP4"
            //if (outputFilename.length > 4) return; // 要用[o(≡°ェ°≡)m]猫抓扩展直接调用cbox时,请解除本行注释,👆

            if (!confirm('开始下载' + outputFilename)) { return; } // 不想要被询问,想直接用浏览器下载时,请注释本行
            // 1.1 解析TS分片URL
            const segments = [];
            for (const line of lines) {
                if (line && !line.startsWith('#') && (line.endsWith('.ts') || line.match(/\.ts\?/))) {
                    const segmentUrl = line.startsWith('http') ? line : new URL(line, baseUrl).href;
                    segments.push(segmentUrl);
                }
            }

            if (segments.length === 0) console.log('在 M3U8 文件中未找到分片.');
            console.log(`找到 ${segments.length} 个ts分片.`);

            // 2. 下载所有分片,采用流式合并
            console.log('正在下载分片...');
            const blobs = [];
            const { onProgress } = options;

            for (let i = 0; i < segments.length; i++) {
                try {
                    const segmentResponse = await fetch(segments[i]);
                    if (!segmentResponse.ok) console.log(`无法访问分片: ${segmentResponse.status}`);

                    const blob = await segmentResponse.blob();
                    blobs.push(blob);

                    // 调用进度回调
                    if (typeof onProgress === 'function') {
                        await onProgress(i + 1, segments.length);
                    }
                } catch (error) {
                    console.error(`下载分片时出现错误 ${segments[i]}:`, error);
                    throw error; // 可以选择继续或抛出错误
                }
            }

            // 3. 下载合并完成后的视频
            console.log('正在创建完整视频的下载链接...');
            const mergedBlob = new Blob(blobs, { type: 'video/mp2t' });
            const url = URL.createObjectURL(mergedBlob);
            const a = document.createElement('a');
            a.href = url;
            a.download = outputFilename;
            a.style.display = "none";
            document.body.appendChild(a);
            a.click();

            // 4. 清理临时链接
            setTimeout(async () => {
                document.body.removeChild(a);
                URL.revokeObjectURL(url);
            }, 100);

            console.log('下载完成!');
            return true;
        } catch (error) {
            console.error('下载完整视频时发生错误:', error);
            throw error;
        }
    }
})();