Greasy Fork is available in English.
VOD 채팅창에서 특정 단어를 포함한 채팅 로그만 다운로드
// ==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();
}
})();