// ==UserScript==
// @name SOOP(숲) - 게시글/다시보기 댓글 엑셀파일로 추출
// @namespace https://greasyfork.org/ko/scripts/520675
// @version 20250305
// @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/exceljs/4.2.1/exceljs.min.js
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const CHAPI = String(atob("aHR0cHM6Ly9jaGFwaS5zb29wbGl2ZS5jby5rci9hcGk="));
// 모든 댓글 저장하는 리스트
let commentData = [];
let isVOD = false;
let index = 1;
// 게시글 정보
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(c, id, isReply = false){
commentData.push({
순번: index++,
번호: isReply === false ? c.p_comment_no : c.c_comment_no,
종류: isReply === false ? "💬" : "⤷",
유형: c.is_pin === true ? '📌(고정)' : c.is_best_top === true ? '💎(인기)' : '',
"스트리머👍": isReply === true ? "" : c.bjlike === null ? "" : "👍",
닉네임: c.user_nick,
아이디: c.user_id,
댓글내용: c.comment,
좋아요: c.like_cnt,
등록시간: c.reg_date,
매니저: c.badge?.is_manager === 1 ? '✔️' : '',
열혈팬: c.badge?.is_top_fan === 1 ? '✔️' : '',
팬클럽: c.badge?.is_fan === 1 ? '✔️' : '',
정기구독: c.badge?.is_subscribe === 1 ? '✔️' : '',
서포터: c.badge?.is_support === 1 ? '✔️' : '',
"🔗링크": isVOD === false ? `https://ch.sooplive.co.kr/${id}/post/${title_no}#comment_noti${isReply === false ? c.p_comment_no : c.c_comment_no}` : `https://vod.sooplive.co.kr/player/${title_no}?referer=noti&comment_no=${isReply === false ? c.p_comment_no : c.c_comment_no}¬i_type=parent`
});
}
// 답글처리
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, id, true) ))
} catch(e){
console.log(e);
alert(e);
}
}
// 댓글정리
async function handleComments(d, id, title_no){
for (let comment of d.data){
await processComment(comment, id);
if (comment.c_comment_cnt > 0){
await handleReplies(id, title_no, comment.p_comment_no);
}
}
}
async function sheetStyle(ws) {
ws.getColumn(2).hidden = true;
ws.columns.forEach((col, colNum) => {
if( colNum === 15 ) return; // 16번째 (0~15)
let maxLen = 0;
col.eachCell({ includeEmpty: true }, (cell) => {
const cellValue = cell.value ? cell.value.toString() : '';
maxLen = Math.max(maxLen, cellValue.length);
});
col.width = maxLen + 5;
});
ws.eachRow((row /* 처리할 객체 */, rowNum /* 해당 행 번호*/) => {
if ( rowNum === 1 ) {
row.eachCell(cell => {
cell.font = { bold: true };
cell.alignment = { horizontal: 'center', vertical: 'middle' };
});
}
if ( rowNum > 1 ) {
const hyperlinkCell = row.getCell(16);
if ( hyperlinkCell.value ) {
hyperlinkCell.value = {
text: "🚀댓글보기",
hyperlink: hyperlinkCell.value
};
hyperlinkCell.style = { font: { color: { argb: 'FF0000FF' }, underline: true } };
}
}
row.eachCell((cell, colNum) => {
cell.border = {
top: { style: 'thin', color: { argb: 'FF000000' } },
left: { style: 'thin', color: { argb: 'FF000000' } },
bottom: { style: 'thin', color: { argb: 'FF000000' } },
right: { style: 'thin', color: { argb: 'FF000000' } }
}
if ( colNum !== 8 && colNum !== 10 ){
cell.alignment = { horizontal: 'center', vertical: 'middle' };
}
});
});
const lastColNum = ws.columns.length;
const lastRowNum = ws.lastRow.number;
for (let col = 1; col <= lastColNum; col++){
const fRow = ws.getCell(1, col);
const lRow = ws.getCell(lastRowNum, col);
fRow.border = { ...fRow.border, top: { style: 'medium' } };
lRow.border = { ...lRow.border, bottom: { style: 'medium' }};
}
for (let row = 1; row <= lastRowNum; row++){
const fCol = ws.getCell(row, 1);
const lCol = ws.getCell(row, lastColNum);
fCol.border = { ...fCol.border, left: { style: 'medium' }};
lCol.border = { ...lCol.border, right: { style: 'medium' }};
}
ws.getRow(1).eachCell(cell =>{
cell.border = {
...cell.border, bottom: { style: 'medium' }
}
});
ws.autoFilter = 'A1:P1';
ws.mergeCells('Q1:R1');
ws.getCell('Q1').value = '< 제한된 보기 해제';
ws.getColumn(10).width = 18; // 등록시간
ws.getColumn(8).width = 40; // 댓글내용
}
async function dataToExcel(id, title_no){
let progress = 0;
const titleName = await getTitleName(id, title_no);
const lastPage = await getCommentInfo(id, title_no);
try{
for (let page = 1; page <= lastPage; page++) {
try{
const r = await fetch(`${CHAPI}/${id}/title/${title_no}/comment?page=${page}`)
const d = await r.json();
await handleComments(d,id, title_no)
progress = ((page / lastPage) * 100).toFixed(2);
console.log(`진행률: ${progress}%`);
document.title = `진행률: ${progress}% - 댓글 추출 중`;
} catch(e) {
console.log(e);
alert(e);
}
}
} catch (e) { console.log(e) };
const invalidChars = /[\\\/:*?"<>|]/g;
let replacedName = titleName.replace(invalidChars, '');
if (replacedName > 150) {
replacedName = replacedName.substring(0, 150)
}
try {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`${id}_${title_no}`,{
views:[
{
state: 'frozen',
ySplit: 1
}
]
});
const headers = Object.keys(commentData[0]);
ws.columns = headers.map(header => ({
header: header,
key: header,
width: 15
}));
// 데이터 추가
commentData.forEach(data => {
ws.addRow(data);
});
await sheetStyle(ws);
wb.xlsx.writeBuffer().then(buffer => {
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${id}_${title_no}_${replacedName}_댓글}`; // 파일 이름 설정
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}).catch(err => {
console.error("엑셀 파일 생성 실패", err);
});
if(parseFloat(progress) === 100.00){
document.title = "댓글 다운로드 완료!";
alert("댓글 다운로드 완료!");
}
} catch (e){
console.error("파일 저장에 실패했습니다.", e);
document.title = "파일 저장 실패";
}
}
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}`);
isVOD = true;
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 = [];
index = 1;
await dataToExcel(streamerId, title_no);
}
GM_registerMenuCommand('Excel로 댓글 추출하기', function() {
main();
});
})();