EZDC

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

  1. // ==UserScript==
  2. // @name EZDC
  3. // @namespace ezdc
  4. // @description 디시인사이드에 글을 작성할 때 이미지(자짤), 말머리, 말꼬리 등을 자동으로 올려줍니다.
  5. // @version 0.1.3
  6. // @author Sangha Lee
  7. // @copyright 2024, Sangha Lee
  8. // @license MIT
  9. // @match https://gall.dcinside.com/board/write/*
  10. // @match https://gall.dcinside.com/mgallery/board/write/*
  11. // @match https://gall.dcinside.com/mini/board/write/*
  12. // @match https://gall.dcinside.com/person/board/write/*
  13. // @icon https://nstatic.dcinside.com/dc/m/img/dcinside_icon.png
  14. // @run-at document-end
  15. // @grant GM_addStyle
  16. // @grant GM_getValue
  17. // @grant GM_setValue
  18. // @grant GM_xmlhttpRequest
  19. // ==/UserScript==
  20.  
  21. /**
  22. * 전역 또는 갤러리 별 설정 객체
  23. * @typedef Option
  24. * @property {string} inherit 빈 설정 값을 물려받을 다른 설정 갤러리 아이디
  25. * @property {string[]} headers 글머리 배열
  26. * @property {string[]} footers 글꼬리 배열
  27. * @property {string[]} imageURLs 이미지 주소 배열
  28. * @property {bool} randomizeFileName 첨부 파일의 파일명을 무작위로 대체할건지?
  29. */
  30.  
  31.  
  32. /*============================================================
  33. * 전역 함수
  34. *============================================================*/
  35.  
  36. /**
  37. * 비동기로 웹 요청을 전송합니다
  38. * @param {Object} options GM_xmlhttpRequest details 인자
  39. * @returns {Promise<Object>}
  40. */
  41. function fetch (options) {
  42. return new Promise((resolve, reject) => {
  43. options.onabort = () => reject('사용자가 작업을 취소했습니다')
  44. options.ontimeout = () => reject('작업 시간이 초과됐습니다')
  45. options.onerror = reject
  46. options.onload = res => {
  47. res.headers = new Headers(
  48. Object.fromEntries(
  49. res.responseHeaders
  50. .split(/\r?\n/)
  51. .map(v => v.split(': '))
  52. .filter(v => v[0] && v[1])
  53. )
  54. )
  55. resolve(res)
  56. }
  57. GM_xmlhttpRequest({ method: 'GET', ...options })
  58. })
  59. }
  60.  
  61. /**
  62. * 비동기로 웹으로부터 파일을 받아옵니다
  63. * @param {Object} options GM_xmlhttpRequest details 인자
  64. * @param {string?} name 파일 이름
  65. * @returns {Promise<File>}
  66. */
  67. async function fetchFile(options, name = null) {
  68. const res = await fetch({ responseType: 'blob', ...options })
  69.  
  70. // Content-Disposition 로부터 파일 이름 유추하기
  71. // https://www.w3.org/Protocols/HTTP/Issues/content-disposition.txt
  72. if (name === null && res.headers.has('Content-Disposition')) {
  73. const raw = res.headers.get('Content-Disposition')
  74. const items = Object.fromEntries(
  75. raw.split('; ')
  76. .map(v => {
  77. const kv = v.split('=')
  78. if (kv.length === 2 && kv[1][0] === '"' && kv[1].slice(-1) === '"') {
  79. kv[1] = decodeURIComponent(kv[1].slice(1, -1))
  80. }
  81. return kv
  82. })
  83. )
  84. if ('filename' in items) {
  85. name = items.filename
  86. }
  87. }
  88.  
  89. // TODO: Content-Type 로부터 파일 이름 유추하기
  90. // TODO: URL 로부터 파일 이름 유추하기
  91.  
  92. return new File([res.response], name)
  93. }
  94.  
  95. /**
  96. * 배열로부터 무작위 배열 요소를 뽑아 반환합니다
  97. * @param {T[]} items
  98. * @returns {T}
  99. */
  100. function pickRandomItem (items) {
  101. return items[Math.floor(Math.random() * items.length)]
  102. }
  103.  
  104.  
  105. /*============================================================
  106. * XML 후킹
  107. *============================================================*/
  108. XMLHttpRequest._hooks = []
  109. XMLHttpRequest.prototype._open = XMLHttpRequest.prototype.open
  110. XMLHttpRequest.prototype._send = XMLHttpRequest.prototype.send
  111.  
  112. /**
  113. * 훅을 추가합니다
  114. * @param {() => boolean} filter
  115. * @param {() => any} callback
  116. */
  117. XMLHttpRequest.addHook = (filter, callback) => XMLHttpRequest._hooks.push({ filter, callback })
  118.  
  119. XMLHttpRequest.prototype.open = function () {
  120. this.hooks = XMLHttpRequest._hooks
  121. .filter(v => v.filter.call(this, ...arguments))
  122. .map(v => v.callback)
  123. return this._open(...arguments)
  124. }
  125.  
  126. XMLHttpRequest.prototype.send = function (body) {
  127. for (let hook of this.hooks) {
  128. body = hook.call(this, body)
  129. }
  130. return this._send(body)
  131. }
  132.  
  133.  
  134. /*============================================================
  135. * 갤러리 및 편집기 관련 기능 클래스
  136. *============================================================*/
  137. class Gallery {
  138. static get SUPPORTED_TYPES () {
  139. return {
  140. board: 'major',
  141. mgallery: 'minor',
  142. mini: 'mini',
  143. person: 'person'
  144. }
  145. }
  146.  
  147. /**
  148. * 갤러리 기본 설정
  149. * @returns {Option}
  150. */
  151. static get DEFAULT_OPTIONS () {
  152. return {
  153. inherit: 'global',
  154. headers: [],
  155. footers: [],
  156. imageURLs: [],
  157. randomizeFileName: true
  158. }
  159. }
  160.  
  161. constructor () {
  162. const url = new URL(location.href)
  163. const type = url.pathname.split('/')[1]
  164.  
  165. if (!url.searchParams.has('id')) {
  166. throw new Error('주소로부터 갤러리 아이디를 가져오지 못 했습니다')
  167. }
  168.  
  169. /**
  170. * 갤러리 아이디
  171. * @type {string}
  172. */
  173. this.id = url.searchParams.get('id')
  174.  
  175. if (!(type in Gallery.SUPPORTED_TYPES)) {
  176. throw new Error(`'${type}' 값은 잘못됐거나 지원하지 않는 갤러리 종류입니다`)
  177. }
  178.  
  179. /**
  180. * 갤러리 종류
  181. * @type {[string, string]}
  182. */
  183. this.type = Gallery.SUPPORTED_TYPES[type]
  184. }
  185.  
  186. /**
  187. * 갤러리 설정 값
  188. * @type {Option}
  189. */
  190. get option () {
  191. let option = GM_getValue(this.optionKey, {})
  192. if (option.inherit || option.inherit === undefined) {
  193. option = {
  194. ...GM_getValue(`option_${option?.inherit ?? 'global'}`, {}),
  195. ...option
  196. }
  197. }
  198.  
  199. return {...Gallery.DEFAULT_OPTIONS, ...option}
  200. }
  201.  
  202. /**
  203. * 갤러리 설정 저장소 키
  204. */
  205. get optionKey () {
  206. return `option_${this.type}_${this.id}`
  207. }
  208.  
  209. /**
  210. * 편집기를 통해 이미지를 업로드합니다
  211. * @param {File} file
  212. * @returns {Object[]}
  213. */
  214. async uploadImage (file) {
  215. // 무작위 파일 이름 적용하기
  216. if (this.option.randomizeFileName) {
  217. file = new File([file], `${crypto.randomUUID()}.${file.name.split('.').pop()}`)
  218. }
  219.  
  220. const data = new FormData()
  221. data.append('r_key', document.getElementById('r_key').value)
  222. data.append('gall_id', this.id)
  223. data.append('files[]', file)
  224.  
  225. const res = await fetch({
  226. method: 'POST',
  227. url: 'https://upimg.dcinside.com/upimg_file.php?id=' + this.id,
  228. responseType: 'json',
  229. data
  230. })
  231.  
  232. if (res.responseText.includes('firewall security policies')) {
  233. throw new Error('웹 방화벽에 의해 차단되어 이미지 업로드에 실패했습니다')
  234. }
  235. return res.response.files
  236. }
  237.  
  238. /**
  239. * 이미지를 편집기에 첨부합니다
  240. * @param {Object[]} files
  241. * @param {Object<string, any>} style
  242. */
  243. async attachImage (files, style = {}) {
  244. // 편집기로부터 이미지 삽입 객체 가져오기
  245. // https://github.com/kakao/DaumEditor/blob/e47ecbea89f98e0ca6e8b2d9eeff4c590007b4eb/daumeditor/js/trex/attacher/image.js
  246. const attacher = Editor.getSidebar().getAttacher('image', this)
  247.  
  248. for (const f of files) {
  249. if (f.error) {
  250. // TODO: 오류 핸들링 추가하기 (지원되지 않는 확장자 등)
  251. continue
  252. }
  253.  
  254. const entry = {
  255. filename: f.name,
  256. filesize: f.size,
  257. file_temp_no: f.file_temp_no,
  258. mp4: f.mp4,
  259. thumburl: f._s_url,
  260. originalurl: f.url,
  261. imageurl: f.url,
  262. imagealign: 'L',
  263. style
  264. }
  265.  
  266. if (f.web__url) {
  267. entry.imageurl = f.web__url
  268. } else if (f.web2__url) {
  269. entry.imageurl = f.web2__url
  270. }
  271. // 파일 추가하기
  272. attacher.attachHandler(entry)
  273. }
  274. }
  275. }
  276.  
  277.  
  278. /*============================================================
  279. * 런타임 코드
  280. *============================================================*/
  281. const gallery = new Gallery()
  282.  
  283. // 말머리와 말꼬리 추가를 위한 훅 추가하기
  284. XMLHttpRequest.addHook(
  285. (method, url) => method === 'POST' && url === '/board/forms/article_submit',
  286. function (body) {
  287. const params = new URLSearchParams(body)
  288. const contents = [params.get('memo')]
  289. if (gallery.option.headers?.length) {
  290. contents.unshift(`<div id="dcappheader">${pickRandomItem(gallery.option.headers)}</div>`)
  291. }
  292. if (gallery.option.footers?.length) {
  293. contents.push(`<div id="dcappfooter">${pickRandomItem(gallery.option.footers)}</div>`)
  294. }
  295.  
  296. params.set('memo', contents.join(''))
  297. return params.toString()
  298. }
  299. )
  300.  
  301. // 편집기를 모두 불러온 뒤 실제 코드 실행하기
  302. EditorJSLoader.ready(() => {
  303. // 편집기에 자짤 이미지 추가하기
  304. const pickedImageURL = pickRandomItem(gallery.option.imageURLs)
  305. if (gallery.option.imageURLs?.length) {
  306. fetchFile({ url: pickedImageURL })
  307. .then(file => gallery.uploadImage(file))
  308. .then(files => gallery.attachImage(files))
  309. .catch(err => {
  310. alert(`자짤 업로드 오류가 발생했습니다:\n${err}`)
  311. console.error(pickedImageURL, err)
  312. })
  313. }
  314.  
  315. // 첨부 이미지 스타일 적용
  316. const Image = Trex.Attachment.Image
  317. const register = Image.prototype.register
  318. const getParaStyle = Image.prototype.getParaStyle
  319. Image.prototype.register = function () {
  320. this.objectStyle = { maxWidth: '100%', ...this.objectStyle }
  321. return register.call(this, ...arguments)
  322. }
  323.  
  324. Image.prototype.getParaStyle = function (data) {
  325. return {
  326. ...getParaStyle.call(this, ...arguments),
  327. ...data?.style || {}
  328. }
  329. }
  330. })
  331.  
  332.  
  333. /*============================================================
  334. * 설정 요소 및 요소 스타일
  335. *============================================================*/
  336. GM_addStyle(`
  337. :root {
  338. --ezdc-color-background: #fff;
  339. --ezdc-color-background-alt: #f1f1f1;
  340. --ezdc-color-background-error: #ffbeb8;
  341. --ezdc-color-background-border: #cdcdcd;
  342. --ezdc-color-primary: #3b4890;
  343. --ezdc-color-error: #b72a1d;
  344. }
  345. /* 김유식이 코드 개같이 짜서 darkmode 클래스 항상 존재함 */
  346. /*
  347. html.darkmode {
  348. --ezdc-color-background: #222;
  349. --ezdc-color-background-alt: #151515;
  350. --ezdc-color-background-error: #402323;
  351. --ezdc-color-background-border: #484848;
  352. }
  353. */
  354. html.refresherDark {
  355. --ezdc-color-background: #151515;
  356. --ezdc-color-background-alt: #111;
  357. --ezdc-color-background-error: #402323;
  358. --ezdc-color-background-border: #484848;
  359. --ezdc-color-primary: #292929;
  360. }
  361.  
  362. .ezdc-preview {
  363. position: fixed;
  364. top: 0;
  365. left: 0;
  366. z-index: 9999;
  367. width: 100%;
  368. height: 100%;
  369. backdrop-filter: brightness(20%) blur(.25rem);
  370. background-position-x: center;
  371. background-position-y: center;
  372. background-size: contain;
  373. background-repeat: no-repeat;
  374. cursor: pointer;
  375. }
  376. .ezdc-preview:not([style]) {
  377. display: none;
  378. }
  379.  
  380. .ezdc-wrap {
  381. margin: 15px 0;
  382. display: grid;
  383. height: 300px;
  384. grid-template-columns: 1fr 1fr;
  385. grid-template-rows: 1fr 1fr;
  386. grid-column-gap: 5px;
  387. grid-row-gap: 5px;
  388. padding: 5px;
  389. border: 1px solid var(--ezdc-color-background-border);
  390. background-color: var(--ezdc-color-background-alt);
  391. }
  392. .ezdc-wrap > ul {
  393. overflow-y: auto;
  394. border: 1px solid var(--ezdc-color-primary);
  395. border-radius: 2px;
  396. box-sizing: border-box;
  397. background-color: var(--ezdc-color-background);
  398. }
  399. .ezdc-wrap > ul > li:first-child {
  400. margin-bottom: 5px;
  401. padding: 5px;
  402. background-color: var(--ezdc-color-primary);
  403. font-weight: bold;
  404. color: white;
  405. }
  406. .ezdc-wrap > ul > li:not(:first-child) {
  407. margin: 0 5px 5px;
  408. display: flex;
  409. gap: 3px;
  410. font-weight: bold;
  411. }
  412. .ezdc-wrap > ul input {
  413. flex-grow: 1;
  414. border: 1px solid var(--ezdc-color-primary);
  415. border-radius: 2px;
  416. }
  417. .ezdc-wrap > ul input.invalid {
  418. border: 1px solid var(--ezdc-color-error);
  419. background-color: var(--ezdc-color-background-error);
  420. }
  421. .ezdc-wrap > ul button {
  422. padding: 0 3px;
  423. background-color: var(--ezdc-color-primary);
  424. border: 1px solid rgba(0, 0, 0, 0.5);
  425. border-radius: 2px;
  426. color: white;
  427. }
  428. .ezdc-wrap > ul button:last-child,
  429. .ezdc-wrap > ul button:nth-last-child(2) {
  430. width: 20px;
  431. }
  432.  
  433. .ezdc-headers {
  434. grid-area: 1/1/2/2;
  435. }
  436. .ezdc-footers {
  437. grid-area: 2/1/3/2;
  438. }
  439. .ezdc-imageURLs {
  440. grid-area: 1/2/3/3;
  441. }
  442. `)
  443.  
  444. const $preview = document.createElement('div')
  445. $preview.classList.add('ezdc-preview')
  446. $preview.addEventListener('click', e => e.target.removeAttribute('style'))
  447. document.body.append($preview)
  448.  
  449. const $optionWrap = document.createElement('div')
  450. $optionWrap.classList.add('ezdc-wrap')
  451.  
  452. const $editorWrap = document.querySelector('.editor_wrap')
  453. $editorWrap.insertAdjacentElement('afterend', $optionWrap)
  454.  
  455. // 목록 옵션 별 요소 생성
  456. for (const i of [
  457. {
  458. field: 'headers',
  459. name: '말머리',
  460. placeholder: '말머리 내용'
  461. },
  462. {
  463. field: 'footers',
  464. name: '말꼬리',
  465. placeholder: '말꼬리 내용'
  466. },
  467. {
  468. field: 'imageURLs',
  469. name: '자짤',
  470. placeholder: 'https://...',
  471. buttons: [['미리보기', function () {
  472. this.addEventListener('click', e => {
  473. e.preventDefault()
  474. const $input = this.parentNode.querySelector('input')
  475. if (!$input.classList.contains('invalid') && $input.value) {
  476. $preview.style.backgroundImage = `url(${$input.value})`
  477. }
  478. })
  479. }]],
  480. validate: value => value === '' || value.match(/^https?:\/\//)
  481. }
  482. ]) {
  483. const $wrap = document.createElement('ul')
  484. $wrap.innerHTML = `<li>${i.name}</li>`
  485. $wrap.classList.add(`ezdc-${i.field}`)
  486.  
  487. function save () {
  488. const option = GM_getValue(gallery.optionKey, {})
  489. const items = [...$wrap.querySelectorAll(':not(:first-child) input:not(.invalid)')]
  490. option[i.field] = items.filter(v => v.value).map(v => v.value)
  491. if (option[i.field].length < 1) {
  492. delete option[i.field]
  493. }
  494.  
  495. GM_setValue(gallery.optionKey, option)
  496. }
  497.  
  498. function onChange () {
  499. if (this.classList.contains('invalid')) {
  500. this.classList.remove('invalid')
  501. }
  502. if (i.validate && !i.validate(this.value)) {
  503. this.classList.add('invalid')
  504. return
  505. }
  506.  
  507. save()
  508. }
  509.  
  510. // 모든 목록 설정에 사용되는 버튼들
  511. const commonButtons = [
  512. ['+', function (e) {
  513. this.addEventListener('click', e => {
  514. e.preventDefault()
  515. // 쉬프트를 누른 상태라면 현재 값 복사
  516. const $input = this.parentNode.querySelector('input')
  517. insert(e.shiftKey ? $input.value : '', this.parentNode)
  518. save()
  519. })
  520. }],
  521. ['-', function (e) {
  522. this.addEventListener('click', e => {
  523. e.preventDefault()
  524. // 값이 비어있지 않을 때 경고 메세지 표시
  525. const $input = this.parentNode.querySelector('input')
  526. if (!e.shiftKey && $input.value && !confirm('삭제된 값은 되돌릴 수 없습니다, 삭제할까요?')) {
  527. return
  528. }
  529. if ($wrap.children.length > 2) {
  530. this.parentNode.remove()
  531. } else {
  532. $input.value = ''
  533. }
  534. save()
  535. })
  536. }]
  537. ]
  538.  
  539. function insert (value, afterNode = null) {
  540. const $item = document.createElement('li')
  541. $item.innerHTML = `<input type="text" placeholder="${i.placeholder}" value="${value}">`
  542.  
  543. afterNode ? afterNode.insertAdjacentElement('afterend', $item) : $wrap.append($item)
  544.  
  545. const $input = $item.querySelector('input')
  546. $input.addEventListener('change', onChange)
  547.  
  548. for (const [name, callback] of [ ...(i.buttons || []), ...commonButtons ]) {
  549. const $button = document.createElement('button')
  550. $button.textContent = name
  551. $item.append($button)
  552. callback.call($button)
  553. }
  554. }
  555.  
  556. for (const value of [...gallery.option[i.field], '']) {
  557. insert(value)
  558. }
  559.  
  560. $optionWrap.append($wrap)
  561. }

QingJ © 2025

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