EZDC

디시인사이드에 글을 작성할 때 이미지(자짤), 말머리, 말꼬리 등을 자동으로 올려줍니다.

// ==UserScript==
// @name        EZDC
// @namespace   ezdc
// @description 디시인사이드에 글을 작성할 때 이미지(자짤), 말머리, 말꼬리 등을 자동으로 올려줍니다.
// @version     0.1.3
// @author      Sangha Lee
// @copyright   2024, Sangha Lee
// @license     MIT
// @match       https://gall.dcinside.com/board/write/*
// @match       https://gall.dcinside.com/mgallery/board/write/*
// @match       https://gall.dcinside.com/mini/board/write/*
// @match       https://gall.dcinside.com/person/board/write/*
// @icon        https://nstatic.dcinside.com/dc/m/img/dcinside_icon.png
// @run-at      document-end
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_xmlhttpRequest
// ==/UserScript==

/**
 * 전역 또는 갤러리 별 설정 객체
 * @typedef Option
 * @property {string} inherit 빈 설정 값을 물려받을 다른 설정 갤러리 아이디
 * @property {string[]} headers 글머리 배열
 * @property {string[]} footers 글꼬리 배열
 * @property {string[]} imageURLs 이미지 주소 배열
 * @property {bool} randomizeFileName 첨부 파일의 파일명을 무작위로 대체할건지?
 */


/*============================================================
 * 전역 함수
 *============================================================*/

/**
 * 비동기로 웹 요청을 전송합니다
 * @param {Object} options GM_xmlhttpRequest details 인자
 * @returns {Promise<Object>}
 */
function fetch (options) {
    return new Promise((resolve, reject) => {
        options.onabort = () => reject('사용자가 작업을 취소했습니다')
        options.ontimeout = () => reject('작업 시간이 초과됐습니다')
        options.onerror = reject
        options.onload = res => {
            res.headers = new Headers(
                Object.fromEntries(
                    res.responseHeaders
                        .split(/\r?\n/)
                        .map(v => v.split(': '))
                        .filter(v => v[0] && v[1])
                )
            )
            resolve(res)
        }
        GM_xmlhttpRequest({ method: 'GET', ...options })
    })
}

/**
 * 비동기로 웹으로부터 파일을 받아옵니다
 * @param {Object} options GM_xmlhttpRequest details 인자
 * @param {string?} name 파일 이름
 * @returns {Promise<File>}
 */
async function fetchFile(options, name = null) {
    const res = await fetch({ responseType: 'blob', ...options })

    // Content-Disposition 로부터 파일 이름 유추하기
    // https://www.w3.org/Protocols/HTTP/Issues/content-disposition.txt
    if (name === null && res.headers.has('Content-Disposition')) {
        const raw = res.headers.get('Content-Disposition')
        const items = Object.fromEntries(
            raw.split('; ')
                .map(v => {
                    const kv = v.split('=')
                    if (kv.length === 2 && kv[1][0] === '"' && kv[1].slice(-1) === '"') {
                        kv[1] = decodeURIComponent(kv[1].slice(1, -1))
                    }
                    return kv
                })
        )
        
        if ('filename' in items) {
            name = items.filename
        }
    }

    // TODO: Content-Type 로부터 파일 이름 유추하기
    // TODO: URL 로부터 파일 이름 유추하기

    return new File([res.response], name)
}

/**
 * 배열로부터 무작위 배열 요소를 뽑아 반환합니다
 * @param {T[]} items
 * @returns {T}
 */
function pickRandomItem (items) {
    return items[Math.floor(Math.random() * items.length)]
}


/*============================================================
 * XML 후킹
 *============================================================*/
XMLHttpRequest._hooks = []
XMLHttpRequest.prototype._open = XMLHttpRequest.prototype.open
XMLHttpRequest.prototype._send = XMLHttpRequest.prototype.send

/**
 * 훅을 추가합니다
 * @param {() => boolean} filter 
 * @param {() => any} callback 
 */
XMLHttpRequest.addHook = (filter, callback) => XMLHttpRequest._hooks.push({ filter, callback })

XMLHttpRequest.prototype.open = function () {
    this.hooks = XMLHttpRequest._hooks
        .filter(v => v.filter.call(this, ...arguments))
        .map(v => v.callback)
    return this._open(...arguments)
}

