dcrmrf

디시인사이드 갤로그 클리너

// ==UserScript==
// @name        dcrmrf
// @namespace   dcrmrf
// @description 디시인사이드 갤로그 클리너
// @version     0.1.9
// @author      Sangha Lee
// @copyright   2025, Sangha Lee
// @license     MIT
// @match       https://gallog.dcinside.com/*/posting*
// @match       https://gallog.dcinside.com/*/comment*
// @icon        https://nstatic.dcinside.com/dc/m/img/dcinside_icon.png
// @run-at      document-end
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_getValues
// @grant       GM_setValue
// @grant       GM_setValues
// @grant       GM_deleteValue
// @grant       GM_deleteValues
// @grant       GM_listValues
// @grant       GM_xmlhttpRequest
// ==/UserScript==

/**
 * @typedef {'G'|'M'|'MI'|'PR'} GalleryType
 */

/**
 * @typedef {'mi$'|'pr$'} GalleryPrefix
 */

/**
 * @typedef {Object} Log
 * @property {Gallery} gallery
 * @property {number} id
 * @property {?string} title
 */

/**
 * @typedef {'posting'|'comment'} LogType
 */

/**
 * @typedef {Object} Logs
 * @property {LogType} type 로그 종류
 * @property {number} page 페이지 번호
 * @property {number} totalCount 전체 로그 수
 * @property {?number} totalCategoryCount 카테고리 내 전체 로그 수
 * @property {number[]} categories 이 로그 종류에 존재하는 모든 카테고리 번호들
 * @property {Log[]} items
 */


class InvalidCaptchaError extends Error {}


class Utils {
    /**
     * 비동기로 웹 요청을 실행합니다
     * @param {Object} options
     * @returns {Promise<Object>}
     */
    static fetch (options) {
        return new Promise((resolve, reject) => {
            if (!('method' in options)) {
                options.method = 'GET'
            }
    
            options.onabort = () => reject('사용자가 작업을 취소했습니다')
            options.ontimeout = () => reject('작업 시간이 초과됐습니다')
            options.onerror = reject
            options.onload = resolve
            GM_xmlhttpRequest(options)
        })
    }

    /**
     * alert 과 console.error 메소드에 오류를 출력합니다
     * @param {Error} err 
     * @param {string|Array<string>} message 
     */
    static printError (err, message) {
        if (typeof(message) === 'string') {
            message = [message]
        }
    
        alert([...message, '자세한 내용은 개발자 도구를 열어 확인해주세요.', err].join('\n'))
        console.error(err)
    }

    /**
     * 특정 시간만큼 비동기로 대기합니다
     * @param {number} duration
     */
    static sleep (duration) {
        return new Promise(r => setTimeout(r, duration))
    }
}


class Gallery {
    /** @type {Object<string, GalleryType>} */
    static pathToType = {
        board: 'G',
        mgallery: 'M',
        mini: 'MI',
        person: 'PR'
    }

    /** @type {Object<string, GalleryPrefix>} */
    static pathToPrefixes = {
        mini: 'mi$',
        person: 'pr$'
    } 

    /**
     * @type {Object<GalleryType, string>}
     */
    static typeToSuffixes = {
        G: '갤러리',
        M: '마이너 갤러리',
        MI: '미니 갤러리',
        PR: '인물 갤러리'
    }

    /**
     * 갤러리나 갤러리에 작성된 글을 가르키는 주소로부터 갤러리 정보를 유추합니다
     * @param {string} url
     * @returns {Gallery}
     */
    static parseURL (url) {
        const parsedURL = new URL(url)
        const parsedFirstPath = parsedURL.pathname.split('/')[1]

        return new Gallery({
            id: parsedURL.searchParams.get('id'),
            idPrefix: this.pathToPrefixes[parsedFirstPath] ?? null,
            type: this.pathToType[parsedFirstPath] ?? ''
        })
    }

    /**
     * 요소의 dataset으로부터 갤러리 데이터를 가져옵니다
     * @param {DOMStringMap} dataset
     */
    static fromDataset (dataset) {
        return new Gallery({...dataset})
    }


