diff --git a/ComicRead-AdGuard.user.js.js b/ComicRead-AdGuard.user.js.js new file mode 100644 index 00000000..6c909510 --- /dev/null +++ b/ComicRead-AdGuard.user.js.js @@ -0,0 +1,10660 @@ +// ==UserScript== +// @name ComicRead +// @namespace ComicRead +// @version 9.0.0 +// @description 为漫画站增加双页阅读、翻译等优化体验的增强功能。百合会——「记录阅读历史、自动签到等」、百合会新站、动漫之家——「解锁隐藏漫画」、E-Hentai——「匹配 nhentai 漫画」、nhentai——「彻底屏蔽漫画、自动翻页」、Yurifans——「自动签到」、拷贝漫画(copymanga)——「显示最后阅读记录」、PonpomuYuri、明日方舟泰拉记事社、禁漫天堂、漫画柜(manhuagui)、漫画DB(manhuadb)、动漫屋(dm5)、绅士漫画(wnacg)、mangabz、komiic、无限动漫、新新漫画、hitomi、Anchira、kemono、nekohouse、welovemanga +// @description:en Add enhanced features to the comic site for optimized experience, including dual-page reading and translation. +// @description:ru Добавляет расширенные функции для удобства на сайт, такие как двухстраничный режим и перевод. +// @author hymbz +// @license AGPL-3.0-or-later +// @noframes +// @match *://bbs.yamibo.com/* +// @match *://www.yamibo.com/* +// @match *://comic.idmzj.com/* +// @match *://comic.dmzj.com/* +// @match *://manhua.idmzj.com/* +// @match *://manhua.dmzj.com/* +// @match *://m.idmzj.com/* +// @match *://m.dmzj.com/* +// @match *://www.idmzj.com/* +// @match *://www.dmzj.com/* +// @match *://exhentai.org/* +// @match *://e-hentai.org/* +// @match *://nhentai.net/* +// @match *://yuri.website/* +// @match *://mangacopy.com/* +// @match *://copymanga.site/* +// @match *://copymanga.info/* +// @match *://copymanga.net/* +// @match *://copymanga.org/* +// @match *://copymanga.tv/* +// @match *://copymanga.com/* +// @match *://www.mangacopy.com/* +// @match *://www.copymanga.site/* +// @match *://www.copymanga.info/* +// @match *://www.copymanga.net/* +// @match *://www.copymanga.org/* +// @match *://www.copymanga.tv/* +// @match *://www.copymanga.com/* +// @match *://www.ponpomu.com/* +// @match *://terra-historicus.hypergryph.com/* +// @match *://18comic.org/* +// @match *://18comic.vip/* +// @match *://tw.manhuagui.com/* +// @match *://m.manhuagui.com/* +// @match *://www.mhgui.com/* +// @match *://www.manhuagui.com/* +// @match *://www.manhuadb.com/* +// @match *://www.manhuaren.com/* +// @match *://m.1kkk.com/* +// @match *://www.1kkk.com/* +// @match *://tel.dm5.com/* +// @match *://en.dm5.com/* +// @match *://www.dm5.cn/* +// @match *://www.dm5.com/* +// @match *://www.wnacg.com/* +// @match *://wnacg.com/* +// @match *://www.mangabz.com/* +// @match *://mangabz.com/* +// @match *://komiic.com/* +// @match *://8.twobili.com/* +// @match *://a.twobili.com/* +// @match *://www.comicabc.com/* +// @match *://m.77mh.me/* +// @match *://www.77mh.me/* +// @match *://m.77mh.xyz/* +// @match *://www.77mh.xyz/* +// @match *://m.77mh.nl/* +// @match *://www.77mh.nl/* +// @match *://hitomi.la/* +// @match *://anchira.to/* +// @match *://kemono.su/* +// @match *://kemono.party/* +// @match *://nekohouse.su/* +// @match *://nicomanga.com/* +// @match *://weloma.art/* +// @match *://welovemanga.one/* +// @match *://comic-read.pages.dev/* +// @connect yamibo.com +// @connect dmzj.com +// @connect idmzj.com +// @connect exhentai.org +// @connect e-hentai.org +// @connect hath.network +// @connect nhentai.net +// @connect hypergryph.com +// @connect mangabz.com +// @connect copymanga.site +// @connect copymanga.info +// @connect copymanga.net +// @connect copymanga.org +// @connect copymanga.tv +// @connect mangacopy.com +// @connect xsskc.com +// @connect self +// @connect 127.0.0.1 +// @connect * +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_addElement +// @grant GM_getResourceText +// @grant GM_addStyle +// @grant GM_xmlhttpRequest +// @grant GM.addValueChangeListener +// @grant GM.removeValueChangeListener +// @grant GM.getResourceText +// @grant GM.getValue +// @grant GM.setValue +// @grant GM.listValues +// @grant GM.deleteValue +// @grant unsafeWindow +// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAACBUExURUxpcWB9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i2B9i////198il17idng49DY3PT297/K0MTP1M3X27rHzaCxupmstbTByK69xOfr7bfFy3WOmqi4wPz9/X+XomSBjqW1vZOmsN/l6GmFkomeqe7x8vn6+kv+1vUAAAAOdFJOUwDsAoYli9zV+lIqAZEDwV05SQAAAUZJREFUOMuFk+eWgjAUhGPBiLohjZACUqTp+z/gJkqJy4rzg3Nn+MjhwB0AANjv4BEtdITBHjhtQ4g+CIZbC4Qb9FGb0J4P0YrgCezQqgIA14EDGN8fYz+f3BGMASFkTJ+GDAYMUSONzrFL7SVvjNQIz4B9VERRmV0rbJWbrIwidnsd6ACMlEoip3uad3X2HJmqb3gCkkJELwk5DExRDxA6HnKaDEPSsBnAsZoANgJaoAkg12IJqBiPACImXQKF9IDULIHUkOk7kDpeAMykHqCEWACy8ACdSM7LGSg5F3HtAU1rrkaK9uGAshXS2lZ5QH/nVhmlD8rKlmbO3ZsZwLe8qnpdxJRnLaci1X1V5R32fjd5CndVkfYdGpy3D+htU952C/ypzPtdt3JflzZYBy7fi/O1euvl/XH1Pp+Cw3/1P1xOZwB+AWMcP/iw0AlKAAAAV3pUWHRSYXcgcHJvZmlsZSB0eXBlIGlwdGMAAHic4/IMCHFWKCjKT8vMSeVSAAMjCy5jCxMjE0uTFAMTIESANMNkAyOzVCDL2NTIxMzEHMQHy4BIoEouAOoXEXTyQjWVAAAAAElFTkSuQmCC +// @resource solid-js https://cdn.jsdelivr.net/npm/solid-js@1.8.17/dist/solid.cjs +// @resource fflate https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js +// @resource qr-scanner https://cdn.jsdelivr.net/npm/qr-scanner@1.4.2/qr-scanner.legacy.min.js +// @resource dmzjDecrypt https://greasyfork.org/scripts/467177/code/dmzjDecrypt.js?version=1207199 +// @resource solid-js|store https://cdn.jsdelivr.net/npm/solid-js@1.8.17/store/dist/store.cjs +// @resource solid-js|web https://cdn.jsdelivr.net/npm/solid-js@1.8.17/web/dist/web.cjs +// @supportURL https://github.com/hymbz/ComicReadScript/issues +// @updateURL https://github.com/hymbz/ComicReadScript/raw/master/ComicRead-AdGuard.user.js +// @downloadURL https://github.com/hymbz/ComicReadScript/raw/master/ComicRead-AdGuard.user.js +// ==/UserScript== + +/** + * 虽然在打包的时候已经尽可能保持代码格式不变了,但因为脚本代码比较多的缘故 + * 所以真对脚本代码感兴趣的话,推荐还是直接上 github 仓库来看 + * + * 对站点逻辑感兴趣的,结合 `src\index.ts` 看 `src\site` 下的对应文件即可 + */ + +const gmApi = { + GM, + GM_addElement: typeof GM_addElement === 'undefined' ? undefined : GM_addElement, + GM_getResourceText, + GM_xmlhttpRequest, + GM_addStyle, + unsafeWindow +}; +const gmApiList = Object.keys(gmApi); +const crsLib = { + // 有些 cjs 模块会检查这个,所以在这里声明下 + process: { + env: { + NODE_ENV: 'production' + } + }, + ...gmApi +}; +const tempName = Math.random().toString(36).slice(2); +const evalCode = code => { + // 因为部分网站会对 eval 进行限制,比如推特(CSP)、hitomi(代理 window.eval 进行拦截) + // 所以优先使用最通用的 GM_addElement 来加载 + if (gmApi.GM_addElement) return GM_addElement('script', { + textContent: code + })?.remove(); + + // eslint-disable-next-line no-eval + eval.call(unsafeWindow, code); +}; + +/** + * 通过 Resource 导入外部模块 + * @param name \@resource 引用的资源名 + */ +const selfImportSync = name => { + const code = name === 'main' ?` +const solidJs = require('solid-js'); +const web = require('solid-js/web'); +const store$2 = require('solid-js/store'); +const fflate = require('fflate'); +const main = require('main'); +const QrScanner = require('qr-scanner'); + +// src/index.ts +var triggerOptions = !web.isServer && solidJs.DEV ? { equals: false, name: "trigger" } : { equals: false }; +var triggerCacheOptions = !web.isServer && solidJs.DEV ? { equals: false, internal: true } : triggerOptions; +var TriggerCache = class { + #map; + constructor(mapConstructor = Map) { + this.#map = new mapConstructor(); + } + dirty(key) { + if (web.isServer) + return; + this.#map.get(key)?.$$(); + } + track(key) { + if (!solidJs.getListener()) + return; + let trigger = this.#map.get(key); + if (!trigger) { + const [$, $$] = solidJs.createSignal(void 0, triggerCacheOptions); + this.#map.set(key, trigger = { $, $$, n: 1 }); + } else + trigger.n++; + solidJs.onCleanup(() => { + if (trigger.n-- === 1) + queueMicrotask(() => trigger.n === 0 && this.#map.delete(key)); + }); + trigger.$(); + } +}; + +// src/index.ts +var $KEYS = Symbol("track-keys"); +var ReactiveSet = class extends Set { + #triggers = new TriggerCache(); + constructor(values) { + super(); + if (values) + for (const v of values) + super.add(v); + } + // reads + get size() { + this.#triggers.track($KEYS); + return super.size; + } + has(v) { + this.#triggers.track(v); + return super.has(v); + } + *keys() { + for (const key of super.keys()) { + this.#triggers.track(key); + yield key; + } + this.#triggers.track($KEYS); + } + values() { + return this.keys(); + } + *entries() { + for (const key of super.keys()) { + this.#triggers.track(key); + yield [key, key]; + } + this.#triggers.track($KEYS); + } + [Symbol.iterator]() { + return this.values(); + } + forEach(callbackfn) { + this.#triggers.track($KEYS); + super.forEach(callbackfn); + } + // writes + add(v) { + if (!super.has(v)) { + super.add(v); + solidJs.batch(() => { + this.#triggers.dirty(v); + this.#triggers.dirty($KEYS); + }); + } + return this; + } + delete(v) { + const r = super.delete(v); + if (r) { + solidJs.batch(() => { + this.#triggers.dirty(v); + this.#triggers.dirty($KEYS); + }); + } + return r; + } + clear() { + if (super.size) { + solidJs.batch(() => { + for (const v of super.keys()) + this.#triggers.dirty(v); + super.clear(); + this.#triggers.dirty($KEYS); + }); + } + } +}; + +// src/index.ts +var debounce$1 = (callback, wait) => { + if (web.isServer) { + return Object.assign(() => void 0, { clear: () => void 0 }); + } + let timeoutId; + const clear = () => clearTimeout(timeoutId); + if (solidJs.getOwner()) + solidJs.onCleanup(clear); + const debounced = (...args) => { + if (timeoutId !== void 0) + clear(); + timeoutId = setTimeout(() => callback(...args), wait); + }; + return Object.assign(debounced, { clear }); +}; +var throttle$1 = (callback, wait) => { + if (web.isServer) { + return Object.assign(() => void 0, { clear: () => void 0 }); + } + let isThrottled = false, timeoutId, lastArgs; + const throttled = (...args) => { + lastArgs = args; + if (isThrottled) + return; + isThrottled = true; + timeoutId = setTimeout(() => { + callback(...lastArgs); + isThrottled = false; + }, wait); + }; + const clear = () => { + clearTimeout(timeoutId); + isThrottled = false; + }; + if (solidJs.getOwner()) + solidJs.onCleanup(clear); + return Object.assign(throttled, { clear }); +}; +var scheduleIdle = web.isServer ? () => Object.assign(() => void 0, { clear: () => void 0 }) : ( + // requestIdleCallback is not supported in Safari + window.requestIdleCallback ? (callback, maxWait) => { + let isDeferred = false, id, lastArgs; + const deferred = (...args) => { + lastArgs = args; + if (isDeferred) + return; + isDeferred = true; + id = requestIdleCallback( + () => { + callback(...lastArgs); + isDeferred = false; + }, + { timeout: maxWait } + ); + }; + const clear = () => { + cancelIdleCallback(id); + isDeferred = false; + }; + if (solidJs.getOwner()) + solidJs.onCleanup(clear); + return Object.assign(deferred, { clear }); + } : ( + // fallback to setTimeout (throttle) + (callback) => throttle$1(callback) + ) +); +function leadingAndTrailing(schedule, callback, wait) { + if (web.isServer) { + let called = false; + const scheduled2 = (...args) => { + if (called) + return; + called = true; + callback(...args); + }; + return Object.assign(scheduled2, { clear: () => void 0 }); + } + let State; + ((State2) => { + State2[State2["Ready"] = 0] = "Ready"; + State2[State2["Leading"] = 1] = "Leading"; + State2[State2["Trailing"] = 2] = "Trailing"; + })(State || (State = {})); + let state = 0 /* Ready */; + const scheduled = schedule((args) => { + state === 2 /* Trailing */ && callback(...args); + state = 0 /* Ready */; + }, wait); + const fn = (...args) => { + if (state !== 2 /* Trailing */) { + if (state === 0 /* Ready */) + callback(...args); + state += 1; + } + scheduled(args); + }; + const clear = () => { + state = 0 /* Ready */; + scheduled.clear(); + }; + if (solidJs.getOwner()) + solidJs.onCleanup(clear); + return Object.assign(fn, { clear }); +} +function createScheduled(schedule) { + let listeners = 0; + let isDirty = false; + const [track, dirty] = solidJs.createSignal(void 0, { equals: false }); + const call = schedule(() => { + isDirty = true; + dirty(); + }); + return () => { + if (!isDirty) + call(), track(); + if (isDirty) { + isDirty = !!listeners; + return true; + } + if (solidJs.getListener()) { + listeners++; + solidJs.onCleanup(() => listeners--); + } + return false; + }; +} + +function getDefaultExportFromCjs (x) { + return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; +} + +var es6 = function equal(a, b) { + if (a === b) return true; + + if (a && b && typeof a == 'object' && typeof b == 'object') { + if (a.constructor !== b.constructor) return false; + + var length, i, keys; + if (Array.isArray(a)) { + length = a.length; + if (length != b.length) return false; + for (i = length; i-- !== 0;) + if (!equal(a[i], b[i])) return false; + return true; + } + + + if ((a instanceof Map) && (b instanceof Map)) { + if (a.size !== b.size) return false; + for (i of a.entries()) + if (!b.has(i[0])) return false; + for (i of a.entries()) + if (!equal(i[1], b.get(i[0]))) return false; + return true; + } + + if ((a instanceof Set) && (b instanceof Set)) { + if (a.size !== b.size) return false; + for (i of a.entries()) + if (!b.has(i[0])) return false; + return true; + } + + if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) { + length = a.length; + if (length != b.length) return false; + for (i = length; i-- !== 0;) + if (a[i] !== b[i]) return false; + return true; + } + + + if (a.constructor === RegExp) return a.source === b.source && a.flags === b.flags; + if (a.valueOf !== Object.prototype.valueOf) return a.valueOf() === b.valueOf(); + if (a.toString !== Object.prototype.toString) return a.toString() === b.toString(); + + keys = Object.keys(a); + length = keys.length; + if (length !== Object.keys(b).length) return false; + + for (i = length; i-- !== 0;) + if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; + + for (i = length; i-- !== 0;) { + var key = keys[i]; + + if (!equal(a[key], b[key])) return false; + } + + return true; + } + + // true if both NaN, false otherwise + return a!==a && b!==b; +}; + +const isEqual = /*@__PURE__*/getDefaultExportFromCjs(es6); + +const throttle = (fn, wait = 100) => leadingAndTrailing(throttle$1, fn, wait); +const debounce = (fn, wait = 100) => debounce$1(fn, wait); +const sleep = ms => new Promise(resolve => { + window.setTimeout(resolve, ms); +}); +const clamp = (min, val, max) => Math.max(Math.min(max, val), min); +const inRange = (min, val, max) => val >= min && val <= max; + +/** 判断两个数是否在指定误差范围内相等 */ +const approx = (val, target, range) => Math.abs(target - val) <= range; + +/** 根据传入的条件列表的真假,对 val 进行取反 */ +const ifNot = (val, ...conditions) => { + let res = Boolean(val); + for (const v of conditions) if (v) res = !res; + return res; +}; + +/** + * 对 document.querySelector 的封装 + * 将默认返回类型改为 HTMLElement + */ +const querySelector = selector => document.querySelector(selector); + +/** + * 对 document.querySelector 的封装 + * 将默认返回类型改为 HTMLElement + */ +const querySelectorAll = selector => [...document.querySelectorAll(selector)]; + +/** + * 添加元素 + * @param node 被添加元素 + * @param textnode 添加元素 + * @param referenceNode 参考元素,添加元素将插在参考元素前 + */ +const insertNode = (node, textnode, referenceNode = null) => { + const temp = document.createElement('div'); + temp.innerHTML = textnode; + const frag = document.createDocumentFragment(); + while (temp.firstChild) frag.append(temp.firstChild); + // TODO: 可以淘汰这个工具函数了 + // eslint-disable-next-line unicorn/prefer-modern-dom-apis + node.insertBefore(frag, referenceNode); +}; + +/** 返回 Dom 的点击函数 */ +const querySelectorClick = (selector, textContent) => { + let getDom; + if (typeof selector === 'function') getDom = selector;else if (textContent) { + getDom = () => querySelectorAll(selector).find(e => e.textContent?.includes(textContent)); + } else getDom = () => querySelector(selector); + if (getDom()) return () => getDom()?.click(); +}; + +/** 找出数组中出现最多次的元素 */ +const getMostItem = list => { + const counts = new Map(); + for (const val of list) counts.set(val, counts.get(val) ?? 0 + 1); + + // eslint-disable-next-line unicorn/no-array-reduce + return [...counts.entries()].reduce((maxItem, item) => maxItem[1] > item[1] ? maxItem : item)[0]; +}; + +/** 创建顺序数组 */ +const createSequence = length => [...Array.from({ + length +}).keys()]; + +/** 判断字符串是否为 URL */ +const isUrl = text => { + // 等浏览器版本上来后可以直接使用 URL.canParse + try { + return Boolean(new URL(text)); + } catch { + return false; + } +}; + +/** 将对象转为 URLParams 类型的字符串 */ +const dataToParams = data => Object.entries(data).map(([key, val]) => \`\${key}=\${String(val)}\`).join('&'); + +/** 将 blob 数据作为文件保存至本地 */ +const saveAs = (blob, name = 'download') => { + const a = document.createElementNS('http://www.w3.org/1999/xhtml', 'a'); + a.download = name; + a.rel = 'noopener'; + a.href = URL.createObjectURL(blob); + setTimeout(() => a.dispatchEvent(new MouseEvent('click'))); +}; + +/** 监听键盘事件 */ +const linstenKeyup = handler => window.addEventListener('keyup', e => { + // 跳过输入框的键盘事件 + switch (e.target.tagName) { + case 'INPUT': + case 'TEXTAREA': + return; + } + handler(e); +}); + +/** 滚动页面到指定元素的所在位置 */ +const scrollIntoView = (selector, behavior = 'instant') => querySelector(selector)?.scrollIntoView({ + behavior +}); + +/** 循环执行指定函数 */ +const loop = async (fn, ms = 0) => { + await fn(); + setTimeout(loop, ms, fn); +}; + +/** 使指定函数延迟运行期间的多次调用直到运行结束 */ +const singleThreaded = (callback, defaultContinueRun = true) => { + const state = { + running: false, + continueRun: false + }; + const fn = async (...args) => { + if (state.continueRun) return; + if (state.running) { + state.continueRun = defaultContinueRun; + return; + } + let res; + try { + state.running = true; + res = await callback(state, ...args); + } catch (error) { + state.continueRun = false; + await sleep(100); + throw error; + } finally { + state.running = false; + } + if (state.continueRun) { + state.continueRun = false; + setTimeout(fn, 0, ...args); + } else state.running = false; + return res; + }; + return fn; +}; + +/** + * 限制 Promise 并发 + * @param fnList 任务函数列表 + * @param callBack 成功执行一个 Promise 后调用,主要用于显示进度 + * @param limit 限制数 + * @returns 所有 Promise 的返回值 + */ +const plimit = async (fnList, callBack = undefined, limit = 10) => { + let doneNum = 0; + const totalNum = fnList.length; + const resList = []; + const execPool = new Set(); + const taskList = fnList.map((fn, i) => { + let p; + return () => { + p = (async () => { + resList[i] = await fn(); + doneNum += 1; + execPool.delete(p); + callBack?.(doneNum, totalNum, resList, i); + })(); + execPool.add(p); + }; + }); + + // eslint-disable-next-line no-unmodified-loop-condition + while (doneNum !== totalNum) { + while (taskList.length > 0 && execPool.size < limit) taskList.shift()(); + await Promise.race(execPool); + } + return resList; +}; + +/** + * 判断使用参数颜色作为默认值时是否需要切换为黑暗模式 + * @param hexColor 十六进制颜色。例如 #112233 + */ +const needDarkMode = hexColor => { + // by: https://24ways.org/2010/calculating-color-contrast + const r = Number.parseInt(hexColor.slice(1, 3), 16); + const g = Number.parseInt(hexColor.slice(3, 5), 16); + const b = Number.parseInt(hexColor.slice(5, 7), 16); + const yiq = (r * 299 + g * 587 + b * 114) / 1000; + return yiq < 128; +}; + +async function wait(fn, timeout = Number.POSITIVE_INFINITY) { + let res = await fn(); + let _timeout = timeout; + while (_timeout > 0 && !res) { + await sleep(10); + _timeout -= 10; + res = await fn(); + } + return res; +} + +/** 等到指定的 dom 出现 */ +const waitDom = selector => wait(() => querySelector(selector)); + +/** 等待指定的图片元素加载完成 */ +const waitImgLoad = (img, timeout = 1000 * 10) => new Promise(resolve => { + const id = window.setTimeout(() => resolve(new ErrorEvent('timeout')), timeout); + img.addEventListener('load', () => { + resolve(null); + window.clearTimeout(id); + }); + img.addEventListener('error', e => { + resolve(e); + window.clearTimeout(id); + }); +}); + +/** 将指定的布尔值转换为字符串或未定义 */ +const boolDataVal = val => val ? '' : undefined; + +/** + * + * 通过滚动到指定图片元素位置并停留一会来触发图片的懒加载,返回图片 src 是否发生变化 + * + * 会在触发后重新滚回原位,当 time 为 0 时,因为滚动速度很快所以是无感的 + */ +const triggerEleLazyLoad = async (e, time, isLazyLoaded) => { + const nowScroll = window.scrollY; + e.scrollIntoView({ + behavior: 'instant' + }); + e.dispatchEvent(new Event('scroll', { + bubbles: true + })); + try { + if (isLazyLoaded && time) return await wait(isLazyLoaded, time); + } finally { + window.scroll({ + top: nowScroll, + behavior: 'auto' + }); + } +}; + +/** 获取图片尺寸 */ +const getImgSize = async (url, breakFn) => { + let error = false; + const image = new Image(); + try { + image.onerror = () => { + error = true; + }; + image.src = url; + await wait(() => !error && (image.naturalWidth || image.naturalHeight) && (breakFn ? !breakFn() : true)); + if (error) return null; + return [image.naturalWidth, image.naturalHeight]; + } catch (error_) { + return null; + } finally { + image.src = ''; + } +}; + +/** 测试图片 url 能否正确加载 */ +const testImgUrl = url => new Promise(resolve => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + img.src = url; +}); +const canvasToBlob = (canvas, type, quality = 1) => new Promise((resolve, reject) => { + canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas toBlob failed')), type, quality); +}); + +/** + * 求 a 和 b 的差集,相当于从 a 中删去和 b 相同的属性 + * + * 不会修改参数对象,返回的是新对象 + */ +const difference = (a, b) => { + const res = {}; + const keys = Object.keys(a); + for (const key of keys) { + if (typeof a[key] === 'object' && typeof b[key] === 'object') { + const _res = difference(a[key], b[key]); + if (Object.keys(_res).length > 0) res[key] = _res; + } else if (a[key] !== b?.[key]) res[key] = a[key]; + } + return res; +}; +const _assign = (a, b) => { + const res = JSON.parse(JSON.stringify(a)); + const keys = Object.keys(b); + for (const key of keys) { + if (res[key] === undefined) res[key] = b[key];else if (typeof b[key] === 'object') { + const _res = _assign(res[key], b[key]); + if (Object.keys(_res).length > 0) res[key] = _res; + } else if (res[key] !== b[key]) res[key] = b[key]; + } + return res; +}; + +/** + * Object.assign 的深拷贝版,不会导致子对象属性的缺失 + * + * 不会修改参数对象,返回的是新对象 + */ +const assign = (target, ...sources) => { + let res = target; + for (let i = 0; i < sources.length; i += 1) if (sources[i] !== undefined) res = _assign(res, sources[i]); + return res; +}; + +/** 根据路径获取对象下的指定值 */ +const byPath = (obj, path, handleVal) => { + const keys = path.split('.'); + let target = obj; + for (let i = 0; i < keys.length; i++) { + let key = keys[i]; + + // 兼容含有「.」的 key + while (!Reflect.has(target, key) && i < keys.length) { + i += 1; + if (keys[i] === undefined) break; + key += \`.\${keys[i]}\`; + } + if (handleVal && i > keys.length - 2 && Reflect.has(target, key)) { + const res = handleVal(target, key); + while (i < keys.length - 1) { + target = target[key]; + i += 1; + key = keys[i]; + } + if (res !== undefined) target[key] = res; + break; + } + target = target[key]; + } + if (target === obj) return null; + return target; +}; +const requestIdleCallback$1 = (callback, timeout) => { + if (Reflect.has(window, 'requestIdleCallback')) return window.requestIdleCallback(callback, { + timeout + }); + return window.setTimeout(callback, 16); +}; + +/** + * 通过监视点击等会触发动态加载的事件,在触发后执行指定动作 + * @param update 动态加载后的重新加载 + */ +const autoUpdate = update => { + const refresh = singleThreaded(update); + for (const eventName of ['click', 'popstate']) window.addEventListener(eventName, refresh, { + capture: true + }); + refresh(); +}; + +/** 获取键盘事件的编码 */ +const getKeyboardCode = e => { + let { + key + } = e; + switch (key) { + case 'Shift': + case 'Control': + case 'Alt': + return key; + } + if (e.ctrlKey) key = \`Ctrl + \${key}\`; + if (e.altKey) key = \`Alt + \${key}\`; + if (e.shiftKey) key = \`Shift + \${key}\`; + return key; +}; + +/** 将快捷键的编码转换成更易读的形式 */ +const keyboardCodeToText = code => code.replace('Control', 'Ctrl').replace('ArrowUp', '↑').replace('ArrowDown', '↓').replace('ArrowLeft', '←').replace('ArrowRight', '→').replace(/^\\s$/, 'Space'); + +/* eslint-disable no-console */ + +const prefix = ['%cComicRead', 'background-color: #607d8b; color: white; padding: 2px 4px; border-radius: 4px;']; +const log = (...args) => Reflect.apply(console.log, null, [...prefix, ...args]); +log.warn = (...args) => Reflect.apply(console.warn, null, [...prefix, ...args]); +log.error = (...args) => Reflect.apply(console.error, null, [...prefix, ...args]); + +const zh = { + alert: { + comic_load_error: "漫画加载出错", + download_failed: "下载失败", + fetch_comic_img_failed: "获取漫画图片失败", + img_load_failed: "图片加载失败", + no_img_download: "没有能下载的图片", + repeat_load: "加载图片中,请稍候", + server_connect_failed: "无法连接到服务器" + }, + button: { + close_current_page_translation: "关闭当前页的翻译", + download: "下载", + download_completed: "下载完成", + downloading: "下载中", + exit: "退出", + grid_mode: "网格模式", + packaging: "打包中", + page_fill: "页面填充", + page_mode_double: "双页模式", + page_mode_single: "单页模式", + scroll_mode: "卷轴模式", + setting: "设置", + translate_current_page: "翻译当前页", + zoom_in: "放大", + zoom_out: "缩小" + }, + description: "为漫画站增加双页阅读、翻译等优化体验的增强功能。", + end_page: { + next_button: "下一话", + prev_button: "上一话", + tip: { + end_jump: "已到结尾,继续向下翻页将跳至下一话", + exit: "已到结尾,继续翻页将退出", + start_jump: "已到开头,继续向上翻页将跳至上一话" + } + }, + hotkeys: { + enter_read_mode: "进入阅读模式", + exit: "退出", + jump_to_end: "跳至尾页", + jump_to_home: "跳至首页", + page_down: "向下翻页", + page_up: "向上翻页", + scroll_down: "向下滚动", + scroll_left: "向左滚动", + scroll_right: "向右滚动", + scroll_up: "向上滚动", + switch_auto_enlarge: "切换图片自动放大选项", + switch_dir: "切换阅读方向", + switch_grid_mode: "切换网格模式", + switch_page_fill: "切换页面填充", + switch_scroll_mode: "切换卷轴模式", + switch_single_double_page_mode: "切换单双页模式", + translate_current_page: "翻译当前页" + }, + img_status: { + error: "加载出错", + loading: "正在加载", + wait: "等待加载" + }, + other: { + auto_enter_read_mode: "自动进入阅读模式", + "default": "默认", + disable: "禁用", + enter_comic_read_mode: "进入漫画阅读模式", + fab_hidden: "隐藏悬浮按钮", + fab_show: "显示悬浮按钮", + fill_page: "填充页", + img_loading: "图片加载中", + loading_img: "加载图片中", + read_mode: "阅读模式" + }, + pwa: { + alert: { + img_data_error: "图片数据错误", + img_not_found: "找不到图片", + img_not_found_files: "请选择图片文件或含有图片文件的压缩包", + img_not_found_folder: "文件夹下没有图片文件或含有图片文件的压缩包", + not_valid_url: "不是有效的 URL", + repeat_load: "正在加载其他文件中……", + unzip_error: "解压出错", + unzip_password_error: "解压密码错误", + userscript_not_installed: "未安装 ComicRead 脚本" + }, + button: { + enter_url: "输入 URL", + install: "安装", + no_more_prompt: "不再提示", + resume_read: "恢复阅读", + select_files: "选择文件", + select_folder: "选择文件夹" + }, + install_md: "### 每次都要打开这个网页很麻烦?\\n如果你希望\\n1. 能有独立的窗口,像是在使用本地软件一样\\n1. 加入本地压缩文件的打开方式之中,方便直接打开\\n1. 离线使用~~(主要是担心国内网络抽风无法访问这个网页~~\\n### 欢迎将本页面作为 PWA 应用安装到电脑上😃👍", + message: { + enter_password: "请输入密码", + unzipping: "解压缩中" + }, + tip_enter_url: "请输入压缩包 URL", + tip_md: "# ComicRead PWA\\n使用 [ComicRead](https://github.com/hymbz/ComicReadScript) 的阅读模式阅读**本地**漫画\\n---\\n### 将图片文件、文件夹、压缩包直接拖入即可开始阅读\\n*也可以选择**直接粘贴**或**输入**压缩包 URL 下载阅读*" + }, + setting: { + hotkeys: { + add: "添加新快捷键", + restore: "恢复默认快捷键" + }, + language: "语言", + option: { + abreast_duplicate: "每列重复比例", + abreast_mode: "并排卷轴模式", + always_load_all_img: "始终加载所有图片", + background_color: "背景颜色", + click_page_turn_area: "点击区域", + click_page_turn_enabled: "点击翻页", + click_page_turn_swap_area: "左右点击区域交换", + click_page_turn_vertical: "上下翻页", + dark_mode: "夜间模式", + dir_ltr: "从左到右(美漫)", + dir_rtl: "从右到左(日漫)", + disable_auto_enlarge: "禁止图片自动放大", + first_page_fill: "默认启用首页填充", + fit_to_width: "图片适合宽度", + jump_to_next_chapter: "翻页至上/下一话", + paragraph_dir: "阅读方向", + paragraph_display: "显示", + paragraph_hotkeys: "快捷键", + paragraph_operation: "操作", + paragraph_other: "其他", + paragraph_scrollbar: "滚动条", + paragraph_translation: "翻译", + preload_page_num: "预加载页数", + scroll_mode_img_scale: "卷轴图片缩放", + scroll_mode_img_spacing: "卷轴图片间距", + scrollbar_auto_hidden: "自动隐藏", + scrollbar_easy_scroll: "快捷滚动", + scrollbar_position: "位置", + scrollbar_position_auto: "自动", + scrollbar_position_bottom: "底部", + scrollbar_position_hidden: "隐藏", + scrollbar_position_right: "右侧", + scrollbar_position_top: "顶部", + scrollbar_show_img_status: "显示图片加载状态", + show_clickable_area: "显示点击区域", + show_comments: "在结束页显示评论", + swap_page_turn_key: "左右翻页键交换" + }, + translation: { + cotrans_tip: "