XMLHttpRequest.prototype.send = function (body) {
    for (let hook of this.hooks) {
        body = hook.call(this, body)
    }
    return this._send(body)
}


/*============================================================
 * 갤러리 및 편집기 관련 기능 클래스
 *============================================================*/
class Gallery {
    static get SUPPORTED_TYPES () {
        return {
            board:      'major',
            mgallery:   'minor',
            mini:       'mini',
            person:     'person'
        }
    }

    /**
     * 갤러리 기본 설정
     * @returns {Option}
     */
    static get DEFAULT_OPTIONS () {
        return {
            inherit: 'global',
            headers: [],
            footers: [],
            imageURLs: [],
            randomizeFileName: true
        }
    }

    constructor () {
        const url = new URL(location.href)
        const type = url.pathname.split('/')[1]

        if (!url.searchParams.has('id')) {
            throw new Error('주소로부터 갤러리 아이디를 가져오지 못 했습니다')
        }

        /**
         * 갤러리 아이디
         * @type {string}
         */
        this.id = url.searchParams.get('id')

        if (!(type in Gallery.SUPPORTED_TYPES)) {
            throw new Error(`'${type}' 값은 잘못됐거나 지원하지 않는 갤러리 종류입니다`)
        }

        /**
         * 갤러리 종류
         * @type {[string, string]}
         */
        this.type = Gallery.SUPPORTED_TYPES[type]
    }

    /**
     * 갤러리 설정 값
     * @type {Option}
     */
    get option () {
        let option = GM_getValue(this.optionKey, {})
        if (option.inherit || option.inherit === undefined) {
            option = {
                ...GM_getValue(`option_${option?.inherit ?? 'global'}`, {}),
                ...option
            }
        }

        return {...Gallery.DEFAULT_OPTIONS, ...option}
    }

    /**
     * 갤러리 설정 저장소 키
     */
    get optionKey () {
        return `option_${this.type}_${this.id}`
    }

    /**
     * 편집기를 통해 이미지를 업로드합니다
     * @param {File} file
     * @returns {Object[]}
     */
    async uploadImage (file) {
        // 무작위 파일 이름 적용하기
        if (this.option.randomizeFileName) {
            file = new File([file], `${crypto.randomUUID()}.${file.name.split('.').pop()}`)
        }

        const data = new FormData()
        data.append('r_key', document.getElementById('r_key').value)
        data.append('gall_id', this.id)
        data.append('files[]', file)

        const res = await fetch({
            method: 'POST',
            url: 'https://upimg.dcinside.com/upimg_file.php?id=' + this.id,
            responseType: 'json',
            data
        })

        if (res.responseText.includes('firewall security policies')) {
            throw new Error('웹 방화벽에 의해 차단되어 이미지 업로드에 실패했습니다')
        }
        
        return res.response.files
    }

    /**
     * 이미지를 편집기에 첨부합니다
     * @param {Object[]} files
     * @param {Object<string, any>} style
     */
    async attachImage (files, style = {}) {
        // 편집기로부터 이미지 삽입 객체 가져오기
        // https://github.com/kakao/DaumEditor/blob/e47ecbea89f98e0ca6e8b2d9eeff4c590007b4eb/daumeditor/js/trex/attacher/image.js
        const attacher = Editor.getSidebar().getAttacher('image', this)

        for (const f of files) {
            if (f.error) {
                // TODO: 오류 핸들링 추가하기 (지원되지 않는 확장자 등)
                continue
            }

            const entry = {
                filename: f.name,
                filesize: f.size,
                file_temp_no: f.file_temp_no,
                mp4: f.mp4,
                thumburl: f._s_url,
                originalurl: f.url,
                imageurl: f.url,
                imagealign: 'L',
                style
            }

            if (f.web__url) {
                entry.imageurl = f.web__url
            } else if (f.web2__url) {
                entry.imageurl = f.web2__url
            }
    
            // 파일 추가하기
            attacher.attachHandler(entry)
        }
    }
}


/*============================================================
 * 런타임 코드
 *============================================================*/
const gallery = new Gallery()

