您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
디시인사이드에 글을 작성할 때 이미지(자짤), 말머리, 말꼬리 등을 자동으로 올려줍니다.
// ==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) }