Skip to content

Instantly share code, notes, and snippets.

@toriato
Last active June 6, 2022 04:47
Show Gist options
  • Save toriato/048b544176144830fb4ee1c52ae582ac to your computer and use it in GitHub Desktop.
Save toriato/048b544176144830fb4ee1c52ae582ac to your computer and use it in GitHub Desktop.
// ==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