// 말머리와 말꼬리 추가를 위한 훅 추가하기
XMLHttpRequest.addHook(
    (method, url) => method === 'POST' && url === '/board/forms/article_submit',
    function (body) {
        const params = new URLSearchParams(body)
        const contents = [params.get('memo')]
        
        if (gallery.option.headers?.length) {
            contents.unshift(`<div id="dcappheader">${pickRandomItem(gallery.option.headers)}</div>`)
        }
        if (gallery.option.footers?.length) {
            contents.push(`<div id="dcappfooter">${pickRandomItem(gallery.option.footers)}</div>`)
        }

        params.set('memo', contents.join(''))
        return params.toString()
    }
)

// 편집기를 모두 불러온 뒤 실제 코드 실행하기
EditorJSLoader.ready(() => {
    // 편집기에 자짤 이미지 추가하기
    const pickedImageURL = pickRandomItem(gallery.option.imageURLs)
    if (gallery.option.imageURLs?.length) {
        fetchFile({ url: pickedImageURL })
            .then(file => gallery.uploadImage(file))
            .then(files => gallery.attachImage(files))
            .catch(err => {
                alert(`자짤 업로드 중 오류가 발생했습니다:\n${err}`)
                console.error(pickedImageURL, err)
            })
    }

    // 첨부 이미지 스타일 적용
    const Image = Trex.Attachment.Image
    const register = Image.prototype.register
    const getParaStyle = Image.prototype.getParaStyle
    
    Image.prototype.register = function () {
        this.objectStyle = { maxWidth: '100%', ...this.objectStyle }
        return register.call(this, ...arguments)
    }

    Image.prototype.getParaStyle = function (data) {
        return {
            ...getParaStyle.call(this, ...arguments),
            ...data?.style || {}
        }
    }
})


/*============================================================
 * 설정 요소 및 요소 스타일
 *============================================================*/
GM_addStyle(`
    :root {
        --ezdc-color-background: #fff;
        --ezdc-color-background-alt: #f1f1f1;
        --ezdc-color-background-error: #ffbeb8;
        --ezdc-color-background-border: #cdcdcd;
        --ezdc-color-primary: #3b4890;
        --ezdc-color-error: #b72a1d;
    }
    
    /* 김유식이 코드 개같이 짜서 darkmode 클래스 항상 존재함 */
    /*
    html.darkmode {
        --ezdc-color-background: #222;
        --ezdc-color-background-alt: #151515;
        --ezdc-color-background-error: #402323;
        --ezdc-color-background-border: #484848;
    }
    */
    
    html.refresherDark {
        --ezdc-color-background: #151515;
        --ezdc-color-background-alt: #111;
        --ezdc-color-background-error: #402323;
        --ezdc-color-background-border: #484848;
        --ezdc-color-primary: #292929;
    }

    .ezdc-preview {
        position: fixed;
        top: 0;
        left: 0;
        z-index: 9999;
        width: 100%;
        height: 100%;
        backdrop-filter: brightness(20%) blur(.25rem);
        background-position-x: center;
        background-position-y: center;
        background-size: contain;
        background-repeat: no-repeat;
        cursor: pointer;
    }
    .ezdc-preview:not([style]) {
        display: none;
    }

    .ezdc-wrap {
        margin: 15px 0;
        display: grid;
        height: 300px;
        grid-template-columns: 1fr 1fr;
        grid-template-rows: 1fr 1fr;
        grid-column-gap: 5px;
        grid-row-gap: 5px;
        padding: 5px;
        border: 1px solid var(--ezdc-color-background-border);
        background-color: var(--ezdc-color-background-alt);
    }
    .ezdc-wrap > ul {
        overflow-y: auto;
        border: 1px solid var(--ezdc-color-primary);
        border-radius: 2px;
        box-sizing: border-box;
        background-color: var(--ezdc-color-background);
    }
    .ezdc-wrap > ul > li:first-child {
        margin-bottom: 5px;
        padding: 5px;
        background-color: var(--ezdc-color-primary);
        font-weight: bold;
        color: white;
    }
    .ezdc-wrap > ul > li:not(:first-child) {
        margin: 0 5px 5px;
        display: flex;
        gap: 3px;
        font-weight: bold;
    }
    .ezdc-wrap > ul input {
        flex-grow: 1;
        border: 1px solid var(--ezdc-color-primary);
        border-radius: 2px;
    }
    .ezdc-wrap > ul input.invalid {
        border: 1px solid var(--ezdc-color-error);
        background-color: var(--ezdc-color-background-error);
    }
    .ezdc-wrap > ul button {
        padding: 0 3px;
        background-color: var(--ezdc-color-primary);
        border: 1px solid rgba(0, 0, 0, 0.5);
        border-radius: 2px;
        color: white;
    }
    .ezdc-wrap > ul button:last-child,
    .ezdc-wrap > ul button:nth-last-child(2) {
        width: 20px;
    }

    .ezdc-headers {
        grid-area: 1/1/2/2;
    }
    .ezdc-footers {
        grid-area: 2/1/3/2;
    }
    .ezdc-imageURLs {
        grid-area: 1/2/3/3;
    }
`)

