Skip to content

Instantly share code, notes, and snippets.

@lifthrasiir
Last active February 1, 2021 13:45
Show Gist options
  • Save lifthrasiir/200ae9a9b289520ceba835cd804c1cdd to your computer and use it in GitHub Desktop.
Save lifthrasiir/200ae9a9b289520ceba835cd804c1cdd to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name 리디북스 신간 캘린더 플러그인
// @namespace https://gist.github.com/lifthrasiir/200ae9a9b289520ceba835cd804c1cdd
// @version 1.4
// @author Kang Seonghoon (https://mearie.org/)
// @match https://ridibooks.com/event/*
// @run-at document-start
// @updateURL https://gist.github.com/lifthrasiir/200ae9a9b289520ceba835cd804c1cdd/raw/ridibooks-event-calendar.user.js
// @downloadURL https://gist.github.com/lifthrasiir/200ae9a9b289520ceba835cd804c1cdd/raw/ridibooks-event-calendar.user.js
// ==/UserScript==
// 사용법:
// 1. GreaseMonkey 또는 그와 호환되는 확장기능(TamperMonkey, ViolentMonkey 등)을 설치한다.
// 2. 다음 주소로 간다: https://gist.github.com/lifthrasiir/200ae9a9b289520ceba835cd804c1cdd/raw/ridibooks-event-calendar.user.js
// 3. 설치하겠냐고 물으면 확인을 누른다.
// 4. 리디북스 신간 캘린더 페이지에 가서 책 제목과 저자명의 링크를 누른다.
// ※ 최대한 노력했지만 종종 이상한 책으로 링크되는 건 어쩔 수 없습니다. 이걸로 문의하지 마시오.
//
// 라이선스:
// I hereby disclaim copyright to this source code. Alternatively it is also available under the terms of
// CC0 1.0 Universal license <https://creativecommons.org/publicdomain/zero/1.0/>.
window.addEventListener('DOMContentLoaded', function() {
'use strict';
const calendarTitle = document.querySelector('.EventCalendar_Title');
if (!calendarTitle) return;
const unknownClass = genRandomId();
//const positiveClass = genRandomId();
//const negativeClass = genRandomId();
const style = document.createElement('style');
style.textContent = `
a.${unknownClass} {
font: inherit;
color: inherit;
text-decoration: none;
border-bottom: 3px solid #ddd;
transition: background-color 0.1s;
}
a.${unknownClass}:hover, a.${unknownClass}:active, a.${unknownClass}:focus {
background-color: #ddd;
}
`;
document.head.append(style);
// 만화와 라노벨은 제목이 완전히 겹치는 경우가 있어서 추가 테스트가 필요함
const preference = {
comic: calendarTitle.textContent.includes('만화'),
lightNovel: calendarTitle.textContent.includes('라노벨'),
};
for (const pubs of document.querySelectorAll('.EventCalendar_Publications')) {
const date = pubs.querySelector('.EventCalendar_PublicationsDayNumber');
const weekday = pubs.querySelector('.EventCalendar_PublicationsDayKorean');
for (const row of pubs.querySelectorAll('.EventCalendar_Table tr')) {
const titleCell = row.querySelector('.EventCalendar_PublicationTitle');
let title = titleCell.firstChild.textContent.trim();
// 아아아아주 어쩌다가 제목이 길어서 두 줄로 나뉘는 경우가 있다...
while (
titleCell.childNodes[2] &&
titleCell.childNodes[1].tagName === 'BR' &&
titleCell.childNodes[2].nodeType === Node.TEXT_NODE
) {
title += ' ' + titleCell.childNodes[2].textContent.trim();
titleCell.childNodes[2].remove();
titleCell.childNodes[1].remove();
}
let titleSuffix = '';
const m = title.match(/^(.*)(\s+\S*\d권)$/); // "8.5권"(...) 같은 사례가 있음
if (m) {
title = m[1];
titleSuffix = m[2] + titleSuffix;
}
titleCell.firstChild.textContent = titleSuffix;
const titleAnchor = makeAnchor('book', title, preference);
titleAnchor.textContent = title;
titleCell.prepend(titleAnchor);
const authorCell = row.querySelector('.EventCalendar_PublicationAuthor');
const authors = authorCell.textContent.split(/,/g).map(s => s.trim());
authorCell.innerHTML = '';
let first = true;
for (const author of authors) {
if (first) {
first = false;
} else {
authorCell.append(', ');
}
const authorAnchor = makeAnchor('author', author);
authorAnchor.textContent = author;
authorCell.append(authorAnchor);
}
}
}
// where: 'book' or 'author'
function makeAnchor(where, keyword, preference) {
const anchor = document.createElement('a');
anchor.href = '/search/?q=' + encodeURIComponent(keyword);
anchor.classList.add(unknownClass);
anchor.target = '_blank';
anchor.onclick = async e => {
e.preventDefault();
e.stopPropagation();
// 링크를 누르고 나서 promise 이후에 창이 뜨게 하면 팝업 블로커에 걸린다.
// 먼저 창을 띄운 뒤 그걸 조절하는 식으로 우회
const win = window.open('', '_blank');
// XXX 이게 일부 브라우저에서 window.document이 undefined가 아니지만 접근하다가 실패한다고 해서 일단 막음
//win.document.write('<!doctype html><body style="height:100vh;margin:0;padding:0;display:grid;place-content:center">로딩 중…');
await Promise.race([
// 클릭 후 이 이상 기다리지 않음
timeOutPromise(500),
// 클릭으로 창이 일단 뜬 뒤에도 요청이 뒤늦게나마 마무리되는 경우를 처리
(async () => {
const controller = new AbortController();
const urlPromise = getUrl(where, keyword, preference, controller.signal);
urlPromise.cancel = () => controller.abort();
const url = await raceAndCancelOthers([urlPromise, timeOutPromise(2000)]);
if (url) anchor.href = url;
//anchor.classList.add(url ? positiveClass : negativeClass);
anchor.onclick = null;
})(),
]);
// 이 시점에서 이미 getUrl이 성공했으면 anchor.href가 갱신되었겠지
win.location.replace(anchor.href);
};
return anchor;
}
async function getUrl(where, keyword, preference, signal) {
const resp = await fetch(
`https://search-api.ridibooks.com/search?site=ridi-store&where=${where}&what=instant&keyword=${encodeURIComponent(keyword)}&adult_exclude=n`,
{ signal });
const json = await resp.json();
if (where === 'author') {
const authors = orderByScore((json.authors || []).map(author => [looselyEquals(author.name, keyword), author]));
// 사람 이름은 겹치는 경우가 은근 생길 수 있어서 결과가 정확히 하나가 아니면 포기한다
if (authors.length === 1) return `https://ridibooks.com/author/${authors[0].id}`;
} else { // where === 'book'
let books = orderByScore((json.books || []).map(book => {
// "[코믹] 라노벨은 코미컬라이즈의 꿈을 꾸는가" 같은 건 점수 매길 때는 무시
const title = book.title.replace(/^(\[\p{L}+\]\s+)*/u, '');
return [looselyEquals(title, keyword), book];
}));
if (books.length === 0) return;
if (books.length > 1 && (!!preference.comic ^ !!preference.lightNovel)) {
// 특히 만화와 라노벨이 겹치는 경우가 있는데 이 경우 만화 제목에 [코믹]을 붙이는 관례가 있다.
// 겹치지 않는 경우에는 이런 관례가 없으므로 처리에 주의할 것.
const comics = [], lightNovels = [];
for (const book of books) {
(book.title.startsWith('[코믹]') ? comics : lightNovels).push(book);
}
if (comics.length > 0 && lightNovels.length > 0) {
books = preference.comic ? comics : lightNovels;
}
}
// 남은 것 중 가장 겹치는 길이가 긴 것을 선택
return `https://ridibooks.com/books/${books[0].b_id}`;
}
}
function looselyEquals(a, b) {
const aWords = [...a.matchAll(/[\p{L}\p{N}]+/gu)].map(([w]) => w);
const bWords = [...b.matchAll(/[\p{L}\p{N}]+/gu)].map(([w]) => w);
// ~장, ~부 등은 떼어낸다.
while (aWords.length > 0 && aWords[aWords.length - 1].match(/\d[장부]$/)) aWords.pop();
while (bWords.length > 0 && bWords[bWords.length - 1].match(/\d[장부]$/)) bWords.pop();
// 한 쪽이 다른 쪽의 prefix이면 통과. prefix 길이가 긴 것일 수록 우선시하기 위해서 길이를 반환한다.
const commonLen = Math.min(aWords.length, bWords.length);
for (let i = 0; i < commonLen; ++i) {
if (aWords[i] !== bWords[i]) return 0;
}
return commonLen;
}
// [[score, value], ...] => score > 0인 것 중 내림차순으로 [value, ...]
function orderByScore(arr) {
return arr.filter(([score]) => score).sort(([aScore], [bScore]) => bScore - aScore).map(([, e]) => e);
}
function genRandomId() {
let arr = new Uint32Array(2);
window.crypto.getRandomValues(arr);
return ((arr[0] | 0xc0000000) >>> 0).toString(16) + ("00000000" + arr[1].toString(16)).slice(-8);
}
async function timeOutPromise(msecs) {
let id = 0;
const promise = new Promise(resolve => {
id = setTimeout(() => {
id = 0;
resolve();
}, msecs);
});
promise.cancel = () => {
if (!id) return;
clearTimeout(id);
id = 0;
};
return promise;
}
async function raceAndCancelOthers(promises) {
const [finishedIndex, ret] = await Promise.race(promises.map((promise, i) => promise.then(ret => [i, ret])));
for (let i = 0; i < promises.length; ++i) {
if (i !== finishedIndex && promises[i].cancel) promises[i].cancel();
}
return ret;
}
}, false);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment