Files
userscript/eh_gallery_script.user.js
2024-03-09 12:00:27 +08:00

604 lines
23 KiB
JavaScript

// ==UserScript==
// @name EH Gallery Script
// @namespace https://github.com/lifegpc/userscript
// @version 0.1.4
// @description :(
// @author lifegpc
// @match https://*.e-hentai.org/g/*/*
// @match https://*.e-hentai.org/
// @match https://*.e-hentai.org/?*
// @match https://*.e-hentai.org/watched
// @match https://*.e-hentai.org/watched?*
// @match https://*.e-hentai.org/popular
// @match https://*.e-hentai.org/popular?*
// @match https://*.e-hentai.org/favorites.php
// @match https://*.e-hentai.org/favorites.php?*
// @match https://*.e-hentai.org/tag/*
// @match https://*.exhentai.org/g/*/*
// @match https://*.exhentai.org/
// @match https://*.exhentai.org/?*
// @match https://*.exhentai.org/watched
// @match https://*.exhentai.org/watched?*
// @match https://*.exhentai.org/popular
// @match https://*.exhentai.org/popular?*
// @match https://*.exhentai.org/favorites.php
// @match https://*.exhentai.org/favorites.php?*
// @match https://*.exhentai.org/tag/*
// @icon https://e-hentai.org/favicon.ico
// @grant GM_addStyle
// @grant GM_getResourceText
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @require https://openuserjs.org/src/libs/sizzle/GM_config.js
// @require https://cdn.jsdelivr.net/npm/marked
// @require https://cdn.jsdelivr.net/npm/jquery
// @require https://unpkg.com/@popperjs/core@2
// @require https://unpkg.com/tippy.js@6
// @resource s https://unpkg.com/tippy.js@6/themes/light-border.css
// @run-at document-start
// @connect raw.githubusercontent.com
// ==/UserScript==
function replaceAll(s, pattern, replacement) {
return s.split(pattern).join(replacement);
}
const SVG_ICON_MSG = "data:image/svg+xml,%3Csvg t='1595842925125' class='icon' viewBox='0 0 1024 1024' version='1.1' xmlns='http://www.w3.org/2000/svg' p-id='2280' width='200' height='200'%3E%3Cpath d='M89.216226 575.029277c-6.501587-7.223986-10.47478-15.892769-12.641975-26.367549-1.805996-10.47478-0.722399-20.22716 3.973192-29.257143l4.695591-10.47478c5.05679-8.307584 11.558377-13.725573 19.865961-15.892769 7.946384-2.167196 15.892769-0.361199 23.477954 5.417989L323.995767 639.322751c8.307584 5.779189 17.698765 8.668783 27.812346 8.307584 10.11358-0.361199 18.782363-3.611993 26.006349-10.11358L898.302646 208.411993c7.585185-5.779189 16.253968-8.307584 26.006349-7.585185 9.752381 0.722399 18.059965 4.334392 24.922751 10.47478l-12.641975-12.641975c6.501587 7.223986 9.752381 15.17037 9.752381 24.561552 0 9.391182-3.250794 17.337566-9.752381 24.561552L376.008466 816.310406c-7.223986 7.223986-15.17037 10.47478-24.200353 10.47478-9.029982 0-16.976367-3.250794-24.200353-9.752381L89.216226 575.029277z' p-id='2281' fill='%23ffffff'%3E%3C/path%3E%3C/svg%3E";
const GALLERY_REG = /e[-x]hentai\.org\/g\/\d+\/[^/]+/;
const IMG_REG = /e[-x]hentai\.org\/s\/[^/]+\/\d+-\d+/;
const MPV_REG = /e[-x]hentai\.org\/mpv\/\d+\/[^/]+/;
/**@type {IDBDatabase} */
let db = undefined;
let need_reinit = false;
let storage = navigator.storage || globalThis['WorkerNavigator']['storage'];
const BLACK_LIST_HOST = [
"tva1.sinaimg.cn",
"img.moegirl.org.cn",
];
function filter_html(html) {
let doc = (new DOMParser).parseFromString(html, "text/html");
doc.querySelectorAll('a').forEach(a => a.setAttribute('target', '_blank'));
doc.querySelectorAll('img[src="#"]').forEach((img) => {
let title = img.title;
try {
new URL(title);
img.src = title;
img.title = "";
} catch (_) {
img.remove();
}
})
doc.querySelectorAll('img').forEach((img) => {
try {
let u = new URL(img.src);
if (BLACK_LIST_HOST.includes(u.host)) {
console.log("Remove blacklist URL: ", u.toString());
img.remove();
}
} catch (_) {}
})
return doc.body.innerHTML;
}
function filter_html2(html) {
let doc = (new DOMParser).parseFromString(html, "text/html");
doc.querySelectorAll('img[src="#"]').forEach((img) => {
let title = img.title;
try {
new URL(title);
img.src = title;
img.title = "";
} catch (_) {
img.remove();
}
})
doc.querySelectorAll('img').forEach((img) => {
img.style.height = "12px";
})
return doc.body.innerHTML;
}
async function make_storage_persist() {
let persisted = await storage.persisted();
if (!persisted) {
persisted = await storage.persist();
}
return persisted;
}
function init() {
return new Promise((resolve, reject) => {
make_storage_persist();
if (db !== undefined && !need_reinit) {
resolve();
return;
}
let indexedReq = indexedDB.open('eh_tags', 1);
/**@param {IDBVersionChangeEvent} event*/
indexedReq.onupgradeneeded = function (event) {
let db = this.result;
console.log(`upgrade eh_tags from ${event.oldVersion} to ${event.newVersion}`);
/*No version or version < 1 -> v1 */
if (isNaN(event.oldVersion) || event.oldVersion < 1) {
db.createObjectStore('meta', { keyPath: 'key' })
db.createObjectStore('tags', { keyPath: 'tag' });
}
}
indexedReq.onsuccess = () => {
db = indexedReq.result;
resolve();
}
indexedReq.onerror = () => {
need_reinit = true;
reject(indexedReq.error);
}
})
}
/**@param {string} key*/
function get_value(key) {
return new Promise((resolve, reject) => {
init().then(() => {
let tx = db.transaction(['meta'], 'readonly');
let store = tx.objectStore('meta');
let req = store.get(key);
req.onsuccess = () => {
let re = req.result;
resolve(re ? re.value : undefined);
}
req.onerror = () => {
need_reinit = true;
reject(req.error);
}
}).catch(reject);
})
}
/**@param {string} key*/
function set_value(key, value) {
return new Promise((resolve, reject) => {
init().then(() => {
let tx = db.transaction(['meta'], 'readwrite');
let store = tx.objectStore('meta');
let req = store.put({ key, value });
req.onsuccess = () => {
resolve();
}
req.onerror = () => {
need_reinit = true;
reject(req.error);
}
}).catch(reject);
})
}
/**
* @typedef {{intro: string, links: string, name: string}} TagData
* @param {string} tag
* @param {TagData} value
*/
function set_tag(tag, value) {
return new Promise((resolve, reject) => {
init().then(() => {
let tx = db.transaction(['tags'], 'readwrite');
let store = tx.objectStore('tags');
let req = store.put({ tag, value });
req.onsuccess = () => {
resolve();
}
req.onerror = () => {
need_reinit = true;
reject(req.error);
}
}).catch(reject);
})
}
/**
* @param {string} tag
* @returns {Promise<TagData | undefined>}
*/
function get_tag(tag) {
return new Promise((resolve, reject) => {
init().then(() => {
let tx = db.transaction(['tags'], 'readonly');
let store = tx.objectStore('tags');
let req = store.get(tag);
req.onsuccess = () => {
let re = req.result;
resolve(re ? re.value : undefined);
}
req.onerror = () => {
need_reinit = true;
reject(req.error);
}
}).catch(reject);
})
}
/**
* @typedef {{method?: string, headers?: Record<string, string>, data?: string|Blob|File|FormData|URLSearchParams, redirect?: RequestRedirect}} FetchOptions
* @typedef {{finalUrl: string, readyState: number, status: number, statusText: string, responseHeaders: string, response: any, responseText: string}} FetchResponse
* @param {string|URL} url
* @param {FetchOptions | undefined} options
* @returns {Promise<FetchResponse>}
*/
function GM_fetch(url, options = undefined) {
XMLHttpRequest
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
url,
method: options?.method ?? "GET",
headers: options?.headers,
data: options?.data,
redirect: options?.redirect,
onabort: () => {
reject("aborted.");
},
onerror: (err) => {
reject(err);
},
onload: (re) => {
resolve(re);
}
})
})
}
async function fetch_tags() {
const uri = "https://raw.githubusercontent.com/EhTagTranslation/DatabaseReleases/master/db.raw.json";
const re = await GM_fetch(uri, { headers: { "Cache-Control": "no-cache" } });
if (re.status != 200) {
throw new Error(`Failed to fetch tags: ${re.status} ${re.statusText}`);
}
let data = JSON.parse(re.responseText);
await set_value("last_updated", Date.now());
const head = await get_value("tags_head");
if (head?.sha == data.head.sha) {
console.log("Same sha, skip update tags.");
return;
}
await set_value("tags_head", data.head);
for (const namespace of data.data) {
const ns = namespace.namespace;
for (const tag in namespace.data) {
const value = namespace.data[tag];
await set_tag(`${ns}:${tag}`, value);
}
}
}
GM_config.init({
id: 'e-hentai',
fields: {
openImageInNewTab: {
type: 'checkbox',
label: 'Open image in new tab.',
default: true
},
openGalleryInNewTab: {
type: 'checkbox',
label: 'Open gallery in new tab.',
default: true
},
openMPVInNewTab: {
type: 'checkbox',
label: 'Open Multi-Page Viewer in new tab.',
default: true
},
enableTagTranslation: {
type: 'checkbox',
label: 'Enable tag translation.',
default: false
}
},
events: {
save: (values) => {
let openImageInNewTab = GM_config.get("openImageInNewTab");
let openGalleryInNewTab = GM_config.get("openGalleryInNewTab");
let openMPVInNewTab = GM_config.get("openMPVInNewTab");
let eles = document.getElementsByTagName("a");
for (let ele of eles) {
if (ele.href.match(IMG_REG)) {
ele.target = openImageInNewTab ? '_blank' : '_self';
} else if (ele.href.match(GALLERY_REG)) {
ele.target = openGalleryInNewTab ? '_blank' : '_self';
} else if (ele.href.match(MPV_REG)) {
ele.target = openMPVInNewTab ? '_blank' : '_self';
}
}
handle_tags();
}
}
});
/**@param {MsgType} level */
function err_handle(e, basic = "", level = "err") {
console.error(e);
let msg = "";
if (typeof e === "string") msg = e;
if (e instanceof Error) msg = e.message;
popMsg(`${basic}${msg}`, level);
}
GM_registerMenuCommand("Edit Settings", () => { GM_config.open() }, "e");
GM_registerMenuCommand("Update translation data", () => {
fetch_tags().then(() => {
popMsg("Update translation data successful.");
}).catch((e) => {
err_handle(e, "Update translation data failed: ");
})
}, "u");
GM_addStyle(`
.egs__msg{display:none;position:fixed;top:10px;left:50%;transform:translateX(-50%);color:#fff;text-align:center;z-index:99996;padding:10px 30px 10px 45px;font-size:16px;border-radius:10px;background-image:url("${SVG_ICON_MSG}");background-size:25px;background-repeat:no-repeat;background-position:15px}
.egs__msg a{color:#fff;text-decoration: underline;}
.egs__msg-ok{background:#4bcc4b}
.egs__msg-err{background:#c33}
.egs__msg-warn{background:#FF9900}
td.tc{text-wrap:nowrap}
`)
GM_addStyle(GM_getResourceText("s"));
/**
* @param {string} msg
* @typedef {'ok'|'err'|'warn'} MsgType
* @param {MsgType} type
*/
function popMsg(msg, type = "ok") {
$('.egs__msg').length > 0 && $('.egs__msg').remove()
let $msg = $(`<div class="egs__msg egs__msg-${type}">${msg}</div>`)
$('body').append($msg)
$msg.slideDown(200)
setTimeout(() => { $msg.fadeOut(500) }, type == 'ok' ? 2000 : 5000)
setTimeout(() => { $msg.remove() }, type == 'ok' ? 2500 : 5500)
}
const instances = {};
let insid = 0;
function set_instance(ins) {
instances[insid] = ins;
return insid++;
}
let observer = new MutationObserver(async (data) => {
let openImageInNewTab = GM_config.get("openImageInNewTab");
let openGalleryInNewTab = GM_config.get("openGalleryInNewTab");
let openMPVInNewTab = GM_config.get("openMPVInNewTab");
let enableTagTranslation = GM_config.get("enableTagTranslation");
if (enableTagTranslation) {
await check_update();
}
for (let i of data) {
let ele = i.target;
if (ele.tagName == 'A') {
if (ele.href.match(IMG_REG)) {
ele.target = openImageInNewTab ? '_blank' : '_self';
} else if (ele.href.match(GALLERY_REG)) {
ele.target = openGalleryInNewTab ? '_blank' : '_self';
} else if (ele.href.match(MPV_REG)) {
ele.target = openMPVInNewTab ? '_blank' : '_self';
}
}
if (ele.tagName == 'TD') {
/**@type {HTMLTableCellElement} */
const e = ele;
if (e.classList.contains("tc")) {
if (enableTagTranslation) {
let tag = e.getAttribute('otag');
if (tag) continue;
const t = e.textContent.split(":")[0];
const value = await get_tag(`rows:${t}`);
e.setAttribute('otag', t);
if (value) {
e.textContent = `${value.name}`;
let html = "";
if (value.intro) {
html += marked.parse(value.intro);
}
if (value.links) {
html += marked.parse(value.links);
}
if (html) {
e.setAttribute("tippy-id", set_instance(tippy(e, {
content: filter_html(html),
allowHTML: true,
interactive: true,
theme: 'light-border',
placement: 'right-start',
maxWidth: 400,
delay: [500, 0]
})));
}
}
} else {
let tag = e.getAttribute('otag');
if (tag) {
e.textContent = `${tag}:`;
e.removeAttribute('otag');
let id = e.getAttribute("tippy-id");
if (id) {
instances[id].destroy();
delete instances[id];
e.removeAttribute("tippy-id");
}
}
}
}
}
if (ele.tagName == 'DIV') {
/**@type {HTMLDivElement} */
const e = ele;
const is_tag = Array.from(e.classList.entries()).findIndex((v) => v[1].startsWith("gt")) != -1;
if (is_tag) {
if (enableTagTranslation) {
let otag = e.getAttribute('otag');
if (otag) continue;
const t = e.id ? replaceAll(e.id.split("td_")[1], '_', ' ') : e.title;
if (!t) continue;
const value = await get_tag(t);
e.setAttribute('otag', t);
if (value) {
const name = filter_html2(replaceAll(marked.parse(value.name), /<\/?p>/, ''));
if (e.id) {
e.children[0].innerHTML = name;
} else {
e.innerHTML = name;
}
let html = "";
if (value.intro) {
html += marked.parse(value.intro);
}
if (value.links) {
html += marked.parse(value.links);
}
if (html) {
e.setAttribute("tippy-id", set_instance(tippy(e, {
content: filter_html(html),
allowHTML: true,
interactive: true,
theme: 'light-border',
placement: 'right-start',
maxWidth: 400,
delay: [500, 0]
})));
}
}
} else {
let tag = e.getAttribute('otag');
if (tag) {
let otag = tag.split(":")[1];
if (e.id) {
e.children[0].innerText = otag;
} else {
e.textContent = otag;
}
e.removeAttribute('otag');
let id = e.getAttribute("tippy-id");
if (id) {
instances[id].destroy();
delete instances[id];
e.removeAttribute("tippy-id");
}
}
}
}
}
}
});
observer.observe(document, { childList: true, subtree: true });
let checked_update = false;
let is_checking_update = false;
async function check_tags_update() {
let last_updated = await get_value("last_updated");
console.log("Last updated: ", new Date(last_updated))
if (!last_updated || Date.now() - last_updated > 1000 * 60 * 60 * 24) {
await fetch_tags();
}
checked_update = true;
}
function check_update() {
return new Promise((resolve, reject) => {
if (checked_update || is_checking_update) {
resolve();
return;
}
is_checking_update = true;
check_tags_update().then(resolve).catch((e) => {
err_handle(e, "Failed to check update: ", "warn");
resolve();
}).finally(() => {
is_checking_update = false;
});
})
}
async function handle_tags() {
let enableTagTranslation = GM_config.get("enableTagTranslation");
if (enableTagTranslation) {
await check_update();
}
let groups = document.querySelectorAll("td.tc");
for (let group of groups) {
if (enableTagTranslation) {
let tag = group.getAttribute('otag');
if (tag) continue;
const t = group.textContent.split(":")[0];
const value = await get_tag(`rows:${t}`);
group.setAttribute('otag', t);
if (value) {
group.textContent = `${value.name}`;
let html = "";
if (value.intro) {
html += marked.parse(value.intro);
}
if (value.links) {
html += marked.parse(value.links);
}
if (html) {
group.setAttribute("tippy-id", set_instance(tippy(group, {
content: filter_html(html),
allowHTML: true,
interactive: true,
theme: 'light-border',
placement: 'right-start',
maxWidth: 400,
delay: [500, 0]
})));
}
}
} else {
let tag = group.getAttribute('otag');
if (tag) {
group.textContent = `${tag}:`;
group.removeAttribute('otag');
let id = group.getAttribute("tippy-id");
if (id) {
instances[id].destroy();
delete instances[id];
group.removeAttribute("tippy-id");
}
}
}
}
let tags = document.querySelectorAll("div[class^=gt]");
for (const i of tags) {
if (enableTagTranslation) {
let otag = i.getAttribute('otag');
if (otag) continue;
const t = i.id ? replaceAll(e.id.split("td_")[1], '_', ' ') : i.title;
if (!t) continue;
const value = await get_tag(t);
i.setAttribute('otag', t);
if (value) {
const name = filter_html2(replaceAll(marked.parse(value.name), /<\/?p>/, ''));
if (i.id) {
i.children[0].innerHTML = name;
} else {
i.innerHTML = name;
}
let html = "";
if (value.intro) {
html += marked.parse(value.intro);
}
if (value.links) {
html += marked.parse(value.links);
}
if (html) {
i.setAttribute("tippy-id", set_instance(tippy(i, {
content: filter_html(html),
allowHTML: true,
interactive: true,
theme: 'light-border',
placement: 'right-start',
maxWidth: 400,
delay: [500, 0]
})));
}
}
} else {
let tag = i.getAttribute('otag');
if (tag) {
let otag = tag.split(":")[1];
if (i.id) {
i.children[0].innerText = otag;
} else {
i.textContent = otag;
}
i.removeAttribute('otag');
let id = i.getAttribute("tippy-id");
if (id) {
instances[id].destroy();
delete instances[id];
i.removeAttribute("tippy-id");
}
}
}
}
}