    /**
     * @param {{
     *  id: string,
     *  idPrefix: ?GalleryPrefix,
     *  category: ?number,
     *  type: ?GalleryType,
     *  name: ?string
     * }} props
     */
    constructor (props) {
        // Object.assign(this, props) // fuck you vscode
        this.id = props.id
        this.idPrefix = props.idPrefix
        this.category = props.category
        this.type = props.type
        this.name = props.name
    }
    

    get suffix () {
        return Gallery.typeToSuffixes[this.type]
    }

    get displayName () {
        return `${this.name} ${this.suffix}`
    }

    get key () {
        return `${this.idPrefix}${this.id}.${this.type}`
    }

    get filterKey () {
        return `filter.gallery.${this.key}`
    }

    /**
     * @return {boolean}
     */
    get isFiltered () {
        return GM_getValue(this.filterKey, false)
    }

    /**
     * @type {boolean} state
     */
    set isFiltered (state) {
        if (state) {
            GM_setValue(this.filterKey, state)
        } else {
            GM_deleteValue(this.filterKey)
        }
    }

    /**
     * 갤러리 데이터를 요소의 dataset으로 내보냅니다
     * @param {DOMStringMap} dataset 
     */
    toDataset (dataset) {
        Object.assign(dataset, this)
    }
}


class Gallog {
    /**
     * @type {Gallery[]} 글 또는 댓글을 작성한 갤러리
     */
    usedGalleries = []

    /**
     * 현재 페이지가 본인의 갤로그 페이지인지?
     * @returns {boolean}
     */
    static get isMine () {
        return !!document.querySelector('.gallog_set_box')
    }

    /**
     * 로그인된 사용자 식별 코드를 현재 페이지로부터 가져옵니다
     * @returns {?string}
     */
    static get username () {
        const $anchor = document.querySelector('.user_data_list li:first-child a')
        if ($anchor) {
            const url = new URL($anchor.href)
            return url.pathname.split('/')[1]
        }

        return null
    }

    /**
     * 갤로그 정보를 새로고칩니다
     */
    async fetch () {
        const res = await Utils.fetch({
            url: `https://gallog.dcinside.com/${Gallog.username}/ajax/config_ajax/load_config`,
            method: 'POST',
            responseType: 'json'
        })

        this.usedGalleries = Object.fromEntries(
            res.response.use_galls.map(i => {
                const nameParts = i.name.split('$')
                return [
                    i.name,
                    new Gallery({
                        id: nameParts.pop(),
                        idPrefix: nameParts.length > 0 ? nameParts.pop() + '$' : null,
                        category: parseInt(i.cno),
                        type: i.gall_type,
                        name: i.ko_name
                    })
                ]
            })
        )
    }
}


class CaptchaService {
    /**
     * @param {string} endpoint 
     * @param {string} clientKey
     */
    constructor(name, endpoint) {
        this.name = name
        this.endpoint = endpoint
    }
    
    /** @returns {?string} */
    get clientKey () {
        return GM_getValue(`captcha.${this.name}.token`, null)
    }

    /** @param {?string} newClientKey */
    set clientKey (newClientKey) {
        if (typeof(newClientKey) === 'string') {
            newClientKey = newClientKey.trim()
        }

        if (newClientKey) {
            GM_setValue(`captcha.${this.name}.token`, newClientKey.trim())
        } else {
            GM_deleteValue(`captcha.${this.name}.token`)
        }
    }

    /**
     * https://2captcha.com/api-docs/recaptcha-v3#recaptchav3taskproxyless-task-type-specification  
     * https://anti-captcha.com/apidoc/task-types/RecaptchaV3TaskProxyless
     * @param {string} type 캡챠 종류 (RecaptchaV2TaskProxyless, RecaptchaV3TaskProxyless 등)
     * @param {string} websiteURL 캡챠가 표시된 웹 페이지의 주소
     * @param {string} websiteKey 캡챠가 표시된 웹 페이지의 캡챠 클라이언트 키
     * @param {number} retries 최대 재시도 횟수
     * @param {number} timeout 작업 대기 시간
     * @returns {Promise<() => Promise<string>>}
     */
    createSimpleSolver (type, websiteURL, websiteKey, retries = -1, timeout = 10000) {
        return () => 
            this.createTask(type, websiteURL, websiteKey)
                .then(async ({ taskId }) => {
                    let response
                    while (!response && retries-- !== 0) {
                        await Utils.sleep(timeout)

                        const result = await this.getTaskResult(taskId)
                        console.debug('CaptchaService', {
                            serviceName: this.name, 
                            taskId,
                            result
                        })

                        if (!result) {
                            throw new Error('캡챠 서비스에서 예측하지 못한 결과를 반환했습니다')
                        }

                        if (result.errorId > 0) {
                            throw new Error(`캡챠 서비스에서 ${result.errorId} 오류를 반환했습니다`)
                        }

                        if (result.status === 'ready') {
                            response = result?.solution?.gRecaptchaResponse
                        }
                    }

                    if (retries === 0) {
                        throw new Error('캡챠 풀이를 너무 많이 시도했습니다')
                    }
                    
                    return response
                })
    }

    async request (path, body = {}) {
        if (!('clientKey' in body)) {
            body.clientKey = this.clientKey
        }

        const res = await Utils.fetch({
            url: `${this.endpoint}${path}`,
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            data: JSON.stringify(body),
            responseType: 'json'
        })

        const result = res.response
        if ('errorId' in result && result.errorId > 0) {
            throw Error(`${result.errorId}: ${result?.errorDescription}`)
        }

        return result
    }

    async createTask (type, websiteURL, websiteKey) {
        return await this.request('/createTask', {
            task: { type, websiteURL, websiteKey }
        })
    }

    async getTaskResult(taskId) {
        return await this.request('/getTaskResult', { taskId })
    }

    async getBalance() {
        return await this.request('/getBalance')
    }
}


class App {
    /**
     * 사용 가능한 캡챠 서비스
     */
    static captchaServices = [
        new CaptchaService(
            '2Captcha',
            'https://api.2captcha.com'
        ),
        new CaptchaService(
            'AntiCaptcha',
            'https://api.anti-captcha.com'
        )
    ]


