Last active
June 6, 2022 04:47
-
-
Save toriato/048b544176144830fb4ee1c52ae582ac to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name dcinside.list.preview.user.js | |
// @namespace https://github.com/toriato/userscripts/dcinside.list.preview.user.js | |
// @description 디시인사이드 갤러리 목록에서 제목 위에 커서를 올려 게시글을 미리 열람합니다 | |
// @author Sangha Lee <[email protected]> | |
// @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/board/lists* | |
// @match https://gall.dcinside.com/mgallery/board/lists* | |
// @match https://gall.dcinside.com/mini/board/lists* | |
// @run-at document-end | |
// @grant GM_addStyle | |
// @grant GM_xmlhttpRequest | |
// @downloadURL https://github.com/toriato/userscripts/raw/master/dcinside.list.preview.user.js | |
// @supportURL https://github.com/toriato/userscripts/issues | |
// ==/UserScript== | |
GM_addStyle(/*css*/` | |
preview { | |
display: none; | |
flex-direction: column; | |
position: fixed; | |
overflow: hidden; | |
overflow-y: auto; | |
top: 0; | |
left: 0; | |
z-index: 100; | |
width: 600px; | |
height: 500px; | |
padding: 1em; | |
box-sizing: border-box; | |
box-shadow: 0 0 10px black; | |
background: rgba(0, 0, 0, 0.75); | |
backdrop-filter: blur(4px); | |
font-size: 1rem; | |
color: white; | |
} | |
preview[data-done] { | |
display: flex; | |
} | |
preview form { | |
display: flex; | |
box-sizing: border-box; | |
margin-bottom: 1em; | |
} | |
preview form input { | |
box-sizing: border-box; | |
width: 100%; | |
padding: .5em; | |
font-size: .75rem; | |
} | |
preview article { | |
overflow: auto; | |
} | |
preview article img { | |
max-width: 100%; | |
max-height: 100px; | |
cursor: pointer; | |
} | |
preview article img.active { | |
max-height: 100%; | |
} | |
.gall_num { | |
cursor: pointer; | |
} | |
`) | |
/** 서비스 코드 복호화를 위한 전역 디코드 키 */ | |
const KEY = 'yL/M=zNa0bcPQdReSfTgUhViWjXkYIZmnpo+qArOBslCt2D3uE4Fv5G6wH178xJ9K' | |
/** @type {() => void} */ | |
let abortArticleFetching | |
const $preview = document.createElement('preview') | |
$preview.innerHTML = /*html*/` | |
<form> | |
<input type="text" placeholder="댓글 내용을 입력해주세요"> | |
<button>작성</button> | |
</form> | |
<article></article> | |
` | |
/** @type {HTMLElement} */ | |
let $previewArticle | |
$previewArticle = $preview.querySelector('article') | |
/** @type {HTMLFormElement} */ | |
let $previewForm | |
$previewForm = $preview.querySelector('form') | |
$previewFormInput = $previewForm.querySelector('input') | |
/** | |
* 난독화된 디시인사이드 서비스 코드를 복호화합니다 | |
* @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) | |
} | |
/** | |
* 게시글 정보를 가져옵니다 | |
* @param {string} galleryId | |
* @param {string} articleId | |
*/ | |
function fetchArticle(galleryId, articleId) { | |
// 이미 다른 작업이 진행 중이라면 해당 작업 취소하기 | |
if (abortArticleFetching) { | |
abortArticleFetching() | |
} | |
return new Promise((resolve, reject) => { | |
const req = GM_xmlhttpRequest({ | |
url: `https://m.dcinside.com/board/${galleryId}/${articleId}`, | |
headers: { | |
Accept: 'image/webp', // webp 이미지 반환을 위한 필수 헤더 | |
'User-Agent': '(Android)' // 모바일 웹 요청을 위한 필수 헤더 | |
}, | |
onload: resolve, | |
onerror: reject, | |
onabort: () => reject(new Error('요청이 취소됐습니다')), | |
ontimeout: () => reject(new Error('요청 시간이 만료됐습니다')) | |
}) | |
abortArticleFetching = req.abort | |
}) | |
} | |
document.body.appendChild($preview) | |
document.addEventListener('mousemove', e => { | |
// 커서가 미리보기 요소 위에 있다면 작업 무시하기 | |
if (e.target.closest('preview')) | |
return | |
// 커서가 게시글 요소 위에 없다면 미리보기 요소 숨기기 | |
/** @type {HTMLElement} */ | |
const $article = e.target.closest('.us-post') | |
if (!$article) { | |
delete $preview.dataset.done | |
$previewFormInput.value = '' | |
return | |
} | |
// 게시글 요소 주소에서 갤러리 아이디와 게시글 번호 가져오기 | |
const href = $article.querySelector('.gall_tit > a').href | |
const params = new URLSearchParams(href.split('?').pop()) | |
const selectedGalleryId = params.get('id') | |
const selectedArticleId = params.get('no') | |
// 현재 미리보는 게시글과 일치한다면 작업 종료하기 | |
if ($preview.dataset.id === selectedGalleryId && $preview.dataset.no === selectedArticleId) | |
return | |
// 비동기 작업 완료 전까지 미리보기 요소 숨기기 | |
delete $preview.dataset.done | |
// 미리보기 요소 아이디와 게시글 번호 변경하기 | |
$preview.dataset.id = selectedGalleryId | |
$preview.dataset.no = selectedArticleId | |
// 디시인사이드 모바일 웹 페이지를 통해 게시글 본문 내용 불러오기 | |
fetchArticle(selectedGalleryId, selectedArticleId) | |
.then(({ responseText }) => { | |
// 본문을 불러온 뒤 다른 게시글이 선택됐다면 무시하기 | |
if ($preview.dataset.id !== selectedGalleryId || $preview.dataset.no !== selectedArticleId) | |
return | |
$preview.dataset.done = null | |
// 미리보기 요소 위치 미리 계산해두기 | |
let { x, y } = e | |
{ | |
const rect = $preview.getBoundingClientRect() | |
// 마우스 커서 우측에 공간 만들기 | |
x += 50 | |
// 페이지 우측 넘어가지 않게 조절 | |
if (x + rect.width > window.innerWidth) | |
x = window.innerWidth - rect.width | |
// 페이지 상단 넘어가지 않게 조절 | |
if (y < 0) | |
y = 0 | |
// 페이지 하단 넘어가지 않게 조절 | |
if (y + rect.height > window.innerHeight) | |
y = window.innerHeight - rect.height | |
} | |
// 미리보기 요소 위치 설정하기 | |
$preview.style.left = x + 'px' | |
$preview.style.top = y + 'px' | |
// 미리보기 요소 속에 본문 내용 넣기 | |
{ | |
const $wrapper = document.createElement('html') | |
$wrapper.innerHTML = responseText | |
$previewArticle.innerHTML = $wrapper.querySelector('.thum-txtin').innerHTML | |
for (let $img of $preview.querySelectorAll('img[data-original]')) { | |
$img.setAttribute('src', $img.dataset.original) | |
$img.addEventListener('click', () => $img.classList.toggle('active')) | |
} | |
} | |
// | |
$previewFormInput.focus() | |
}) | |
.catch(console.error) | |
}) | |
$previewForm.addEventListener('submit', function (e) { | |
e.preventDefault() | |
const galleryId = $preview.dataset.id | |
const articleId = $preview.dataset.no | |
const memo = $previewFormInput.value.trim() | |
// 내용이 비어있다면 무시하기 | |
if (memo === '') | |
return | |
$previewFormInput.value = '' | |
// TODO: 미니 갤러리 지원하기, 엔드포인트 지정 필요 (/mgallery -> /mini) | |
fetch({ url: `https://gall.dcinside.com/mgallery/board/view/?id=${galleryId}&no=${articleId}` }) | |
// 서비스 코드 디코딩하기 | |
.then(({ responseText }) => { | |
return deobfuscate( | |
responseText.match(/_d\('([^']+)/)[1], // 자바스크립트 단에 있는 난독화된 키 | |
responseText.match(/service_code" value="([^"]+)/)[1] // 폼 데이터 | |
) | |
}) | |
// 댓글 작성 요청하기 | |
.then(serviceCode => { | |
const payload = { | |
// 갤러리 종류: 메이저 'G', 마이너 'M', 미니 'MI' | |
// TODO: 갤러리 별로 값 잡아줘야함 | |
_GALLTYPE_: 'M', | |
// 갤러리 아이디와 게시글 번호 | |
id: galleryId, | |
no: articleId, | |
// 유동일 때 사용되는 작성자 정보 | |
// TODO: 쿠키에서 기본 값 가져오기 | |
// name: 'ㅇㅇ', | |
// password: 'ㅇㅇ', | |
// 댓글 내용 | |
memo, | |
// 아마도... 중복 댓글인지 확인하기 위한 페이로드, 무작위 값을 넣을 필요는 없음 | |
check_6: generateRandomString(), | |
check_7: generateRandomString(), | |
check_8: generateRandomString(), | |
check_9: generateRandomString(), | |
// 자동 입력을 방지하기 위해 클라이언트 측에서 복호화 처리하는 토큰 비스무리한 값 | |
service_code: serviceCode, | |
} | |
return fetch({ | |
method: 'POST', | |
url: 'https://gall.dcinside.com/board/forms/comment_submit', | |
headers: { | |
Referer: 'https://gall.dcinside.com', | |
'Content-Type': 'application/x-www-form-urlencoded', | |
'X-Requested-With': 'XMLHttpRequest' | |
}, | |
data: new URLSearchParams(payload).toString() | |
}) | |
}) | |
.then(({ responseText }) => { | |
// 댓글이 정상적으로 작성됐다면 댓글 번호를 반환해주므로 | |
// 모두 숫자가 아니라면 오류로 처리하기 | |
if (!responseText.match(/^\d+$/)) { | |
throw new Error(responseText) | |
} | |
// alert('성공적으로 댓글을 작성했습니다') | |
}) | |
.catch(err => { | |
// 현재 미리보는 게시글과 일치한다면 이전 댓글 내용 붙여넣기 | |
if ($preview.dataset.id === galleryId && $preview.dataset.no === articleId) | |
$previewFormInput.value = memo | |
alert('댓글 작성 중 오류가 발생했습니다:\n' + err.message) | |
console.error(err) | |
}) | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment