// ==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
// ==/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
}
})();