Instantly share code, notes, and snippets.
-
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.
// ==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 파일 개발자용 파이어폭스에 설치는 될겁니다.
감사합니다. 버전넘버 업데이트했습니다. modal 을 페이지 내에 직접 구현해야 할거같은데 그러면 일이 커질테니 지금 그대로 쓰려고 합니다.