// ==UserScript== // @name SOOP (숲) - 특정 단어 채팅 로그 저장 // @name:ko SOOP (숲) - 특정 단어 채팅 로그 저장 // @namespace https://greasyfork.org/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 // @downloadURL https://update.greasyfork.icu/scripts/571068/SOOP%20%28%EC%88%B2%29%20-%20%ED%8A%B9%EC%A0%95%20%EB%8B%A8%EC%96%B4%20%EC%B1%84%ED%8C%85%20%EB%A1%9C%EA%B7%B8%20%EC%A0%80%EC%9E%A5.user.js // @updateURL https://update.greasyfork.icu/scripts/571068/SOOP%20%28%EC%88%B2%29%20-%20%ED%8A%B9%EC%A0%95%20%EB%8B%A8%EC%96%B4%20%EC%B1%84%ED%8C%85%20%EB%A1%9C%EA%B7%B8%20%EC%A0%80%EC%9E%A5.meta.js // ==/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(); } })();