将使用 Cotrans 提供的接口翻译图片,该服务器由其维护者用爱发电自费维护

\\n

多人同时使用时需要排队等待,等待队列达到上限后再上传新图片会报错,需要过段时间再试

\\n

所以还请 注意用量

\\n

更推荐使用自己本地部署的项目,既不占用服务器资源也不需要排队

", + options: { + detection_resolution: "文本扫描清晰度", + direction: "渲染字体方向", + direction_auto: "原文一致", + direction_horizontal: "仅限水平", + direction_vertical: "仅限垂直", + forceRetry: "忽略缓存强制重试", + localUrl: "自定义服务器 URL", + onlyDownloadTranslated: "只下载完成翻译的图片", + target_language: "目标语言", + text_detector: "文本扫描器", + translator: "翻译服务" + }, + server: "翻译服务器", + server_selfhosted: "本地部署", + translate_after_current: "翻译当前页至结尾", + translate_all_img: "翻译全部图片" + } + }, + site: { + add_feature: { + associate_nhentai: "关联nhentai", + auto_page_turn: "自动翻页", + block_totally: "彻底屏蔽漫画", + detect_ad: "识别广告页", + hotkeys_page_turn: "快捷键翻页", + load_original_image: "加载原图", + open_link_new_page: "在新页面中打开链接", + remember_current_site: "记住当前站点" + }, + changed_load_failed: "网站发生变化,无法加载漫画", + ehentai: { + fetch_img_page_source_failed: "获取图片页源码失败", + fetch_img_page_url_failed: "从详情页获取图片页地址失败", + fetch_img_url_failed: "从图片页获取图片地址失败", + html_changed_nhentai_failed: "页面结构发生改变,关联 nhentai 漫画功能无法正常生效", + ip_banned: "IP地址被禁", + nhentai_error: "nhentai 匹配出错", + nhentai_failed: "匹配失败,请在确认登录 {{nhentai}} 后刷新" + }, + need_captcha: "需要人机验证", + nhentai: { + fetch_next_page_failed: "获取下一页漫画数据失败", + tag_blacklist_fetch_failed: "标签黑名单获取失败" + }, + settings_tip: "设置", + show_settings_menu: "显示设置菜单", + simple: { + auto_read_mode_message: "已默认开启「自动进入阅读模式」", + no_img: "未找到合适的漫画图片,\\n如有需要可点此关闭简易阅读模式", + simple_read_mode: "使用简易阅读模式" + } + }, + touch_area: { + menu: "菜单", + next: "下页", + prev: "上页", + type: { + edge: "边缘", + l: "L", + left_right: "左右", + up_down: "上下" + } + }, + translation: { + status: { + "default": "未知状态", + detection: "正在检测文本", + downscaling: "正在缩小图片", + error: "翻译出错", + "error-lang": "你选择的翻译服务不支持你选择的语言", + "error-translating": "翻译服务没有返回任何文本", + "error-with-id": "翻译出错", + finished: "正在整理结果", + inpainting: "正在修补图片", + "mask-generation": "正在生成文本掩码", + ocr: "正在识别文本", + pending: "正在等待", + "pending-pos": "正在等待", + rendering: "正在渲染", + saved: "保存结果", + textline_merge: "正在整合文本", + translating: "正在翻译文本", + upscaling: "正在放大图片" + }, + tip: { + check_img_status_failed: "检查图片状态失败", + download_img_failed: "下载图片失败", + error: "翻译出错", + get_translator_list_error: "获取可用翻译服务列表时出错", + id_not_returned: "未返回 id", + img_downloading: "正在下载图片", + img_not_fully_loaded: "图片未加载完毕", + pending: "正在等待,列队还有 {{pos}} 张图片", + resize_img_failed: "缩放图片失败", + translation_completed: "翻译完成", + upload_error: "图片上传出错", + upload_return_error: "服务器翻译出错", + wait_translation: "等待翻译" + }, + translator: { + baidu: "百度", + deepl: "DeepL", + google: "谷歌", + "gpt3.5": "GPT-3.5", + none: "删除文本", + offline: "离线模型", + original: "原文", + youdao: "有道" + } + } +}; + +const en = { + alert: { + comic_load_error: "Comic loading error", + download_failed: "Download failed", + fetch_comic_img_failed: "Failed to fetch comic images", + img_load_failed: "Image loading failed", + no_img_download: "No images available for download", + repeat_load: "Loading image, please wait", + server_connect_failed: "Unable to connect to the server" + }, + button: { + close_current_page_translation: "Close translation of the current page", + download: "Download", + download_completed: "Download completed", + downloading: "Downloading", + exit: "Exit", + grid_mode: "Grid mode", + packaging: "Packaging", + page_fill: "Page fill", + page_mode_double: "Double page mode", + page_mode_single: "Single page mode", + scroll_mode: "Scroll mode", + setting: "Settings", + translate_current_page: "Translate current page", + zoom_in: "Zoom in", + zoom_out: "Zoom out" + }, + description: "Add enhanced features to the comic site for optimized experience, including dual-page reading and translation.", + end_page: { + next_button: "Next chapter", + prev_button: "Prev chapter", + tip: { + end_jump: "Reached the last page, scrolling down will jump to the next chapter", + exit: "Reached the last page, scrolling down will exit", + start_jump: "Reached the first page, scrolling up will jump to the previous chapter" + } + }, + hotkeys: { + enter_read_mode: "Enter reading mode", + exit: "Exit", + jump_to_end: "Jump to the last page", + jump_to_home: "Jump to the first page", + page_down: "Turn the page to the down", + page_up: "Turn the page to the up", + scroll_down: "Scroll down", + scroll_left: "Scroll left", + scroll_right: "Scroll right", + scroll_up: "Scroll up", + switch_auto_enlarge: "Switch auto image enlarge option", + switch_dir: "Switch reading direction", + switch_grid_mode: "Switch grid mode", + switch_page_fill: "Switch page fill", + switch_scroll_mode: "Switch scroll mode", + switch_single_double_page_mode: "Switch single/double page mode", + translate_current_page: "Translate current page" + }, + img_status: { + error: "Load Error", + loading: "Loading", + wait: "Waiting for load" + }, + other: { + auto_enter_read_mode: "Auto enter reading mode", + "default": "Default", + disable: "Disable", + enter_comic_read_mode: "Enter comic reading mode", + fab_hidden: "Hide floating button", + fab_show: "Show floating button", + fill_page: "Fill Page", + img_loading: "Image loading", + loading_img: "Loading image", + read_mode: "Reading mode" + }, + pwa: { + alert: { + img_data_error: "Image data error", + img_not_found: "Image not found", + img_not_found_files: "Please select an image file or a compressed file containing image files", + img_not_found_folder: "No image files or compressed files containing image files in the folder", + not_valid_url: "Not a valid URL", + repeat_load: "Loading other files…", + unzip_error: "Decompression error", + unzip_password_error: "Decompression password error", + userscript_not_installed: "ComicRead userscript not installed" + }, + button: { + enter_url: "Enter URL", + install: "Install", + no_more_prompt: "Do not prompt again", + resume_read: "Restore reading", + select_files: "Select File", + select_folder: "Select folder" + }, + install_md: "### Tired of opening this webpage every time?\\nIf you wish to:\\n1. Have an independent window, as if using local software\\n1. Add to the local compressed file opening method for easy direct opening\\n1. Use offline\\n### Welcome to install this page as a PWA app on your computer😃👍", + message: { + enter_password: "Please enter your password", + unzipping: "Unzipping" + }, + tip_enter_url: "Please enter the URL of the compressed file", + tip_md: "# ComicRead PWA\\nRead **local** comics using [ComicRead](https://github.com/hymbz/ComicReadScript) reading mode.\\n---\\n### Drag and drop image files, folders, or compressed files directly to start reading\\n*You can also choose to **paste directly** or **enter** the URL of the compressed file for downloading and reading*" + }, + setting: { + hotkeys: { + add: "Add new hotkeys", + restore: "Restore default hotkeys" + }, + language: "Language", + option: { + abreast_duplicate: "Column duplicates ratio", + abreast_mode: "Abreast scroll mode", + always_load_all_img: "Always load all images", + background_color: "Background Color", + click_page_turn_area: "Touch area", + click_page_turn_enabled: "Click to turn page", + click_page_turn_swap_area: "Swap LR clickable areas", + click_page_turn_vertical: "Vertically arranged clickable areas", + dark_mode: "Dark mode", + dir_ltr: "LTR (American comics)", + dir_rtl: "RTL (Japanese manga)", + disable_auto_enlarge: "Disable automatic image enlarge", + first_page_fill: "Enable first page fill by default", + fit_to_width: "Fit to width", + jump_to_next_chapter: "Turn to the next/previous chapter", + paragraph_dir: "Reading direction", + paragraph_display: "Display", + paragraph_hotkeys: "Hotkeys", + paragraph_operation: "Operation", + paragraph_other: "Other", + paragraph_scrollbar: "Scrollbar", + paragraph_translation: "Translation", + preload_page_num: "Preload page number", + scroll_mode_img_scale: "Scroll mode image zoom ratio", + scroll_mode_img_spacing: "Scroll mode image spacing", + scrollbar_auto_hidden: "Auto hide", + scrollbar_easy_scroll: "Easy scroll", + scrollbar_position: "position", + scrollbar_position_auto: "Auto", + scrollbar_position_bottom: "Bottom", + scrollbar_position_hidden: "Hidden", + scrollbar_position_right: "Right", + scrollbar_position_top: "Top", + scrollbar_show_img_status: "Show image loading status", + show_clickable_area: "Show clickable areas", + show_comments: "Show comments on the end page", + swap_page_turn_key: "Swap LR page-turning keys" + }, + translation: { + cotrans_tip: "