const $preview = document.createElement('div')
$preview.classList.add('ezdc-preview')
$preview.addEventListener('click', e => e.target.removeAttribute('style'))
document.body.append($preview)

const $optionWrap = document.createElement('div')
$optionWrap.classList.add('ezdc-wrap')

const $editorWrap = document.querySelector('.editor_wrap')
$editorWrap.insertAdjacentElement('afterend', $optionWrap)

// 목록 옵션 별 요소 생성
for (const i of [
    {
        field: 'headers',
        name: '말머리', 
        placeholder: '말머리 내용'
    },
    { 
        field: 'footers', 
        name: '말꼬리',
        placeholder: '말꼬리 내용'
    },
    {
        field: 'imageURLs', 
        name: '자짤',
        placeholder: 'https://...',
        buttons: [['미리보기', function () {
            this.addEventListener('click', e => {
                e.preventDefault()
                const $input = this.parentNode.querySelector('input')
                if (!$input.classList.contains('invalid') && $input.value) {
                    $preview.style.backgroundImage = `url(${$input.value})`
                }
            })
        }]],
        validate: value => value === '' || value.match(/^https?:\/\//)
    }
]) {
    const $wrap = document.createElement('ul')
    $wrap.innerHTML = `<li>${i.name}</li>`
    $wrap.classList.add(`ezdc-${i.field}`)

    function save () {
        const option = GM_getValue(gallery.optionKey, {})
        const items = [...$wrap.querySelectorAll(':not(:first-child) input:not(.invalid)')]
        option[i.field] = items.filter(v => v.value).map(v => v.value)
        if (option[i.field].length < 1) {
            delete option[i.field]
        }

        GM_setValue(gallery.optionKey, option)
    }

    function onChange () {
        if (this.classList.contains('invalid')) {
            this.classList.remove('invalid')
        }
        
        if (i.validate && !i.validate(this.value)) {
            this.classList.add('invalid')
            return
        }

        save()
    }

    // 모든 목록 설정에 사용되는 버튼들
    const commonButtons = [
        ['+', function (e) {
            this.addEventListener('click', e => {
                e.preventDefault()
            
                // 쉬프트를 누른 상태라면 현재 값 복사
                const $input = this.parentNode.querySelector('input')
                insert(e.shiftKey ? $input.value : '', this.parentNode)
                save()
            })
        }],
        ['-', function (e) {
            this.addEventListener('click', e => {
                e.preventDefault()
    
                // 값이 비어있지 않을 때 경고 메세지 표시
                const $input = this.parentNode.querySelector('input')
                if (!e.shiftKey && $input.value && !confirm('삭제된 값은 되돌릴 수 없습니다, 삭제할까요?')) {
                    return
                }
        
                if ($wrap.children.length > 2) {
                    this.parentNode.remove()
                } else {
                    $input.value = ''
                }
        
                save()
            })
        }]
    ]

    function insert (value, afterNode = null) {
        const $item = document.createElement('li')
        $item.innerHTML = `<input type="text" placeholder="${i.placeholder}" value="${value}">`

        afterNode ? afterNode.insertAdjacentElement('afterend', $item) : $wrap.append($item)

        const $input = $item.querySelector('input')
        $input.addEventListener('change', onChange)

        for (const [name, callback] of [ ...(i.buttons || []), ...commonButtons ]) {
            const $button = document.createElement('button')
            $button.textContent = name
            $item.append($button)
            callback.call($button)
        }
    }

    for (const value of [...gallery.option[i.field], '']) {
        insert(value)
    }

    $optionWrap.append($wrap)
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址