|
// ==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); |
|
} |
|
} |
|
})(); |
기능
FAQ
설치했더니 모든 작품이 회색으로 보여요
네이버에서 데이터를 가져오지 않고, 플러그인 설치 이후에 쌓인 데이터만을 사용합니다. 그래서 처음 설치하면 데이터가 없어서 모든 작품이 "한번도 안 본 거나 4주쯤 안 본 것들"로 분류되어서 회색으로 보입니다. 침착하게 평소대로 보시던 걸 보시면 데이터가 쌓여서 자연스럽게 자주 보는게 위로 올라오고, 안 보는건 아래에 눈에 안띄게 사라집니다.
설치법