    constructor () {
        GM_addStyle(`
            :root {
                --dcrmrf-wrapper-border-color: #ccc;
                --dcrmrf-wrapper-background-color: #fff;
                --dcrmrf-wrapper-foreground-color: #000;
        
                --dcrmrf-primary-background-color: #3b4890;
                --dcrmrf-primary-foreground-color: #ffffff;
        
                --dcrmrf-secondary-background-color:rgb(117, 121, 143);
                --dcrmrf-secondary-foreground-color: #ffffff;
        
                --dcrmrf-success-background-color:rgb(99, 136, 92);
                --dcrmrf-success-foreground-color: #ffffff;
                --dcrmrf-danger-background-color:rgb(177, 85, 85);
                --dcrmrf-danger-foreground-color: #ffffff;
            }
        
            .dcrmrf {
                z-index: 10;
                position: relative;
            }
            .dcrmrf:not(.on) > :not(a) {
                display: none;
            }
        
        
            .dcrmrf button {
                display: block;
                width: 100%;
                border-radius: 2px;
                padding: 1em;
                font-weight: bold;
                background-color: var(--dcrmrf-primary-background-color);
                color: var(--dcrmrf-primary-foreground-color);
            }
            .dcrmrf button.togglable {
                background-color: var(--dcrmrf-danger-background-color);
                color: var(--dcrmrf-danger-foreground-color);
            }
            .dcrmrf button.togglable.toggled {
                background-color: var(--dcrmrf-success-background-color);
                color: var(--dcrmrf-success-foreground-color);
            }
        
        
            .dcrmrf blockquote {
                margin: 0;
                padding: .5em;
                border-left: 5px solid rgba(0, 0, 0, 0.25);
                align-content: center;
                text-align: center;
                background-color: var(--dcrmrf-secondary-background-color);
                color: var(--dcrmrf-secondary-foreground-color);
            }
        
        
            .dcrmrf form {
                z-index: -1;
                position: absolute;
                top: calc(100% - 1px);
                width: 300%;
                border: 1px var(--dcrmrf-wrapper-border-color) solid;
                padding: 1em;
                background-color: var(--dcrmrf-wrapper-background-color);
                box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5);
            }
        
        
            .dcrmrf form footer {
                margin-top: 1em;
                text-align: right;
            }
            .dcrmrf form footer a {
                all: initial !important;
                font-size: 15px !important;
                font-weight: bold !important;
                text-decoration: underline !important;
                color: var(--dcrmrf-primary-background-color) !important;
                cursor: pointer !important;
            }
        
        
            .dcrmrf fieldset {
                border: 1px var(--dcrmrf-wrapper-border-color) solid;
                border-radius: 2px;
                padding: 1em;
            }
            .dcrmrf fieldset:not(:first-child) {
                margin-top: 1em;
            }
        
            .dcrmrf fieldset legend {
                padding: .25em;
                border-radius: 2px;
                background-color: var(--dcrmrf-primary-background-color);
                font-weight: bold;
                color: var(--dcrmrf-primary-foreground-color);
            }
        
            .dcrmrf fieldset > blockquote {
                grid-column: 1 / -1;
            }


            .dcrmrf-control {
                position: relative;
            }
        
            .dcrmrf-control button {
                font-size: 1rem;
            }
            .dcrmrf-control button h1 {
                font-size: 1.25rem;
            }
            .dcrmrf-control button p {
                font-size: .75rem;
            }
        
        
            .dcrmrf-galleries {
                resize: vertical;
                overflow: auto;
                margin-top: .5em;
                height: 200px;
                text-align: center;
            }

            .dcrmrf-galleries-control {
                margin: .5em 0;
                display: grid;
                grid-template: repeat(1, 1fr) / repeat(3, 1fr);
                grid-gap: .5em;
            }

            .dcrmrf-galleries.loading,
            .dcrmrf-galleries:empty:not(.loading) {
                align-content: center;
            }
            .dcrmrf-galleries.loading::after {
                content: '불러오는 중...'
            }
            .dcrmrf-galleries:empty:not(.loading)::after {
                content: '갤질이 부족하시네요...'
            }
        
            .dcrmrf-galleries button {
                margin: calc(.25em / 2);
                display: inline-block;
                width: auto;
                border-radius: 15px;
                padding: .5em 1em;
            }
            .dcrmrf-galleries button:not(.toggled) {
                text-decoration: line-through;
            }
            .dcrmrf-galleries button::after {
                content: ' 갤러리';
                font-size: 10px;
            }
            .dcrmrf-galleries button[data-type="M"]::after {
                content: ' 마이너 갤러리';
            }
            .dcrmrf-galleries button[data-type="MI"]::after {
                content: ' 미니 갤러리';
            }
            .dcrmrf-galleries button[data-type="PR"]::after {
                content: ' 인물 갤러리';
            }
        
        
            .dcrmrf-captcha,
            .dcrmrf-setting {
                display: grid;
                grid-template: repeat(2, 1fr) / repeat(2, 1fr);
                grid-gap: .5em;
            }
        `)

        /** @type {LogType} */
        this.type = location.href.includes('/posting')
            ? 'posting'
            : 'comment'

        this.gallog = new Gallog()
        this.createElements(GM_getValue('wrapper.opened', false))

        this.job = new Job(this)
    }

    get typeName () {
        switch (this.type) {
            case 'posting':
                return '게시글'
            case 'comment':
                return '댓글'
        }

        return '⊙﹏⊙' // ???
    }

