Greasy Fork

Greasy Fork is available in English.

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

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

当前为 2025-03-01 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         SOOP(숲) - 게시글/다시보기 댓글 엑셀파일로 추출
// @namespace    http://greasyfork.icu/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();
    });
})();