EZDC

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

Verze ze dne 24. 09. 2024. Zobrazit nejnovější verzi.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

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