Skip to content

Instantly share code, notes, and snippets.

@foriequal0
Last active July 10, 2021 15:48
Show Gist options
  • Save foriequal0/f65c8bb896762f55b2f8efb521addf9a to your computer and use it in GitHub Desktop.
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);
}
}
})();
@foriequal0
Copy link
Author

foriequal0 commented Jul 10, 2021

이쪽에서 개발 진행중입니다.
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