아프리카TV - 다시보기 채팅창 부검기

VOD 채팅창에서 채팅, 별풍선, 대결미션, 도전미션 로그를 다운로드

// ==UserScript==
// @name         아프리카TV - 다시보기 채팅창 부검기
// @name:ko         아프리카TV - 다시보기 채팅창 부검기
// @namespace    https://vod.afreecatv.com/
// @version      20240317
// @description  VOD 채팅창에서 채팅, 별풍선, 대결미션, 도전미션 로그를 다운로드
// @description:ko  VOD 채팅창에서 채팅, 별풍선, 대결미션, 도전미션 로그를 다운로드
// @author       You
// @match        https://vod.afreecatv.com/player/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=afreecatv.com
// @run-at       document-end
// @license MIT
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    let accumulatedTextData = '';
    let balloonCutoff = 1;

    function secondsToHMS(seconds) {
        if(seconds < 0){
            return `[00:00:00]`;
        }
        seconds = Math.floor(seconds);

        const hours = Math.floor(seconds / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const remainingSeconds = seconds % 60;

        const formattedHours = String(hours).padStart(2, '0');
        const formattedMinutes = String(minutes).padStart(2, '0');
        const formattedSeconds = String(remainingSeconds).padStart(2, '0');

        return `[${formattedHours}:${formattedMinutes}:${formattedSeconds}]`;
    }

    // XML을 JSON으로 변환하는 함수
    function xmlToJson(xml) {
        var obj = {};

        if (xml.nodeType === 1) {
            // element 노드인 경우
            if (xml.attributes.length > 0) {
                obj["@attributes"] = {};
                for (var j = 0; j < xml.attributes.length; j++) {
                    var attribute = xml.attributes.item(j);
                    obj["@attributes"][attribute.nodeName] = attribute.nodeValue;
                }
            }
        } else if (xml.nodeType === 3) {
            // text 노드인 경우
            obj = xml.nodeValue;
        } else if (xml.nodeType === 4) {
            // CDATA 노드인 경우
            obj = xml.nodeValue;
        }

        // CDATA 노드 처리
        if (xml.nodeType === 4) {
            obj = xml.nodeValue;
        }

        // 하위 노드가 있는 경우
        if (xml.hasChildNodes()) {
            for (var i = 0; i < xml.childNodes.length; i++) {
                var item = xml.childNodes.item(i);
                var nodeName = item.nodeName;
                if (typeof(obj[nodeName]) === "undefined") {
                    obj[nodeName] = xmlToJson(item);
                } else {
                    if (typeof(obj[nodeName].push) === "undefined") {
                        var old = obj[nodeName];
                        obj[nodeName] = [];
                        obj[nodeName].push(old);
                    }
                    obj[nodeName].push(xmlToJson(item));
                }
            }
        }
        return obj;
    }

    function removeTextAfterRoot(jsonData) {
        if (!jsonData || typeof jsonData !== 'object') {
            return jsonData;
        }

        const rootKeys = Object.keys(jsonData);

        // "root" 다음에 바로 오는 "#text"를 제거합니다.
        if (rootKeys.length === 1 && rootKeys[0] === 'root') {
            const rootObj = jsonData.root;

            // 만약 "root" 객체 안에 "#text"가 있다면 제거합니다.
            if (rootObj && Array.isArray(rootObj['#text'])) {
                delete rootObj['#text'];
            }
        }

        return jsonData;
    }

    async function fetchChatData(url) {
        try {
            const response = await fetch(url, {
                cache: "force-cache" // 항상 캐시를 사용하도록 설정
            });
            const data = await response.text();
            const parser = new DOMParser();
            const xmlDoc = parser.parseFromString(data, "text/xml");
            const jsonData = xmlToJson(xmlDoc);
            const modifiedJsonData = removeTextAfterRoot(jsonData);
            return modifiedJsonData;
        } catch (error) {
            console.error('데이터를 불러오는 중 오류가 발생했습니다:', error);
            throw error;
        }
    }

    async function retrieveAndLogChatData(url, startTime, cmd, accumulatedTime) {
        try {
            const chatData = await fetchChatData(`${url}&startTime=${startTime}`);
            let textData = '';

            switch (true) {
                case (cmd === "getChatLog"):
                    textData = convertChatObjToText(chatData, accumulatedTime, '', '');
                    break;
                case (cmd === "getBalloonLog"):
                    textData = convertBalloonObjToText(chatData, accumulatedTime);
                    break;
                case (cmd === "getChallengeMissionLog"):
                    textData = convertChallengeMissionObjToText(chatData, accumulatedTime);
                    break;
                case (cmd === "getBattleMissionLog"):
                    textData = convertBattleMissionObjToText(chatData, accumulatedTime);
                    break;
                case cmd.includes("getChatLogByID"):
                    textData = convertChatObjToText(chatData, accumulatedTime, cmd.split('getChatLogByID_')[1], '');
                    break;
                case cmd.includes("getChatLogByWord"):
                    textData = convertChatObjToText(chatData, accumulatedTime, '', cmd.split('getChatLogByWord_')[1]);
                    break;
                default:
                    console.error('잘못된 명령입니다:', cmd);
                    return;
            }

            if (textData) {
                accumulatedTextData += textData; // 텍스트 데이터를 누적
            }
        } catch (error) {
            console.error('채팅 데이터를 가져오는 중 오류가 발생했습니다:', error);
        }
    }

    function generateFileName(bjid, videoid, cmd) {
        let fileType = "";
        switch (true) {
            case (cmd === "getChatLog"):
                fileType = "채팅_전체";
                break;
            case (cmd === "getBalloonLog"):
                fileType = `별풍선_전체_${balloonCutoff}개이상`;
                break;
            case (cmd === "getChallengeMissionLog"):
                fileType = `도전미션_전체_${balloonCutoff}개이상`;
                break;
            case (cmd === "getBattleMissionLog"):
                fileType = `배틀미션_전체_${balloonCutoff}개이상`;
                break;
            case cmd.includes("getChatLogByID"):
                fileType = `채팅_${cmd.split('getChatLogByID_')[1]}`;
                break;
            case cmd.includes("getChatLogByWord"):
                fileType = `채팅_단어_${cmd.split('getChatLogByWord_')[1]}`;
                break;
        }
        return `${bjid}_${videoid}_${fileType}.txt`;
    }

    async function retrieveChatDataForDuration(duration, fileInfoKey, cmd, isLastIteration, accumulatedTime) {
        const url = fileInfoKey.indexOf("clip_") !== -1 ?
              `https://vod-normal-kr-cdn-z01.afreecatv.com/${fileInfoKey.split("_").join("/")}_c.xml?type=clip&rowKey=${fileInfoKey}_c` :
              `https://videoimg.afreecatv.com/php/ChatLoadSplit.php?rowKey=${fileInfoKey}_c`;
        const bjid = vodCore.config.copyright.user_id || vodCore.config.bjId;
        const filename = generateFileName(bjid, vodCore.config.titleNo, cmd);
        const intervalDuration = 300; // 300초마다 채팅 데이터 가져오기
        let currentSeconds = 0;

        while (currentSeconds <= duration) {
            document.title = `채팅 데이터를 받는 중... ${parseInt((currentSeconds+accumulatedTime)/vodCore.config.totalFileDuration*100)}%`;
            await retrieveAndLogChatData(url, currentSeconds, cmd, accumulatedTime);
            currentSeconds += intervalDuration;

            if (currentSeconds > duration && isLastIteration) {
                // 마지막 반복이면서 현재 시간이 지속 시간을 초과하면 저장
                if(accumulatedTextData.length > 0) {
                    saveTextToFile(accumulatedTextData, filename)
                } else {
                    alert('저장할 데이터가 없습니다.');
                }
            }
        }
    }

    async function saveTextToFile(textData, fileName) {
        const blob = new Blob([textData], { type: 'text/plain' });
        const blobUrl = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = blobUrl;
        link.download = fileName;
        link.click();
        URL.revokeObjectURL(blobUrl);
    }

    function convertChatObjToText(jsonData, accumulatedTime, targetid, targetword) {

        if (Array.isArray(jsonData.root.chat)) {
            // 배열일 경우

            const chatArray = jsonData.root.chat;
            let text = '';

            chatArray.forEach(chatObj => {
                const t = chatObj.t ? secondsToHMS(parseFloat(chatObj.t['#text']) + accumulatedTime) : '';
                const u = chatObj.u ? chatObj.u['#text'].split('(')[0] : '';
                const n = chatObj.n ? chatObj.n['#cdata-section'] : '';
                const m = chatObj.m ? chatObj.m['#cdata-section'] : '';

                if(targetid.length > 0){
                    if(targetid === u) text += `${t} ${n}(${u}): ${m}\n`;
                } else if(targetword.length > 0){
                    if(m.includes(targetword)) text += `${t} ${n}(${u}): ${m}\n`;
                } else {
                    text += `${t} ${n}(${u}): ${m}\n`;
                }

            });

            return text;
        } else if (typeof jsonData.root.chat === 'object') {
            // 객체일 경우

            const chatObj = jsonData.root.chat;
            let text = '';

            const t = chatObj.t ? secondsToHMS(parseFloat(chatObj.t['#text']) + accumulatedTime) : '';
            const u = chatObj.u ? chatObj.u['#text'].split('(')[0] : '';
            const n = chatObj.n ? chatObj.n['#cdata-section'] : '';
            const m = chatObj.m ? chatObj.m['#cdata-section'] : '';

            if(targetid.length > 0){
                if(targetid === u) text += `${t} ${n}(${u}): ${m}\n`;
            } else if(targetword.length > 0){
                if(m.includes(targetword)) text += `${t} ${n}(${u}): ${m}\n`;
            } else {
                text += `${t} ${n}(${u}): ${m}\n`;
            }

            return text;
        } else {
            return '';
        }
    }

    function convertBalloonObjToText(jsonData, accumulatedTime) {

        if (Array.isArray(jsonData.root.balloon)) {
            // 배열일 경우
            const balloonArray = jsonData.root.balloon;
            let text = '';

            balloonArray.forEach(balloonObj => {
                const t = balloonObj.t ? secondsToHMS(parseFloat(balloonObj.t['#text']) + accumulatedTime) : '';
                const u = balloonObj.u ? balloonObj.u['#text'].split('(')[0] : '';
                const n = balloonObj.n ? balloonObj.n['#cdata-section'] : '';
                const c = balloonObj.c ? balloonObj.c['#text'] : '';

                if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}\n`;
            });

            return text;
        } else if (typeof jsonData.root.balloon === 'object') {
            // 객체일 경우
            const balloonObj = jsonData.root.balloon;
            let text = '';

            const t = balloonObj.t ? secondsToHMS(parseFloat(balloonObj.t['#text']) + accumulatedTime) : '';
            const u = balloonObj.u ? balloonObj.u['#text'].split('(')[0] : '';
            const n = balloonObj.n ? balloonObj.n['#cdata-section'] : '';
            const c = balloonObj.c ? balloonObj.c['#text'] : '';

            if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}\n`;

            return text;
        } else {
            return '';
        }
    }

    function convertChallengeMissionObjToText(jsonData, accumulatedTime) {

        if (Array.isArray(jsonData.root.challenge_mission)) {
            // 배열일 경우
            const challengeMissionArray = jsonData.root.challenge_mission;
            let text = '';

            challengeMissionArray.forEach(cmObj => {
                const t = cmObj.t ? secondsToHMS(parseFloat(cmObj.t['#text']) + accumulatedTime) : '';
                const u = cmObj.u ? cmObj.u['#text'].split('(')[0] : '';
                const n = cmObj.n ? cmObj.n['#cdata-section'] : '';
                const c = cmObj.c ? cmObj.c['#text'] : '';
                const title = cmObj.title ? cmObj.title['#cdata-section'] : '';

                if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
            });

            return text;
        } else if (typeof jsonData.root.challenge_mission === 'object') {
            // 객체일 경우
            const cmObj = jsonData.root.challenge_mission;
            let text = '';

            const t = cmObj.t ? secondsToHMS(parseFloat(cmObj.t['#text']) + accumulatedTime) : '';
            const u = cmObj.u ? cmObj.u['#text'].split('(')[0] : '';
            const n = cmObj.n ? cmObj.n['#cdata-section'] : '';
            const c = cmObj.c ? cmObj.c['#text'] : '';
            const title = cmObj.title ? cmObj.title['#cdata-section'] : '';

            if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
            return text;
        } else {
            return '';
        }

    }

    function convertBattleMissionObjToText(jsonData, accumulatedTime) {

        if (Array.isArray(jsonData.root.battle_mission)) {
            // 배열일 경우
            const battleMissionArray = jsonData.root.battle_mission;
            let text = '';

            battleMissionArray.forEach(bmObj => {
                const t = bmObj.t ? secondsToHMS(parseFloat(bmObj.t['#text']) + accumulatedTime) : '';
                const u = bmObj.u ? bmObj.u['#text'].split('(')[0] : '';
                const n = bmObj.n ? bmObj.n['#cdata-section'] : '';
                const c = bmObj.c ? bmObj.c['#text'] : '';
                const title = bmObj.title ? bmObj.title['#cdata-section'] : '';

                if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
            });

            return text;
        } else if (typeof jsonData.root.battle_mission === 'object') {
            // 객체일 경우
            const bmObj = jsonData.root.battle_mission;
            let text = '';

            const t = bmObj.t ? secondsToHMS(parseFloat(bmObj.t['#text']) + accumulatedTime) : '';
            const u = bmObj.u ? bmObj.u['#text'].split('(')[0] : '';
            const n = bmObj.n ? bmObj.n['#cdata-section'] : '';
            const c = bmObj.c ? bmObj.c['#text'] : '';
            const title = bmObj.title ? bmObj.title['#cdata-section'] : '';

            if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;

            return text;
        } else {
            return '';
        }

    }

    // 변수가 정의될 때까지 시도하는 함수
    function waitForVariable() {
        return new Promise((resolve, reject) => {
            let elapsedTime = 0; // 경과 시간 변수 초기화

            const interval = setInterval(() => {
                elapsedTime += 1000; // 1초씩 경과 시간 증가

                // 변수가 정의되었는지 확인
                if (typeof vodCore !== 'undefined' && vodCore !== null) {
                    clearInterval(interval); // 변수가 정의되면 setInterval 중지
                    resolve(vodCore); // Promise를 성공 상태로 전이
                }

                // 최대 20초까지 기다린 후에도 변수가 정의되지 않으면 중단
                if (elapsedTime >= 20000) {
                    clearInterval(interval); // 지정된 시간이 경과하면 setInterval 중지
                    reject(new Error('변수가 20초 안에 선언되지 않았습니다.')); // Promise를 거부 상태로 전이
                }
            }, 1000); // 1초마다 변수 확인
        });
    }

    async function getChatLog(cmd) {
        try {
            accumulatedTextData = '';
            let accumulatedTime = 0;
            const vodCore = await waitForVariable();
            const itemsCount = vodCore.fileItems.length;
            for (const [index, item] of vodCore.fileItems.entries()) {
                const startTime = performance.now(); // 요청 시작 시간 기록
                const isLastIteration = index === itemsCount - 1; // 현재 아이템이 마지막 아이템인지 확인
                await retrieveChatDataForDuration(item.duration, item.fileInfoKey, cmd, isLastIteration, accumulatedTime);
                accumulatedTime += parseInt(item.duration);
                const endTime = performance.now(); // 요청 종료 시간 기록
                const elapsedTime = endTime - startTime; // 요청에 걸린 시간 계산

                // 만약 요청에 걸린 시간이 500ms를 초과하지 않으면 남은 시간을 기다리지 않고 다음으로 넘어갑니다.
                if (elapsedTime < 500) {
                    await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
                }

            }
            document.title = '모든 작업이 완료되었습니다.';
        } catch (error) {
            console.error('전체 프로세스 중 오류 발생:', error);
        }
    }

    GM_registerMenuCommand('전체 채팅 로그 저장', function() {
        getChatLog("getChatLog");
    });
    GM_registerMenuCommand('전체 별풍선 로그 저장', function() {
        var balloonCutoffInput = prompt('몇 개 이상의 별풍선만 기록할까요?', 1);
        if (parseInt(balloonCutoffInput) > 0){
            balloonCutoff = balloonCutoffInput;
            getChatLog("getBalloonLog");
        }
    });
    GM_registerMenuCommand('전체 도전 미션 로그 저장', function() {
        var balloonCutoffInput = prompt('몇 개 이상의 별풍선만 기록할까요?', 1);
        if (parseInt(balloonCutoffInput) > 0){
            balloonCutoff = balloonCutoffInput;
            getChatLog("getChallengeMissionLog");
        }
    });
    GM_registerMenuCommand('전체 대결 미션 로그 저장', function() {
        var balloonCutoffInput = prompt('몇 개 이상의 별풍선만 기록할까요?', 1);
        if (parseInt(balloonCutoffInput) > 0){
            balloonCutoff = balloonCutoffInput;
            getChatLog("getBattleMissionLog");
        }
    });
    GM_registerMenuCommand('특정 ID 채팅 로그 저장', function() {
        var targetUseridInput = prompt('ID를 입력하세요', '');
        if (targetUseridInput.length > 0){
            const targetUserid = targetUseridInput.split('(')[0];
            getChatLog(`getChatLogByID_${targetUserid}`);
        }
    });
    GM_registerMenuCommand('내 채팅 로그 저장', function() {
        var myidInput = vodCore.config.loginId;
        if (myidInput && myidInput.length > 0){
            const targetUserid = myidInput.split('(')[0];
            getChatLog(`getChatLogByID_${targetUserid}`);
        } else {
            alert('로그인 상태가 아닙니다.');
        }
    });
    GM_registerMenuCommand('특정 단어를 포함한 채팅 로그 저장', function() {
        var targetWordInput = prompt('단어를 입력하세요', '');
        if (targetWordInput && targetWordInput.length > 0){
            const targetWord = targetWordInput;
            getChatLog(`getChatLogByWord_${targetWord}`);
        }
    });

})();