NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name DCInside Report Helper // @description 디시인사이드에서 신문고 글이 있는 마이너 또는 미니 갤러리에 신고 댓글을 자동으로 만들어 올려주는 버튼을 추가합니다 // @version 1.0.0 // @author toriato // @copyright 2021, Sangha Lee // @license MIT // @icon https://nstatic.dcinside.com/dc/m/img/dcinside_icon.png // @require https://github.com/toriato/userscripts/raw/master/library/fetch.js // @match https://gall.dcinside.com/mgallery/board/view/* // @match https://gall.dcinside.com/mini/board/view/* // @match https://m.dcinside.com/board/* // @run-at document-end // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @updateURL https://openuserjs.org/meta/toriato/DCInside_Report_Helper.user.js // @supportURL https://github.com/toriato/userscripts/issues // @homepageURL https://github.com/toriato/userscripts // ==/UserScript== /** * @typedef Article * @property {string} galleryId 갤러리 아이디 * @property {string} articleId 게시글 번호 * @property {string} username 작성자 아이디 또는 아이피 * @property {string} nickname 작성자 닉네임 * @property {string} category 말머리 * @property {string} subject 제목 * @property {string} content 내용 * @property {Object[]} attachments 첨부 파일 * @property {string} createdAt 작성 시간 * @property {number} createdAtTimestamp 작성 시간 (유닉스 타임스탬프) */ /** 서비스 코드 복호화를 위한 전역 디코드 키 */ const KEY = 'yL/M=zNa0bcPQdReSfTgUhViWjXkYIZmnpo+qArOBslCt2D3uE4Fv5G6wH178xJ9K' /** Sia Skynet 업로드 주소 */ const SKYNET_ENDPOINT_UPLOAD = 'https://siasky.net' /** Sia Skynet 다운로드 주소 */ const SKYNET_ENDPOINT_DOWNLOAD = 'https://siasky.net' /** 신문고 익명 댓글 닉네임 */ const COMMENT_NICKNAME = 'ㅇㅇ' /** 신문고 익명 댓글 비밀번호, 비어있다면 무작위 생성 */ const COMMENT_PASSWORD = null /** 항상 익명으로 신문고 댓글을 작성할지? */ const ALWAYS_ANONYMOUS = false /** 게시글 백업 디렉터리 내 index.html */ const INDEX = /*html*/` <!Doctype HTML> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> html, body { margin: 0; padding: 0; width: 100%; } body { padding: .5em } img { max-width: 100% } </style> </head> <body> <ul> <li> <b><a href="https://github.com/toriato/userscripts/blob/master/dcinside.reporter.user.js">dcinside.reporter.user.js</a></b> by <b><a href="https://gallog.dcinside.com/springkat">애옹이도둑</a></b> </li> <li><a href="index.json">메타데이터 받기 (JSON)</a></li> <li><a href="attachments">첨부 파일 받기 (ZIP)</a></li> </ul> <article> <h3 class="title"> <span data-property="category"></span> <span data-property="subject"></span> </h3> <div class="summary"> <p><span data-property="nickname"></span> (<span data-property="username"></span>)</p> </div> <div data-property="content"></div> </article> <script> fetch('index.json') .then(res => res.json()) .then(metadata => { for (let element of document.querySelectorAll('[data-property]')) { const key = element.dataset.property if (!(key in metadata.article)) continue element.innerHTML = metadata.article[key] } }) .catch(e => { alert('메타데이터를 불러오는 중 오류가 발생했습니다:\\n' + e.message) console.error(e) }) </script> </body> </html> ` /** 파일 확장명 <-> MIME 종류 맵핑 변수 */ const fileExtensions = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', bmp: 'image/bmp', gif: 'image/gif', webp: 'image/webp', mp4: 'video/mp4', webm: 'video/webm' } // 현재 페이지 갤러리 아이디와 게시글 번호 가져오기 const isMobile = location.hostname.startsWith('m.dcinside.com') const params = (new URL(location.href)).searchParams let galleryId = params.get('id') let articleId = params.get('no') // 모바일 페이지에선 주소에서 값 가져오기 if (isMobile) { [galleryId, articleId] = location.pathname.replace(/^\/board\//, '').split('/', 2) } /** * 난독화된 디시인사이드 서비스 코드를 복호화합니다 * @param {string} keys 페이지에서 제공한 난독화된 키 * @param {string} code 난독화된 서비스 코드 (service_code) * @returns {string} 복호화된 서비스 코드 */ function deobfuscate(keys, code) { // common.js?v=210817:858 const k = Array(4) let o = [] for (let c = 0; c < keys.length;) { for (let i = 0; i < k.length; i++) k[i] = KEY.indexOf(keys.charAt(c++)) o.push(k[0] << 2 | k[1] >> 4) if (k[2] != 64) o.push((15 & k[1]) << 4 | k[2] >> 2) if (k[3] != 64) o.push((3 & k[2]) << 6 | k[3]) } keys = o.map(v => String.fromCharCode(v)).join('') // common.js?v=210817:862 const fi = parseInt(keys.charAt()) keys = (fi + (fi > 5 ? -5 : 4)) + keys.slice(1) // common.js?v=210817:859 o = [code.slice(0, -10)] keys .split(',') .map((v, idx) => { const key = parseFloat(v) o.push(String.fromCharCode(2 * (key - idx - 1) / (13 - idx - 1))) }) return o.join('') } /** * 무작위 문자열을 생성합니다 * @returns {string} 무작위 문자열 */ function generateRandomString() { return (Math.random() + 1).toString(36).substring(2) } /** * Blob 의 해시 문자열을 구합니다 * @param {Blob} blob * @returns {Promise<string>} 해시 문자열 */ async function hashBlob(algorithm, blob) { const buffer = await blob.arrayBuffer() const hash = await crypto.subtle.digest(algorithm, buffer) const hashArray = Array.from(new Uint8Array(hash)) const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') return hashHex } /** * 현재 페이지에서 글 정보를 가져옵니다 * @returns {Article} */ function fetchArticle() { /** @type {Article} */ const article = { galleryId, articleId, attachments: [] } if (isMobile) { } else { // 작성자 정보 const author = document.querySelector('.gall_writer') article.username = author.dataset.uid + author.dataset.ip article.nickname = author.dataset.nick // 말머리와 제목 article.category = document.querySelector('.title_headtext').textContent.trim() article.subject = document.querySelector('.title_subject').textContent.trim() // 내용 article.content = document.querySelector('.write_div').innerHTML } return article } /** * 현재 갤러리의 신문고 글을 찾아 유저스크립트 저장 공간에 저장합니다 * @returns {Promise<void>} */ async function fetchReportArticle() { // 모바일 디시인사이드 API 로 글 목록 불러오기 const { response } = await fetch({ method: 'POST', url: 'https://m.dcinside.com/ajax/response-list', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, responseType: 'json', data: new URLSearchParams({ id: galleryId }).toString() }) for (let article of response.gall_list.data) { // 공지 글은 가장 상단에 위치하므로 처음으로 찾은 신문고 글 사용하기 if (article.subject.includes('신문고')) { // 유저스크립트 저장 공간에 신문고 글 번호 저장하기 GM_setValue(galleryId, article.no) return } } // 찾을 수 없다면 오류 반환하기 throw new Error('신문고 게시글을 찾을 수 없습니다') } /** * 신문고 글 페이지에서 서비스 코드를 가져옵니다 * @returns {Promise<string>} 복호화된 서비스 코드 */ async function fetchServiceCode() { const reportArticleId = GM_getValue(galleryId, 0) const { responseText, status } = await fetch({ // TODO: 미니 갤러리 지원하기, 엔드포인트 지정 필요 (/mgallery -> /mini) url: `https://gall.dcinside.com/mgallery/board/view/?id=${galleryId}&no=${reportArticleId}` }) // 삭제됐거나 존재하지 않는 글이라면 신문고 글 새로 찾은 뒤 함수 재실행하기 if (status !== 200) { await fetchReportArticle() return fetchServiceCode() } // 난독화된 서비스 코드 복호화하기 return deobfuscate( responseText.match(/_d\('([^']+)/)[1], // 자바스크립트 단에 있는 난독화된 키 responseText.match(/service_code" value="([^"]+)/)[1] // 폼 데이터 ) } /** * 현재 글에 포함된 파일과 본문 내용을 File 배열로 변환합니다 * @param {Article} article 게시글 정보 * @returns {Promise<File[]>} */ async function articleToFiles(article) { const currentDate = new Date() const metadata = { href: location.href, article, backupAt: currentDate.toString(), backupAtTimestamp: +currentDate, } // 중복 업로드 방지를 위해 해시 기준으로 파일 임시 보관하기 // 비동기로 작동하므로 딱히 트래픽을 줄여주진 않음 const hashs = {} // 본문에 포함된 파일을 비동기로 불러와야하므로 비동기 배열 준비하기 const promises = [] // 본문 내용 후 처리 시작하기 const content = document.createElement('div') content.innerHTML = metadata.article.content // 원본 주소를 가지고 있는 모든 요소를 파일로 백업하기 for (let element of content.querySelectorAll('[src]')) { const url = element.getAttribute('src') const p = fetch({ url, headers: { Referer: 'https://gall.dcinside.com' }, // 디시인사이드 이미지는 레퍼 값을 요구 responseType: 'blob' }) .then( async ({ response, responseHeaders }) => { const hash = await hashBlob('SHA-1', response) const attachment = { originalName: null, url, name: `attachments/${hash}`, type: 'application/octet-stream' } // 서버에서 반환한 헤더 파싱하기 const headers = {} responseHeaders.split('\n').map(line => { const [k, v] = line.split(':', 2).map(v => v.trim()) headers[k] = v }) // 서버에서 반환한 헤더로 파일 이름과 종류 가져오기 if ('content-disposition' in headers) { const attrs = [] headers['content-disposition'].split(';').map(attr => { const [k, v] = attr.split('=', 2).map(v => v.trim()) attrs[k] = v }) if (attrs.filename) { // 따옴표로 감싸있는 경우 첫번째와 마지막 글자 제외하기 let filename = attrs.filename if (filename.match(/^['"]/)) { filename = filename.replace(/^.|.$/, '') } attachment.originalName = filename // 파일 이름 중 점으로 나눴을 때 가장 마지막 값을 확장자로 가져오기 const extension = filename.split('.').pop() // 일치하는 MIME 가 있다면 파일 종류 값 설정하기 if (extension in fileExtensions) { attachment.type = fileExtensions[extension] } attachment.name += '.' + extension } } else if ('content-type' in headers) { // 일치하는 MIME 가 있다면 파일 경로 끝에 확장자 추가하기 for (let [extension, mime] of Object.entries(fileExtensions)) { if (mime === headers['content-type']) { attachment.name += '.' + extension break } } attachment.type = headers['content-type'] } else { console.warn('파일의 이름과 종류를 유추할 수 있는 값이 반환되지 않았습니다 ') } // 중복된 파일이 아닌 경우 해시에 추가하기 if (!(hash in hashs)) { hashs[hash] = new File([response], attachment.name, { type: attachment.type }) } metadata.article.attachments.push(attachment) // 기존 요소의 원본 주소를 업로드할 파일 경로로 변경하기 element.setAttribute('src', hashs[hash].name) } ) .catch(console.error) promises.push(p) } // 모든 비동기 작업 대기하기 await Promise.all(promises) // 후 처리 끝난 본문 내용 메타데이터에 저장하기 metadata.article.content = content.innerHTML const files = Object.values(hashs) files.push( new File([new Blob([INDEX])], 'index.html', { type: 'text/html' })) files.push( new File([new Blob([JSON.stringify(metadata)])], 'index.json', { type: 'application/json' })) return files } /** * Sia Skynet 에 여러 파일을 업로드합니다 * @param {File[]} files * @returns {Promise<string>} Skylink */ async function submitFilesToSkynet(files) { const data = new FormData() for (let file of files) { data.append('files[]', file) } const { response } = await fetch({ method: 'POST', url: SKYNET_ENDPOINT_UPLOAD + '/skynet/skyfile?filename=undefined', responseType: 'json', data }) if (!response.skylink) { throw new Error('스카이넷 파일 업로드에 실패했습니다') } return response.skylink } /** * 새 댓글을 만듭니다 * @param {Object} payload * @returns {Promise<string>} 작성된 새 댓글 아이디 */ async function submitComment(payload) { const { responseText } = await fetch({ method: 'POST', url: 'https://gall.dcinside.com/board/forms/comment_submit', // anonymous 옵션이 true 라면 기존 쿠키를 전송하지도 반환된 쿠키를 저장하지도 않음 anonymous: ALWAYS_ANONYMOUS, // 댓글 작성 요청할 때 서버에서 확인하는 필수 헤더들 headers: { Referer: 'https://gall.dcinside.com', 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' }, data: new URLSearchParams(payload).toString() }) // 댓글이 정상적으로 작성됐다면 댓글 번호를 반환해주므로 // 모두 숫자가 아니라면 오류로 처리하기 if (!responseText.match(/^\d+$/)) { throw new Error(responseText) } return parseInt(responseText, 10) } async function submitReport() { const article = fetchArticle() const payload = { // 갤러리 종류: 마이너 'M', 미니 'MI' _GALLTYPE_: 'M', // 갤러리 아이디와 게시글 번호 id: galleryId, no: GM_getValue(galleryId), // 유동일 때 사용되는 작성자 정보 name: COMMENT_NICKNAME, password: COMMENT_PASSWORD ? COMMENT_PASSWORD : generateRandomString(), // 댓글 내용 memo: '', // 아마도... 중복 댓글인지 확인하기 위한 페이로드, 무작위 값을 넣을 필요는 없음 check_6: generateRandomString(), check_7: generateRandomString(), check_8: generateRandomString(), check_9: generateRandomString(), // 자동 입력을 방지하기 위해 클라이언트 측에서 복호화 처리하는 토큰 비스무리한 값 service_code: null, } let skylink await Promise.all([ fetchServiceCode() .then(code => payload.service_code = code), articleToFiles(article) .then(files => submitFilesToSkynet(files)) .then(v => skylink = v) ]) if (!payload.service_code) { return } // 게시글 작성자 정보와 말머리, 제목 payload.memo += `${article.nickname} (${article.username}) ${article.category} ${article.subject}` // 원본 게시글 주소 payload.memo += `\nhttps://m.dcinside.com/board/${article.galleryId}/${article.articleId}` // 백업 게시글 주소 payload.memo += `\n${SKYNET_ENDPOINT_DOWNLOAD}/${skylink}` // 신문고 글에 댓글 작성하기 const reportCommentId = await submitComment(payload) return reportCommentId } document.querySelector('.btn_report') .addEventListener('click', () => { submitReport() .then(reportCommentId => alert('신문고에 새 댓글을 올렸습니다: ' + reportCommentId)) .catch(e => { alert('신문고 댓글 작성 중 오류가 발생했습니다:\n' + e.message) console.error(e) }) })