From 1e4b748de46c9314bdc9bf9555c93168c4c4fdd4 Mon Sep 17 00:00:00 2001 From: hymbz Date: Sun, 7 Jul 2024 16:59:17 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20:recycle:=20=E5=B0=86=20eh=20?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=A0=81=E6=8B=86=E5=88=86=E5=BC=80=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rollup.config.ts | 26 +- src/site/ehentai.tsx | 667 -------------------------- src/site/ehentai/associateNhentai.tsx | 139 ++++++ src/site/ehentai/hotkeys.ts | 36 ++ src/site/ehentai/index.tsx | 305 ++++++++++++ src/site/ehentai/quickFavorite.tsx | 230 +++++++++ 6 files changed, 727 insertions(+), 676 deletions(-) delete mode 100644 src/site/ehentai.tsx create mode 100644 src/site/ehentai/associateNhentai.tsx create mode 100644 src/site/ehentai/hotkeys.ts create mode 100644 src/site/ehentai/index.tsx create mode 100644 src/site/ehentai/quickFavorite.tsx diff --git a/rollup.config.ts b/rollup.config.ts index 544005ba..e4fb674a 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import { resolve, dirname } from 'node:path'; +import { resolve, dirname, basename, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; import shell from 'shelljs'; @@ -63,16 +63,21 @@ const { meta, createMetaHeader } = getMetaData(isDevMode); const generateScopedName = '[local]'; export const buildOptions = ( - fileName: string, + path: string, watchFiles?: string[], fn?: (options: RollupOptions) => RollupOptions, ): RollupOptions => { - const isUserScript = ['dev', 'index'].includes(fileName); + const isUserScript = ['dev', 'index'].includes(path); + + const dir = isUserScript ? 'dist' : 'dist/cache'; + const fileName = path.endsWith('index.tsx') + ? path.split('/')[1] + : basename(path, extname(path)); const options: RollupOptions = { treeshake: true, external: [...Object.keys(meta.resource ?? {}), 'main', 'dmzjDecrypt'], - input: resolve(__dirname, 'src', fileName), + input: resolve(__dirname, 'src', path), // 忽略使用 eval 的警告 onwarn(warning, warn) { if (warning.code !== 'EVAL') warn(warning); @@ -116,8 +121,7 @@ export const buildOptions = ( watchFiles && isDevMode && watchExternal({ entries: watchFiles }), ], output: { - // dev 和 index 外的文件都放到 cache 文件夹下 - dir: resolve(__dirname, isUserScript ? 'dist' : 'dist/cache'), + file: `${dir}/${fileName}.js`, format: 'cjs', strict: false, generatedCode: 'es2015', @@ -129,7 +133,7 @@ export const buildOptions = ( renderChunk(rawCode) { let code = rawCode; - switch (fileName) { + switch (path) { case 'index': updateReadme(); if (isDevMode) @@ -226,8 +230,12 @@ const optionList: RollupOptions[] = [ buildOptions('main'), ...fs - .readdirSync('src/site') - .map((fileName) => buildOptions(`site/${fileName}`)), + .readdirSync('src/site', { withFileTypes: true }) + .map((item) => + item.isFile() + ? buildOptions(`site/${item.name}`) + : buildOptions(`site/${item.name}/index.tsx`), + ), buildOptions('helper/import', ['dist/cache/main.js']), diff --git a/src/site/ehentai.tsx b/src/site/ehentai.tsx deleted file mode 100644 index 9d1c8b36..00000000 --- a/src/site/ehentai.tsx +++ /dev/null @@ -1,667 +0,0 @@ -import { type Accessor, For, createSignal, Show } from 'solid-js'; -import { render } from 'solid-js/web'; -import { - t, - insertNode, - linstenKeyup, - querySelector, - scrollIntoView, - request, - useInit, - toast, - plimit, - querySelectorAll, - wait, - log, - testImgUrl, - singleThreaded, - store, - createEffectOn, - getAdPageByFileName, - getAdPageByContent, - ReactiveSet, - domParse, -} from 'main'; - -declare const selected_tagname: string; - -let hasStyle = false; -/** 添加快捷收藏的界面 */ -const addQuickFavorite = ( - favoriteButton: HTMLElement, - root: HTMLElement, - apiUrl: string, - position: [number, number], -) => { - if (!hasStyle) { - hasStyle = true; - GM_addStyle(` - .comidread-favorites { - position: absolute; - left: 0; - width: 100%; - padding-left: 0.6em; - box-sizing: border-box; - z-index: 75; - border: none; - border-radius: 0; - overflow: auto; - align-content: center; - } - - .comidread-favorites-item { - display: flex; - align-items: center; - margin: 1em 0; - cursor: pointer; - width: fit-content; - text-align: left; - } - - .comidread-favorites-item > input { - margin: 0 0.5em 0 0; - pointer-events: none; - } - - .comidread-favorites-item > div { - margin: 0 0.5em 0 0; - height: 15px; - width: 15px; - background-repeat: no-repeat; - background-image: url(https://ehgt.org/g/fav.png); - flex-shrink: 0; - } - - .gl1t > .comidread-favorites { - padding: 1em 1.5em; - } - `); - } - root.style.position = 'relative'; - root.style.height = '100%'; - - const [show, setShow] = createSignal(false); - - const [favorites, setFavorites] = createSignal([]); - - const updateFavorite = async () => { - try { - const res = await request(apiUrl, { - errorText: t('site.ehentai.fetch_favorite_failed'), - }); - const dom = domParse(res.responseText); - const list = [...dom.querySelectorAll('.nosel > div')] as HTMLElement[]; - if (list.length === 10) list[0].querySelector('input')!.checked = false; - setFavorites(list); - } catch { - toast.error(t('site.ehentai.fetch_favorite_failed')); - setFavorites([]); - } - }; - - let hasRender = false; - const renderDom = () => { - if (hasRender) return; - hasRender = true; - - const FavoriteItem = (e: HTMLElement, index: Accessor) => { - const handleClick = async () => { - setShow(false); - - const formData = new FormData(); - formData.append('favcat', index() === 10 ? 'favdel' : `${index()}`); - formData.append('apply', 'Apply Changes'); - formData.append('favnote', ''); - formData.append('update', '1'); - const res = await request(apiUrl, { - method: 'POST', - data: formData, - errorText: t('site.ehentai.change_favorite_failed'), - }); - - toast.success(t('site.ehentai.change_favorite_success')); - - // 修改收藏按钮样式的 js 代码 - const updateCode = /\nif\(window.opener.document.+\n/ - .exec(res.responseText)?.[0] - ?.replaceAll('window.opener.document', 'window.document'); - if (updateCode) eval(updateCode); // eslint-disable-line no-eval - - await updateFavorite(); - }; - - return ( -
- - -
- - {e.textContent?.trim()} -
- ); - }; - - let background = 'rgba(0, 0, 0, 0)'; - let dom = root; - while (background === 'rgba(0, 0, 0, 0)') { - background = getComputedStyle(dom).backgroundColor; - dom = dom.parentElement!; - } - - render( - () => ( - - - loading...} - /> - - - ), - root, - ); - }; - - // 将原本的收藏按钮改为切换显示快捷收藏夹 - const rawClick = favoriteButton.onclick as (ev: MouseEvent) => unknown; - favoriteButton.onclick = null; - favoriteButton.addEventListener('mousedown', async (e) => { - e.stopPropagation(); - e.preventDefault(); - - if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey || e.buttons === 4) - return rawClick.call(favoriteButton, e); - - renderDom(); - setShow((val) => !val); - if (show()) await updateFavorite(); - }); -}; - -(async () => { - const { - options, - init, - setFab, - setManga, - dynamicUpdate, - onLoading, - mangaProps, - } = await useInit('ehentai', { - /** 关联 nhentai */ - associate_nhentai: true, - /** 快捷键翻页 */ - hotkeys_page_turn: true, - /** 识别广告 */ - detect_ad: true, - /** 快捷收藏 */ - quick_favorite: true, - autoShow: false, - }); - - if (Reflect.has(unsafeWindow, 'mpvkey')) { - const imgEleList = querySelectorAll('.mi0[id]'); - init( - dynamicUpdate( - (setImg) => - 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 wait(getUrl); - setImg(i, imgUrl); - }), - undefined, - 4, - ), - imgEleList.length, - ), - ); - return; - } - - // 不是漫画页的话 - if (!Reflect.has(unsafeWindow, 'apikey')) { - if (options.hotkeys_page_turn) { - linstenKeyup((e) => { - switch (e.key) { - case 'ArrowRight': - case 'd': - querySelector('#dnext')?.click(); - break; - - case 'ArrowLeft': - case 'a': - querySelector('#dprev')?.click(); - break; - } - }); - } - - if (options.quick_favorite) { - switch ( - querySelector('#ujumpbox ~ div > select')?.value - ) { - case 't': { - for (const item of querySelectorAll('.gl1t')) { - const button = item.querySelector('[id][onclick]')!; - const top = - item.firstElementChild!.getBoundingClientRect().bottom - - item.getBoundingClientRect().top; - const bottom = - item.lastElementChild!.getBoundingClientRect().top - - item.getBoundingClientRect().top; - addQuickFavorite( - button, - item, - /http.+?(?=')/.exec(button.getAttribute('onclick')!)![0], - [top, bottom], - ); - } - break; - } - - case 'e': { - for (const item of querySelectorAll('.gl1e')) { - const button = - item.nextElementSibling!.querySelector( - '[id][onclick]', - )!; - addQuickFavorite( - button, - item, - /http.+?(?=')/.exec(button.getAttribute('onclick')!)![0], - [0, Number.parseInt(getComputedStyle(item).height, 10)], - ); - } - break; - } - } - } - - return; - } - - const sidebarDom = document.getElementById('gd5')!; - // 表站开启了 Multi-Page Viewer 的话会将点击按钮挤出去,得缩一下位置 - if (sidebarDom.children[6]) - (sidebarDom.children[6] as HTMLElement).style.padding = '0'; - // 虽然有 Fab 了不需要这个按钮,但都点习惯了没有还挺别扭的( - insertNode( - sidebarDom, - '