    /**
     * 메뉴 요소를 삽입합니다
     * @param {boolean} openByDefault 즉시 메뉴를 열어둘지?
     * @returns {HTMLElement}
     */
    createElements (openByDefault = false) {
        // 이미 요소가 존재한다면 제거하기
        if (this?.$) {
            this.$.remove()
        }

        // 요소 생성하고 메뉴 목록에 추가하기
        this.$ = document.createElement('li')
        document
            .querySelector('.gallog_menu')
            .append(this.$)
        
        this.$.classList.add('dcrmrf')
        this.$.innerHTML = `
            <a href="#">클리너</a>
    
            <form>
                <fieldset class="dcrmrf-control">
                    <button>
                        <h1></h1>
                        <p></p>
                    </button>
                </fieldset>
                <fieldset class="dcrmrf-filter">
                    <legend>필터</legend>
                    <blockquote>
                        <p>특정 갤러리를 제외할 수 있습니다.</p>
                    </blockquote>
                    <div class="dcrmrf-galleries-control">
                        <button>모두 제외</button>
                        <button>모두 해제</button>
                        <button>새로고침</button>
                    </div>
                    <div class="dcrmrf-galleries"></div>
                </fieldset>
                <fieldset class="dcrmrf-captcha">
                    <legend>캡챠</legend>
                    <blockquote>
                        <p>빠르게 게시글이나 댓글을 삭제하면 캡챠가 발생할 수 있습니다.</p>
                        <p>아래 유료 서비스를 통해 캡챠 풀이를 자동화합니다.</p>
                    </blockquote>
                </fieldset>
                <fieldset class="dcrmrf-setting">
                    <legend>설정</legend>
                    <blockquote>
                        <p>설정을 내보내거나 가져옵니다.</p>
                    </blockquote>
                    <button class="import">가져오기</button>
                    <button class="export">내보내기</button>
                </fieldset>
                <footer>
                    <p><a href="https://gist.github.com/toriato/183e05071873ab95bc2ad9f63e1c0f63">dcrmrf</a> by <a href="https://gallog.dcinside.com/springkat/guestbook">애옹이도둑</a> with ❤️</p>
                </footer>
            </form>
        `


        const $controlButton = this.$.querySelector('.dcrmrf-control button')
        $controlButton.addEventListener('click', e => {
            e.preventDefault()

            this.job.running
                ? this.job.pause()
                : this.job.resume()
                    .then(() => {
                        alert('작업이 완료됐습니다.')
                    })
                    .catch(err => {
                        this.job.pause()
                        Utils.printError(err, `${this.typeName} 삭제 중 오류가 발생했습니다`)
                    })
        })
    
    
        // 작업 버튼 삽입하기
        $controlButton.querySelector('h1')
            .textContent = `${this.typeName} 클리너 실행`


        // 갤러리 필터 제어 버튼
        const $galleries = this.$.querySelector('.dcrmrf-galleries')
        const $galleriesControlButtons = this.$.querySelectorAll('.dcrmrf-galleries-control button')

        $galleriesControlButtons[0].addEventListener('click', e => {
            e.preventDefault()

            if (!confirm('갤러리를 모두 제외할까요?\n이 작업은 되돌릴 수 없습니다.')) {
                return
            }

            $galleries.querySelectorAll('button')
                .forEach($ => {
                    Gallery
                        .fromDataset($.dataset)
                        .isFiltered = true
                })

            $galleriesControlButtons[2].click()
        })

        $galleriesControlButtons[1].addEventListener('click', e => {
            e.preventDefault()

            if (!confirm('제외된 갤러리를 모두 해제할까요?\n이 작업은 되돌릴 수 없습니다.')) {
                return
            }

            $galleries.querySelectorAll('button')
                .forEach($ => {
                    Gallery
                        .fromDataset($.dataset)
                        .isFiltered = false
                })

            $galleriesControlButtons[2].click()
        })

        $galleriesControlButtons[2].addEventListener('click', e => {
            e.preventDefault()

            this.updateGalleryElements()
                .catch(err => 
                    Utils.printError(err, '갤러리 목록을 새로고치는 중 오류가 발생했습니다')
                )
        })
    
    
        // 캡챠 버튼 삽입하기
        for (const service of App.captchaServices) {
            const $button = document.createElement('button')
            $button.textContent = service.name
            $button.classList.add('togglable')
    
            // API 키가 존재한다면 버튼 색상 변경하기
            if (service.clientKey) {
                $button.classList.add('toggled')
            }
    
            $button.addEventListener('click', async function (e) {
                e.preventDefault()
    
                const previousClientKey = service.clientKey
                const nextClientKey = prompt(
                    [
                        `캡챠 풀이에 사용될 ${service.name} 서비스의 API 키 값을 입력해주세요.`,
                        `빈 값을 입력하면 해당 서비스를 비활성화합니다.`
                    ].join('\n'),
                    previousClientKey ?? ''
                )
    
                // 입력을 취소했다면 아무 작업도 하지 않기
                if (nextClientKey === null) {
                    return
                }
    
                service.clientKey = nextClientKey
    
                // 빈 키가 입력된 경우 서비스 비활성화하기
                if (!service.clientKey) {
                    if (this.classList.contains('toggled')) {
                        this.classList.remove('toggled')
                    }
                    return
                }
    
                try {
                    const response = await service.getBalance()
    
                    alert([
                        `입력 받은 API 키와 관련된 정보는 다음과 같습니다:`,
                        `- 서비스: ${service.name}`,
                        `- 엔드포인트: ${service.endpoint}`,
                        `- 크레딧: ${response.balance}`
                    ].join('\n'))
    
                    if (!this.classList.contains('toggled')) {
                        this.classList.add('toggled')
                    }
                } catch (err) {
                    // 오류 발생시 기존 키 되돌리기
                    service.clientKey = previousClientKey
                    Utils.printError(err, '캡챠 서비스 연결 중 오류가 발생했습니다.')
                    return
                }
            })
    
            this.$.querySelector('.dcrmrf-captcha').append($button)
        }
    
    
        // 가져오기 버튼 이벤트 추가하기
        this.$.querySelector('.dcrmrf-setting .import')
            .addEventListener('click', e => {
                e.preventDefault()
    
                const $file = document.createElement('input')
                $file.type = 'file'
                $file.accept = '.json, application/json'
                $file.addEventListener('change', e => {
                    $file.files[0].text()
                        .then(raw => {
                            const values = JSON.parse(raw)
                            GM_deleteValues(GM_listValues())
                            GM_setValues(values)
                            this.createElements(true)
                        })
                        .catch(err => 
                            Utils.printError(err, '설정 파일을 가져오는 중 오류가 발생했습니다.')
                        )
                })
    
                $file.click()
            })
    
    
        // 내보내기 버튼 이벤트 추가하기
        this.$.querySelector('.dcrmrf-setting .export')
            .addEventListener('click', e => {
                e.preventDefault()
                const values = JSON.stringify(GM_getValues(GM_listValues()))
    
                const $anchor = document.createElement('a')
                $anchor.href = `data:application/json;charset=utf-8,${encodeURIComponent(values)}`
                $anchor.download = `dcrmrf_${new Date().toJSON().slice(0, 10)}.json`
                $anchor.click()
            })
    
    
        // 좌측 사이드바 메뉴 이벤트 추가하기
        this.$.addEventListener('click', (e) => {
            if (e.target.nodeName !== 'A') {
                return
            }
    
            e.preventDefault()
            e.stopPropagation()
            
            if (this.$.classList.toggle('on')) {
                GM_setValue('wrapper.opened', true)

                // 갤러리 목록 새로고치기
                $galleriesControlButtons[2].click()
            } else {
                GM_deleteValue('wrapper.opened')
            }
        })
    
        // 메뉴 열어두기
        if (openByDefault) {
            this.$.querySelector(':scope > a').click()
        }
    
        return this.$
    }

