SOOP - 채널 게시글, VOD 댓글 엑셀로 추출

SOOP 채널(방송국)의 게시글이나 다시보기(VOD)에서 댓글과 답글(대댓글)을 엑셀로 추출하여 저장하는 스크립트.

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