Greasy Fork

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

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

目前为 2025-03-01 提交的版本。查看 最新版本

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