Greasy Fork

Greasy Fork is available in English.

SOOP (숲) - 특정 단어 채팅 로그 저장

VOD 채팅창에서 특정 단어를 포함한 채팅 로그만 다운로드

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         SOOP (숲) - 특정 단어 채팅 로그 저장
// @name:ko      SOOP (숲) - 특정 단어 채팅 로그 저장
// @namespace    http://greasyfork.icu/ko/scripts/488057
// @version      20260325
// @description  VOD 채팅창에서 특정 단어를 포함한 채팅 로그만 다운로드
// @description:ko  VOD 채팅창에서 특정 단어를 포함한 채팅 로그만 다운로드
// @author       따르개조 + 수정
// @match        https://vod.sooplive.com/player/*
// @icon         https://res.sooplive.co.kr/afreeca.ico
// @run-at       document-end
// @license      MIT
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    let accumulatedTextData = '';
    let isRunning = false;

    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;

        return `[${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}]`;
    }

    function xmlToJson(xml) {
        let obj = {};

        if (xml.nodeType === 1) {
            if (xml.attributes.length > 0) {
                obj['@attributes'] = {};
                for (let j = 0; j < xml.attributes.length; j++) {
                    const attribute = xml.attributes.item(j);
                    obj['@attributes'][attribute.nodeName] = attribute.nodeValue;
                }
            }
        } else if (xml.nodeType === 3 || xml.nodeType === 4) {
            obj = xml.nodeValue;
        }

        if (xml.hasChildNodes()) {
            for (let i = 0; i < xml.childNodes.length; i++) {
                const item = xml.childNodes.item(i);
                const nodeName = item.nodeName;

                if (typeof obj[nodeName] === 'undefined') {
                    obj[nodeName] = xmlToJson(item);
                } else {
                    if (typeof obj[nodeName].push === 'undefined') {
                        const 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);
        if (rootKeys.length === 1 && rootKeys[0] === 'root') {
            const rootObj = jsonData.root;
            if (rootObj && Array.isArray(rootObj['#text'])) {
                delete rootObj['#text'];
            }
        }

        return jsonData;
    }

    async function fetchChatData(url) {
        const response = await fetch(url, { cache: 'force-cache' });
        const data = await response.text();
        const parser = new DOMParser();
        const xmlDoc = parser.parseFromString(data, 'text/xml');
        return removeTextAfterRoot(xmlToJson(xmlDoc));
    }

    function convertChatObjToText(jsonData, accumulatedTime, targetWord) {
        if (!jsonData || !jsonData.root || !jsonData.root.chat) {
            return '';
        }

        const chatArray = Array.isArray(jsonData.root.chat)
            ? jsonData.root.chat
            : [jsonData.root.chat];

        let text = '';

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

            if (m.includes(targetWord)) {
                text += `${t} ${n}(${u}): ${m}\n`;
            }
        });

        return text;
    }

    async function retrieveAndLogChatData(url, startTime, accumulatedTime, targetWord) {
        try {
            const separator = url.includes('?') ? '&' : '?';
            const chatData = await fetchChatData(`${url}${separator}startTime=${startTime}`);
            const textData = convertChatObjToText(chatData, accumulatedTime, targetWord);

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

    function generateFileName(bjid, videoid, targetWord) {
        const safeWord = String(targetWord).replace(/[\\/:*?"<>|]/g, '_');
        return `${bjid}_${videoid}_채팅_단어_${safeWord}.txt`;
    }

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

        link.href = blobUrl;
        link.download = fileName;
        document.body.appendChild(link);
        link.click();
        link.remove();

        setTimeout(() => {
            URL.revokeObjectURL(blobUrl);
        }, 1000);
    }

    async function retrieveChatDataForDuration(duration, fileInfoKey, isLastIteration, accumulatedTime, targetWord, vodCoreRef) {
        const url = fileInfoKey.indexOf('clip_') !== -1
            ? `https://vod-normal-kr-cdn-z01.sooplive.co.kr/${fileInfoKey.split('_').join('/')}_c.xml?type=clip&rowKey=${fileInfoKey}_c`
            : `https://videoimg.sooplive.co.kr/php/ChatLoadSplit.php?rowKey=${fileInfoKey}_c`;

        const bjid = vodCoreRef.config.copyright.user_id || vodCoreRef.config.bjId;
        const filename = generateFileName(bjid, vodCoreRef.config.titleNo, targetWord);
        const intervalDuration = 300;
        let currentSeconds = 0;

        while (currentSeconds <= duration) {
            const progress = parseInt(((currentSeconds + accumulatedTime) / vodCoreRef.config.totalFileDuration) * 100, 10);
            document.title = `채팅 데이터를 받는 중... ${Math.min(progress, 100)}%`;

            await retrieveAndLogChatData(url, currentSeconds, accumulatedTime, targetWord);
            currentSeconds += intervalDuration;

            if (currentSeconds > duration && isLastIteration) {
                if (accumulatedTextData.length > 0) {
                    await saveTextToFile(accumulatedTextData, filename);
                } else {
                    alert('저장할 데이터가 없습니다.');
                }
            }
        }
    }

    function waitForVariable() {
        return new Promise((resolve, reject) => {
            let elapsedTime = 0;

            const interval = setInterval(() => {
                elapsedTime += 1000;

                if (typeof vodCore !== 'undefined' && vodCore !== null) {
                    clearInterval(interval);
                    resolve(vodCore);
                }

                if (elapsedTime >= 20000) {
                    clearInterval(interval);
                    reject(new Error('vodCore 변수가 20초 안에 선언되지 않았습니다.'));
                }
            }, 1000);
        });
    }

    async function getChatLogByWord(targetWord) {
        if (isRunning) {
            alert('이미 작업이 진행 중입니다.');
            return;
        }

        try {
            isRunning = true;
            accumulatedTextData = '';

            const trimmedWord = String(targetWord || '').trim();
            if (!trimmedWord) {
                alert('단어를 입력하세요.');
                return;
            }

            const vodCoreRef = await waitForVariable();
            let accumulatedTime = 0;
            const itemsCount = vodCoreRef.fileItems.length;

            for (const [index, item] of vodCoreRef.fileItems.entries()) {
                const startTime = performance.now();
                const isLastIteration = index === itemsCount - 1;

                await retrieveChatDataForDuration(
                    item.duration,
                    item.fileInfoKey,
                    isLastIteration,
                    accumulatedTime,
                    trimmedWord,
                    vodCoreRef
                );

                accumulatedTime += parseInt(item.duration, 10);

                const endTime = performance.now();
                const elapsedTime = endTime - startTime;

                if (elapsedTime < 500) {
                    await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
                }
            }

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

    function promptAndRunWordSearch() {
        const targetWordInput = prompt('저장할 채팅에 포함될 단어를 입력하세요', '');
        if (targetWordInput && targetWordInput.trim().length > 0) {
            getChatLogByWord(targetWordInput.trim());
        }
    }

    function createFloatingButton() {
        if (document.getElementById('soop-word-chat-save-button')) return;

        const button = document.createElement('button');
        button.id = 'soop-word-chat-save-button';
        button.textContent = '단어 채팅 저장';
        button.title = '특정 단어가 포함된 채팅 로그 저장';

        Object.assign(button.style, {
            position: 'fixed',
            right: '20px',
            bottom: '20px',
            zIndex: '999999',
            padding: '12px 16px',
            border: 'none',
            borderRadius: '12px',
            background: '#1f6feb',
            color: '#ffffff',
            fontSize: '14px',
            fontWeight: '700',
            lineHeight: '1',
            cursor: 'pointer',
            boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)',
            opacity: '0.95'
        });

        button.addEventListener('mouseenter', function () {
            button.style.opacity = '1';
            button.style.transform = 'translateY(-1px)';
        });

        button.addEventListener('mouseleave', function () {
            button.style.opacity = '0.95';
            button.style.transform = 'translateY(0)';
        });

        button.addEventListener('click', function () {
            promptAndRunWordSearch();
        });

        document.body.appendChild(button);
    }

    function ensureFloatingButton() {
        createFloatingButton();

        const observer = new MutationObserver(() => {
            if (!document.getElementById('soop-word-chat-save-button')) {
                createFloatingButton();
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    GM_registerMenuCommand('특정 단어를 포함한 채팅 로그 저장', promptAndRunWordSearch);

    if (document.readyState === 'loading') {
        window.addEventListener('DOMContentLoaded', ensureFloatingButton);
    } else {
        ensureFloatingButton();
    }
})();