    /**
     * 갤러리 목록을 새로고칩니다
     */
    async updateGalleryElements () {
        const $galleries = this.$.querySelector('.dcrmrf-galleries')

        // 이미 불러오는 중이면 무시하기
        if ($galleries.classList.contains('loading')) {
            return
        }

        $galleries.innerHTML = ''
        $galleries.classList.add('loading')

        try {
            await this.gallog.fetch()

            for (const gallery of Object.values(this.gallog.usedGalleries)) {
                const $item = document.createElement('button')
                gallery.toDataset($item.dataset)
                $item.innerHTML = gallery.name      // 갤러리 이름
                $item.title = `${gallery.name}`     // 갤러리 아이디
                $item.classList.add('togglable')
                $item.addEventListener('click', e => {
                    e.preventDefault()

                    if ($item.classList.toggle('toggled')) {
                        GM_deleteValue(gallery.filterKey)
                    } else {
                        GM_setValue(gallery.filterKey, true)
                    }
                })

                if (!GM_getValue(gallery.filterKey, false)) {
                    $item.classList.add('toggled')
                }

                $galleries.append($item)
            }
        } finally {
            $galleries.classList.remove('loading')
        }
    }
}


class Job {
    /**
     * @param {App} app
     */
    constructor (app) {
        this.app = app

        this.$title = app.$.querySelector('.dcrmrf-control button h1')
        this.$description = app.$.querySelector('.dcrmrf-control button p')

        let running
        Object.defineProperty(this, 'running', {
            get: () => running,
            set: value => {
                running = value

                if (value) {
                    this.$title.textContent = `${this.app.typeName} 클리너 중지`
                } else {
                    this.$title.textContent = `${this.app.typeName} 클리너 시작`
                }

                this.$description.textContent = ''
            }
        })

        this.running = false
        
        /**
         * 클리너 작업이 필요한 갤러리들 
         * @type {?Gallery[]} 
         */
        this.pendingGalleries = null

        /**
         * 클리너 작업이 필요한 로그들 
         * @type {?Logs} 
         */
        this.pendingLogs = null

        /**
         * 성공적으로 삭제한 로그 개수
         */
        this.deletedLogs = 0

        /**
         * 현재 페이지
         */
        this.page = 1

        /**
         * 현재 작업 중인 갤러리
         * @type {?Gallery}
         */
        this.currentGallery = null
        
        /**
         * 현재 작업 중인 로그
         * @type {?Log}
         */
        this.currentLog = null

        /**
         * 현재 작업에 사용할 캡챠 응답 값 (풀이 성공 시)
         * @type {?string}
         */
        this.currentCaptchaResponse = null
    }

