diff --git a/src/site/ehentai/crossSiteLink.tsx b/src/site/ehentai/crossSiteLink.tsx index 9789b39e..0e292e33 100644 --- a/src/site/ehentai/crossSiteLink.tsx +++ b/src/site/ehentai/crossSiteLink.tsx @@ -4,10 +4,10 @@ import { For, Show } from 'solid-js'; import { createStore } from 'solid-js/store'; import { render } from 'solid-js/web'; -import { fileType, hijackFn, querySelector, querySelectorAll, t } from 'helper'; +import { hijackFn, querySelector, querySelectorAll, t } from 'helper'; import { request, toast } from 'main'; -import { searchNhentai } from '../../userscript/nhentaiApi'; +import { searchNhentai, getNhentaiData, getNhentaiImageUrl } from '../../userscript/nhentaiApi'; import { type GalleryContext, isInCategories } from './helper'; type ItemData = { @@ -24,34 +24,36 @@ type SiteFn = { }; const nhentai: SiteFn = async ({ setState, galleryTitle }) => { - const downImg = async (i: number, media_id: string, type: string) => { - const imgRes = await request( - `https://i.nhentai.net/galleries/${media_id}/${i + 1}.${fileType[type]}`, - { - headers: { Referer: `https://nhentai.net/g/${media_id}` }, - responseType: 'blob', - fetch: false, - }, - ); - return URL.createObjectURL(imgRes.response); - }; - const result = await searchNhentai(galleryTitle!); - return result.map(({ id, title, images, num_pages, media_id }) => { + return result.map(({ id, media_id, english_title, japanese_title }) => { const itemId = `@nh:${id}`; + + const galleryPromise = getNhentaiData(String(id)); + setState('comicMap', itemId, { - getImgList: ({ dynamicLazyLoad }) => - dynamicLazyLoad({ - loadImg: (i) => downImg(i, media_id, images.pages[i].t), - length: num_pages, + getImgList: async ({ dynamicLazyLoad }) => { + const gallery = await galleryPromise; + return dynamicLazyLoad({ + loadImg: async (i) => { + const url = getNhentaiImageUrl(gallery, i); + if (!url) throw new Error('nhentai image url not found'); + const imgRes = await request(url, { + headers: { Referer: `https://nhentai.net/g/${id}` }, + responseType: 'blob', + fetch: false, + }); + return URL.createObjectURL(imgRes.response); + }, + length: gallery.num_pages, id: itemId, - }), + }); + }, }); return { id: itemId, showText: `${id}`, - title: title.english || title.japanese, + title: japanese_title || english_title || '', href: `https://nhentai.net/g/${id}`, class: 'gtl', }; diff --git a/src/site/nhentai.tsx b/src/site/nhentai.tsx index 5ed6c7ac..f9030f68 100644 --- a/src/site/nhentai.tsx +++ b/src/site/nhentai.tsx @@ -3,15 +3,18 @@ import { domParse, fileType, log, + onUrlChange, querySelector, querySelectorAll, scrollIntoView, singleThreaded, t, useStyle, + wait, } from 'helper'; import { ReactiveSet, request, toast, useInit } from 'main'; import { getAdPageByContent } from 'userscript/detectAd'; +import { getNhentaiData, getNhentaiImageUrl } from '../userscript/nhentaiApi'; type Images = { thumbnail: { t: keyof typeof fileType; w: number; h: number }; @@ -31,8 +34,28 @@ declare const _gallery: { num_pages: number; media_id: string; images: Images }; detect_ad: true, }); - // 在漫画详情页 - if (Reflect.has(unsafeWindow, 'gallery')) { + let currentGalleryId: string | null = null; + + const removeComicReadMode = () => { + querySelector('#comicReadMode')?.remove(); + }; + + const createComicReadMode = () => { + const el = document.createElement('a'); + el.href = 'javascript:;'; + el.id = 'comicReadMode'; + el.className = 'btn btn-secondary'; + el.addEventListener('click', showComic); + el.innerHTML = ' Read'; + return el; + }; + + const setupDetailPage = async (id: string) => { + if (currentGalleryId === id) return; + currentGalleryId = id; + + removeComicReadMode(); + setState('manga', { onExit(isEnd) { if (isEnd) scrollIntoView('#comment-container'); @@ -40,38 +63,47 @@ declare const _gallery: { num_pages: number; media_id: string; images: Images }; }, }); - // nh 自己是每张图随机选一个 cdn,但反正只是分流,简单点顺序分配应该也没问题吧 - const cdn = unsafeWindow._n_app.options.image_cdn_urls as string[]; - const getImgList = () => - _gallery.images.pages.map(({ t, w: width, h: height }, i) => { - const src = `https://${cdn[i % cdn.length]}/galleries/${_gallery.media_id}/${i + 1}.${fileType[t]}`; - return { src, width, height }; - }); - setState('comicMap', '', { getImgList }); + let gallery: any = null; + try { + gallery = await getNhentaiData(id); + } catch (error) { + log.error('nhentai getNhentaiData failed', error); + } + + if (gallery) { + const getImgList = () => + gallery.pages.map((page: any, i: number) => ({ + src: getNhentaiImageUrl(gallery, i), + width: page.width, + height: page.height, + })); + + setState('comicMap', '', { getImgList }); + } + setState('fab', 'initialShow', options.autoShow); - const comicReadModeDom = ( - showComic()} - > - {/* eslint-disable-next-line i18next/no-literal-string */} - Read - - ) as HTMLAnchorElement; - document.getElementById('download')!.after(comicReadModeDom); - - const enableDetectAd = - options.detect_ad && querySelector('#tags .tag.tag-144644'); + const comicReadModeDom = createComicReadMode(); + const downloadBtn = document.getElementById('download'); + if (downloadBtn) downloadBtn.after(comicReadModeDom); + else document.body.append(comicReadModeDom); + + const shouldDetectAd = options.detect_ad; + const adTagSelector = '#tags a[href="/tag/extraneous-ads/"]'; + const detectedTag = shouldDetectAd + ? await wait( + () => querySelector(adTagSelector), + 1000, + ) + : null; + const enableDetectAd = Boolean(detectedTag); if (enableDetectAd) { setState('comicMap', '', 'adList', new ReactiveSet()); // 先使用缩略图识别 await getAdPageByContent( querySelectorAll('.thumb-container img').map( - (img) => img.dataset.src, + (img) => img.dataset.src || img.src, ), store.comicMap[''].adList!, ); @@ -100,92 +132,111 @@ declare const _gallery: { num_pages: number; media_id: string; images: Images }; return styleList.join('\n'); }); } + }; - return; - } + const setupListPage = async () => { + currentGalleryId = null; + removeComicReadMode(); + + // 在漫画浏览页 + if (!document.getElementsByClassName('gallery').length) return; + if (location.pathname.startsWith('/g/')) return; - // 在漫画浏览页 - if (document.getElementsByClassName('gallery').length > 0) { if (options.open_link_new_page) for (const e of querySelectorAll('a:not([href^="javascript:"])')) e.setAttribute('target', '_blank'); - const blacklist: number[] = (unsafeWindow?._n_app ?? unsafeWindow?.n) - ?.options?.blacklisted_tags; - if (blacklist === undefined) + const app = await wait( + () => (window as any)._n_app ?? (window as any).n, + 2000, + 100, + ); + const blacklist: number[] | null | undefined = app?.options?.blacklisted_tags; + + if (typeof blacklist === 'undefined' && app !== undefined) toast.error(t('site.nhentai.tag_blacklist_fetch_failed')); - // blacklist === null 时是未登录 - if (options.block_totally && blacklist?.length) + if (options.block_totally && Array.isArray(blacklist) && blacklist.length) useStyle('.blacklisted.gallery { display: none; }'); - if (options.auto_page_turn) { - let nextUrl = querySelector('a.next')?.href; - if (!nextUrl) return; - - useStyle(` - hr { bottom: 1px; 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; } } - `); - - const blackSet = new Set(blacklist); - const contentDom = document.getElementById('content')!; - const getObserveDom = () => - contentDom.querySelector( - ':is(.index-container, #favcontainer):last-of-type', + const blackSet = new Set(Array.isArray(blacklist) ? blacklist : []); + const contentDom = document.getElementById('content')!; + let nextUrl = querySelector('a.next')?.href; + const getObserveDom = () => + contentDom.querySelector( + ':is(.index-container, #favcontainer):last-of-type', + )!; + + const loadNextPage = singleThreaded( + async (): Promise => { + if (!nextUrl) return; + + const res = await request(nextUrl, { + fetch: true, + errorText: t('site.nhentai.fetch_next_page_failed'), + }); + const html = domParse(res.responseText); + history.replaceState(null, '', nextUrl); + + const container = html.querySelector( + '.index-container, #favcontainer', )!; - - const loadNextPage = singleThreaded( - async (): Promise => { - if (!nextUrl) return; - - const res = await request(nextUrl, { - fetch: true, - errorText: t('site.nhentai.fetch_next_page_failed'), - }); - const html = domParse(res.responseText); - history.replaceState(null, '', nextUrl); - - const container = html.querySelector( - '.index-container, #favcontainer', - )!; - for (const galleryDom of container.querySelectorAll( - '.gallery', - )) { - for (const img of galleryDom.getElementsByTagName('img')) - img.setAttribute('src', img.dataset.src!); - - // 判断是否有黑名单标签 - const tags = galleryDom.dataset.tags!.split(' ').map(Number); - if (tags.some((tag) => blackSet.has(tag))) - galleryDom.classList.add('blacklisted'); + for (const galleryDom of container.querySelectorAll( + '.gallery', + )) { + for (const img of galleryDom.getElementsByTagName('img')) { + const src = img.dataset.src || img.src; + if (src) img.setAttribute('src', src); } - const pagination = html.querySelector('.pagination')!; - nextUrl = pagination.querySelector('a.next')?.href; + const tags = galleryDom.dataset.tags!.split(' ').map(Number); + if (tags.some((tag) => blackSet.has(tag))) + galleryDom.classList.add('blacklisted'); + } - contentDom.append(container, pagination); + const pagination = html.querySelector('.pagination')!; + nextUrl = pagination.querySelector('a.next')?.href; - const hr = document.createElement('hr'); - contentDom.append(hr); - observer.disconnect(); - observer.observe(getObserveDom()); - if (!nextUrl) hr.style.animationPlayState = 'paused'; - }, - { abandon: true }, - ); - - loadNextPage(); + contentDom.append(container, pagination); - const observer = new IntersectionObserver( - (entries) => entries[0].isIntersecting && loadNextPage(), - ); - observer.observe(getObserveDom()); - - if (querySelector('section.pagination')) - contentDom.append(document.createElement('hr')); + const hr = document.createElement('hr'); + contentDom.append(hr); + observer.disconnect(); + observer.observe(getObserveDom()); + if (!nextUrl) hr.style.animationPlayState = 'paused'; + }, + { abandon: true }, + ); + + loadNextPage(); + + const observer = new IntersectionObserver( + (entries) => entries[0].isIntersecting && loadNextPage(), + ); + observer.observe(getObserveDom()); + + if (querySelector('section.pagination')) + contentDom.append(document.createElement('hr')); + }; + + const processPage = async () => { + const galleryPathMatch = location.pathname.match(/^\/g\/(\d+)/); + if (galleryPathMatch) { + await setupDetailPage(galleryPathMatch[1]); + } else { + await setupListPage(); + } + }; + + await processPage(); + onUrlChange(async (_, nowUrl) => { + const match = new URL(nowUrl).pathname.match(/^\/g\/(\d+)/); + if (match) { + await setupDetailPage(match[1]); + } else { + await setupListPage(); } - } + }); + + return; })().catch((error) => log.error(error)); diff --git a/src/userscript/nhentaiApi.ts b/src/userscript/nhentaiApi.ts index 37917ea7..a6c889e1 100644 --- a/src/userscript/nhentaiApi.ts +++ b/src/userscript/nhentaiApi.ts @@ -1,12 +1,44 @@ -import { fileType, t } from 'helper'; +import { t } from 'helper'; import { request } from 'main'; -type ComicInfo = { +type PageInfo = { + number: number; + path: string; + width: number; + height: number; + thumbnail: string; + thumbnail_width: number; + thumbnail_height: number; +}; + +type GalleryTitle = { + english: string; + japanese?: string | null; + pretty?: string; +}; + +export type ComicInfo = { id: number; media_id: string; num_pages: number; - images: { pages: { t: string; w: number; h: number }[] }; - title: { japanese: string; english: string }; + title: GalleryTitle; + pages: PageInfo[]; +}; + +const resolvePageUrl = (page: PageInfo) => { + const path = page.path || ''; + + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + + if (path.startsWith('/')) { + return `https://i.nhentai.net${path}`; + } + + if (path) { + return `https://i.nhentai.net/${path}`; + } }; // 只要带上 cf_clearance cookie 就能通过 Cloudflare 验证,但其是 httpOnly @@ -16,7 +48,7 @@ type ComicInfo = { export const getNhentaiData = async (id: string) => { const { response } = await request( - `https://nhentai.net/api/gallery/${id}`, + `https://nhentai.net/api/v2/galleries/${id}`, { responseType: 'json', errorText: t('site.ehentai.nhentai_error'), @@ -29,8 +61,15 @@ export const getNhentaiData = async (id: string) => { }; export const searchNhentai = async (title: string) => { - const { response } = await request<{ result: ComicInfo[] }>( - `https://nhentai.net/api/galleries/search?query=${title}`, + const { response } = await request<{ + result: Array<{ + id: number; + media_id: string; + english_title: string; + japanese_title?: string | null; + }>; + }>( + `https://nhentai.net/api/v2/search?query=${encodeURIComponent(title)}`, { responseType: 'json', errorText: t('site.ehentai.nhentai_error'), @@ -42,10 +81,11 @@ export const searchNhentai = async (title: string) => { return response.result; }; -export const toImgList = (data: ComicInfo) => { - const { media_id, images } = data; - return images.pages.map( - ({ t }, i) => - `https://i.nhentai.net/galleries/${media_id}/${i + 1}.${fileType[t]}`, - ); +export const toImgList = (data: ComicInfo) => + data.pages.map((page) => resolvePageUrl(page)); + +export const getNhentaiImageUrl = (data: ComicInfo, index: number) => { + const page = data.pages[index]; + if (!page) throw new Error('nhentai page data missing at index ' + index); + return resolvePageUrl(page); };