Using the interface provided by Cotrans to translate images, which is maintained by its maintainer at their own expense.

\\n

When multiple people use it at the same time, they need to queue and wait. If the waiting queue reaches its limit, uploading new images will result in an error. Please try again after a while.

\\n

So please mind the frequency of use.

\\n

It is highly recommended to use your own locally deployed project, as it does not consume server resources and does not require queuing.

", + options: { + detection_resolution: "Text detection resolution", + direction: "Render text orientation", + direction_auto: "Follow source", + direction_horizontal: "Horizontal only", + direction_vertical: "Vertical only", + forceRetry: "Force retry (ignore cache)", + localUrl: "customize server URL", + onlyDownloadTranslated: "Download only the translated images", + target_language: "Target language", + text_detector: "Text detector", + translator: "Translator" + }, + server: "Translation server", + server_selfhosted: "Selfhosted", + translate_after_current: "Translate the current page to the end", + translate_all_img: "Translate all images" + } + }, + site: { + add_feature: { + associate_nhentai: "Associate nhentai", + auto_page_turn: "Auto page turning", + block_totally: "Totally block comics", + detect_ad: "Detect advertise page", + hotkeys_page_turn: "Page turning with hotkeys", + load_original_image: "Load original image", + open_link_new_page: "Open links in a new page", + remember_current_site: "Remember the current site" + }, + changed_load_failed: "The website has undergone changes, unable to load comics", + ehentai: { + fetch_img_page_source_failed: "Failed to get the source code of the image page", + fetch_img_page_url_failed: "Failed to get the image page address from the detail page", + fetch_img_url_failed: "Failed to get the image address from the image page", + html_changed_nhentai_failed: "The web page structure has changed, the function to associate nhentai comics is not working properly", + ip_banned: "IP address is banned", + nhentai_error: "Error in nhentai matching", + nhentai_failed: "Matching failed, please refresh after confirming login to {{nhentai}}" + }, + need_captcha: "Need CAPTCHA verification", + nhentai: { + fetch_next_page_failed: "Failed to get next page of comic data", + tag_blacklist_fetch_failed: "Failed to fetch tag blacklist" + }, + settings_tip: "Settings", + show_settings_menu: "Show settings menu", + simple: { + auto_read_mode_message: "\\"Auto enter reading mode\\" is enabled by default", + no_img: "No suitable comic images were found. If necessary, you can click here to close the simple reading mode.", + simple_read_mode: "Enter simple reading mode" + } + }, + touch_area: { + menu: "Menu", + next: "Next Page", + prev: "Prev Page", + type: { + edge: "Edge", + l: "L", + left_right: "Left Right", + up_down: "Up Down" + } + }, + translation: { + status: { + "default": "Unknown status", + detection: "Detecting text", + downscaling: "Downscaling", + error: "Error during translation", + "error-lang": "The target language is not supported by the chosen translator", + "error-translating": "Did not get any text back from the text translation service", + "error-with-id": "Error during translation", + finished: "Finishing", + inpainting: "Inpainting", + "mask-generation": "Generating mask", + ocr: "Scanning text", + pending: "Pending", + "pending-pos": "Pending", + rendering: "Rendering", + saved: "Saved", + textline_merge: "Merging text lines", + translating: "Translating", + upscaling: "Upscaling" + }, + tip: { + check_img_status_failed: "Failed to check image status", + download_img_failed: "Failed to download image", + error: "Translation error", + get_translator_list_error: "Error occurred while getting the list of available translation services", + id_not_returned: "No id returned", + img_downloading: "Downloading images", + img_not_fully_loaded: "Image has not finished loading", + pending: "Pending, {{pos}} in queue", + resize_img_failed: "Failed to resize image", + translation_completed: "Translation completed", + upload_error: "Image upload error", + upload_return_error: "Error during server translation", + wait_translation: "Waiting for translation" + }, + translator: { + baidu: "baidu", + deepl: "DeepL", + google: "Google", + "gpt3.5": "GPT-3.5", + none: "Remove texts", + offline: "offline translator", + original: "Original", + youdao: "youdao" + } + } +}; + +const ru = { + alert: { + comic_load_error: "Ошибка загрузки комикса", + download_failed: "Ошибка загрузки", + fetch_comic_img_failed: "Не удалось загрузить изображения", + img_load_failed: "Не удалось загрузить изображение", + no_img_download: "Нет доступных картинок для загрузки", + repeat_load: "Загрузка изображения, пожалуйста подождите", + server_connect_failed: "Не удалось подключиться к серверу" + }, + button: { + close_current_page_translation: "Скрыть перевод текущей страницы", + download: "Скачать", + download_completed: "Загрузка завершена", + downloading: "Скачивание", + exit: "Выход", + grid_mode: "Режим сетки", + packaging: "Упаковка", + page_fill: "Заполнить страницу", + page_mode_double: "Двухчастичный режим", + page_mode_single: "Одностраничный режим", + scroll_mode: "Режим прокрутки", + setting: "Настройки", + translate_current_page: "Перевести текущую страницу", + zoom_in: "Приблизить", + zoom_out: "Уменьшить" + }, + description: "Добавляет расширенные функции для удобства на сайт, такие как двухстраничный режим и перевод.", + end_page: { + next_button: "Следующая глава", + prev_button: "Предыдущая глава", + tip: { + end_jump: "Последняя страница, следующая глава ниже", + exit: "Последняя страница, ниже комикс будет закрыт", + start_jump: "Первая страница, выше будет загружена предыдущая глава" + } + }, + hotkeys: { + enter_read_mode: "Режим чтения", + exit: "Выход", + jump_to_end: "Перейти к последней странице", + jump_to_home: "Перейти к первой странице", + page_down: "Перелистнуть страницу вниз", + page_up: "Перелистнуть страницу вверх", + scroll_down: "Прокрутить вниз", + scroll_left: "Прокрутить влево", + scroll_right: "Прокрутите вправо", + scroll_up: "Прокрутите вверх", + switch_auto_enlarge: "Автоматическое приближение", + switch_dir: "Направление чтения", + switch_grid_mode: "Режим сетки", + switch_page_fill: "Заполнение страницы", + switch_scroll_mode: "Режим прокрутки", + switch_single_double_page_mode: "Одностраничный/Двухстраничный режим", + translate_current_page: "Перевести текущую страницу" + }, + img_status: { + error: "Ошибка загрузки", + loading: "Загрузка", + wait: "Ожидание загрузки" + }, + other: { + auto_enter_read_mode: "Автоматически включать режим чтения", + "default": "Дефолт", + disable: "Отключить", + enter_comic_read_mode: "Режим чтения комиксов", + fab_hidden: "Скрыть плавающую кнопку", + fab_show: "Показать плавающую кнопку", + fill_page: "Заполнить страницу", + img_loading: "Изображение загружается", + loading_img: "Загрузка изображения", + read_mode: "Режим чтения" + }, + pwa: { + alert: { + img_data_error: "Ошибка данных изображения", + img_not_found: "Изображение не найдено", + img_not_found_files: "Пожалуйста выберите файл или архив с изображениями", + img_not_found_folder: "В папке не найдены изображения или архивы с изображениями", + not_valid_url: "Невалидный URL", + repeat_load: "Загрузка других файлов…", + unzip_error: "Ошибка распаковки", + unzip_password_error: "Неверный пароль от архива", + userscript_not_installed: "ComicRead не установлен" + }, + button: { + enter_url: "Ввести URL", + install: "Установить", + no_more_prompt: "Больше не показывать", + resume_read: "Продолжить чтение", + select_files: "Выбрать файл", + select_folder: "Выбрать папку" + }, + install_md: "### Устали открывать эту страницу каждый раз?\\nЕсли вы хотите:\\n1. Иметь отдельное окно, как если бы вы использовали обычное программное обеспечение\\n1. Открывать архивы напрямую\\n1. Пользоваться оффлайн\\n### Установите эту страницу в качестве [PWA](https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%BE%D0%B3%D1%80%D0%B5%D1%81%D1%81%D0%B8%D0%B2%D0%BD%D0%BE%D0%B5_%D0%B2%D0%B5%D0%B1-%D0%BF%D1%80%D0%B8%D0%BB%D0%BE%D0%B6%D0%B5%D0%BD%D0%B8%D0%B5) на свой компьютер 🐺☝️", + message: { + enter_password: "Пожалуйста введите пароль", + unzipping: "Распаковка" + }, + tip_enter_url: "Введите URL архива", + tip_md: "# ComicRead PWA\\nИспользуйте [ComicRead](https://github.com/hymbz/ComicReadScript) для чтения комиксов **локально**.\\n---\\n### Перетащите изображения, папки или архивы чтобы начать читать\\n*Вы так же можете **открыть** или **вставить** URL архива на напрямую*" + }, + setting: { + hotkeys: { + add: "Добавить горячие клавиши", + restore: "Восстановить горячие клавиши по умолчанию" + }, + language: "Язык", + option: { + abreast_duplicate: "Коэффициент дублирования столбцов", + abreast_mode: "Режим прокрутки в ряд", + always_load_all_img: "Всегда загружать все изображения", + background_color: "Цвет фона", + click_page_turn_area: "Область нажатия", + click_page_turn_enabled: "Перелистывать по клику", + click_page_turn_swap_area: "Поменять местами правую и левую области переключения страниц", + click_page_turn_vertical: "Вертикальная область переключения страниц", + dark_mode: "Ночная тема", + dir_ltr: "Чтение слева направо (Американские комиксы)", + dir_rtl: "Чтение справа налево (Японская манга)", + disable_auto_enlarge: "Отключить автоматическое масштабирование изображений", + first_page_fill: "Включить заполнение первой страницы по умолчанию", + fit_to_width: "По ширине", + jump_to_next_chapter: "Перелистнуть главу", + paragraph_dir: "Направление чтения", + paragraph_display: "Отображение", + paragraph_hotkeys: "Горячие клавиши", + paragraph_operation: "Управление", + paragraph_other: "Другое", + paragraph_scrollbar: "Полоса прокрутки", + paragraph_translation: "Перевод", + preload_page_num: "Предзагружать страниц", + scroll_mode_img_scale: "Коэффициент масштабирования изображения в режиме скроллинга", + scroll_mode_img_spacing: "Расстояние между страницами в режиме скроллинга", + scrollbar_auto_hidden: "Автоматически скрывать", + scrollbar_easy_scroll: "Лёгкая прокрутка", + scrollbar_position: "Позиция", + scrollbar_position_auto: "Авто", + scrollbar_position_bottom: "Снизу", + scrollbar_position_hidden: "Спрятано", + scrollbar_position_right: "Справа", + scrollbar_position_top: "Сверху", + scrollbar_show_img_status: "Показывать статус загрузки изображения", + show_clickable_area: "Показывать кликабельные области", + show_comments: "Показывать комментарии на последней странице", + swap_page_turn_key: "Поменять местами клавиши переключения страниц" + }, + translation: { + cotrans_tip: "