    /**
     * 
     * @param {string} message 
     */
    print (message) {
        this.$description.textContent = message
    }

    /**
     * 갤로그 항목을 가져옵니다
     * @param {?Gallery} gallery
     * @returns {Promise<Logs>}
     */
    async fetchLogs (gallery = null) {
        const url = new URL(`https://gallog.dcinside.com/${Gallog.username}/${this.app.type}/index`)
        url.searchParams.set('page', this.page)
        url.searchParams.set('cno', gallery?.category ?? 0)

        const res = await Utils.fetch({ url })
        const $ = new DOMParser().parseFromString(res.response, 'text/html')

        /** @type {Logs} */
        const result = {
            totalCategoryCount: null,
            totalCount: parseInt($.querySelector('.cont_head .num').textContent.replace(/[^\d]/g, ''), 10),
            categories: [...$.querySelectorAll('.gallog [data-value]:not([data-value=""])')]
                .map($item =>
                    parseInt($item.dataset.value, 10)
                ),
            items: [...$.querySelectorAll('.cont_listbox li[data-no]')]
                .map($item => {
                    return {
                        gallery: Gallery.parseURL($item.querySelector('a.link').href),
                        id: parseInt($item.dataset.no, 10),
                        title: $item.querySelector('.galltit').textContent
                    }
                })
        }

        // 특정 갤러리 내 로그 수 가져오기
        if (gallery) {
            const $totalCategoryCount = $.querySelector('.cont_box .num')
            if ($totalCategoryCount) {
                result.totalCategoryCount = parseInt($totalCategoryCount.textContent.replace(/[^\d]/g, ''), 10)
            } else {
                result.totalCategoryCount = 0
                result.items = []
            }
        }

        return result
    }


    /**
     * 갤로그 항목을 삭제합니다
     */
    async deleteLog () {
        const data = new FormData()
        data.set('no', this.currentLog.id)
        if (this.currentCaptchaResponse) {
            data.set('g-recaptcha-response', this.currentCaptchaResponse)
        }

        const res = await Utils.fetch({
            url: `https://gallog.dcinside.com/${Gallog.username}/ajax/log_list_ajax/delete`,
            method: 'POST',
            headers: { 'X-Requested-With': 'XMLHttpRequest' },
            responseType: 'json',
            data
        })

        const result = res.response?.result
        const message = res.response?.msg
        if (result === 'success') {
            return
        }

        // 캡챠 입력이 필요할 때
        if (result === 'captcha') {
            throw new InvalidCaptchaError()
        }

        // 회신한 캡챠 결과가 일치하지 않을 때
        // TODO: 오류 따로 핸들링하기?
        if (result === 'fail' && message === 'g-recaptcha error!') {
            throw new InvalidCaptchaError()
        }

        throw new Error(message ?? res.response)
    }

