// ==UserScript==
// @name SOOP(숲) - 게시글/다시보기 댓글 엑셀파일로 추출
// @namespace https://greasyfork.org/ko/scripts/520675
// @version 20250302
// @description SOOP 채널의 게시글이나 다시보기에서 댓글, 답글을 추출하여 엑셀파일로 저장합니다.
// @author 0hawawa
// @match https://vod.sooplive.co.kr/player/*
// @include https://ch.sooplive.co.kr/*/post/*
// @icon https://res.sooplive.co.kr/afreeca.ico
// @grant GM_registerMenuCommand
// @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
// ==/UserScript==
(function() {
'use strict';
const CHAPI = String(atob("aHR0cHM6Ly9jaGFwaS5zb29wbGl2ZS5jby5rci9hcGk="));
// 모든 댓글 저장하는 리스트
let commentData = [];
// 게시글 정보
async function getTitleName(streamerId, title_no){
try {
const r = await fetch( `${CHAPI}/${streamerId}/title/${title_no}` );
const d = await r.json();
return d.title_name;
} catch(e){
console.log(e);
alert(e);
}
}
// 댓글 수, 마지막 페이지 수
async function getCommentInfo(streamerId, title_no){
try {
const r = await fetch( `${CHAPI}/${streamerId}/title/${title_no}/comment` );
const d = await r.json();
return d.meta.last_page
} catch (e){
console.log(e);
alert(e);
}
}
// 댓글 처리
async function processComment(comment, isReply = false){
const {
p_comment_no: pComntNo,
c_comment_no: cComntNo,
is_best_top: isBestTop,
user_nick: userNick,
user_id: userId,
comment: comntTxt,
like_cnt: likeCnt,
reg_date: time,
} = comment;
const {
is_manager: isManager,
is_top_fan: isTopFan,
is_fan: isFan,
is_subscribe: isSubs,
is_support: isSupp
} = comment.badge || {};
commentData.push({
pComntNo: isReply ? ' └' : pComntNo,
cComntNo,
isBestTop: isBestTop === true ? '✔️' : '',
userNick,
userId,
comntTxt,
likeCnt,
time,
isManager: isManager === 1 ? '✔️' : '',
isTopFan: isTopFan === 1 ? '✔️' : '',
isFan: isFan === 1 ? '✔️' : '',
isSubs: isSubs === 1 ? '✔️' : '',
isSupp: isSupp === 1 ? '✔️' : ''
});
}
// 답글처리
async function handleReplies(id, title_no, pCommentNo){
try{
await fetch( `${CHAPI}/${id}/title/${title_no}/comment/${pCommentNo}/reply` )
.then(r => r.json())
.then(d => d.data.forEach( reply => processComment(reply, true) ))
} catch(e){
console.log(e);
alert(e);
}
}
// 댓글정리
async function handleComments(d, id, title_no){
for (let comment of d.data){
await processComment(comment);
if (comment.c_comment_cnt > 0){
await handleReplies(id, title_no, comment.p_comment_no);
}
}
}
async function dataToExcel(id, title_no){
try{
let progress = 0;
const titleName = await getTitleName(id, title_no);
const lastPage = await getCommentInfo(id, title_no);
for (let page = 1; page <= lastPage; page++) {
try{
await fetch(`${CHAPI}/${id}/title/${title_no}/comment?page=${page}`)
.then(r => r.json())
.then(d => handleComments(d, id, title_no))
progress = ((page / lastPage) * 100).toFixed(2);
console.log(`진행률: ${progress}%`);
document.title = `진행률: ${progress}% - 댓글 추출 중`;
} catch(e) {
console.log(e);
alert(e);
}
}
const formattedData = commentData.map((comment, index) => ({
"순번": index + 1,
"댓글번호": comment.pComntNo,
"답글번호": comment.cComntNo,
"인기댓글": comment.isBestTop,
"닉네임": comment.userNick,
"아이디": comment.userId,
"댓글": comment.comntTxt,
"좋아요": comment.likeCnt,
"등록시간": comment.time,
"매니저": comment.isManager,
"열혈팬": comment.isTopFan,
"정기구독": comment.isSubs,
"팬가입": comment.isFan,
"서포터": comment.isSupp
}));
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(formattedData);
XLSX.utils.book_append_sheet(workbook, worksheet, "댓글");
const excelFileName = `${id}_${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);
if(parseFloat(progress) === 100.00){
document.title = "댓글 다운로드 완료!";
alert("댓글 다운로드 완료!");
}
} catch (e){
console.error("파일 저장에 실패했습니다.", e);
document.title = "파일 저장 실패";
alert("파일 저장 중 오류가 발생했습니다. 다시 시도해주세요.");
}
}
function find_streamer_ID() {
const element = document.querySelector('#player_area > div.wrapping.player_bottom > div > div:nth-child(1) > div.thumbnail_box > a');
const href = element.getAttribute('href');
streamerId = href.split('/')[3];
console.log('[스트리머 ID찾는 중 ...]');
if (streamerId === null || streamerId === 'N/A'){}
else{
observer.disconnect();
console.log(`[DOM감지 종료!!] 스트리머 ID: ${streamerId}`);
return streamerId;
}
}
const currentUrl = new URL(window.location.href);
const pathname = currentUrl.pathname;
let streamerId = null;
let title_no = null;
const observer = new MutationObserver(find_streamer_ID);
if(pathname.startsWith('/player/')){
title_no = pathname.split('/')[2];
observer.observe(document.body, { childList: true, subtree: true });
} else if (pathname.includes('/post/')){
streamerId = pathname.split('/')[1];
title_no = pathname.split('/')[3];
}
async function main(){
if(streamerId === null){
streamerId = find_streamer_ID();
}
console.log(`[스트리머 ID: ${streamerId}]\n[타이틀 번호: ${title_no}]`);
commentData = [];
await dataToExcel(streamerId, title_no);
}
GM_registerMenuCommand('Excel로 댓글 추출하기', function() {
main();
});
})();