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);
}
}
})();
@saschanaz
Copy link

안녕하세요, 항상 잘 쓰고 있습니다! 제안할 점이 있는데,

봤던 화는 반투명하게 보여줌.

혹시 이 처리를 댓글창이 보일 만큼 스크롤을 내렸을 때 처리하도록 할 수 있을까요? 실수로 누르거나 또는 중간까지밖에 못 본 경우에도 본 걸로 처리가 되는데 조금 아쉬움이 있습니다.

@foriequal0
Copy link
Author

@saschanaz 좋은 방법인거같아서 그렇게 수정했습니다.

@saschanaz
Copy link

빠른 패치 감사합니다, 확인했습니다 👍👍 다만 버전 넘버를 업데이트해주셔야 자동 업데이트가 가능할 것 같습니다!

별개로, 가끔 웹툰 보다 말고 끌 때 뜨는 메시지가 뭔지 궁금했는데 다음 화를 안 보고 끌 때 발생하는 메시지였네요! 항상 '데이터를 잃을 수 있습니다' 같이 별 관계없어 보이는 메시지가 떠서 의아하게 생각했는데 아마 최신 브라우저에선 beforeunload 이벤트에서 커스텀 메시지를 넣어도 무시하기 때문이 아닐까 싶습니다. 대안이 있는지는 잘 모르겠네요 🤔

@foriequal0
Copy link
Author

감사합니다. 버전넘버 업데이트했습니다. modal 을 페이지 내에 직접 구현해야 할거같은데 그러면 일이 커질테니 지금 그대로 쓰려고 합니다.

@saschanaz
Copy link

혹시 모바일 페이지에서 최근 업데이트된 클라우드 웹툰 읽음 기록을 불러올 수 있을까요?

혹 깃허브 저장소로 이전이 가능하면 기여도 할 수 있을 듯합니다.

@foriequal0
Copy link
Author

foriequal0 commented Dec 10, 2020

GET https://m.comic.naver.com/api/recentlyview/get.nhn?page=<number> 요 엔드포인트를 조회하면 본 작품들의 가장 최근회차는 조회할 수 있는 모양입니다. 근데 작품 내 개별회차 조회는 서버에서 렌더링돼서 내려오는것도 아니고 페이지 끝에 <script>(function() { window.__state__ = ... })()</script> 이렇게 오네요. 한동안은 제가 시간이 부족해서 하지는 못할거같고 시간 나면 천천히 이전하고 작업하겠습니다.

@saschanaz
Copy link

혹시 제가 이 코드를 제 저장소로 가져가도 될까요? 가능하다면 라이센스는 어떻게 하면 될지 궁금합니다.

@foriequal0
Copy link
Author

"시간 나면" 이 어려운 조건이었네요. 벌써 반년이나 지났다니.
백업기능이 있는것도 아닌데 제가 PC 를 바꾸면서 회차 관람 이력이 날아가는 바람에 지금 이 스크립트에는 또 흥미를 잃었습니다.
간단한 스크립트인데 퍼블릭 도메인으로 해도 괜찮을 거 같습니다. 편하게 가져가셔서 수정하셔도 됩니다.
나중에 정말 만약에 시간이 난다면 아마 WebExtension 으로 바닥부터 작성할거같네요.

@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