Load comic

', - ); - const comicReadModeDom = document.getElementById('comicReadMode')!; - - /** 从图片页获取图片地址 */ - const getImgFromImgPage = async (url: string): Promise => { - const res = await request( - url, - { - fetch: true, - errorText: t('site.ehentai.fetch_img_page_source_failed'), - }, - 10, - ); - - try { - return /id="img" src="(.+?)"/.exec(res.responseText)![1]; - } catch { - throw new Error(t('site.ehentai.fetch_img_url_failed')); - } - }; - - /** 从详情页获取图片页的地址 */ - const getImgFromDetailsPage = async ( - pageNum = 0, - ): Promise> => { - const res = await request( - `${window.location.pathname}${pageNum ? `?p=${pageNum}` : ''}`, - { fetch: true, errorText: t('site.ehentai.fetch_img_page_url_failed') }, - ); - // 从详情页获取图片页的地址 - const reRes = res.responseText.matchAll( - /.+?title=".+?: [url, fileName]); - }; - - const getImgNum = async () => { - let numText = querySelector('.gtb .gpc') - ?.textContent?.replaceAll(',', '') - .match(/\d+/g) - ?.at(-1); - if (numText) return Number(numText); - - const res = await request(window.location.href); - numText = /(?<=)\d+(?= pages<\/td>)/.exec( - res.responseText, - )?.[0]; - if (numText) return Number(numText); - - toast.error(t('site.changed_load_failed')); - return 0; - }; - - const totalImgNum = await getImgNum(); - const placeValueNum = `${totalImgNum}`.length; - - const ehImgList: string[] = []; - const ehImgPageList: string[] = []; - const ehImgFileNameList: string[] = []; - - const stylesheet = new CSSStyleSheet(); - document.adoptedStyleSheets.push(stylesheet); - 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 ReactiveSet()); - /** 缩略图元素列表 */ - const thumbnailEleList: HTMLImageElement[] = []; - - for (const e of querySelectorAll('#gdt img')) { - const index = Number(e.alt) - 1; - if (Number.isNaN(index)) return; - thumbnailEleList[index] = e; - // 根据当前显示的图片获取一部分文件名 - [, ehImgFileNameList[index]] = e.title.split(/:|: /); - } - // 先根据文件名判断一次 - await getAdPageByFileName(ehImgFileNameList, mangaProps.adList); - // 不行的话再用缩略图识别 - if (mangaProps.adList!.size === 0) - await getAdPageByContent(thumbnailEleList, mangaProps.adList); - } - - const { loadImgList } = init( - dynamicUpdate(async (setImg) => { - comicReadModeDom.innerHTML = ` loading`; - - const totalPageNum = Number( - 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 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: `${t('other.loading_img')} - ${doneNum}/${totalImgNum}`, - }); - comicReadModeDom.innerHTML = ` loading - ${doneNum}/${totalImgNum}`; - - if (doneNum === totalImgNum) { - comicReadModeDom.innerHTML = ` Read`; - if (enableDetectAd) { - await getAdPageByFileName(ehImgFileNameList, mangaProps.adList); - await getAdPageByContent(ehImgList, mangaProps.adList); - } - } - }, - ); - } - }, totalImgNum), - ); - - /** 获取新的图片页地址 */ - const getNewImgPageUrl = async (url: string) => { - const res = await request(url, { - errorText: t('site.ehentai.fetch_img_page_source_failed'), - }); - const nl = /nl\('(.+?)'\)/.exec(res.responseText)?.[1]; - if (!nl) throw new Error(t('site.ehentai.fetch_img_url_failed')); - const newUrl = new URL(url); - newUrl.searchParams.set('nl', nl); - return newUrl.href; - }; - - /** 刷新指定图片 */ - const reloadImg = async (i: number) => { - const pageUrl = await getNewImgPageUrl(ehImgPageList[i]); - let imgUrl = ''; - while (!imgUrl || !(await testImgUrl(imgUrl))) { - imgUrl = await getImgFromImgPage(pageUrl); - log(`刷新图片 ${i}\n${ehImgList[i]} ->\n${imgUrl}`); - } - ehImgList[i] = imgUrl; - ehImgPageList[i] = pageUrl; - setManga('imgList', i, imgUrl); - }; - - /** 判断当前显示的是否是 eh 源 */ - const isShowEh = () => store.imgList[0]?.src === ehImgList[0]; - - /** 刷新所有错误图片 */ - const reloadErrorImg = singleThreaded(() => - plimit( - store.imgList.map(({ loadType }, i) => () => { - if (loadType !== 'error' || !isShowEh()) return; - return reloadImg(i); - }), - ), - ); - - setManga({ - onExit(isEnd) { - if (isEnd) scrollIntoView('#cdiv'); - setManga('show', false); - }, - // 在图片加载出错时刷新图片 - async onLoading(imgList, img) { - onLoading(imgList, img); - if (!img) return; - if (img.loadType !== 'error' || (await 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) { - linstenKeyup((e) => { - switch (e.key) { - case 'ArrowRight': - case 'd': - querySelector('.ptt td:last-child:not(.ptdd)')?.click(); - break; - - case 'ArrowLeft': - case 'a': - querySelector('.ptt td:first-child:not(.ptdd)')?.click(); - break; - } - }); - } - - if (options.associate_nhentai) - (async () => { - const titleDom = document.getElementById('gn'); - const taglistDom = querySelector('#taglist tbody'); - if (!titleDom || !taglistDom) { - toast.error(t('site.ehentai.html_changed_nhentai_failed')); - return; - } - - const title = encodeURI(titleDom.textContent!); - - const newTagLine = document.createElement('tr'); - - let nHentaiComicInfo: { - result: Array<{ - id: number; - media_id: string; - num_pages: number; - images: { pages: Array<{ t: string }> }; - title: { japanese: string; english: string }; - }>; - }; - try { - const res = await request( - `https://nhentai.net/api/galleries/search?query=${title}`, - { - responseType: 'json', - errorText: t('site.ehentai.nhentai_error'), - noTip: true, - }, - ); - nHentaiComicInfo = res.response; - } catch { - newTagLine.innerHTML = ` - nhentai: - - ${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: Record = {}; - const raw_refresh_tagmenu_act = unsafeWindow._refresh_tagmenu_act; - // eslint-disable-next-line func-names - unsafeWindow._refresh_tagmenu_act = function _refresh_tagmenu_act( - a: HTMLAnchorElement, - ) { - 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 plimit( - images.pages.map((page, i) => async () => { - const imgRes = await 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) as unknown; - }; - })(); - - // 快捷收藏。必须处于登录状态 - if (unsafeWindow.apiuid !== -1 && options.quick_favorite) { - const button = querySelector('#gdf')!; - const root = querySelector('#gd3')!; - addQuickFavorite(button, root, `${unsafeWindow.popbase}addfav`, [ - 0, - (button.firstElementChild as HTMLElement).offsetTop, - ]); - } -})().catch((error) => log.error(error)); diff --git a/src/site/ehentai/associateNhentai.tsx b/src/site/ehentai/associateNhentai.tsx new file mode 100644 index 00000000..701dffe2 --- /dev/null +++ b/src/site/ehentai/associateNhentai.tsx @@ -0,0 +1,139 @@ +import { t, querySelector, request, type useInit, toast, plimit } from 'main'; +import { type AsyncReturnType } from 'type-fest'; + +declare const selected_tagname: string; + +/** 关联 nhentai */ +export const associateNhentai = async ( + init: AsyncReturnType['init'], + dynamicUpdate: AsyncReturnType['dynamicUpdate'], +) => { + const titleDom = document.getElementById('gn'); + const taglistDom = querySelector('#taglist tbody'); + if (!titleDom || !taglistDom) { + if ((document.getElementById('taglist')?.children.length ?? 1) > 0) + toast.error(t('site.ehentai.html_changed_nhentai_failed')); + return; + } + + const title = encodeURI(titleDom.textContent!); + + const newTagLine = document.createElement('tr'); + + let nHentaiComicInfo: { + result: Array<{ + id: number; + media_id: string; + num_pages: number; + images: { pages: Array<{ t: string }> }; + title: { japanese: string; english: string }; + }>; + }; + try { + const res = await request( + `https://nhentai.net/api/galleries/search?query=${title}`, + { + responseType: 'json', + errorText: t('site.ehentai.nhentai_error'), + noTip: true, + }, + ); + nHentaiComicInfo = res.response; + } catch { + newTagLine.innerHTML = ` + nhentai: + + ${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: Record = {}; + const raw_refresh_tagmenu_act = unsafeWindow._refresh_tagmenu_act; + // eslint-disable-next-line func-names + unsafeWindow._refresh_tagmenu_act = function _refresh_tagmenu_act( + a: HTMLAnchorElement, + ) { + 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 plimit( + images.pages.map((page, i) => async () => { + const imgRes = await 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) as unknown; + }; +}; diff --git a/src/site/ehentai/hotkeys.ts b/src/site/ehentai/hotkeys.ts new file mode 100644 index 00000000..d394a3c6 --- /dev/null +++ b/src/site/ehentai/hotkeys.ts @@ -0,0 +1,36 @@ +import { linstenKeyup, querySelector } from 'main'; + +import { type PageType } from '.'; + +/** 快捷键翻页 */ +export const hotkeysPageTurn = (pageType: PageType) => { + if (pageType === 'gallery') { + linstenKeyup((e) => { + switch (e.key) { + case 'ArrowRight': + case 'd': + querySelector('#dnext')?.click(); + break; + + case 'ArrowLeft': + case 'a': + querySelector('#dprev')?.click(); + break; + } + }); + } else { + linstenKeyup((e) => { + switch (e.key) { + case 'ArrowRight': + case 'd': + querySelector('#unext')?.click(); + break; + + case 'ArrowLeft': + case 'a': + querySelector('#uprev')?.click(); + break; + } + }); + } +}; diff --git a/src/site/ehentai/index.tsx b/src/site/ehentai/index.tsx new file mode 100644 index 00000000..c2367fcc --- /dev/null +++ b/src/site/ehentai/index.tsx @@ -0,0 +1,305 @@ +import { + t, + insertNode, + querySelector, + scrollIntoView, + request, + useInit, + toast, + plimit, + querySelectorAll, + wait, + log, + testImgUrl, + singleThreaded, + store, + createEffectOn, + getAdPageByFileName, + getAdPageByContent, + ReactiveSet, +} from 'main'; + +import { quickFavorite } from './quickFavorite'; +import { associateNhentai } from './associateNhentai'; +import { hotkeysPageTurn } from './hotkeys'; + +export type PageType = 'gallery' | 't' | 'e'; + +(async () => { + const { + options, + init, + setFab, + setManga, + dynamicUpdate, + onLoading, + mangaProps, + } = await useInit('ehentai', { + /** 关联 nhentai */ + associate_nhentai: true, + /** 快捷键翻页 */ + hotkeys_page_turn: true, + /** 识别广告 */ + detect_ad: true, + /** 快捷收藏 */ + quick_favorite: true, + autoShow: false, + }); + + if (Reflect.has(unsafeWindow, 'mpvkey')) { + const imgEleList = querySelectorAll('.mi0[id]'); + init( + dynamicUpdate( + (setImg) => + 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 wait(getUrl); + setImg(i, imgUrl); + }), + undefined, + 4, + ), + imgEleList.length, + ), + ); + return; + } + + let pageType: PageType | undefined; + if (Reflect.has(unsafeWindow, 'display_comment_field')) pageType = 'gallery'; + else + pageType = querySelector('#ujumpbox ~ div > select') + ?.value as PageType | undefined; + + if (!pageType) return; + + // 快捷键翻页 + if (options.hotkeys_page_turn) hotkeysPageTurn(pageType); + // 快捷收藏。必须处于登录状态 + if (unsafeWindow.apiuid !== -1 && options.quick_favorite) + quickFavorite(pageType); + + // 不是漫画页的话 + if (pageType !== 'gallery') return; + + const sidebarDom = document.getElementById('gd5')!; + // 表站开启了 Multi-Page Viewer 的话会将点击按钮挤出去,得缩一下位置 + if (sidebarDom.children[6]) + (sidebarDom.children[6] as HTMLElement).style.padding = '0'; + // 虽然有 Fab 了不需要这个按钮,但都点习惯了没有还挺别扭的( + insertNode( + sidebarDom, + '

