SOOP(숲) - 게시글/다시보기 댓글 엑셀파일로 추출

SOOP 채널의 게시글이나 다시보기에서 댓글, 답글을 추출하여 엑셀파일로 저장합니다.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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