Использует для перевода Cotrans API, работающий исключительно за счёт своего создателя.

\\n

Запросы обрабатываются по одному в порядке синхронной очереди. Когда очередь превышает лимит новые запросы будут приводить к ошибке. Если такое случилось попробуйте позже.

\\n

Так что пожалуйста учитывайте загруженность при выборе

\\n

Настоятельно рекомендовано использовать проект развёрнутый локально т.к. это не потребляет серверные ресурсы и вы не ограничены очередью.

", + options: { + detection_resolution: "Разрешение распознавания текста", + direction: "Ориетнация текста", + direction_auto: "Следование оригиналу", + direction_horizontal: "Только горизонтально", + direction_vertical: "Только вертикально", + forceRetry: "Принудительный повтор(Игнорировать кэш)", + localUrl: "Настроить URL сервера", + onlyDownloadTranslated: "Скачать только переведённые изображения", + target_language: "Целевой язык", + text_detector: "Детектор текста", + translator: "Переводчик" + }, + server: "Сервер", + server_selfhosted: "Свой", + translate_after_current: "Переводить страницу до конца", + translate_all_img: "Перевести все изображения" + } + }, + site: { + add_feature: { + associate_nhentai: "Ассоциация с nhentai", + auto_page_turn: "Автопереворот страниц", + block_totally: "Глобально заблокировать комиксы", + detect_ad: "Detect advertise page", + hotkeys_page_turn: "Переворот страниц горячими клавишами", + load_original_image: "Загружать оригинальное изображение", + open_link_new_page: "Открывать ссылки в новой вкладке", + remember_current_site: "Запомнить текущий сайт" + }, + changed_load_failed: "Страница изменилась, невозможно загрузить комикс", + ehentai: { + fetch_img_page_source_failed: "Не удалось получить исходный код страницы с изображениями", + fetch_img_page_url_failed: "Не удалось получить адрес страницы изображений из деталей", + fetch_img_url_failed: "Не удалось получить адрес изображения", + html_changed_nhentai_failed: "Структура страницы изменилась, функция nhentai manga работает некорректно", + ip_banned: "IP адрес забанен", + nhentai_error: "Ошибка сопоставления с nhentai", + nhentai_failed: "Ошибка сопостовления. Пожалуйста перезагрузите страницу после входа на {{nhentai}}" + }, + need_captcha: "CAPTCHA", + nhentai: { + fetch_next_page_failed: "Не удалось получить следующую страницу", + tag_blacklist_fetch_failed: "Не удалось получить заблокированные теги" + }, + settings_tip: "Настройки", + show_settings_menu: "Показать меню настроек", + simple: { + auto_read_mode_message: "\\"Автоматически включать режим чтения\\" по умолчанию", + no_img: "Не найдено подходящих изображений. Можно нажать тут что бы выключить режим простого чтения.", + simple_read_mode: "Включить простой режим чтения" + } + }, + touch_area: { + menu: "Меню", + next: "Следующая страница", + prev: "Предыдущая страница", + type: { + edge: "Грань", + l: "L", + left_right: "Лево Право", + up_down: "Верх Низ" + } + }, + translation: { + status: { + "default": "Неизвестный статус", + detection: "Распознавание текста", + downscaling: "Уменьшение масштаба", + error: "Ошибка перевода", + "error-lang": "Целевой язык не поддерживается выбранным переводчиком", + "error-translating": "Ошибка перевода(пустой ответ)", + "error-with-id": "Ошибка во время перевода", + finished: "Завершение", + inpainting: "Наложение", + "mask-generation": "Генерация маски", + ocr: "Распознавание текста", + pending: "Ожидание", + "pending-pos": "Ожидание", + rendering: "Отрисовка", + saved: "Сохранено", + textline_merge: "Обьединение текста", + translating: "Переводится", + upscaling: "Увеличение изображения" + }, + tip: { + check_img_status_failed: "Не удалось проверить статус изображения", + download_img_failed: "Не удалось скачать изображение", + error: "Ошибка перевода", + get_translator_list_error: "Произошла ошибка во время получения списка доступных переводчиков", + id_not_returned: "ID не вернули(", + img_downloading: "Скачивание изображений", + img_not_fully_loaded: "Изображение всё ещё загружается", + pending: "Ожидение, позиция в очереди {{pos}}", + resize_img_failed: "Не удалось изменить размер изображения", + translation_completed: "Перевод завершён", + upload_error: "Ошибка загрузки изображения", + upload_return_error: "Ошибка перевода на сервере", + wait_translation: "Ожидание перевода" + }, + translator: { + baidu: "baidu", + deepl: "DeepL", + google: "Google", + "gpt3.5": "GPT-3.5", + none: "Убрать текст", + offline: "Оффлайн переводчик", + original: "Оригинал", + youdao: "youdao" + } + } +}; + +const langList = ['zh', 'en', 'ru']; +/** 判断传入的字符串是否是支持的语言类型代码 */ +const isLanguages = lang => Boolean(lang) && langList.includes(lang); + +/** 返回浏览器偏好语言 */ +const getBrowserLang = () => { + let newLang; + for (let i = 0; i < navigator.languages.length; i++) { + const language = navigator.languages[i]; + const matchLang = langList.find(l => l === language || l === language.split('-')[0]); + if (matchLang) { + newLang = matchLang; + break; + } + } + return newLang; +}; +const getSaveLang = async () => typeof GM === 'undefined' ? localStorage.getItem('Languages') : GM.getValue('Languages'); +const setSaveLang = async val => typeof GM === 'undefined' ? localStorage.setItem('Languages', val) : GM.setValue('Languages', val); +const getInitLang = async () => { + const saveLang = await getSaveLang(); + if (isLanguages(saveLang)) return saveLang; + const lang = getBrowserLang() ?? 'zh'; + setSaveLang(lang); + return lang; +}; + +const [lang, setLang] = solidJs.createSignal('zh'); +const setInitLang = async () => setLang(await getInitLang()); +const t = solidJs.createRoot(() => { + solidJs.createEffect(solidJs.on(lang, async () => setSaveLang(lang()), { + defer: true + })); + const locales = solidJs.createMemo(() => { + switch (lang()) { + case 'en': + return en; + case 'ru': + return ru; + default: + return zh; + } + }); + return (keys, variables) => { + let text = byPath(locales(), keys) ?? ''; + if (variables) for (const [k, v] of Object.entries(variables)) text = text.replaceAll(\`{{\${k}}}\`, \`\${String(v)}\`); + return text; + }; +}); + +var css$3 = ".root{align-items:flex-end;bottom:0;flex-direction:column;font-size:16px;pointer-events:none;position:fixed;right:0;z-index:2147483647}.item,.root{display:flex}.item{align-items:center;animation:bounceInRight .5s 1;background:#fff;border-radius:4px;box-shadow:0 1px 10px 0 #0000001a,0 2px 15px 0 #0000000d;color:#000;cursor:pointer;margin:1em;max-width:min(30em,100vw);overflow:hidden;padding:.8em 1em;pointer-events:auto;position:relative;width:-moz-fit-content;width:fit-content}.item>svg{color:var(--theme);margin-right:.5em;width:1.5em}.item[data-exit]{animation:bounceOutRight .5s 1}.schedule{background-color:var(--theme);bottom:0;height:.2em;left:0;position:absolute;transform-origin:left;width:100%}.item[data-schedule] .schedule{transition:transform .1s}.item:not([data-schedule]) .schedule{animation:schedule linear 1 forwards}:is(.item:hover,.item[data-schedule],.root[data-paused]) .schedule{animation-play-state:paused}.msg{line-height:1.4em;text-align:start;white-space:break-spaces;width:-moz-fit-content;width:fit-content}.msg h2{margin:0}.msg h3{margin:.7em 0}.msg ul{margin:0;text-align:left}.msg button{background-color:#eee;border:none;border-radius:.4em;cursor:pointer;font-size:inherit;margin:0 .5em;outline:none;padding:.2em .6em}.msg button:hover{background:#e0e0e0}p{margin:0}@keyframes schedule{0%{transform:scaleX(1)}to{transform:scaleX(0)}}@keyframes bounceInRight{0%,60%,75%,90%,to{animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;transform:translate3d(3000px,0,0) scaleX(3)}60%{opacity:1;transform:translate3d(-25px,0,0) scaleX(1)}75%{transform:translate3d(10px,0,0) scaleX(.98)}90%{transform:translate3d(-5px,0,0) scaleX(.995)}to{transform:translateZ(0)}}@keyframes bounceOutRight{20%{opacity:1;transform:translate3d(-20px,0,0) scaleX(.9)}to{opacity:0;transform:translate3d(2000px,0,0) scaleX(2)}}"; +var modules_c21c94f2$3 = {"root":"root","item":"item","bounceInRight":"bounceInRight","bounceOutRight":"bounceOutRight","schedule":"schedule","msg":"msg"}; + +const [_state$1, _setState$1] = store$2.createStore({ + list: [], + map: {} +}); +const setState$1 = fn => _setState$1(store$2.produce(fn)); +const store$1 = _state$1; +const creatId = () => { + let id = \`\${Date.now()}\`; + while (Reflect.has(store$1.map, id)) id += '_'; + return id; +}; + +var _tmpl$$P = /*#__PURE__*/web.template(\`\`); +const MdCheckCircle = ((props = {}) => (() => { + var _el$ = _tmpl$$P(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var _tmpl$$O = /*#__PURE__*/web.template(\`\`); +const MdWarning = ((props = {}) => (() => { + var _el$ = _tmpl$$O(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var _tmpl$$N = /*#__PURE__*/web.template(\`\`); +const MdError = ((props = {}) => (() => { + var _el$ = _tmpl$$N(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var _tmpl$$M = /*#__PURE__*/web.template(\`\`); +const MdInfo = ((props = {}) => (() => { + var _el$ = _tmpl$$M(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +const toast$2 = (msg, options) => { + if (!msg) return; + const id = options?.id ?? (typeof msg === 'string' ? msg : creatId()); + setState$1(state => { + if (Reflect.has(state.map, id)) { + Object.assign(state.map[id], { + msg, + ...options, + update: true + }); + return; + } + state.map[id] = { + id, + type: 'info', + duration: 3000, + msg, + ...options + }; + state.list.push(id); + }); + + /** 弹窗后记录一下 */ + let fn = log; + switch (options?.type) { + case 'warn': + fn = log.warn; + break; + case 'error': + fn = log.error; + break; + } + fn('Toast:', msg); + if (options?.throw && typeof msg === 'string') throw new Error(msg); +}; +toast$2.dismiss = id => { + if (!Reflect.has(store$1.map, id)) return; + _setState$1('map', id, 'exit', true); +}; +toast$2.set = (id, options) => { + if (!Reflect.has(store$1.map, id)) return; + setState$1(state => Object.assign(state.map[id], options)); +}; +toast$2.success = (msg, options) => toast$2(msg, { + ...options, + exit: undefined, + type: 'success' +}); +toast$2.warn = (msg, options) => toast$2(msg, { + ...options, + exit: undefined, + type: 'warn' +}); +toast$2.error = (msg, options) => toast$2(msg, { + ...options, + exit: undefined, + type: 'error' +}); + +var _tmpl$$L = /*#__PURE__*/web.template(\`
\`), + _tmpl$2$c = /*#__PURE__*/web.template(\`
\`); +const iconMap = { + info: MdInfo, + success: MdCheckCircle, + warn: MdWarning, + error: MdError +}; +const colorMap = { + info: '#3a97d7', + success: '#23bb35', + warn: '#f0c53e', + error: '#e45042', + custom: '#1f2936' +}; + +/** 删除 toast */ +const dismissToast = id => setState$1(state => { + state.map[id].onDismiss?.({ + ...state.map[id] + }); + const i = state.list.indexOf(id); + if (i !== -1) state.list.splice(i, 1); + Reflect.deleteProperty(state.map, id); +}); + +/** 重置 toast 的 update 属性 */ +const resetToastUpdate = id => _setState$1('map', id, 'update', undefined); +const ToastItem = props => { + /** 是否要显示进度 */ + const showSchedule = solidJs.createMemo(() => props.duration === Number.POSITIVE_INFINITY && props.schedule ? true : undefined); + const dismiss = e => { + e.stopPropagation(); + if (showSchedule() && 'animationName' in e) return; + toast$2.dismiss(props.id); + }; + + // 在退出动画结束后才真的删除 + const handleAnimationEnd = () => { + if (!props.exit) return; + dismissToast(props.id); + }; + let scheduleRef; + solidJs.createEffect(() => { + if (!props.update) return; + resetToastUpdate(props.id); + if (!scheduleRef) return; + for (const animation of scheduleRef.getAnimations()) { + animation.cancel(); + animation.play(); + } + }); + const handleClick = e => { + props.onClick?.(); + dismiss(e); + }; + return (() => { + var _el$ = _tmpl$2$c(), + _el$2 = _el$.firstChild; + _el$.addEventListener("animationend", handleAnimationEnd); + _el$.addEventListener("click", handleClick); + web.insert(_el$, web.createComponent(web.Dynamic, { + get component() { + return iconMap[props.type]; + } + }), _el$2); + web.insert(_el$2, (() => { + var _c$ = web.memo(() => typeof props.msg === 'string'); + return () => _c$() ? props.msg : web.createComponent(props.msg, {}); + })()); + web.insert(_el$, web.createComponent(solidJs.Show, { + get when() { + return props.duration !== Number.POSITIVE_INFINITY || props.schedule !== undefined; + }, + get children() { + var _el$3 = _tmpl$$L(); + _el$3.addEventListener("animationend", dismiss); + var _ref$ = scheduleRef; + typeof _ref$ === "function" ? web.use(_ref$, _el$3) : scheduleRef = _el$3; + web.effect(_p$ => { + var _v$ = modules_c21c94f2$3.schedule, + _v$2 = \`\${props.duration}ms\`, + _v$3 = showSchedule() ? \`scaleX(\${props.schedule})\` : undefined; + _v$ !== _p$.e && web.className(_el$3, _p$.e = _v$); + _v$2 !== _p$.t && ((_p$.t = _v$2) != null ? _el$3.style.setProperty("animation-duration", _v$2) : _el$3.style.removeProperty("animation-duration")); + _v$3 !== _p$.a && ((_p$.a = _v$3) != null ? _el$3.style.setProperty("transform", _v$3) : _el$3.style.removeProperty("transform")); + return _p$; + }, { + e: undefined, + t: undefined, + a: undefined + }); + return _el$3; + } + }), null); + web.effect(_p$ => { + var _v$4 = modules_c21c94f2$3.item, + _v$5 = colorMap[props.type], + _v$6 = showSchedule(), + _v$7 = props.exit, + _v$8 = modules_c21c94f2$3.msg; + _v$4 !== _p$.e && web.className(_el$, _p$.e = _v$4); + _v$5 !== _p$.t && ((_p$.t = _v$5) != null ? _el$.style.setProperty("--theme", _v$5) : _el$.style.removeProperty("--theme")); + _v$6 !== _p$.a && web.setAttribute(_el$, "data-schedule", _p$.a = _v$6); + _v$7 !== _p$.o && web.setAttribute(_el$, "data-exit", _p$.o = _v$7); + _v$8 !== _p$.i && web.className(_el$2, _p$.i = _v$8); + return _p$; + }, { + e: undefined, + t: undefined, + a: undefined, + o: undefined, + i: undefined + }); + return _el$; + })(); +}; + +var _tmpl$$K = /*#__PURE__*/web.template(\`
\`); +const [ref, setRef] = solidJs.createSignal(); +const Toaster = () => { + const [visible, setVisible] = solidJs.createSignal(document.visibilityState === 'visible'); + solidJs.onMount(() => { + const handleVisibilityChange = () => { + setVisible(document.visibilityState === 'visible'); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + solidJs.onCleanup(() => document.removeEventListener('visibilitychange', handleVisibilityChange)); + }); + return (() => { + var _el$ = _tmpl$$K(); + web.use(setRef, _el$); + web.insert(_el$, web.createComponent(solidJs.For, { + get each() { + return store$1.list; + }, + children: id => web.createComponent(ToastItem, web.mergeProps(() => store$1.map[id])) + })); + web.effect(_p$ => { + var _v$ = modules_c21c94f2$3.root, + _v$2 = visible() ? undefined : ''; + _v$ !== _p$.e && web.className(_el$, _p$.e = _v$); + _v$2 !== _p$.t && web.setAttribute(_el$, "data-paused", _p$.t = _v$2); + return _p$; + }, { + e: undefined, + t: undefined + }); + return _el$; + })(); +}; + +const ToastStyle = new CSSStyleSheet(); +ToastStyle.replaceSync(css$3); + +const getDom = id => { + let dom = document.getElementById(id); + if (dom) { + dom.innerHTML = ''; + return dom; + } + dom = document.createElement('div'); + dom.id = id; + document.body.append(dom); + return dom; +}; + +/** 挂载 solid-js 组件 */ +const mountComponents = (id, fc, styleSheets) => { + const dom = getDom(id); + dom.style.setProperty('display', 'unset', 'important'); + const shadowDom = dom.attachShadow({ + mode: 'closed' + }); + if (styleSheets) shadowDom.adoptedStyleSheets = styleSheets; + web.render(fc, shadowDom); + return dom; +}; + +let dom$2; +const init = () => { + if (dom$2 || ref()) return; + + // 提前挂载漫画节点,防止 toast 没法显示在漫画上层 + if (!document.getElementById('comicRead')) { + const _dom = document.createElement('div'); + _dom.id = 'comicRead'; + document.body.append(_dom); + } + dom$2 = mountComponents('toast', () => web.createComponent(Toaster, {}), [ToastStyle]); + dom$2.style.setProperty('z-index', '2147483647', 'important'); +}; +const toast$1 = new Proxy(toast$2, { + get(target, propKey) { + init(); + return target[propKey]; + }, + apply(target, propKey, args) { + init(); + const fn = propKey in target ? target[propKey] : target; + return fn(...args); + } +}); + +// 将 xmlHttpRequest 包装为 Promise +const xmlHttpRequest = details => new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + ...details, + onload: resolve, + onerror: reject, + ontimeout: reject + }); +}); +/** 发起请求 */ +const request$1 = async (url, details, retryNum = 0, errorNum = 0) => { + const headers = { + Referer: window.location.href + }; + const errorText = \`\${details?.errorText ?? t('alert.comic_load_error')} - \${url}\`; + try { + // 虽然 GM_xmlhttpRequest 有 fetch 选项,但在 stay 上不太稳定 + // 为了支持 ios 端只能自己实现一下了 + if (details?.fetch ?? (url.startsWith('/') || url.startsWith(window.location.origin))) { + const res = await fetch(url, { + method: 'GET', + headers, + ...details, + signal: AbortSignal.timeout?.(details?.timeout ?? 1000 * 10) + }); + let response = null; + switch (details?.responseType) { + case 'arraybuffer': + response = await res.arrayBuffer(); + break; + case 'blob': + response = await res.blob(); + break; + case 'json': + response = await res.json(); + break; + } + return { + status: res.status, + response, + responseText: response ? '' : await res.text() + }; + } + const res = await xmlHttpRequest({ + method: 'GET', + url, + headers, + timeout: 1000 * 10, + ...details + }); + if (!details?.noCheckCode && res.status !== 200) { + log.error(errorText, res); + throw new Error(errorText); + } + return res; + } catch (error) { + if (errorNum >= retryNum) { + (details?.noTip ? console.error : toast$1.error)(errorText); + throw new Error(errorText); + } + log.error(errorText, error); + await sleep(1000); + return request$1(url, details, retryNum, errorNum + 1); + } +}; + +/** 轮流向多个 api 发起请求 */ +const eachApi = async (url, baseUrlList, details) => { + for (const baseUrl of baseUrlList) { + try { + return await request$1(\`\${baseUrl}\${url}\`, { + ...details, + noTip: true + }); + } catch {} + } + const errorText = details?.errorText ?? t('alert.comic_load_error'); + if (!details?.noTip) toast$1.error(errorText); + log.error('所有 api 请求均失败', url, baseUrlList, details); + throw new Error(errorText); +}; + +var _tmpl$$J = /*#__PURE__*/web.template(\`\`); +const MdAutoFixHigh = ((props = {}) => (() => { + var _el$ = _tmpl$$J(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var _tmpl$$I = /*#__PURE__*/web.template(\`\`); +const MdAutoFixOff = ((props = {}) => (() => { + var _el$ = _tmpl$$I(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var _tmpl$$H = /*#__PURE__*/web.template(\`\`); +const MdAutoFlashOn = ((props = {}) => (() => { + var _el$ = _tmpl$$H(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var _tmpl$$G = /*#__PURE__*/web.template(\`\`); +const MdAutoFlashOff = ((props = {}) => (() => { + var _el$ = _tmpl$$G(); + web.spread(_el$, props, true, true); + return _el$; +})()); + +var css$2 = ".iconButtonItem{position:relative}.iconButton,.iconButtonItem{align-items:center;display:flex}.iconButton{background-color:transparent;border-radius:9999px;border-style:none;color:var(--text,#fff);cursor:pointer;font-size:1.5em;height:1.5em;justify-content:center;margin:.1em;outline:none;padding:0;width:1.5em}.iconButton:focus,.iconButton:hover{background-color:var(--hover-bg-color,#fff3)}.iconButton.enabled{background-color:var(--text,#fff);color:var(--text-bg,#121212)}.iconButton.enabled:focus,.iconButton.enabled:hover{background-color:var(--hover-bg-color-enable,#fffa)}.iconButton>svg{width:1em}.iconButtonPopper{align-items:center;background-color:#303030;border-radius:.3em;color:#fff;display:flex;font-size:.8em;opacity:0;padding:.4em .5em;pointer-events:none;position:absolute;top:50%;transform:translateY(-50%);-webkit-user-select:none;user-select:none;white-space:nowrap}.iconButtonPopper[data-placement=right]{left:calc(100% + 1.5em)}.iconButtonPopper[data-placement=right]:before{border-right-color:var(--switch-bg,#6e6e6e);border-right-width:.5em;right:calc(100% + .5em)}.iconButtonPopper[data-placement=left]{right:calc(100% + 1.5em)}.iconButtonPopper[data-placement=left]:before{border-left-color:var(--switch-bg,#6e6e6e);border-left-width:.5em;left:calc(100% + .5em)}.iconButtonPopper:before{background-color:transparent;border:.4em solid transparent;content:\\"\\";pointer-events:none;position:absolute;transition:opacity .15s}.iconButtonItem:focus .iconButtonPopper,.iconButtonItem:hover .iconButtonPopper,.iconButtonItem[data-show=true] .iconButtonPopper{opacity:1}.hidden{display:none}"; +var modules_c21c94f2$2 = {"iconButtonItem":"iconButtonItem","iconButton":"iconButton","enabled":"enabled","iconButtonPopper":"iconButtonPopper","hidden":"hidden"}; + +var _tmpl$$F = /*#__PURE__*/web.template(\`

+
+ `); + main.querySelector('button').addEventListener('click', async () => { + const comicName = main.querySelector('input')?.value; + if (!comicName) return; + const res = await main.request(`https://s.acg.dmzj.com/comicsum/search.php?s=${comicName}`, { + errorText: '搜索漫画时出错' + }); + const comicList = JSON.parse(res.responseText.slice(20, -1)); + main.querySelector('#list').innerHTML = comicList.map(({ + id, + comic_name, + comic_author, + comic_url + }) => ` + 《${comic_name}》——${comic_author} + Web端 + 移动端 + `).join('
'); + }); + return; + } + const res = await main.request(`https://v4api.idmzj.com/comic/detail/${comicId}?uid=2665531&disable_level=1`, { + errorText: '获取漫画数据失败' + }); + const { + comicInfo: { + last_updatetime, + title, + chapters + } + } = dmzjDecrypt(res.responseText); + document.title = title; + main.insertNode(document.body, `

${title}

`); + for (const chapter of Object.values(chapters)) { + // 手动构建添加章节 dom + let temp = `

${chapter.title}

`; + let i = chapter.data.length; + while (i--) temp += `${chapter.data[i].chapter_title}`; + main.insertNode(document.body, temp); + } + document.body.childNodes[0].remove(); + GM_addStyle(` + h1 { + margin: 0 -20vw; + } + + h1, + h2 { + text-align: center; + } + + body { + padding: 0 20vw; + } + + a { + display: inline-block; + + min-width: 4em; + margin: 0 1em; + + line-height: 2em; + white-space: nowrap; + } + `); + break; + } + case 'view': + { + // 如果不是隐藏漫画,直接进入阅读模式 + if (unsafeWindow.comic_id) { + GM_addStyle('.subHeader{display:none !important}'); + await main.universalInit({ + name: 'dmzj', + getImgList: () => main.querySelectorAll('#commicBox img').map(e => e.dataset.original).filter(Boolean), + getCommentList: () => getViewpoint(unsafeWindow.subId, unsafeWindow.chapterId), + onNext: main.querySelectorClick('#loadNextChapter'), + onPrev: main.querySelectorClick('#loadPrevChapter') + }); + return; + } + const tipDom = document.createElement('p'); + tipDom.textContent = '正在加载中,请坐和放宽,若长时间无反应请刷新页面'; + document.body.append(tipDom); + let data; + let comicId; + let chapterId; + try { + [, comicId, chapterId] = /(\d+)\/(\d+)/.exec(window.location.pathname); + data = await getChapterInfo(comicId, chapterId); + } catch (error) { + main.toast.error('获取漫画数据失败', { + duration: Number.POSITIVE_INFINITY + }); + tipDom.textContent = error.message; + throw error; + } + tipDom.textContent = `加载完成,即将进入阅读模式`; + const { + folder, + chapter_name, + next_chap_id, + prev_chap_id, + comic_id, + page_url + } = data; + document.title = `${chapter_name} ${folder.split('/').at(1)}`; + setManga({ + // 进入阅读模式后禁止退出,防止返回空白页面 + onExit: undefined, + onNext: next_chap_id ? () => { + window.location.href = `https://m.dmzj.com/view/${comic_id}/${next_chap_id}.html`; + } : undefined, + onPrev: prev_chap_id ? () => { + window.location.href = `https://m.dmzj.com/view/${comic_id}/${prev_chap_id}.html`; + } : undefined, + editButtonList: e => e + }); + init(() => { + if (page_url.length > 0) return page_url; + tipDom.innerHTML = `无法获得漫画数据,请通过 GithubGreasy Fork 进行反馈`; + return []; + }); + setManga('commentList', await getViewpoint(comicId, chapterId)); + break; + } + } +})().catch(error => main.log.error(error)); +; + break; + } + case 'www.idmzj.com': + case 'www.dmzj.com': + { +const main = require('main'); + +/** 根据漫画 id 和章节 id 获取章节数据 */ +const getChapterInfo = async (comicId, chapterId) => { + const res = await main.request(`https://m.dmzj.com/chapinfo/${comicId}/${chapterId}.html`, { + responseType: 'json', + errorText: '获取章节数据失败' + }); + return res.response; +}; + +const turnPage = chapterId => { + if (!chapterId) return undefined; + return () => { + window.open(window.location.href.replace(/(?<=\/)\d+(?=\.html)/, `${chapterId}`), '_self'); + }; +}; +(async () => { + await main.waitDom('.head_wz'); + // 只在漫画页内运行 + const comicId = main.querySelector('.head_wz [id]')?.id; + const chapterId = /(?<=\/)\d+(?=\.html)/.exec(window.location.pathname)?.[0]; + if (!comicId || !chapterId) return; + const { + setManga, + init + } = await main.useInit('dmzj'); + try { + const { + next_chap_id, + prev_chap_id, + page_url + } = await getChapterInfo(comicId, chapterId); + init(() => page_url); + setManga({ + onNext: turnPage(next_chap_id), + onPrev: turnPage(prev_chap_id) + }); + } catch { + main.toast.error('获取漫画数据失败', { + duration: Number.POSITIVE_INFINITY + }); + } +})().catch(error => main.log.error(error)); +; + break; + } + + // #E-Hentai——「匹配 nhentai 漫画」 + case 'exhentai.org': + case 'e-hentai.org': + { +const main = require('main'); + +(async () => { + const { + options, + init, + setFab, + setManga, + dynamicUpdate, + onLoading, + mangaProps + } = await main.useInit('ehentai', { + /** 关联 nhentai */ + associate_nhentai: true, + /** 快捷键翻页 */ + hotkeys_page_turn: true, + /** 识别广告 */ + detect_ad: true, + autoShow: false + }); + if (Reflect.has(unsafeWindow, 'mpvkey')) { + const imgEleList = main.querySelectorAll('.mi0[id]'); + init(dynamicUpdate(setImg => main.plimit(imgEleList.map((ele, i) => async () => { + const getUrl = () => ele.querySelector('img')?.src; + if (!getUrl()) unsafeWindow.load_image(i + 1); + unsafeWindow.next_possible_request = 0; + const imgUrl = await main.wait(getUrl); + setImg(i, imgUrl); + }), undefined, 4), imgEleList.length)); + return; + } + + // 不是漫画页的话 + if (!Reflect.has(unsafeWindow, 'apikey')) { + if (options.hotkeys_page_turn) { + main.linstenKeyup(e => { + switch (e.key) { + case 'ArrowRight': + case 'd': + main.querySelector('#dnext')?.click(); + break; + case 'ArrowLeft': + case 'a': + main.querySelector('#dprev')?.click(); + break; + } + }); + } + return; + } + + // 虽然有 Fab 了不需要这个按钮,但都点习惯了没有还挺别扭的( + main.insertNode(document.getElementById('gd5'), '

Load comic

'); + const comicReadModeDom = document.getElementById('comicReadMode'); + + /** 从图片页获取图片地址 */ + const getImgFromImgPage = async url => { + const res = await main.request(url, { + fetch: true, + errorText: main.t('site.ehentai.fetch_img_page_source_failed') + }); + try { + return /id="img" src="(.+?)"/.exec(res.responseText)[1]; + } catch { + throw new Error(main.t('site.ehentai.fetch_img_url_failed')); + } + }; + + /** 从详情页获取图片页的地址 */ + const getImgFromDetailsPage = async (pageNum = 0) => { + const res = await main.request(`${window.location.pathname}${pageNum ? `?p=${pageNum}` : ''}`, { + fetch: true, + errorText: main.t('site.ehentai.fetch_img_page_url_failed') + }); + // 从详情页获取图片页的地址 + const reRes = res.responseText.matchAll(/.+?title=".+?: [url, fileName]); + }; + const getImgNum = async () => { + let numText = main.querySelector('.gtb .gpc')?.textContent?.replaceAll(',', '').match(/\d+/g)?.at(-1); + if (numText) return Number(numText); + const res = await main.request(window.location.href); + numText = /(?<=)\d+(?= pages<\/td>)/.exec(res.responseText)?.[0]; + if (numText) return Number(numText); + main.toast.error(main.t('site.changed_load_failed')); + return 0; + }; + const totalImgNum = await getImgNum(); + const placeValueNum = `${totalImgNum}`.length; + const ehImgList = []; + const ehImgPageList = []; + const ehImgFileNameList = []; + const stylesheet = new CSSStyleSheet(); + document.adoptedStyleSheets.push(stylesheet); + main.createEffectOn(() => [...(mangaProps.adList ?? [])], adList => { + if (adList.length === 0) return; + const styleList = adList.map(i => { + const alt = `${i + 1}`.padStart(placeValueNum, '0'); + return `img[alt="${alt}"]:not(:hover) { + filter: blur(8px); + clip-path: border-box; + backdrop-filter: blur(8px); + }`; + }); + return stylesheet.replace(styleList.join('\n')); + }); + const enableDetectAd = options.detect_ad && document.getElementById('ta_other:extraneous_ads'); + if (enableDetectAd) { + setManga('adList', new main.ReactiveSet()); + /** 缩略图元素列表 */ + const thumbnailEleList = []; + for (const e of main.querySelectorAll('#gdt img')) { + const index = Number(e.alt) - 1; + if (Number.isNaN(index)) return; + thumbnailEleList[index] = e; + // 根据当前显示的图片获取一部分文件名 + [, ehImgFileNameList[index]] = e.title.split(/:|: /); + } + // 先根据文件名判断一次 + await main.getAdPageByFileName(ehImgFileNameList, mangaProps.adList); + // 不行的话再用缩略图识别 + if (mangaProps.adList.size === 0) await main.getAdPageByContent(thumbnailEleList, mangaProps.adList); + } + const { + loadImgList + } = init(dynamicUpdate(async setImg => { + comicReadModeDom.innerHTML = ` loading`; + const totalPageNum = Number(main.querySelector('.ptt td:nth-last-child(2)').textContent); + for (let pageNum = 0; pageNum < totalPageNum; pageNum++) { + const startIndex = ehImgList.length; + const imgPageUrlList = await getImgFromDetailsPage(pageNum); + await main.plimit(imgPageUrlList.map(([imgPageUrl, fileName], i) => async () => { + const imgUrl = await getImgFromImgPage(imgPageUrl); + const index = startIndex + i; + ehImgList[index] = imgUrl; + ehImgPageList[index] = imgPageUrl; + ehImgFileNameList[index] = fileName; + setImg(index, imgUrl); + }), async _doneNum => { + const doneNum = startIndex + _doneNum; + setFab({ + progress: doneNum / totalImgNum, + tip: `${main.t('other.loading_img')} - ${doneNum}/${totalImgNum}` + }); + comicReadModeDom.innerHTML = ` loading - ${doneNum}/${totalImgNum}`; + if (doneNum === totalImgNum) { + comicReadModeDom.innerHTML = ` Read`; + if (enableDetectAd) { + await main.getAdPageByFileName(ehImgFileNameList, mangaProps.adList); + await main.getAdPageByContent(ehImgList, mangaProps.adList); + } + } + }); + } + }, totalImgNum)); + + /** 获取新的图片页地址 */ + const getNewImgPageUrl = async url => { + const res = await main.request(url, { + errorText: main.t('site.ehentai.fetch_img_page_source_failed') + }); + const nl = /nl\('(.+?)'\)/.exec(res.responseText)?.[1]; + if (!nl) throw new Error(main.t('site.ehentai.fetch_img_url_failed')); + const newUrl = new URL(url); + newUrl.searchParams.set('nl', nl); + return newUrl.href; + }; + + /** 刷新指定图片 */ + const reloadImg = async i => { + const pageUrl = await getNewImgPageUrl(ehImgPageList[i]); + let imgUrl = ''; + while (!imgUrl || !(await main.testImgUrl(imgUrl))) imgUrl = await getImgFromImgPage(pageUrl); + ehImgList[i] = imgUrl; + ehImgPageList[i] = pageUrl; + setManga('imgList', i, imgUrl); + }; + + /** 判断当前显示的是否是 eh 源 */ + const isShowEh = () => main.store.imgList[0]?.src === ehImgList[0]; + + /** 刷新所有错误图片 */ + const reloadErrorImg = main.singleThreaded(() => main.plimit(main.store.imgList.map(({ + loadType + }, i) => () => { + if (loadType !== 'error' || !isShowEh()) return; + return reloadImg(i); + }))); + setManga({ + onExit(isEnd) { + if (isEnd) main.scrollIntoView('#cdiv'); + setManga('show', false); + }, + // 在图片加载出错时刷新图片 + async onLoading(imgList, img) { + onLoading(imgList, img); + if (!img) return; + if (img.loadType !== 'error' || (await main.testImgUrl(img.src))) return; + return reloadErrorImg(); + } + }); + setFab('initialShow', options.autoShow); + comicReadModeDom.addEventListener('click', () => loadImgList(ehImgList.length > 0 ? ehImgList : undefined, true)); + if (options.hotkeys_page_turn) { + main.linstenKeyup(e => { + switch (e.key) { + case 'ArrowRight': + case 'd': + main.querySelector('.ptt td:last-child:not(.ptdd)')?.click(); + break; + case 'ArrowLeft': + case 'a': + main.querySelector('.ptt td:first-child:not(.ptdd)')?.click(); + break; + } + }); + } + if (options.associate_nhentai) { + const titleDom = document.getElementById('gn'); + const taglistDom = main.querySelector('#taglist tbody'); + if (!titleDom || !taglistDom) { + main.toast.error(main.t('site.ehentai.html_changed_nhentai_failed')); + return; + } + const title = encodeURI(titleDom.textContent); + const newTagLine = document.createElement('tr'); + let nHentaiComicInfo; + try { + const res = await main.request(`https://nhentai.net/api/galleries/search?query=${title}`, { + responseType: 'json', + errorText: main.t('site.ehentai.nhentai_error'), + noTip: true + }); + nHentaiComicInfo = res.response; + } catch { + newTagLine.innerHTML = ` + nhentai: + + ${main.t('site.ehentai.nhentai_failed', { + nhentai: `nhentai` + })} + `; + taglistDom.append(newTagLine); + return; + } + + // 构建新标签行 + if (nHentaiComicInfo.result.length > 0) { + let temp = 'nhentai:'; + let i = nHentaiComicInfo.result.length; + while (i) { + i -= 1; + const tempComicInfo = nHentaiComicInfo.result[i]; + const _title = tempComicInfo.title.japanese || tempComicInfo.title.english; + temp += ` + `; + } + newTagLine.innerHTML = `${temp}`; + } else newTagLine.innerHTML = 'nhentai:Null'; + taglistDom.append(newTagLine); + + // 重写 _refresh_tagmenu_act 函数,加入脚本的功能 + const nhentaiImgList = {}; + const raw_refresh_tagmenu_act = unsafeWindow._refresh_tagmenu_act; + // eslint-disable-next-line func-names + unsafeWindow._refresh_tagmenu_act = function _refresh_tagmenu_act(a) { + if (a.hasAttribute('nhentai-index')) { + const tagmenu_act_dom = document.getElementById('tagmenu_act'); + tagmenu_act_dom.innerHTML = ['', ` Jump to nhentai`, ` ${nhentaiImgList[selected_tagname] ? 'Read' : 'Load comic'}`].join('>'); + const nhentaiComicReadButton = tagmenu_act_dom.querySelector('a[href="#"]'); + const { + media_id, + num_pages, + images + } = nHentaiComicInfo.result[Number(a.getAttribute('nhentai-index'))]; + // nhentai api 对应的扩展名 + const fileType = { + j: 'jpg', + p: 'png', + g: 'gif' + }; + const showNhentaiComic = init(dynamicUpdate(async setImg => { + nhentaiComicReadButton.innerHTML = ` loading - 0/${num_pages}`; + nhentaiImgList[selected_tagname] = await main.plimit(images.pages.map((page, i) => async () => { + const imgRes = await main.request(`https://i.nhentai.net/galleries/${media_id}/${i + 1}.${fileType[page.t]}`, { + headers: { + Referer: `https://nhentai.net/g/${media_id}` + }, + responseType: 'blob' + }); + const blobUrl = URL.createObjectURL(imgRes.response); + setImg(i, blobUrl); + return blobUrl; + }), (doneNum, totalNum) => { + nhentaiComicReadButton.innerHTML = ` loading - ${doneNum}/${totalNum}`; + }); + nhentaiComicReadButton.innerHTML = ' Read'; + }, num_pages)).showComic; + + // 加载 nhentai 漫画 + nhentaiComicReadButton.addEventListener('click', showNhentaiComic); + } + // 非 nhentai 标签列的用原函数去处理 + else raw_refresh_tagmenu_act(a); + }; + } +})().catch(error => main.log.error(error)); +; + break; + } + + // #nhentai——「彻底屏蔽漫画、自动翻页」 + case 'nhentai.net': + { +const main = require('main'); + +/** 用于转换获得图片文件扩展名 */ +const fileType = { + j: 'jpg', + p: 'png', + g: 'gif' +}; +(async () => { + const { + options, + setFab, + setManga, + init + } = await main.useInit('nhentai', { + /** 自动翻页 */ + auto_page_turn: true, + /** 彻底屏蔽漫画 */ + block_totally: true, + /** 在新页面中打开链接 */ + open_link_new_page: true + }); + + // 在漫画详情页 + if (Reflect.has(unsafeWindow, 'gallery')) { + setManga({ + onExit(isEnd) { + if (isEnd) main.scrollIntoView('#comment-container'); + setManga('show', false); + } + }); + + // 虽然有 Fab 了不需要这个按钮,但我自己都点习惯了没有还挺别扭的( + main.insertNode(document.getElementById('download').parentNode, ' Read'); + const comicReadModeDom = document.getElementById('comicReadMode'); + const { + showComic + } = init(() => gallery.images.pages.map(({ + number, + extension + }) => `https://i.nhentai.net/galleries/${gallery.media_id}/${number}.${extension}`)); + setFab('initialShow', options.autoShow); + comicReadModeDom.addEventListener('click', showComic); + return; + } + + // 在漫画浏览页 + if (document.getElementsByClassName('gallery').length > 0) { + if (options.open_link_new_page) for (const e of main.querySelectorAll('a:not([href^="javascript:"])')) e.setAttribute('target', '_blank'); + const blacklist = (unsafeWindow?._n_app ?? unsafeWindow?.n)?.options?.blacklisted_tags; + if (blacklist === undefined) main.toast.error(main.t('site.nhentai.tag_blacklist_fetch_failed')); + // blacklist === null 时是未登录 + + if (options.block_totally && blacklist?.length) GM_addStyle('.blacklisted.gallery { display: none; }'); + if (options.auto_page_turn) { + GM_addStyle(` + hr { bottom: 0; box-sizing: border-box; margin: -1em auto 2em; } + hr:last-child { position: relative; animation: load .8s linear alternate infinite; } + hr:not(:last-child) { display: none; } + @keyframes load { 0% { width: 100%; } 100% { width: 0; } } + `); + let pageNum = Number(main.querySelector('.page.current')?.innerHTML ?? ''); + if (Number.isNaN(pageNum)) return; + const contentDom = document.getElementById('content'); + let apiUrl = ''; + if (window.location.pathname === '/') apiUrl = '/api/galleries/all?';else if (main.querySelector('a.tag')) apiUrl = `/api/galleries/tagged?tag_id=${main.querySelector('a.tag')?.classList[1].split('-')[1]}&`;else if (window.location.pathname.includes('search')) apiUrl = `/api/galleries/search?query=${new URLSearchParams(window.location.search).get('q')}&`; + let observer; // eslint-disable-line no-autofix/prefer-const + + const loadNewComic = main.singleThreaded(async () => { + pageNum += 1; + const res = await main.request(`${apiUrl}page=${pageNum}${window.location.pathname.includes('popular') ? '&sort=popular ' : ''}`, { + fetch: true, + responseType: 'json', + errorText: main.t('site.nhentai.fetch_next_page_failed') + }); + const { + result, + num_pages + } = res.response; + let comicDomHtml = ''; + for (const comic of result) { + const blacklisted = comic.tags.some(tag => blacklist?.includes(tag.id)); + comicDomHtml += ``; + } + + // 构建页数按钮 + if (comicDomHtml) { + const target = options.open_link_new_page ? 'target="_blank" ' : ''; + const pageNumDom = []; + for (let i = pageNum - 5; i <= pageNum + 5; i += 1) { + if (i > 0 && i <= num_pages) pageNumDom.push(`${i}`); + } + main.insertNode(contentDom, `

${pageNum}

+
${comicDomHtml}
+
+ + + + + + ${pageNumDom.join('')} + ${pageNum === num_pages ? '' : ` + + + + `} +
`); + } + + // 添加分隔线 + const hr = document.createElement('hr'); + contentDom.append(hr); + observer.disconnect(); + observer.observe(hr); + if (pageNum >= num_pages) hr.style.animationPlayState = 'paused'; + const urlParams = new URLSearchParams(window.location.search); + urlParams.set('page', `${pageNum}`); + history.replaceState(null, '', `?${urlParams.toString()}`); + }, false); + observer = new IntersectionObserver(entries => entries[0].isIntersecting && loadNewComic()); + observer.observe(contentDom.lastElementChild); + if (main.querySelector('section.pagination')) contentDom.append(document.createElement('hr')); + } + } +})().catch(error => main.log.error(error)); +; + break; + } + + // #Yurifans——「自动签到」 + case 'yuri.website': + { +const main = require('main'); + +(async () => { + const { + options, + setManga, + init, + needAutoShow + } = await main.useInit('yurifans', { + 自动签到: true + }); + + // 自动签到 + if (options.自动签到) (async () => { + // 跳过未登录的情况 + if (!globalThis.b2token) return; + const todayString = new Date().toLocaleDateString('zh-CN'); + // 判断当前日期与上次成功签到日期是否相同 + if (todayString === localStorage.getItem('signDate')) return; + try { + const res = await main.request('/wp-json/b2/v1/userMission', { + method: 'POST', + noTip: true, + headers: { + Authorization: `Bearer ${b2token}` + } + }); + const data = JSON.parse(res.responseText); + + // 首次成功签到 或 重复签到 + if (!(data?.mission?.date || !Number.isNaN(Number(data)))) throw new Error('签到失败'); + main.toast('自动签到成功'); + localStorage.setItem('signDate', todayString); + } catch { + main.toast.error('自动签到失败'); + } + })(); + + // 跳过漫画区外的页面 + if (!main.querySelector('a.post-list-cat-item[title="在线区-漫画"]')) return; + + // 需要购买的漫画 + if (main.querySelector('.content-hidden')) { + const imgBody = main.querySelector('.content-hidden'); + const imgList = imgBody.getElementsByTagName('img'); + if (await main.wait(() => imgList.length, 1000)) init(() => [...imgList].map(e => e.src)); + return; + } + + // 有折叠内容的漫画 + if (main.querySelector('.xControl')) { + needAutoShow.val = false; + const { + loadImgList + } = init(() => []); + const imgListMap = []; + const loadChapterImg = async i => { + const imgList = imgListMap[i]; + await loadImgList([...imgList].map(e => e.dataset.src), true); + setManga({ + onPrev: i === 0 ? undefined : () => loadChapterImg(i - 1), + onNext: i === imgListMap.length - 1 ? undefined : () => loadChapterImg(i + 1) + }); + }; + for (const [i, a] of main.querySelectorAll('.xControl > a').entries()) { + const imgRoot = a.parentElement.nextElementSibling; + imgListMap.push(imgRoot.getElementsByTagName('img')); + a.addEventListener('click', () => { + // 只在打开折叠内容时进入阅读模式 + if (imgRoot.style.display === 'none' || imgRoot.style.height && imgRoot.style.height.split('.')[0].length <= 2) return loadChapterImg(i); + }); + } + return; + } + + // 没有折叠的单篇漫画 + await main.wait(() => main.querySelectorAll('.entry-content img').length); + return init(() => main.querySelectorAll('.entry-content img').map(e => e.src)); +})(); +; + break; + } + + // #拷贝漫画(copymanga)——「显示最后阅读记录」 + case 'mangacopy.com': + case 'copymanga.site': + case 'copymanga.info': + case 'copymanga.net': + case 'copymanga.org': + case 'copymanga.tv': + case 'copymanga.com': + case 'www.mangacopy.com': + case 'www.copymanga.site': + case 'www.copymanga.info': + case 'www.copymanga.net': + case 'www.copymanga.org': + case 'www.copymanga.tv': + case 'www.copymanga.com': + { +const main = require('main'); + +(() => { + const headers = { + webp: '1', + region: '1', + 'User-Agent': 'COPY/2.0.7|', + version: '2.0.7', + source: 'copyApp', + referer: 'com.copymanga.app-2.0.7' + }; + const token = document.cookie.split('; ').find(cookie => cookie.startsWith('token='))?.replace('token=', ''); + if (token) Reflect.set(headers, 'Authorization', `Token ${token}`); + let name = ''; + let id = ''; + if (window.location.href.includes('/chapter/')) [,, name,, id] = window.location.pathname.split('/');else if (window.location.href.includes('/comicContent/')) [,,, name, id] = window.location.pathname.split('/'); + if (name && id) { + const getImgList = async () => { + const res = await main.request(`/api/v3/comic/${name}/chapter2/${id}?platform=3`, { + responseType: 'json', + headers + }); + const imgList = []; + const { + words, + contents + } = res.response.results.chapter; + for (let i = 0; i < contents.length; i++) imgList[words[i]] = contents[i].url.replace('.c800x.', '.c1500x.'); + return imgList; + }; + options = { + name: 'copymanga', + getImgList, + onNext: main.querySelectorClick('.comicContent-next a:not(.prev-null)'), + onPrev: main.querySelectorClick('.comicContent-prev:not(.index,.list) a:not(.prev-null)'), + async getCommentList() { + const chapter_id = window.location.pathname.split('/').at(-1); + const res = await main.request(`/api/v3/roasts?chapter_id=${chapter_id}&limit=100&offset=0&_update=true`, { + responseType: 'json', + errorText: '获取漫画评论失败' + }); + return res.response.results.list.map(({ + comment + }) => comment); + } + }; + return; + } + + // 在目录页显示上次阅读记录 + if (window.location.href.includes('/comic/')) { + const comicName = window.location.href.split('/comic/')[1]; + if (!comicName || !token) return; + let a; + const stylesheet = new CSSStyleSheet(); + document.adoptedStyleSheets.push(stylesheet); + const updateLastChapter = async () => { + // 因为拷贝漫画的目录是动态加载的,所以要等目录加载出来再往上添加 + if (!a) (async () => { + a = document.createElement('a'); + const tableRight = await main.wait(() => main.querySelector('.table-default-right')); + a.target = '_blank'; + tableRight.insertBefore(a, tableRight.firstElementChild); + const span = document.createElement('span'); + span.textContent = '最後閱讀:'; + tableRight.insertBefore(span, tableRight.firstElementChild); + })(); + a.textContent = '獲取中'; + a.removeAttribute('href'); + const res = await main.request(`${window.location.origin}/api/v3/comic2/${comicName}/query?platform=3`, { + responseType: 'json', + fetch: false, + headers + }); + const data = res.response?.results?.browse; + if (!data) { + a.textContent = data === null ? '無' : '未返回數據'; + return; + } + const lastChapterId = data.chapter_id; + if (!lastChapterId) { + a.textContent = '接口異常'; + return; + } + await stylesheet.replace(`ul a[href*="${lastChapterId}"] { + color: #fff !important; + background: #1790E6; + }`); + a.href = `${window.location.pathname}/chapter/${lastChapterId}`; + a.textContent = data.chapter_name; + }; + setTimeout(updateLastChapter); + document.addEventListener('visibilitychange', updateLastChapter); + } +})(); +; + break; + } + + // #[PonpomuYuri](https://www.ponpomu.com) + case 'www.ponpomu.com': + { + options = { + name: 'terraHistoricus', + wait: () => Boolean(main.querySelector('.comic-page-container img')), + getImgList: () => main.querySelectorAll('.comic-page-container img').map(e => e.dataset.srcset), + SPA: { + isMangaPage: () => window.location.href.includes('/comic/'), + getOnPrev: () => main.querySelectorClick('.prev-btn:not(.invisible) a'), + getOnNext: () => main.querySelectorClick('.next-btn:not(.invisible) a') + } + }; + break; + } + + // #[明日方舟泰拉记事社](https://terra-historicus.hypergryph.com) + case 'terra-historicus.hypergryph.com': + { + const apiUrl = () => `https://terra-historicus.hypergryph.com/api${window.location.pathname}`; + const getImgUrl = i => async () => { + const res = await main.request(`${apiUrl()}/page?pageNum=${i + 1}`); + return JSON.parse(res.response).data.url; + }; + options = { + name: 'terraHistoricus', + wait: () => Boolean(main.querySelector('.HG_COMIC_READER_main')), + async getImgList({ + setFab + }) { + const res = await main.request(apiUrl()); + const pageList = JSON.parse(res.response).data.pageInfos; + if (pageList.length === 0 && window.location.pathname.includes('episode')) throw new Error('获取图片列表时出错'); + return main.plimit(main.createSequence(pageList.length).map(getImgUrl), (doneNum, totalNum) => { + setFab({ + progress: doneNum / totalNum, + tip: `加载图片中 - ${doneNum}/${totalNum}` + }); + }); + }, + SPA: { + isMangaPage: () => window.location.href.includes('episode'), + getOnPrev: () => main.querySelectorClick('footer .HG_COMIC_READER_prev a'), + getOnNext: () => main.querySelectorClick('footer .HG_COMIC_READER_prev+.HG_COMIC_READER_buttonEp a') + } + }; + break; + } + + // #[禁漫天堂](https://18comic.vip) + case 'jmcomic.me': + case '18comic-erdtree.xyz': + case '18-comicstellar.xyz': + case '18comic.org': + case '18comic.vip': + { +const main = require('main'); + +// 已知问题:某些漫画始终会有几页在下载原图时出错 +// 并且这类漫画下即使关掉脚本,也还是会有几页就是加载不出来 +// 比较神秘的是这两种情况下加载不出来的图片还不一样 +// 并且在多次刷新的情况下都是那几张图片加载不出来 +// 另外这类漫画也有概率出现,在关闭脚本的情况下所有图片都加载不出来的情况,只能刷新 +// 就很怪 +// 对此只能放弃 +(async () => { + // 只在漫画页内运行 + if (!window.location.pathname.includes('/photo/')) return; + const { + init, + setManga, + setFab, + dynamicUpdate, + mangaProps + } = await main.useInit('jm'); + while (!unsafeWindow?.onImageLoaded) { + if (document.readyState === 'complete') { + main.toast.error('无法获取图片', { + duration: Number.POSITIVE_INFINITY + }); + return; + } + await main.sleep(100); + } + setManga({ + onPrev: main.querySelectorClick(() => main.querySelector('.menu-bolock-ul .fa-angle-double-left')?.parentElement), + onNext: main.querySelectorClick(() => main.querySelector('.menu-bolock-ul .fa-angle-double-right')?.parentElement) + }); + const imgEleList = main.querySelectorAll('.scramble-page:not(.thewayhome) > img'); + + // 判断当前漫画是否有被分割,没有就直接获取图片链接加载 + // 判断条件来自页面上的 scramble_image 函数 + if (unsafeWindow.aid < unsafeWindow.scramble_id || unsafeWindow.speed === '1') { + init(() => imgEleList.map(e => e.dataset.original)); + return; + } + const getImgUrl = async imgEle => { + if (imgEle.src.startsWith('blob:')) return imgEle.src; + const originalUrl = imgEle.src; + const res = await main.request(imgEle.dataset.original, { + responseType: 'blob', + revalidate: true, + fetch: true + }); + if (res.response.size === 0) { + main.toast.warn(`下载原图时出错: ${imgEle.dataset.page}`); + return ''; + } + imgEle.src = URL.createObjectURL(res.response); + const err = await main.waitImgLoad(imgEle); + if (err) { + URL.revokeObjectURL(imgEle.src); + imgEle.src = originalUrl; + main.toast.warn(`加载原图时出错: ${imgEle.dataset.page}`); + return ''; + } + try { + unsafeWindow.onImageLoaded(imgEle); + const blob = await main.canvasToBlob(imgEle.nextElementSibling, 'image/webp', 1); + URL.revokeObjectURL(imgEle.src); + if (!blob) throw new Error('转换图片时出错'); + return `${URL.createObjectURL(blob)}#.webp`; + } catch { + imgEle.src = originalUrl; + main.toast.warn(`转换图片时出错: ${imgEle.dataset.page}`); + return ''; + } + }; + + // 先等懒加载触发完毕 + await main.wait(() => main.querySelectorAll('.lazy-loaded.hide').length > 0 && main.querySelectorAll('.lazy-loaded.hide').length === main.querySelectorAll('canvas').length); + init(dynamicUpdate(setImg => main.plimit(imgEleList.map((img, i) => async () => setImg(i, await getImgUrl(img))), (doneNum, totalNum) => { + setFab({ + progress: doneNum / totalNum, + tip: `加载图片中 - ${doneNum}/${totalNum}` + }); + }), imgEleList.length)); + const retry = async (num = 0) => { + for (const [i, imgEle] of imgEleList.entries()) { + if (mangaProps.imgList[i]) continue; + setManga('imgList', i, await getImgUrl(imgEle)); + await main.sleep(1000); + } + if (num < 60 && mangaProps.imgList.some(url => !url)) setTimeout(retry, 1000 * 5, num + 1); + }; + await retry(); +})().catch(error => main.log.error(error)); +; + break; + } + + // #[漫画柜(manhuagui)](https://www.manhuagui.com) + case 'tw.manhuagui.com': + case 'm.manhuagui.com': + case 'www.mhgui.com': + case 'www.manhuagui.com': + { + if (!/\/comic\/\d+\/\d+\.html/.test(window.location.pathname)) break; + let comicInfo; + try { + const dataScript = main.querySelector('body > script:not([src])'); + comicInfo = JSON.parse( + // 只能通过 eval 获得数据 + // eslint-disable-next-line no-eval + eval(dataScript.innerHTML.slice(26)).match(/(?<=.*?\(){.+}/)[0]); + } catch { + main.toast.error(main.t('site.changed_load_failed')); + break; + } + + // 让切换章节的提示可以显示在漫画页上 + GM_addStyle(`#smh-msg-box { z-index: 2147483647 !important }`); + const handlePrevNext = cid => { + if (cid === 0) return undefined; + const newUrl = window.location.pathname.replace(/(?<=\/)\d+(?=\.html)/, `${cid}`); + return () => window.location.assign(newUrl); + }; + options = { + name: 'manhuagui', + getImgList() { + const sl = Object.entries(comicInfo.sl).map(attr => `${attr[0]}=${attr[1]}`).join('&'); + if (comicInfo.files) return comicInfo.files.map(file => `${unsafeWindow.pVars.manga.filePath}${file}?${sl}`); + if (comicInfo.images) { + const { + origin + } = new URL(main.querySelector('#manga img').src); + return comicInfo.images.map(url => `${origin}${url}?${sl}`); + } + main.toast.error(main.t('site.changed_load_failed'), { + throw: true + }); + return []; + }, + onNext: handlePrevNext(comicInfo.nextId), + onPrev: handlePrevNext(comicInfo.prevId) + }; + break; + } + + // #[漫画DB(manhuadb)](https://www.manhuadb.com) + case 'www.manhuadb.com': + { + if (!Reflect.has(unsafeWindow, 'img_data_arr')) break; + options = { + name: 'manhuaDB', + getImgList: () => unsafeWindow.img_data_arr.map(data => `${unsafeWindow.img_host}/${unsafeWindow.img_pre}/${data.img}`), + onPrev: () => unsafeWindow.goNumPage('pre'), + onNext: () => unsafeWindow.goNumPage('next') + }; + break; + } + + // #[动漫屋(dm5)](https://www.dm5.com) + case 'www.manhuaren.com': + case 'm.1kkk.com': + case 'www.1kkk.com': + case 'tel.dm5.com': + case 'en.dm5.com': + case 'www.dm5.cn': + case 'www.dm5.com': + { + if (!Reflect.has(unsafeWindow, 'DM5_CID')) break; + const imgNum = unsafeWindow.DM5_IMAGE_COUNT ?? unsafeWindow.imgsLen; + if (!(Number.isSafeInteger(imgNum) && imgNum > 0)) { + main.toast.error(main.t('site.changed_load_failed')); + break; + } + const getPageImg = async i => { + const res = await unsafeWindow.$.ajax({ + type: 'GET', + url: 'chapterfun.ashx', + data: { + cid: unsafeWindow.DM5_CID, + page: i, + key: unsafeWindow.$('#dm5_key').length > 0 ? unsafeWindow.$('#dm5_key').val() : '', + language: 1, + gtk: 6, + _cid: unsafeWindow.DM5_CID, + _mid: unsafeWindow.DM5_MID, + _dt: unsafeWindow.DM5_VIEWSIGN_DT, + _sign: unsafeWindow.DM5_VIEWSIGN + } + }); + // eslint-disable-next-line no-eval + return eval(res); + }; + const handlePrevNext = (pcSelector, mobileText) => main.querySelectorClick(() => main.querySelector(pcSelector) ?? main.querySelectorAll('.view-bottom-bar a').find(e => e.textContent?.includes(mobileText))); + options = { + name: 'dm5', + getImgList({ + dynamicUpdate + }) { + // manhuaren 和 1kkk 的移动端上会直接用一个变量存储所有图片的链接 + if (Array.isArray(unsafeWindow.newImgs) && unsafeWindow.newImgs.every(main.isUrl)) return unsafeWindow.newImgs; + return dynamicUpdate(async setImg => { + for (let i = 0; i < imgNum;) { + const newImgs = await getPageImg(i + 1); + for (const url of newImgs) setImg(i++, url); + } + }, imgNum)(); + }, + onPrev: handlePrevNext('.logo_1', '上一章'), + onNext: handlePrevNext('.logo_2', '下一章'), + onExit: isEnd => isEnd && main.scrollIntoView('.postlist') + }; + break; + } + + // #[绅士漫画(wnacg)](https://www.wnacg.com) + case 'www.wn01.cc': + case 'www.wnacg.com': + case 'wnacg.com': + { + // 突出显示下拉阅读的按钮 + const buttonDom = main.querySelector('#bodywrap a.btn'); + if (buttonDom) { + buttonDom.style.setProperty('background-color', '#607d8b'); + buttonDom.style.setProperty('background-image', 'none'); + } + if (!Reflect.has(unsafeWindow, 'imglist')) break; + options = { + name: 'wnacg', + getImgList: () => unsafeWindow.imglist.filter(({ + caption + }) => caption !== '喜歡紳士漫畫的同學請加入收藏哦!').map(({ + url + }) => new URL(url, window.location.origin).href) + }; + break; + } + + // #[mangabz](https://mangabz.com) + case 'www.mangabz.com': + case 'mangabz.com': + { + if (!Reflect.has(unsafeWindow, 'MANGABZ_CID')) break; + const imgNum = unsafeWindow.MANGABZ_IMAGE_COUNT ?? unsafeWindow.imgsLen; + if (!(Number.isSafeInteger(imgNum) && imgNum > 0)) { + main.toast.error(main.t('site.changed_load_failed')); + break; + } + const getPageImg = async i => { + const res = await unsafeWindow.$.ajax({ + type: 'GET', + url: 'chapterimage.ashx', + data: { + cid: unsafeWindow.MANGABZ_CID, + page: i, + key: '', + _cid: unsafeWindow.MANGABZ_CID, + _mid: unsafeWindow.MANGABZ_MID, + _dt: unsafeWindow.MANGABZ_VIEWSIGN_DT, + _sign: unsafeWindow.MANGABZ_VIEWSIGN + } + }); + // eslint-disable-next-line no-eval + return eval(res); + }; + const handlePrevNext = (pcSelector, mobileText) => main.querySelectorClick(() => main.querySelector(pcSelector) ?? main.querySelectorAll('.bottom-bar-tool a').find(e => e.textContent?.includes(mobileText))); + options = { + name: 'mangabz', + getImgList: ({ + dynamicUpdate + }) => dynamicUpdate(async setImg => { + for (let i = 0; i < imgNum; i++) { + const newImgs = await getPageImg(i + 1); + for (const url of newImgs) setImg(i, url); + } + }, imgNum)(), + onNext: handlePrevNext('body > .container a[href^="/"]:last-child', '下一'), + onPrev: handlePrevNext('body > .container a[href^="/"]:first-child', '上一') + }; + break; + } + + // #[komiic](https://komiic.com) + case 'komiic.com': + { + const query = ` + query imagesByChapterId($chapterId: ID!) { + imagesByChapterId(chapterId: $chapterId) { + id + kid + height + width + __typename + } + }`; + const getImgList = async () => { + const chapterId = /chapter\/(\d+)/.exec(window.location.pathname)?.[1]; + if (!chapterId) throw new Error(main.t('site.changed_load_failed')); + const res = await main.request('/api/query', { + method: 'POST', + responseType: 'json', + headers: { + 'content-type': 'application/json' + }, + data: JSON.stringify({ + operationName: 'imagesByChapterId', + variables: { + chapterId: `${chapterId}` + }, + query + }) + }); + return res.response.data.imagesByChapterId.map(({ + kid + }) => `https://komiic.com/api/image/${kid}`); + }; + const handlePrevNext = text => async () => { + await main.waitDom('.v-bottom-navigation__content'); + return main.querySelectorClick('.v-bottom-navigation__content > button:not([disabled])', text); + }; + options = { + name: 'komiic', + getImgList, + SPA: { + isMangaPage: () => /comic\/\d+\/chapter\/\d+\/images\//.test(window.location.href), + getOnPrev: handlePrevNext('上一'), + getOnNext: handlePrevNext('下一') + } + }; + break; + } + + // #[无限动漫](https://www.comicabc.com) + case '8.twobili.com': + case 'a.twobili.com': + case 'www.comicabc.com': + { + const pathStartList = ['/online/', '/ReadComic/', '/comic/']; + if (!pathStartList.some(path => location.pathname.startsWith(path))) break; + const getImgList = () => { + const imgList = []; + if (Reflect.has(unsafeWindow, 'ss')) { + const { + ss, + c, + ti, + nn, + mm, + f + } = unsafeWindow; + for (let i = 1; i <= unsafeWindow.ps; i++) { + imgList.push([`https://img${ss(c, 4, 2)}.8comic.com`, ss(c, 6, 1), ti, ss(c, 0, 4), + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + `${nn([i])}_${ss(c, mm([i]) + 10, 3, f)}.jpg`].join('/')); + } + } else { + const mainCode = [...document.scripts].find(s => s.textContent.includes('ge(e)')).textContent; + // 取得混淆過的關鍵代碼 + const [, keyCode] = /ge\([^.]+\.src\s?=\s?([^;]+)/.exec(mainCode); + const total = unsafeWindow.ps; + for (let i = 1; i <= total; i++) { + // 把關鍵代碼裡的(p)或(pp)替換成頁數(1) + const code = keyCode.replaceAll(/\(pp?\)/g, `(${i})`); + // 使用 eval 來取得圖片網址 + // eslint-disable-next-line no-eval + imgList.push(`${location.protocol}${eval(code)}`); + } + } + return imgList; + }; + options = { + name: '8comic', + getImgList, + onNext: main.querySelectorClick('#nextvol'), + onPrev: main.querySelectorClick('#prevvol') + }; + break; + } + + // #[新新漫画](https://www.77mh.nl) + case 'm.77mh.me': + case 'www.77mh.me': + case 'm.77mh.xyz': + case 'www.77mh.xyz': + case 'm.77mh.nl': + case 'www.77mh.nl': + { + if (!Reflect.has(unsafeWindow, 'msg')) break; + options = { + name: '77mh', + async getImgList() { + const baseUrl = unsafeWindow.img_qianz ?? unsafeWindow.ImgSvrList; + return unsafeWindow.msg.split('|').map(path => `${baseUrl}${path}`); + }, + onNext: main.querySelectorClick('#pnpage > a', '下一'), + onPrev: main.querySelectorClick('#pnpage > a', '上一') + }; + break; + } + + // #[hitomi](https://hitomi.la) + case 'hitomi.la': + { + options = { + name: 'hitomi', + wait: () => Reflect.has(unsafeWindow.galleryinfo, 'files'), + getImgList: () => (unsafeWindow.galleryinfo?.files).map(img => unsafeWindow.url_from_url_from_hash(unsafeWindow.galleryinfo.id, img, 'webp', undefined, 'a')) + }; + break; + } + + // #[Anchira](https://anchira.to) + case 'anchira.to': + { + options = { + name: 'hitomi', + async getImgList({ + fabProps + }) { + const [,, galleryId, galleryKey] = window.location.pathname.split('/'); + const headers = { + 'X-Requested-With': 'XMLHttpRequest', + Referer: window.location.href + }; + const res = await main.request(`/api/v1/library/${galleryId}/${galleryKey}/data`, { + headers, + noCheckCode: true + }); + if (res.status !== 200) main.toast.error(main.t('site.need_captcha'), { + throw: true, + duration: Number.POSITIVE_INFINITY, + onClick: () => fabProps?.onClick?.() + }); + const { + names, + key, + hash + } = JSON.parse(res.response); + return names.map(name => `https://kisakisexo.xyz/${galleryId}/${key}/${hash}/b/${name}`); + }, + SPA: { + isMangaPage: () => window.location.href.includes('/g/') + } + }; + break; + } + + // #[kemono](https://kemono.su) + case 'kemono.su': + case 'kemono.party': + { +const main = require('main'); + +(async () => { + const { + init, + options, + setManga + } = await main.useInit('kemono', { + autoShow: false, + defaultOption: { + onePageMode: true + }, + /** 加载原图 */ + load_original_image: true + }); + const getImglist = () => options.load_original_image ? main.querySelectorAll('.post__thumbnail a').map(e => e.href) : main.querySelectorAll('.post__thumbnail img').map(e => e.src); + init(getImglist); + + // 在切换时重新获取图片 + main.createEffectOn(() => options.load_original_image, () => setManga('imgList', getImglist())); + + // 加上跳转至 pwa 的链接 + const zipExtension = new Set(['zip', 'rar', '7z', 'cbz', 'cbr', 'cb7']); + for (const e of main.querySelectorAll('.post__attachment a')) { + if (!zipExtension.has(e.href.split('.').pop())) continue; + const a = document.createElement('a'); + a.href = `https://comic-read.pages.dev/?url=${encodeURIComponent(e.href)}`; + a.textContent = e.textContent.replace('Download ', 'ComicReadPWA - '); + a.className = e.className; + a.style.opacity = '.6'; + e.parentNode.insertBefore(a, e.nextElementSibling); + } +})(); +; + break; + } + + // #[nekohouse](https://nekohouse.su) + case 'nekohouse.su': + { + options = { + name: 'nekohouse', + getImgList: () => main.querySelectorAll('.fileThumb').map(e => e.getAttribute('href')), + initOptions: { + autoShow: false, + defaultOption: { + onePageMode: true + } + } + }; + break; + } + + // #[welovemanga](https://welovemanga.one) + case 'nicomanga.com': + case 'weloma.art': + case 'welovemanga.one': + { + if (!main.querySelector('#listImgs, .chapter-content')) break; + const getImgList = async () => { + const imgList = main.querySelectorAll('img.chapter-img:not(.ls-is-cached)').map(e => (e.dataset.src ?? e.dataset.srcset ?? e.dataset.original ?? e.src).trim()); + if (imgList.length > 0 && imgList.every(url => !/loading.*\.gif/.test(url))) return imgList; + await main.sleep(500); + return getImgList(); + }; + options = { + name: 'welovemanga', + getImgList, + onNext: main.querySelectorClick('.rd_top-right.next:not(.disabled)'), + onPrev: main.querySelectorClick('.rd_top-left.prev:not(.disabled)') + }; + break; + } + + // 为 pwa 版页面提供 api,以便翻译功能能正常运作 + // case 'localhost': + case 'comic-read.pages.dev': + { + unsafeWindow.GM_xmlhttpRequest = GM_xmlhttpRequest; + unsafeWindow.toast = main.toast; + break; + } + default: + { +const main = require('main'); + +const langList = ['zh', 'en', 'ru']; +/** 判断传入的字符串是否是支持的语言类型代码 */ +const isLanguages = lang => Boolean(lang) && langList.includes(lang); + +/** 返回浏览器偏好语言 */ +const getBrowserLang = () => { + let newLang; + for (let i = 0; i < navigator.languages.length; i++) { + const language = navigator.languages[i]; + const matchLang = langList.find(l => l === language || l === language.split('-')[0]); + if (matchLang) { + newLang = matchLang; + break; + } + } + return newLang; +}; +const getSaveLang = async () => typeof GM === 'undefined' ? localStorage.getItem('Languages') : GM.getValue('Languages'); +const setSaveLang = async val => typeof GM === 'undefined' ? localStorage.setItem('Languages', val) : GM.setValue('Languages', val); +const getInitLang = async () => { + const saveLang = await getSaveLang(); + if (isLanguages(saveLang)) return saveLang; + const lang = getBrowserLang() ?? 'zh'; + setSaveLang(lang); + return lang; +}; + +const getTagText = ele => { + let text = ele.nodeName; + if (ele.id && !/\d/.test(ele.id)) text += `#${ele.id}`; + return text; +}; + +/** 获取元素仅记录了层级结构关系的选择器 */ +const getEleSelector = ele => { + const parents = [ele.nodeName]; + const root = ele.getRootNode(); + let e = ele; + while (e.parentNode && e.parentNode !== root) { + e = e.parentNode; + parents.push(getTagText(e)); + } + return parents.reverse().join('>'); +}; + +/** 判断指定元素是否符合选择器 */ +const isEleSelector = (ele, selector) => { + const parents = selector.split('>').reverse(); + let e = ele; + for (let i = 0; e && i < parents.length; i++) { + if (getTagText(e) !== parents[i]) return false; + e = e.parentNode; + } + return e === e.getRootNode(); +}; + +// 目录页和漫画页的图片层级相同 +// https://www.biliplus.com/manga/ +// 图片路径上有 id 元素并且 id 含有漫画 id,不同话数 id 也不同 + +// 测试案例 +// https://www.177picyy.com/html/2023/03/5505307.html +// 需要配合其他翻页脚本使用 +// https://www.colamanga.com/manga-za76213/1/5.html +// 直接跳转到图片元素不会立刻触发,还需要停留20ms +// https://www.colamanga.com/manga-kg45140/1/2.html +(async () => { + /** 执行脚本操作。如果中途中断,将返回 true */ + const start = async () => { + const { + setManga, + setFab, + init, + options, + setOptions, + isStored, + mangaProps + } = await main.useInit(window.location.hostname, { + remember_current_site: true, + selector: '' + }); + + // 通过 options 来迂回的实现禁止记住当前站点 + if (!options.remember_current_site) { + await GM.deleteValue(window.location.hostname); + return true; + } + if (!isStored) main.toast(main.autoReadModeMessage(setOptions), { + duration: 1000 * 7 + }); + + // 为避免卡死,提供一个删除 selector 的菜单项 + const menuId = console.debug(main.t('site.simple.simple_read_mode'), () => setOptions({ + selector: '' + })); + + // 等待 selector 匹配到目标后再继续执行,避免在漫画页外的其他地方运行 + await main.wait(() => !options.selector || main.querySelectorAll(options.selector).length >= 2); + console.debug(menuId); + + /** 记录传入的图片元素中最常见的那个 selector */ + const saveImgEleSelector = imgEleList => { + if (imgEleList.length < 7) return; + const selector = main.getMostItem(imgEleList.map(getEleSelector)); + if (selector !== options.selector) setOptions({ + selector + }); + }; + const blobUrlMap = new Map(); + // 处理那些 URL.createObjectURL 后马上 URL.revokeObjectURL 的图片 + const handleBlobImg = async e => { + if (blobUrlMap.has(e.src)) return blobUrlMap.get(e.src); + if (!e.src.startsWith('blob:')) return e.src; + if (await main.testImgUrl(e.src)) return e.src; + const canvas = document.createElement('canvas'); + const canvasCtx = canvas.getContext('2d'); + canvas.width = e.naturalWidth; + canvas.height = e.naturalHeight; + canvasCtx.drawImage(e, 0, 0); + const url = URL.createObjectURL(await main.canvasToBlob(canvas)); + blobUrlMap.set(e.src, url); + return url; + }; + const handleImgUrl = async e => { + const url = await handleBlobImg(e); + if (url.startsWith('http:') && window.location.protocol === 'https:') return url.replace('http:', 'https:'); + return url; + }; + const imgBlackList = [ + // 东方永夜机的预加载图片 + '#pagetual-preload', + // 177picyy 上会在图片下加一个 noscript + // 本来只是图片元素的 html 代码,但经过东方永夜机加载后就会变成真的图片元素,导致重复 + 'noscript']; + const getAllImg = () => main.querySelectorAll(`:not(${imgBlackList.join(',')}) > img`); + let imgEleList; + let updateImgListTimeout; + /** 检查筛选符合标准的图片元素用于更新 imgList */ + const updateImgList = main.singleThreaded(async () => { + imgEleList = await main.wait(() => { + const newImgList = getAllImg().filter(e => e.offsetHeight > 100 && e.offsetWidth > 100 && (e.naturalHeight > 500 && e.naturalWidth > 500 || isEleSelector(e, options.selector))).sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y); + return newImgList.length >= 2 && newImgList; + }); + if (imgEleList.length === 0) { + setFab('show', false); + setManga('show', false); + return; + } + + /** 找出应该是漫画图片,且还需要继续触发懒加载的图片个数 */ + const expectCount = options.selector ? main.querySelectorAll(options.selector).filter(main.needTrigged).length : 0; + const _imgEleList = expectCount ? [...imgEleList, ...Array.from({ + length: expectCount + })] : imgEleList; + let isEdited = false; + await main.plimit(_imgEleList.map((e, i) => async () => { + const newUrl = e ? await handleImgUrl(e) : ''; + if (newUrl === mangaProps.imgList[i]) return; + isEdited ||= true; + setManga('imgList', i, newUrl); + })); + if (isEdited) saveImgEleSelector(imgEleList); + + // colamanga 会创建随机个数的假 img 元素,导致刚开始时高估页数,需要再删掉多余的页数 + if (mangaProps.imgList.length > _imgEleList.length) setManga('imgList', mangaProps.imgList.slice(0, _imgEleList.length)); + if (isEdited || expectCount || imgEleList.some(e => !e.naturalWidth && !e.naturalHeight)) { + if (updateImgListTimeout) window.clearTimeout(updateImgListTimeout); + updateImgListTimeout = window.setTimeout(updateImgList, 1000); + } + }); + let timeout = false; + setTimeout(() => { + timeout = true; + if (mangaProps.imgList.length > 0) return; + main.toast.warn(main.t('site.simple.no_img'), { + id: 'no_img', + duration: Number.POSITIVE_INFINITY, + async onClick() { + await setOptions({ + remember_current_site: false + }); + window.location.reload(); + } + }); + }, 3000); + const triggerAllLazyLoad = () => main.triggerLazyLoad(getAllImg, () => + // 只在`开启了阅读模式所以用户看不到网页滚动`和`当前可显示图片数量不足`时停留一段时间 + mangaProps.show || !timeout && mangaProps.imgList.length === 0 ? 300 : 0); + + /** 监视页面元素发生变化的 Observer */ + const imgDomObserver = new MutationObserver(() => { + updateImgList(); + triggerAllLazyLoad(); + }); + init(async () => { + if (!imgEleList) { + imgEleList = []; + imgDomObserver.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['src'] + }); + updateImgList(); + triggerAllLazyLoad(); + } + await main.wait(() => mangaProps.imgList.length); + main.toast.dismiss('no_img'); + return mangaProps.imgList; + }); + + // 同步滚动显示网页上的图片,用于以防万一保底触发漏网之鱼 + main.createEffectOn(main.renderImgList, main.throttle(list => { + if (list.size === 0 || !main.store.show) return; + const lastImgIndex = [...list].at(-1); + if (lastImgIndex === undefined) return; + imgEleList[lastImgIndex]?.scrollIntoView({ + behavior: 'instant', + block: 'end' + }); + main.openScrollLock(500); + }, 1000), { + defer: true + }); + + // 在退出阅读模式时跳回之前的滚动位置 + let laseScroll = window.scrollY; + main.createEffectOn(() => main.store.show, show => { + if (show) laseScroll = window.scrollY;else { + main.openScrollLock(1000); + // 稍微延迟一下,等之前触发懒加载时的滚动结束 + requestAnimationFrame(() => window.scrollTo(0, laseScroll)); + } + }); + }; + if ((await GM.getValue(window.location.hostname)) !== undefined) return start(); + const menuId = console.debug(((lang) => { + switch (lang) { + case 'en': return 'Enter simple reading mode';case 'ru': return 'Включить простой режим чтения'; + default: return '使用简易阅读模式'; + } + })(await getInitLang()), async () => !(await start()) && GM.unregisterMenuCommand(menuId)); +})().catch(error => main.log.error(error)); +; + } + } + if (options) main.universalInit(options); +} catch (error) { + main.log.error(error); +} diff --git a/README.md b/README.md index ef962cd1..8f86b412 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ 1. 首先需要在浏览器上装好 [Violentmonkey](https://violentmonkey.github.io/)、[Tampermonkey](https://tampermonkey.net/) 之类的油猴扩展 2. 然后通过 GreasyFork 安装脚本:[点我](https://sleazyfork.org/zh-CN/scripts/374903-comicread) +> 另外也有 [AdGuard版](https://github.com/hymbz/ComicReadScript/raw/master/ComicRead-AdGuard.user.js) + ## 快捷键 | 操作 | 快捷键 | diff --git a/docs/index.md b/docs/index.md index 88648059..8b4ee229 100644 --- a/docs/index.md +++ b/docs/index.md @@ -30,6 +30,8 @@ 1. 首先需要在浏览器上装好 [Violentmonkey](https://violentmonkey.github.io/)、[Tampermonkey](https://tampermonkey.net/) 之类的油猴扩展 2. 然后通过 GreasyFork 安装脚本:[点我](https://sleazyfork.org/zh-CN/scripts/374903-comicread) +> 另外也有 [AdGuard版](https://github.com/hymbz/ComicReadScript/raw/master/ComicRead-AdGuard.user.js) + ## 快捷键 | 操作 | 快捷键 | diff --git a/release.mjs b/release.mjs index cadbbe19..c3fd817f 100644 --- a/release.mjs +++ b/release.mjs @@ -29,6 +29,11 @@ const exec = (...commands) => { path.join(__dirname, './dist/index.js'), path.join(__dirname, './ComicRead.user.js'), ); + shell.cp( + '-f', + path.join(__dirname, './dist/adguard.js'), + path.join(__dirname, './ComicRead-AdGuard.user.js'), + ); // 提交上传更改 exec( diff --git a/rollup.config.ts b/rollup.config.ts index c1029ec8..544005ba 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -12,7 +12,12 @@ import json from '@rollup/plugin-json'; import alias from '@rollup/plugin-alias'; import replace from '@rollup/plugin-replace'; import { watchExternal } from 'rollup-plugin-watch-external'; -import type { InputPluginOption, Plugin, RollupOptions } from 'rollup'; +import type { + InputPluginOption, + OutputOptions, + OutputPluginOption, + RollupOptions, +} from 'rollup'; import { createServer } from 'vite'; import { parse as parseMd } from 'marked'; @@ -60,11 +65,11 @@ const generateScopedName = '[local]'; export const buildOptions = ( fileName: string, watchFiles?: string[], - ...plugins: Array + fn?: (options: RollupOptions) => RollupOptions, ): RollupOptions => { const isUserScript = ['dev', 'index'].includes(fileName); - return { + const options: RollupOptions = { treeshake: true, external: [...Object.keys(meta.resource ?? {}), 'main', 'dmzjDecrypt'], input: resolve(__dirname, 'src', fileName), @@ -109,7 +114,6 @@ export const buildOptions = ( }), watchFiles && isDevMode && watchExternal({ entries: watchFiles }), - ...plugins, ], output: { // dev 和 index 外的文件都放到 cache 文件夹下 @@ -156,6 +160,8 @@ export const buildOptions = ( ], }, }; + + return fn ? fn(options) : options; }; // 清空 dist 文件夹 @@ -200,8 +206,7 @@ shell.rm('-rf', resolve(__dirname, 'dist/*')); server.printUrls(); })(); -// eslint-disable-next-line import/no-anonymous-default-export -export default [ +const optionList: RollupOptions[] = [ // // 打包 dmzjDecrypt 时用的配置 // (() => { // const options = buildOptions( @@ -228,3 +233,76 @@ export default [ buildOptions('index', ['dist/**/*', '!dist/index.js']), ]; + +if (!isDevMode) + optionList.push( + buildOptions('index', ['dist/**/*', '!dist/index.js'], (options) => { + (options.output as OutputOptions).file = 'dist/adguard.js'; + Reflect.deleteProperty(options.output!, 'dir'); + ((options.output as OutputOptions).plugins as OutputPluginOption[]).push({ + name: 'selfAdGuardPlugin', + renderChunk(rawCode) { + let code = rawCode; + + // 不知道为啥俄罗斯访问不了 npmmirror + // https://github.com/hymbz/ComicReadScript/issues/170 + // 或许和 unpkg 功能的白名单有关 + // + // 可能再过一段时间就能恢复?但总之目前只能先改用 jsdelivr + code = code.replaceAll( + /@resource .+? https:\/\/registry.npmmirror.com\/.+(?=\n)/g, + (text) => + text + .replace('registry.npmmirror.com/', 'cdn.jsdelivr.net/npm/') + .replace(/(npm\/[^/]+)\//, '$1@') + .replace('files/', ''), + ); + + // AdGuard 无法支持简易阅读模式,所以改为只在支持网站上运行 + const indexCode = fs.readFileSync( + resolve(__dirname, 'src/index.ts'), + 'utf8', + ); + const matchList = [ + ...indexCode.matchAll(/(?<=\n\s+case ').+?(?=':)/g), + ] + .filter(([url]) => !url.includes('siteUrl#')) + .flatMap(([url]) => `// @match *://${url}/*`); + code = code.replace( + /\/\/ @match \s+ \*:\/\/\*\/\*/, + matchList.join('\n'), + ); + + // 删掉不支持的菜单 api + code = code.replaceAll( + /\/\/ @grant \s+ GM\.(registerMenuCommand|unregisterMenuCommand)\n/g, + '', + ); + + // 把菜单 api 的调用也改掉 + code = code.replaceAll( + /await GM\.(registerMenuCommand|unregisterMenuCommand)/g, + 'console.debug', + ); + + // 脚本更新链接也换掉 + code = code.replaceAll( + '/raw/master/ComicRead.user.js', + '/raw/master/ComicRead-AdGuard.user.js', + ); + + // 不知道为啥会提示 'Access to function "GM_getValue" is not allowed.' + // 明明我用的是 GM.getValue。虽然好像对功能没有影响,但以防万一还是加上吧 + code = code.replace( + /\n(?=\/\/ @grant)/, + '\n// @grant GM_getValue\n// @grant GM_setValue\n', + ); + + return code; + }, + }); + return options; + }), + ); + +export default optionList;