SOOP (숲) - 다시보기 채팅창 부검기

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

  1. // ==UserScript==
  2. // @name SOOP (숲) - 다시보기 채팅창 부검기
  3. // @name:ko SOOP (숲) - 다시보기 채팅창 부검기
  4. // @namespace https://gf.qytechs.cn/ko/scripts/488057
  5. // @version 20241015
  6. // @description VOD 채팅창에서 채팅, 별풍선, 대결미션, 도전미션 로그를 다운로드
  7. // @description:ko VOD 채팅창에서 채팅, 별풍선, 대결미션, 도전미션 로그를 다운로드
  8. // @author You
  9. // @match https://vod.sooplive.co.kr/player/*
  10. // @icon https://res.sooplive.co.kr/afreeca.ico
  11. // @run-at document-end
  12. // @license MIT
  13. // @grant GM_registerMenuCommand
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. let accumulatedTextData = '';
  20. let balloonCutoff = 1;
  21.  
  22. function secondsToHMS(seconds) {
  23. if(seconds < 0){
  24. return `[00:00:00]`;
  25. }
  26. seconds = Math.floor(seconds);
  27.  
  28. const hours = Math.floor(seconds / 3600);
  29. const minutes = Math.floor((seconds % 3600) / 60);
  30. const remainingSeconds = seconds % 60;
  31.  
  32. const formattedHours = String(hours).padStart(2, '0');
  33. const formattedMinutes = String(minutes).padStart(2, '0');
  34. const formattedSeconds = String(remainingSeconds).padStart(2, '0');
  35.  
  36. return `[${formattedHours}:${formattedMinutes}:${formattedSeconds}]`;
  37. }
  38.  
  39. // XML을 JSON으로 변환하는 함수
  40. function xmlToJson(xml) {
  41. var obj = {};
  42.  
  43. if (xml.nodeType === 1) {
  44. // element 노드인 경우
  45. if (xml.attributes.length > 0) {
  46. obj["@attributes"] = {};
  47. for (var j = 0; j < xml.attributes.length; j++) {
  48. var attribute = xml.attributes.item(j);
  49. obj["@attributes"][attribute.nodeName] = attribute.nodeValue;
  50. }
  51. }
  52. } else if (xml.nodeType === 3) {
  53. // text 노드인 경우
  54. obj = xml.nodeValue;
  55. } else if (xml.nodeType === 4) {
  56. // CDATA 노드인 경우
  57. obj = xml.nodeValue;
  58. }
  59.  
  60. // CDATA 노드 처리
  61. if (xml.nodeType === 4) {
  62. obj = xml.nodeValue;
  63. }
  64.  
  65. // 하위 노드가 있는 경우
  66. if (xml.hasChildNodes()) {
  67. for (var i = 0; i < xml.childNodes.length; i++) {
  68. var item = xml.childNodes.item(i);
  69. var nodeName = item.nodeName;
  70. if (typeof(obj[nodeName]) === "undefined") {
  71. obj[nodeName] = xmlToJson(item);
  72. } else {
  73. if (typeof(obj[nodeName].push) === "undefined") {
  74. var old = obj[nodeName];
  75. obj[nodeName] = [];
  76. obj[nodeName].push(old);
  77. }
  78. obj[nodeName].push(xmlToJson(item));
  79. }
  80. }
  81. }
  82. return obj;
  83. }
  84.  
  85. function removeTextAfterRoot(jsonData) {
  86. if (!jsonData || typeof jsonData !== 'object') {
  87. return jsonData;
  88. }
  89.  
  90. const rootKeys = Object.keys(jsonData);
  91.  
  92. // "root" 다음에 바로 오는 "#text"를 제거합니다.
  93. if (rootKeys.length === 1 && rootKeys[0] === 'root') {
  94. const rootObj = jsonData.root;
  95.  
  96. // 만약 "root" 객체 안에 "#text"가 있다면 제거합니다.
  97. if (rootObj && Array.isArray(rootObj['#text'])) {
  98. delete rootObj['#text'];
  99. }
  100. }
  101.  
  102. return jsonData;
  103. }
  104.  
  105. async function fetchChatData(url) {
  106. try {
  107. const response = await fetch(url, {
  108. cache: "force-cache" // 항상 캐시를 사용하도록 설정
  109. });
  110. const data = await response.text();
  111. const parser = new DOMParser();
  112. const xmlDoc = parser.parseFromString(data, "text/xml");
  113. const jsonData = xmlToJson(xmlDoc);
  114. const modifiedJsonData = removeTextAfterRoot(jsonData);
  115. return modifiedJsonData;
  116. } catch (error) {
  117. console.error('데이터를 불러오는 중 오류가 발생했습니다:', error);
  118. throw error;
  119. }
  120. }
  121.  
  122. async function retrieveAndLogChatData(url, startTime, cmd, accumulatedTime) {
  123. try {
  124. const chatData = await fetchChatData(`${url}&startTime=${startTime}`);
  125. let textData = '';
  126.  
  127. switch (true) {
  128. case (cmd === "getChatLog"):
  129. textData = convertChatObjToText(chatData, accumulatedTime, '', '');
  130. break;
  131. case (cmd === "getBalloonLog"):
  132. textData = convertBalloonObjToText(chatData, accumulatedTime);
  133. break;
  134. case (cmd === "getChallengeMissionLog"):
  135. textData = convertChallengeMissionObjToText(chatData, accumulatedTime);
  136. break;
  137. case (cmd === "getBattleMissionLog"):
  138. textData = convertBattleMissionObjToText(chatData, accumulatedTime);
  139. break;
  140. case cmd.includes("getChatLogByID"):
  141. textData = convertChatObjToText(chatData, accumulatedTime, cmd.split('getChatLogByID_')[1], '');
  142. break;
  143. case cmd.includes("getChatLogByWord"):
  144. textData = convertChatObjToText(chatData, accumulatedTime, '', cmd.split('getChatLogByWord_')[1]);
  145. break;
  146. default:
  147. console.error('잘못된 명령입니다:', cmd);
  148. return;
  149. }
  150.  
  151. if (textData) {
  152. accumulatedTextData += textData; // 텍스트 데이터를 누적
  153. }
  154. } catch (error) {
  155. console.error('채팅 데이터를 가져오는 중 오류가 발생했습니다:', error);
  156. }
  157. }
  158.  
  159. function generateFileName(bjid, videoid, cmd) {
  160. let fileType = "";
  161. switch (true) {
  162. case (cmd === "getChatLog"):
  163. fileType = "채팅_전체";
  164. break;
  165. case (cmd === "getBalloonLog"):
  166. fileType = `별풍선_전체_${balloonCutoff}개이상`;
  167. break;
  168. case (cmd === "getChallengeMissionLog"):
  169. fileType = `도전미션_전체_${balloonCutoff}개이상`;
  170. break;
  171. case (cmd === "getBattleMissionLog"):
  172. fileType = `배틀미션_전체_${balloonCutoff}개이상`;
  173. break;
  174. case cmd.includes("getChatLogByID"):
  175. fileType = `채팅_${cmd.split('getChatLogByID_')[1]}`;
  176. break;
  177. case cmd.includes("getChatLogByWord"):
  178. fileType = `채팅_단어_${cmd.split('getChatLogByWord_')[1]}`;
  179. break;
  180. }
  181. return `${bjid}_${videoid}_${fileType}.txt`;
  182. }
  183.  
  184. async function retrieveChatDataForDuration(duration, fileInfoKey, cmd, isLastIteration, accumulatedTime) {
  185. const url = fileInfoKey.indexOf("clip_") !== -1 ?
  186. `https://vod-normal-kr-cdn-z01.sooplive.co.kr/${fileInfoKey.split("_").join("/")}_c.xml?type=clip&rowKey=${fileInfoKey}_c` :
  187. `https://videoimg.sooplive.co.kr/php/ChatLoadSplit.php?rowKey=${fileInfoKey}_c`;
  188. const bjid = vodCore.config.copyright.user_id || vodCore.config.bjId;
  189. const filename = generateFileName(bjid, vodCore.config.titleNo, cmd);
  190. const intervalDuration = 300; // 300초마다 채팅 데이터 가져오기
  191. let currentSeconds = 0;
  192.  
  193. while (currentSeconds <= duration) {
  194. document.title = `채팅 데이터를 받는 중... ${parseInt((currentSeconds+accumulatedTime)/vodCore.config.totalFileDuration*100)}%`;
  195. await retrieveAndLogChatData(url, currentSeconds, cmd, accumulatedTime);
  196. currentSeconds += intervalDuration;
  197.  
  198. if (currentSeconds > duration && isLastIteration) {
  199. // 마지막 반복이면서 현재 시간이 지속 시간을 초과하면 저장
  200. if(accumulatedTextData.length > 0) {
  201. saveTextToFile(accumulatedTextData, filename)
  202. } else {
  203. alert('저장할 데이터가 없습니다.');
  204. }
  205. }
  206. }
  207. }
  208.  
  209. async function saveTextToFile(textData, fileName) {
  210. const blob = new Blob([textData], { type: 'text/plain' });
  211. const blobUrl = URL.createObjectURL(blob);
  212. const link = document.createElement('a');
  213. link.href = blobUrl;
  214. link.download = fileName;
  215. link.click();
  216. URL.revokeObjectURL(blobUrl);
  217. }
  218.  
  219. function convertChatObjToText(jsonData, accumulatedTime, targetid, targetword) {
  220.  
  221. if (Array.isArray(jsonData.root.chat)) {
  222. // 배열일 경우
  223.  
  224. const chatArray = jsonData.root.chat;
  225. let text = '';
  226.  
  227. chatArray.forEach(chatObj => {
  228. const t = chatObj.t ? secondsToHMS(parseFloat(chatObj.t['#text']) + accumulatedTime) : '';
  229. const u = chatObj.u ? chatObj.u['#text'].split('(')[0] : '';
  230. const n = chatObj.n ? chatObj.n['#cdata-section'] : '';
  231. const m = chatObj.m ? chatObj.m['#cdata-section'] : '';
  232.  
  233. if(targetid.length > 0){
  234. if(targetid === u) text += `${t} ${n}(${u}): ${m}\n`;
  235. } else if(targetword.length > 0){
  236. if(m.includes(targetword)) text += `${t} ${n}(${u}): ${m}\n`;
  237. } else {
  238. text += `${t} ${n}(${u}): ${m}\n`;
  239. }
  240.  
  241. });
  242.  
  243. return text;
  244. } else if (typeof jsonData.root.chat === 'object') {
  245. // 객체일 경우
  246.  
  247. const chatObj = jsonData.root.chat;
  248. let text = '';
  249.  
  250. const t = chatObj.t ? secondsToHMS(parseFloat(chatObj.t['#text']) + accumulatedTime) : '';
  251. const u = chatObj.u ? chatObj.u['#text'].split('(')[0] : '';
  252. const n = chatObj.n ? chatObj.n['#cdata-section'] : '';
  253. const m = chatObj.m ? chatObj.m['#cdata-section'] : '';
  254.  
  255. if(targetid.length > 0){
  256. if(targetid === u) text += `${t} ${n}(${u}): ${m}\n`;
  257. } else if(targetword.length > 0){
  258. if(m.includes(targetword)) text += `${t} ${n}(${u}): ${m}\n`;
  259. } else {
  260. text += `${t} ${n}(${u}): ${m}\n`;
  261. }
  262.  
  263. return text;
  264. } else {
  265. return '';
  266. }
  267. }
  268.  
  269. function convertBalloonObjToText(jsonData, accumulatedTime) {
  270.  
  271. if (Array.isArray(jsonData.root.balloon)) {
  272. // 배열일 경우
  273. const balloonArray = jsonData.root.balloon;
  274. let text = '';
  275.  
  276. balloonArray.forEach(balloonObj => {
  277. const t = balloonObj.t ? secondsToHMS(parseFloat(balloonObj.t['#text']) + accumulatedTime) : '';
  278. const u = balloonObj.u ? balloonObj.u['#text'].split('(')[0] : '';
  279. const n = balloonObj.n ? balloonObj.n['#cdata-section'] : '';
  280. const c = balloonObj.c ? balloonObj.c['#text'] : '';
  281.  
  282. if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}\n`;
  283. });
  284.  
  285. return text;
  286. } else if (typeof jsonData.root.balloon === 'object') {
  287. // 객체일 경우
  288. const balloonObj = jsonData.root.balloon;
  289. let text = '';
  290.  
  291. const t = balloonObj.t ? secondsToHMS(parseFloat(balloonObj.t['#text']) + accumulatedTime) : '';
  292. const u = balloonObj.u ? balloonObj.u['#text'].split('(')[0] : '';
  293. const n = balloonObj.n ? balloonObj.n['#cdata-section'] : '';
  294. const c = balloonObj.c ? balloonObj.c['#text'] : '';
  295.  
  296. if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}\n`;
  297.  
  298. return text;
  299. } else {
  300. return '';
  301. }
  302. }
  303.  
  304. function convertChallengeMissionObjToText(jsonData, accumulatedTime) {
  305.  
  306. if (Array.isArray(jsonData.root.challenge_mission)) {
  307. // 배열일 경우
  308. const challengeMissionArray = jsonData.root.challenge_mission;
  309. let text = '';
  310.  
  311. challengeMissionArray.forEach(cmObj => {
  312. const t = cmObj.t ? secondsToHMS(parseFloat(cmObj.t['#text']) + accumulatedTime) : '';
  313. const u = cmObj.u ? cmObj.u['#text'].split('(')[0] : '';
  314. const n = cmObj.n ? cmObj.n['#cdata-section'] : '';
  315. const c = cmObj.c ? cmObj.c['#text'] : '';
  316. const title = cmObj.title ? cmObj.title['#cdata-section'] : '';
  317.  
  318. if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
  319. });
  320.  
  321. return text;
  322. } else if (typeof jsonData.root.challenge_mission === 'object') {
  323. // 객체일 경우
  324. const cmObj = jsonData.root.challenge_mission;
  325. let text = '';
  326.  
  327. const t = cmObj.t ? secondsToHMS(parseFloat(cmObj.t['#text']) + accumulatedTime) : '';
  328. const u = cmObj.u ? cmObj.u['#text'].split('(')[0] : '';
  329. const n = cmObj.n ? cmObj.n['#cdata-section'] : '';
  330. const c = cmObj.c ? cmObj.c['#text'] : '';
  331. const title = cmObj.title ? cmObj.title['#cdata-section'] : '';
  332.  
  333. if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
  334. return text;
  335. } else {
  336. return '';
  337. }
  338.  
  339. }
  340.  
  341. function convertBattleMissionObjToText(jsonData, accumulatedTime) {
  342.  
  343. if (Array.isArray(jsonData.root.battle_mission)) {
  344. // 배열일 경우
  345. const battleMissionArray = jsonData.root.battle_mission;
  346. let text = '';
  347.  
  348. battleMissionArray.forEach(bmObj => {
  349. const t = bmObj.t ? secondsToHMS(parseFloat(bmObj.t['#text']) + accumulatedTime) : '';
  350. const u = bmObj.u ? bmObj.u['#text'].split('(')[0] : '';
  351. const n = bmObj.n ? bmObj.n['#cdata-section'] : '';
  352. const c = bmObj.c ? bmObj.c['#text'] : '';
  353. const title = bmObj.title ? bmObj.title['#cdata-section'] : '';
  354.  
  355. if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
  356. });
  357.  
  358. return text;
  359. } else if (typeof jsonData.root.battle_mission === 'object') {
  360. // 객체일 경우
  361. const bmObj = jsonData.root.battle_mission;
  362. let text = '';
  363.  
  364. const t = bmObj.t ? secondsToHMS(parseFloat(bmObj.t['#text']) + accumulatedTime) : '';
  365. const u = bmObj.u ? bmObj.u['#text'].split('(')[0] : '';
  366. const n = bmObj.n ? bmObj.n['#cdata-section'] : '';
  367. const c = bmObj.c ? bmObj.c['#text'] : '';
  368. const title = bmObj.title ? bmObj.title['#cdata-section'] : '';
  369.  
  370. if(balloonCutoff <= parseInt(c)) text += `${t} ${n}(${u}): ${c}, ${title}\n`;
  371.  
  372. return text;
  373. } else {
  374. return '';
  375. }
  376.  
  377. }
  378.  
  379. // 변수가 정의될 때까지 시도하는 함수
  380. function waitForVariable() {
  381. return new Promise((resolve, reject) => {
  382. let elapsedTime = 0; // 경과 시간 변수 초기화
  383.  
  384. const interval = setInterval(() => {
  385. elapsedTime += 1000; // 1초씩 경과 시간 증가
  386.  
  387. // 변수가 정의되었는지 확인
  388. if (typeof vodCore !== 'undefined' && vodCore !== null) {
  389. clearInterval(interval); // 변수가 정의되면 setInterval 중지
  390. resolve(vodCore); // Promise를 성공 상태로 전이
  391. }
  392.  
  393. // 최대 20초까지 기다린 후에도 변수가 정의되지 않으면 중단
  394. if (elapsedTime >= 20000) {
  395. clearInterval(interval); // 지정된 시간이 경과하면 setInterval 중지
  396. reject(new Error('변수가 20초 안에 선언되지 않았습니다.')); // Promise를 거부 상태로 전이
  397. }
  398. }, 1000); // 1초마다 변수 확인
  399. });
  400. }
  401.  
  402. async function getChatLog(cmd) {
  403. try {
  404. accumulatedTextData = '';
  405. let accumulatedTime = 0;
  406. const vodCore = await waitForVariable();
  407. const itemsCount = vodCore.fileItems.length;
  408. for (const [index, item] of vodCore.fileItems.entries()) {
  409. const startTime = performance.now(); // 요청 시작 시간 기록
  410. const isLastIteration = index === itemsCount - 1; // 현재 아이템이 마지막 아이템인지 확인
  411. await retrieveChatDataForDuration(item.duration, item.fileInfoKey, cmd, isLastIteration, accumulatedTime);
  412. accumulatedTime += parseInt(item.duration);
  413. const endTime = performance.now(); // 요청 종료 시간 기록
  414. const elapsedTime = endTime - startTime; // 요청에 걸린 시간 계산
  415.  
  416. // 만약 요청에 걸린 시간이 500ms를 초과하지 않으면 남은 시간을 기다리지 않고 다음으로 넘어갑니다.
  417. if (elapsedTime < 500) {
  418. await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
  419. }
  420.  
  421. }
  422. document.title = '모든 작업이 완료되었습니다.';
  423. } catch (error) {
  424. console.error('전체 프로세스 중 오류 발생:', error);
  425. }
  426. }
  427.  
  428. GM_registerMenuCommand('전체 채팅 로그 저장', function() {
  429. getChatLog("getChatLog");
  430. });
  431. GM_registerMenuCommand('전체 별풍선 로그 저장', function() {
  432. var balloonCutoffInput = prompt('몇 개 이상의 별풍선만 기록할까요?', 1);
  433. if (parseInt(balloonCutoffInput) > 0){
  434. balloonCutoff = balloonCutoffInput;
  435. getChatLog("getBalloonLog");
  436. }
  437. });
  438. GM_registerMenuCommand('전체 도전 미션 로그 저장', function() {
  439. var balloonCutoffInput = prompt('몇 개 이상의 별풍선만 기록할까요?', 1);
  440. if (parseInt(balloonCutoffInput) > 0){
  441. balloonCutoff = balloonCutoffInput;
  442. getChatLog("getChallengeMissionLog");
  443. }
  444. });
  445. GM_registerMenuCommand('전체 대결 미션 로그 저장', function() {
  446. var balloonCutoffInput = prompt('몇 개 이상의 별풍선만 기록할까요?', 1);
  447. if (parseInt(balloonCutoffInput) > 0){
  448. balloonCutoff = balloonCutoffInput;
  449. getChatLog("getBattleMissionLog");
  450. }
  451. });
  452. GM_registerMenuCommand('특정 ID 채팅 로그 저장', function() {
  453. var targetUseridInput = prompt('ID를 입력하세요', '');
  454. if (targetUseridInput.length > 0){
  455. const targetUserid = targetUseridInput.split('(')[0];
  456. getChatLog(`getChatLogByID_${targetUserid}`);
  457. }
  458. });
  459. GM_registerMenuCommand('내 채팅 로그 저장', function() {
  460. var myidInput = vodCore.config.loginId;
  461. if (myidInput && myidInput.length > 0){
  462. const targetUserid = myidInput.split('(')[0];
  463. getChatLog(`getChatLogByID_${targetUserid}`);
  464. } else {
  465. alert('로그인 상태가 아닙니다.');
  466. }
  467. });
  468. GM_registerMenuCommand('특정 단어를 포함한 채팅 로그 저장', function() {
  469. var targetWordInput = prompt('단어를 입력하세요', '');
  470. if (targetWordInput && targetWordInput.length > 0){
  471. const targetWord = targetWordInput;
  472. getChatLog(`getChatLogByWord_${targetWord}`);
  473. }
  474. });
  475.  
  476. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址