Load comic

', + ); + const comicReadModeDom = document.getElementById('comicReadMode')!; + + /** 从图片页获取图片地址 */ + const getImgFromImgPage = async (url: string): Promise => { + const res = await request( + url, + { + fetch: true, + errorText: t('site.ehentai.fetch_img_page_source_failed'), + }, + 10, + ); + + try { + return /id="img" src="(.+?)"/.exec(res.responseText)![1]; + } catch { + throw new Error(t('site.ehentai.fetch_img_url_failed')); + } + }; + + /** 从详情页获取图片页的地址 */ + const getImgFromDetailsPage = async ( + pageNum = 0, + ): Promise> => { + const res = await request( + `${window.location.pathname}${pageNum ? `?p=${pageNum}` : ''}`, + { fetch: true, errorText: t('site.ehentai.fetch_img_page_url_failed') }, + ); + // 从详情页获取图片页的地址 + const reRes = res.responseText.matchAll( + /.+?title=".+?: [url, fileName]); + }; + + const getImgNum = async () => { + let numText = querySelector('.gtb .gpc') + ?.textContent?.replaceAll(',', '') + .match(/\d+/g) + ?.at(-1); + if (numText) return Number(numText); + + const res = await request(window.location.href); + numText = /(?<=)\d+(?= pages<\/td>)/.exec( + res.responseText, + )?.[0]; + if (numText) return Number(numText); + + toast.error(t('site.changed_load_failed')); + return 0; + }; + + const totalImgNum = await getImgNum(); + const placeValueNum = `${totalImgNum}`.length; + + const ehImgList: string[] = []; + const ehImgPageList: string[] = []; + const ehImgFileNameList: string[] = []; + + const enableDetectAd = + options.detect_ad && document.getElementById('ta_other:extraneous_ads'); + if (enableDetectAd) { + setManga('adList', new ReactiveSet()); + /** 缩略图元素列表 */ + const thumbnailEleList: HTMLImageElement[] = []; + + for (const e of querySelectorAll('#gdt img')) { + const index = Number(e.alt) - 1; + if (Number.isNaN(index)) return; + thumbnailEleList[index] = e; + // 根据当前显示的图片获取一部分文件名 + [, ehImgFileNameList[index]] = e.title.split(/:|: /); + } + // 先根据文件名判断一次 + await getAdPageByFileName(ehImgFileNameList, mangaProps.adList); + // 不行的话再用缩略图识别 + if (mangaProps.adList!.size === 0) + await getAdPageByContent(thumbnailEleList, mangaProps.adList); + + // 模糊广告页的缩略图 + const stylesheet = new CSSStyleSheet(); + document.adoptedStyleSheets.push(stylesheet); + 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 { loadImgList } = init( + dynamicUpdate(async (setImg) => { + comicReadModeDom.innerHTML = ` loading`; + + const totalPageNum = Number( + 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 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: `${t('other.loading_img')} - ${doneNum}/${totalImgNum}`, + }); + comicReadModeDom.innerHTML = ` loading - ${doneNum}/${totalImgNum}`; + + if (doneNum === totalImgNum) { + comicReadModeDom.innerHTML = ` Read`; + if (enableDetectAd) { + await getAdPageByFileName(ehImgFileNameList, mangaProps.adList); + await getAdPageByContent(ehImgList, mangaProps.adList); + } + } + }, + ); + } + }, totalImgNum), + ); + + /** 获取新的图片页地址 */ + const getNewImgPageUrl = async (url: string) => { + const res = await request(url, { + errorText: t('site.ehentai.fetch_img_page_source_failed'), + }); + const nl = /nl\('(.+?)'\)/.exec(res.responseText)?.[1]; + if (!nl) throw new Error(t('site.ehentai.fetch_img_url_failed')); + const newUrl = new URL(url); + newUrl.searchParams.set('nl', nl); + return newUrl.href; + }; + + /** 刷新指定图片 */ + const reloadImg = async (i: number) => { + const pageUrl = await getNewImgPageUrl(ehImgPageList[i]); + let imgUrl = ''; + while (!imgUrl || !(await testImgUrl(imgUrl))) { + imgUrl = await getImgFromImgPage(pageUrl); + log(`刷新图片 ${i}\n${ehImgList[i]} ->\n${imgUrl}`); + } + ehImgList[i] = imgUrl; + ehImgPageList[i] = pageUrl; + setManga('imgList', i, imgUrl); + }; + + /** 判断当前显示的是否是 eh 源 */ + const isShowEh = () => store.imgList[0]?.src === ehImgList[0]; + + /** 刷新所有错误图片 */ + const reloadErrorImg = singleThreaded(() => + plimit( + store.imgList.map(({ loadType }, i) => () => { + if (loadType !== 'error' || !isShowEh()) return; + return reloadImg(i); + }), + ), + ); + + setManga({ + onExit(isEnd) { + if (isEnd) scrollIntoView('#cdiv'); + setManga('show', false); + }, + // 在图片加载出错时刷新图片 + async onLoading(imgList, img) { + onLoading(imgList, img); + if (!img) return; + if (img.loadType !== 'error' || (await testImgUrl(img.src))) return; + return reloadErrorImg(); + }, + }); + + setFab('initialShow', options.autoShow); + comicReadModeDom.addEventListener('click', () => + loadImgList(ehImgList.length > 0 ? ehImgList : undefined, true), + ); + + // 关联 nhentai + if (options.associate_nhentai) associateNhentai(init, dynamicUpdate); +})().catch((error) => log.error(error)); diff --git a/src/site/ehentai/quickFavorite.tsx b/src/site/ehentai/quickFavorite.tsx new file mode 100644 index 00000000..354304b2 --- /dev/null +++ b/src/site/ehentai/quickFavorite.tsx @@ -0,0 +1,230 @@ +import { type Accessor, For, createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { + t, + request, + toast, + domParse, + querySelector, + querySelectorAll, +} from 'main'; + +import { type PageType } from '.'; + +let hasStyle = false; +const addQuickFavorite = ( + favoriteButton: HTMLElement, + root: HTMLElement, + apiUrl: string, + position: [number, number], +) => { + if (!hasStyle) { + hasStyle = true; + GM_addStyle(` + .comidread-favorites { + position: absolute; + left: 0; + width: 100%; + padding-left: 0.6em; + box-sizing: border-box; + z-index: 75; + border: none; + border-radius: 0; + overflow: auto; + align-content: center; + } + + .comidread-favorites-item { + display: flex; + align-items: center; + margin: 1em 0; + cursor: pointer; + width: fit-content; + text-align: left; + } + + .comidread-favorites-item > input { + margin: 0 0.5em 0 0; + pointer-events: none; + } + + .comidread-favorites-item > div { + margin: 0 0.5em 0 0; + height: 15px; + width: 15px; + background-repeat: no-repeat; + background-image: url(https://ehgt.org/g/fav.png); + flex-shrink: 0; + } + + .gl1t > .comidread-favorites { + padding: 1em 1.5em; + } + `); + } + root.style.position = 'relative'; + root.style.height = '100%'; + + const [show, setShow] = createSignal(false); + + const [favorites, setFavorites] = createSignal([]); + + const updateFavorite = async () => { + try { + const res = await request(apiUrl, { + errorText: t('site.ehentai.fetch_favorite_failed'), + }); + const dom = domParse(res.responseText); + const list = [...dom.querySelectorAll('.nosel > div')] as HTMLElement[]; + if (list.length === 10) list[0].querySelector('input')!.checked = false; + setFavorites(list); + } catch { + toast.error(t('site.ehentai.fetch_favorite_failed')); + setFavorites([]); + } + }; + + let hasRender = false; + const renderDom = () => { + if (hasRender) return; + hasRender = true; + + const FavoriteItem = (e: HTMLElement, index: Accessor) => { + const checked = e.querySelector('input')!.checked; + + const handleClick = async () => { + if (checked) return; + + setShow(false); + + const formData = new FormData(); + formData.append('favcat', index() === 10 ? 'favdel' : `${index()}`); + formData.append('apply', 'Apply Changes'); + formData.append('favnote', ''); + formData.append('update', '1'); + const res = await request(apiUrl, { + method: 'POST', + data: formData, + errorText: t('site.ehentai.change_favorite_failed'), + }); + + toast.success(t('site.ehentai.change_favorite_success')); + + // 修改收藏按钮样式的 js 代码 + const updateCode = /\nif\(window.opener.document.+\n/ + .exec(res.responseText)?.[0] + ?.replaceAll('window.opener.document', 'window.document'); + if (updateCode) eval(updateCode); // eslint-disable-line no-eval + + await updateFavorite(); + }; + + return ( +
+ + +
+ + {e.textContent?.trim()} +
+ ); + }; + + let background = 'rgba(0, 0, 0, 0)'; + let dom = root; + while (background === 'rgba(0, 0, 0, 0)') { + background = getComputedStyle(dom).backgroundColor; + dom = dom.parentElement!; + } + + render( + () => ( + + + loading...} + /> + + + ), + root, + ); + }; + + // 将原本的收藏按钮改为切换显示快捷收藏夹 + const rawClick = favoriteButton.onclick as (ev: MouseEvent) => unknown; + favoriteButton.onclick = null; + favoriteButton.addEventListener('mousedown', async (e) => { + if (e.buttons !== 1 && e.buttons !== 4) return; + + e.stopPropagation(); + e.preventDefault(); + + if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey || e.buttons === 4) + return rawClick.call(favoriteButton, e); + + renderDom(); + setShow((val) => !val); + if (show()) await updateFavorite(); + }); +}; + +/** 快捷收藏的界面 */ +export const quickFavorite = (pageType: PageType) => { + if (pageType === 'gallery') { + const button = querySelector('#gdf')!; + const root = querySelector('#gd3')!; + addQuickFavorite(button, root, `${unsafeWindow.popbase}addfav`, [ + 0, + (button.firstElementChild as HTMLElement).offsetTop, + ]); + return; + } + + // 列表页根据不同显示方式分别处理 + switch (pageType) { + case 't': { + for (const item of querySelectorAll('.gl1t')) { + const button = item.querySelector('[id][onclick]')!; + const top = + item.firstElementChild!.getBoundingClientRect().bottom - + item.getBoundingClientRect().top; + const bottom = + item.lastElementChild!.getBoundingClientRect().top - + item.getBoundingClientRect().top; + addQuickFavorite( + button, + item, + /http.+?(?=')/.exec(button.getAttribute('onclick')!)![0], + [top, bottom], + ); + } + break; + } + + case 'e': { + for (const item of querySelectorAll('.gl1e')) { + const button = + item.nextElementSibling!.querySelector('[id][onclick]')!; + addQuickFavorite( + button, + item, + /http.+?(?=')/.exec(button.getAttribute('onclick')!)![0], + [0, Number.parseInt(getComputedStyle(item).height, 10)], + ); + } + break; + } + } +};