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