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