Instantly share code, notes, and snippets.
Last active
July 10, 2021 15:48
-
Star
(4)
4
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save foriequal0/f65c8bb896762f55b2f8efb521addf9a to your computer and use it in GitHub Desktop.
네이버 웹툰 최신회차 바로가기
This file contains 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 네이버 웹툰 최신회차 바로가기 버튼 | |
// @namespace gist.github.com/foreiqual0 | |
// @include https://comic.naver.com/webtoon/list.nhn?* | |
// @include https://comic.naver.com/webtoon/detail.nhn?* | |
// @include https://comic.naver.com/bestChallenge/list.nhn?* | |
// @include https://comic.naver.com/bestChallenge/detail.nhn?* | |
// @include https://comic.naver.com/challenge/list.nhn?* | |
// @include https://comic.naver.com/challenge/detail.nhn?* | |
// @include https://comic.naver.com/webtoon/weekday.nhn | |
// @include https://comic.naver.com/webtoon/weekdayList.nhn?* | |
// @downloadURL https://gist.github.com/foriequal0/f65c8bb896762f55b2f8efb521addf9a/raw/naver-webtoon-most-recent.user.js | |
// @version 20 | |
// @grant GM.setValue | |
// @grant GM.getValue | |
// @run-at document-end | |
// ==/UserScript== | |
(async function () { | |
function getKey(type) { | |
if (type == "webtoon") { | |
return "v1"; | |
} else if (type == "bestChallenge") { | |
return "v1:bestChallenge"; | |
} else if (type == "challenge") { | |
return "v1:challenge"; | |
} | |
} | |
async function getStates(type) { | |
const states = await GM.getValue(getKey(type), {}) | |
.then(states => { | |
for (const [titleId, state] of Object.entries(states)) { | |
states[titleId] = { | |
...state, | |
seen: new Set(state.seen), | |
lastSeenAt: state.lastSeenAt ? new Date(state.lastSeenAt) : null, | |
}; | |
} | |
return states; | |
}); | |
console.groupCollapsed("states"); | |
console.table(states); | |
console.groupEnd(); | |
return states; | |
} | |
function getState(states, titleId) { | |
if (states[titleId]) { | |
return states[titleId]; | |
} | |
return { | |
seen: new Set(), | |
lastSeenAt: null | |
} | |
} | |
async function tryUpdateSeen(type, states, titleId, no) { | |
no = parseInt(no) || undefined; | |
const now = new Date(); | |
let state = getState(states, titleId); | |
if (state.seen.has(no)) { | |
return states; | |
} | |
// HACK: addEventListener에서는 async함수가 끝나길 기다리지 않음. 그래서 await 경계를 넘는 side-effect는 반영되지 않음. | |
// 아래 detail() 함수중에 onclick에서 states = tryUpdate... 하는 부분에서 states를 업데이트하길 기대하고, | |
// beforeunload에서 getState(states, titleId).seen.has(no) 가 있는데, 그 부분을 위한 핵. | |
states[titleId] = { | |
...state, | |
title: document.title, | |
seen: state.seen.add(no), | |
lastSeenAt: now, | |
}; | |
// lock이 없는 관계로 lost update problem 이 발생한다. loop 를 돌면서 업데이트가 확정 반영될 때 까지 반복한다. | |
while(true) { | |
// reload state | |
states = await getStates(type); | |
state = getState(states, titleId); | |
if (state.seen.has(no)) { | |
return states; | |
} | |
states[titleId] = { | |
...state, | |
title: document.title, | |
seen: state.seen.add(no), | |
lastSeenAt: now, | |
}; | |
console.log("업데이트", state); | |
const serializedStates = {}; | |
for(const [titleId, state] of Object.entries(states)) { | |
const lastSeenDays = (now - state.lastSeenAt) / 1000 / 60 / 60 / 24; | |
serializedStates[titleId] = { | |
...state, | |
seen: [...state.seen].sort(), | |
lastSeenAt: state.lastSeenAt ? state.lastSeenAt.toISOString() : undefined, | |
}; | |
} | |
await GM.setValue(getKey(type), serializedStates); | |
} | |
return states; | |
} | |
const url = new URL(window.location.href); | |
if (url.pathname.startsWith("/webtoon/list.nhn")) { | |
await list("webtoon") | |
} else if (url.pathname.startsWith("/bestChallenge/list.nhn")) { | |
await list("bestChallenge"); | |
} else if (url.pathname.startsWith("/challenge/list.nhn")) { | |
await list("challenge"); | |
} else if (url.pathname.startsWith("/webtoon/detail.nhn")){ | |
await detail("webtoon"); | |
} else if (url.pathname.startsWith("/bestChallenge/detail.nhn")) { | |
await detail("bestChallenge"); | |
} else if (url.pathname.startsWith("/challenge/detail.nhn")) { | |
await detail("challenge"); | |
} else if (url.pathname.startsWith("/webtoon/weekdayList.nhn")) { | |
const states = await getStates("webtoon"); | |
sort(states, document.querySelector(".img_list")); | |
return; | |
} else if (url.pathname.startsWith("/webtoon/weekday.nhn")) { | |
const states = await getStates("webtoon"); | |
for (const column of document.querySelectorAll("div.col")) { | |
const ul = column.querySelector("ul"); | |
const days = column.querySelector("span").textContent; | |
sort(states, ul, days); | |
} | |
return; | |
} | |
async function list(type) { | |
let states = await getStates(type); | |
const titleId = url.searchParams.get("titleId"); | |
function seen() { | |
return getState(states, titleId).seen; | |
} | |
const mostRecent = new URL(document.querySelector(".title > a").href); | |
const mostRecentNo = parseInt(mostRecent.searchParams.get("no")); | |
// 썸네일 링크는 마지막으로 본 화거나 최신회차로 | |
const thumb = document.querySelector(".thumb > a"); | |
const lastSeen = Math.max(...seen()); | |
if (lastSeen >= 0) { | |
const toSee = new URL(mostRecent.href); | |
toSee.searchParams.set("no", Math.min(lastSeen + 1, mostRecentNo)); | |
thumb.href = toSee.href; | |
} else { | |
thumb.href = mostRecent.href; | |
} | |
async function makeTransparent(target) { | |
if (target === thumb || target.href == thumb.href) { | |
thumb.style.opacity = 0.5; | |
} | |
if (target == thumb) { | |
document.querySelector(".title > a").closest("tr").style.opacity = 0.5; | |
} else { | |
target.closest("tr").style.opacity = 0.5; | |
} | |
} | |
// 본 회차 흐리게 | |
const links = document.querySelectorAll('.viewList * a[href*="/detail.nhn"]'); | |
for (const link of [thumb, ...links]) { | |
const currentNo = parseInt(new URL(link.href).searchParams.get("no")); | |
if (seen().has(currentNo)) { | |
makeTransparent(link); | |
} | |
} | |
// 이전에 본 화 바로 다음 업데이트가 최신화면 그대로 이동 | |
if (seen().has(mostRecentNo -1) && !seen().has(mostRecentNo)) { | |
window.location.href = mostRecent.href; | |
return; | |
} | |
// 나올 시간 됐는데 안나온거 있으면 새로고침함 | |
const weekdays = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; | |
const weekday = weekdays[url.searchParams.get("weekday")]; | |
if (weekday) { | |
// 네이버 웹툰은 1시간 일찍 공개된다 | |
const todays = new Date(new Date().getTime() + (9 + 1) * 60 * 60 * 1000).getUTCDay() | |
const seenAll = seen().has(mostRecentNo); | |
const theDay = todays == weekday; | |
const existNew = document.querySelector("img[alt='UP']") != null; | |
if (seenAll && theDay && !existNew) { | |
const delay = 5 - Math.random() * 2; // 3~5분 무작위 | |
console.log("새로고침 스케줄", delay); | |
setTimeout(() => { window.location.reload() }, delay * 60 * 1000); | |
} | |
} | |
} | |
async function detail(type) { | |
const states = await getStates(type); | |
const titleId = url.searchParams.get("titleId"); | |
const no = parseInt(url.searchParams.get("no")); | |
const items = [...document.querySelectorAll("#comic_move > div.item")]; | |
for (const item of items) { | |
const link = item.querySelector("a"); | |
if (link == null) continue; | |
const no = parseInt(new URL(link.href).searchParams.get("no")); | |
const state = getState(states, titleId); | |
if (state.seen.has(no)) { | |
item.style.opacity = 0.5; | |
} | |
} | |
if (!getState(states, titleId).seen.has(no)) { | |
async function onScroll() { | |
const target = document.getElementById("comic_view_area").getBoundingClientRect(); | |
if ( | |
target.bottom - window.innerHeight < 0 // 웹툰 끝이 화면 안에 들어왔다. | |
// 로딩이 덜 됐는데 웹툰 끝이 화면 안에 들어와버린 경우에 성급하게 판단하는걸 방지하기 위해 일단 절반 이상 스크롤을 내려야 함. | |
&& (target.top + target.bottom)/2 < 0 | |
) { | |
window.removeEventListener('scroll', onScroll); | |
await tryUpdateSeen(type, states, titleId, no); | |
} | |
} | |
window.addEventListener('scroll', onScroll); | |
window.addEventListener("beforeunload", function (event) { | |
if (getState(states, titleId).seen.has(no+1) || items[4].querySelector("a") == null) { | |
return; | |
} | |
event.returnValue = "다음 화를 안 봤는데 그냥 종료하십니까?"; | |
}); | |
} | |
} | |
function sort(states, ul, days) { | |
const reminders = []; | |
const actives = []; | |
const freshes = []; | |
const inactives = []; | |
const ups = new Set(); | |
const now = new Date(); | |
for (const li of ul.children) { | |
const titleId = new URL(li.querySelector("a").href).searchParams.get("titleId"); | |
const state = getState(states, titleId); | |
const watched = state ? state.seen.size != 0 : false; | |
const lastSeenDays = (now - state.lastSeenAt) / 1000 / 60 / 60 / 24; | |
const fresh = li.querySelector("span.ico_new2") !== null; | |
const up = li.querySelector("em.ico_updt") !== null; | |
if (fresh) { | |
if (!watched) { // 새로 나왔고 한번도 안봄 | |
freshes.push(li); | |
} else if (lastSeenDays >= 7 * 2.5) { // 새로 나왔고 좀 봤는데 3주동안 안봄 | |
inactives.push(li); | |
} else if (lastSeenDays >= 7 * 1.5) { // 새로 나왔고 2주 밀림 | |
reminders.push(li); | |
} else { | |
actives.push(li); | |
} | |
} else { | |
if (!watched || lastSeenDays >= 7 * 3.5) { // 한번도 안보거나 4주이상 안봄 | |
inactives.push(li); | |
} else if (lastSeenDays >= 7 * 2.5) { // 3주 밀림 | |
reminders.push(li); | |
} else { | |
actives.push(li); | |
} | |
} | |
if (up) { | |
ups.add(li) | |
} | |
} | |
if (days) { | |
console.groupCollapsed(days); | |
} | |
function lookup(li) { | |
const titleId = new URL(li.querySelector("a").href).searchParams.get("titleId"); | |
const altTitle = li.querySelector("a").title; | |
return [ | |
titleId, | |
{ | |
up: ups.has(li), | |
...(states[titleId] || { seen: new Set(), lastSeenAt: null, title: altTitle, }), | |
} | |
] | |
} | |
function group(name, values) { | |
console.group(name); | |
console.table(Object.fromEntries(values.map(lookup))); | |
console.groupEnd(); | |
} | |
group("freshes", freshes); | |
group("reminders", reminders); | |
group("actives", actives); | |
group("inactives", inactives); | |
if (days){ | |
console.groupEnd(); | |
} | |
ul.innerHTML=''; | |
for (const li of [...freshes, ...reminders, ...actives]) { | |
if (ups.has(li)) { | |
ul.appendChild(li); | |
} | |
} | |
for (const li of [...freshes, ...reminders, ...actives]) { | |
if (!ups.has(li)) { | |
ul.appendChild(li); | |
} | |
} | |
inactives.sort((liA, liB) => { | |
const titleIdA = new URL(liA.querySelector("a").href).searchParams.get("titleId"); | |
const titleIdB = new URL(liB.querySelector("a").href).searchParams.get("titleId"); | |
// seen이 더 많으면 더 상위권으로 정렬됨 | |
return -(getState(states, titleIdA).seen.size - getState(states, titleIdB).seen.size); | |
}); | |
for (const li of inactives) { | |
li.style.opacity = 0.3; | |
ul.appendChild(li); | |
} | |
} | |
})(); |
GET https://m.comic.naver.com/api/recentlyview/get.nhn?page=<number>
요 엔드포인트를 조회하면 본 작품들의 가장 최근회차는 조회할 수 있는 모양입니다. 근데 작품 내 개별회차 조회는 서버에서 렌더링돼서 내려오는것도 아니고 페이지 끝에 <script>(function() { window.__state__ = ... })()</script>
이렇게 오네요. 한동안은 제가 시간이 부족해서 하지는 못할거같고 시간 나면 천천히 이전하고 작업하겠습니다.
혹시 제가 이 코드를 제 저장소로 가져가도 될까요? 가능하다면 라이센스는 어떻게 하면 될지 궁금합니다.
"시간 나면" 이 어려운 조건이었네요. 벌써 반년이나 지났다니.
백업기능이 있는것도 아닌데 제가 PC 를 바꾸면서 회차 관람 이력이 날아가는 바람에 지금 이 스크립트에는 또 흥미를 잃었습니다.
간단한 스크립트인데 퍼블릭 도메인으로 해도 괜찮을 거 같습니다. 편하게 가져가셔서 수정하셔도 됩니다.
나중에 정말 만약에 시간이 난다면 아마 WebExtension 으로 바닥부터 작성할거같네요.
이쪽에서 개발 진행중입니다.
https://github.com/foriequal0/naver-webtoon-helper
언제 배포할지는 모르지만 make build 해서 나온 zip 파일 개발자용 파이어폭스에 설치는 될겁니다.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
혹시 모바일 페이지에서 최근 업데이트된 클라우드 웹툰 읽음 기록을 불러올 수 있을까요?
혹 깃허브 저장소로 이전이 가능하면 기여도 할 수 있을 듯합니다.