    /**
     * 작업을 시작합니다
     */
    async resume () {
        if (this.running) {
            return
        }

        this.running = true

        // 모든 캡챠 서비스의 API 키가 유효한지 확인하기
        const captchaSolvers = []
        for (const service of App.captchaServices) {
            if (service.clientKey === null) {
                continue
            }

            this.print(`캡챠 서비스(${service.name}) 유효성 확인 중...`)

            try {
                await service.getBalance()
            } catch (err) {
                Utils.printError(err, '캡챠 서비스가 유효하지 않습니다, API 키를 다시 확인해보세요')
                throw err
            }
            
            captchaSolvers.push(
                service.createSimpleSolver(
                    'RecaptchaV2TaskProxyless',
                    'https://gallog.dcinside.com/',
                    '6LcJyr4UAAAAAOy9Q_e9sDWPSHJ_aXus4UnYLfgL'
                )
            )
        }

        this.print('갤로그 정보 새로고치는 중...')
        await this.app.updateGalleryElements()
        
        if (this.pendingGalleries === null) {
            this.print(`${this.app.typeName} 작성된 갤러리 목록 가져오는 중...`)
            
            const { categories } = await this.fetchLogs()
            
            this.pendingGalleries = Object
                .values(this.app.gallog.usedGalleries)
                .filter(gallery => 
                    !gallery.isFiltered && categories.includes(gallery.category)
                )
        }

        let iter = 0
        while (this.running) {
            iter++

            if (this.currentGallery === null) {
                // 작업할 갤러리가 남아있지 않는다면 작업 마치기
                if (this.pendingGalleries.length < 1) {
                    this.pendingGalleries = null
                    this.pendingLogs = null
                    this.currentGallery = null
                    this.currentLog = null
                    this.currentCaptchaResponse = null
                    this.deletedLogs = 0
                    break
                }

                this.currentGallery = this.pendingGalleries.pop()
            }

            // 작업할 로그가 아예 존재하지 않는다면 초기화하기
            if (this.pendingLogs === null) {
                this.print(`${this.currentGallery.displayName}에 작성된 로그 가져오는 중...`)
                this.pendingLogs = await this.fetchLogs(this.currentGallery)
            }

            // 작업할 로그가 남아있지 않는다면 새로고치기
            if (this.pendingLogs.items.length < 1) {
                // 현재 갤러리에 로그가 남아있지 않다면 다음 갤러리로 넘어가기
                if (!this.pendingLogs.totalCategoryCount) {
                    this.currentGallery = null
                    this.deletedLogs = 0
                }

                this.pendingLogs = null
                this.currentLog = null
                this.currentCaptchaResponse = null
                continue
            }

            if (this.currentLog === null) {
                this.currentLog = this.pendingLogs.items.pop()
            }

            const prefix = `${this.currentGallery.displayName}의 ${this.app.typeName} ${this.pendingLogs.totalCategoryCount}개 중 ${this.deletedLogs + 1}번`

            this.print(`${prefix} 삭제 중...`)
            console.debug(
                `${prefix} 삭제 중...`,
                this.currentGallery,
                this.currentLog
            )

            try {
                await this.deleteLog()
            } catch (err) {
                // 캡챠 발생시 유효한 캡챠 서비스가 있을 경우
                if (err instanceof InvalidCaptchaError && captchaSolvers.length > 0) {
                    this.print(`${prefix} 캡챠 풀이 중...`)
                    console.debug(
                        `${prefix} 캡챠 풀이 중...`,
                        this.currentGallery,
                        this.currentLog
                    )

                    this.currentCaptchaResponse = await captchaSolvers[iter % captchaSolvers.length]()
                    continue
                }

                throw err
            }

            this.deletedLogs++
            this.currentLog = null
            this.currentCaptchaResponse = null
        }

        await this.pause()
    }

    /**
     * 작업을 일시 정지합니다
     */
    async pause () {
        this.running = false
    }
}


if (Gallog.isMine) {
    new App
}