// ==UserScript== // @name SOOP - 채널 게시글, VOD 댓글 엑셀로 추출 // @namespace https://greasyfork.org/ko/scripts/520675 // @version 20241216 // @description SOOP 채널(방송국)의 게시글이나 다시보기(VOD)에서 댓글과 답글(대댓글)을 엑셀로 추출하여 저장하는 스크립트. // @author 0hawawa // @match https://ch.sooplive.co.kr/* // @match https://vod.sooplive.co.kr/player/* // @icon https://res.sooplive.co.kr/afreeca.ico // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @grant GM_download // @require https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; const CHAPI = "https://chapi.sooplive.co.kr/api"; const HEADERS = { "User-Agent": "Mozilla/5.0" }; let commentData = []; // 방송 제목 정보 가져오기 const getTitleInfo = async (bjid, title_no) => { const url = `${CHAPI}/${bjid}/title/${title_no}`; const response = await fetch(url, { headers: HEADERS }); const data = await response.json(); return { titleName: data.title_name, userNick: data.user_nick, userId: data.user_id, likeCount: data.count.like_cnt, readCount: data.count.read_cnt, commentCount: data.count.comment_cnt }; }; // 댓글 수와 마지막 페이지 수 가져오기 const getCommentInfo = async (bjid, title_no) => { const url = `${CHAPI}/${bjid}/title/${title_no}/comment`; const response = await fetch(url, { headers: HEADERS }); const data = await response.json(); return { totalComments: data.comment_count, lastPage: data.meta.last_page }; }; // 댓글 처리 함수 const processComment = (comment, isReply = false) => { const { p_comment_no: pCommentNo = '', c_comment_no: cCommentNo = null, is_best_top: isBestTop = null, user_nick: userNick, user_id: userId, comment: commentText, like_cnt: likeCount, reg_date: time, badge = {} } = comment; const { is_manager: isManager, is_top_fan: isTopFan, is_fan: isFan, is_subscribe: isSubscribe, is_support: isSupport } = badge || {}; commentData.push({ pCommentNo: isReply ? ' └' : pCommentNo, cCommentNo, isBestTop, userNick, userId, comment: commentText, likeCount, time, isManager, isTopFan, isFan, isSubscribe, isSupport }); }; // 댓글과 대댓글 처리 const handleComments = async (jsonData, bjid, title_no) => { for (const comment of jsonData.data) { processComment(comment); if (comment.c_comment_cnt > 0) { await handleReplies(bjid, title_no, comment.p_comment_no); } } }; // 대댓글 처리 const handleReplies = async (bjid, title_no, pCommentNo) => { const url = `${CHAPI}/${bjid}/title/${title_no}/comment/${pCommentNo}/reply`; const response = await fetch(url, { headers: HEADERS }); const data = await response.json(); data.data.forEach(reply => processComment(reply, true)); }; // 댓글 데이터를 Excel 파일로 저장 const dataToExcel = async (bjid, title_no) => { try{ let progress = 0; const { lastPage } = await getCommentInfo(bjid, title_no); const { titleName } = await getTitleInfo(bjid, title_no); const excelData = []; for (let page = 1; page <= lastPage; page++) { const url = `${CHAPI}/${bjid}/title/${title_no}/comment?page=${page}`; const response = await fetch(url, { headers: HEADERS }); const jsonData = await response.json(); await handleComments(jsonData, bjid, title_no); progress = ((page / lastPage) * 100).toFixed(2); console.log(`진행률: ${progress}%`); document.title = `진행률: ${progress}% - 댓글 추출 중`; } // Excel 파일 생성 const formattedData = commentData.map((comment, index) => ({ "번호": index + 1, "댓글번호": comment.pCommentNo, "답글번호": comment.cCommentNo, "인기댓글": comment.isBestTop, "닉네임": comment.userNick, "아이디": comment.userId, "댓글": comment.comment, "좋아요": comment.likeCount, "등록시간": comment.time, "매니저": comment.isManager, "열혈": comment.isTopFan, "구독": comment.isSubscribe, "팬": comment.isFan, "서포터": comment.isSupport })); const workbook = XLSX.utils.book_new(); const worksheet = XLSX.utils.json_to_sheet(formattedData); XLSX.utils.book_append_sheet(workbook, worksheet, "댓글"); const excelFileName = `${bjid}_${titleName}_댓글.xlsx`; // Excel 파일을 브라우저에서 다운로드 const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'binary' }); const buffer = new ArrayBuffer(wbout.length); const view = new Uint8Array(buffer); for (let i = 0; i < wbout.length; i++) { view[i] = wbout.charCodeAt(i) & 0xFF; } const blob = new Blob([view], { type: "application/octet-stream" }); // FileSaver.js를 사용하여 파일 다운로드 saveAs(blob, excelFileName); console.log(progress); if(parseFloat(progress) === 100.00){ document.title = "댓글 다운로드 완료!"; alert("댓글 다운로드 완료!"); } } catch (error){ console.error("파일 저장에 실패했습니다.", error); document.title = "파일 저장 실패"; alert("파일 저장 중 오류가 발생했습니다. 다시 시도해주세요."); } }; // 메인 함수: 방송 아이디와 제목 아이디를 지정하여 데이터 다운로드 시작 async function main() { const currentUrl = window.location.href; const urlObj = new URL(currentUrl); const pathname = urlObj.pathname; let bjid = null; let title_no = null; if (pathname.startsWith('/player/')) { title_no = pathname.split('/')[2]; if(bjid === null){ bjid = callbacks(); } } else if (pathname.includes('/post/')) { bjid = pathname.split('/')[1]; title_no = pathname.split('/')[3]; } console.log('BJID:', bjid); console.log('Title No:', title_no); await dataToExcel(bjid, title_no); } GM_registerMenuCommand('Excel로 댓글 추출하기', function() { main(); }); const observer = new MutationObserver(callbacks); observer.observe(document.body, { childList: true, subtree: true }); function callbacks() { const element = document.querySelector('#player_area > div.wrapping.player_bottom > div > div:nth-child(1) > div.thumbnail_box > a'); const href = element.getAttribute('href'); const bjid = href.split('/')[3]; console.log('스트리머 ID찾는 중'); if (bjid === null || bjid === 'N/A'){ } else{ observer.disconnect(); } return bjid } })();