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 을 페이지 내에 직접 구현해야 할거같은데 그러면 일이 커질테니 지금 그대로 쓰려고 합니다.