import type { WebContents } from 'electron'; import os from 'os'; import path from 'path'; import { Readable } from 'stream'; import { URL } from 'url'; import { createReadStream, remove } from 'fs-extra'; import { injectable } from 'inversify'; import { inject } from '../../core/inject'; import { SiteAppWindow } from '../app-window/site-app-window'; import { WindowClosedError } from '../app-window/window-closed-error'; import type { SessionHelperInterface } from '../session/session-helper-interface'; import type { NhentaiAppWindowInterface } from './nhentai-app-window-interface'; import { url as nhentaiUrl, hostname as nhentaiHostname, paths as nhentaiPaths, getFavoritePageUrl, nextFavoritePageSelector, coverLinkSelector, downloadLinkId, getGalleryId, getBookUrl, preTitleSelector, tagLabelArtists, labeledTagContainerSelector, tagNameSelector, tagSelector, tagLabelGroups, tagLabelParodies, tagLabelCharacters, tagLabelTags, mainTitleSelector, postTitleSelector, } from './nhentai-util'; const waitInterval = 2000; @injectable() export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowInterface { public constructor( @inject('logger') logger: LoggerInterface, @inject('session-helper') sessionHelper: SessionHelperInterface ) { super(logger, sessionHelper, nhentaiUrl); } public async getFavorites(): Promise { const release = await this.acquireLock(); try { if (this.isClosed()) { await this.open(); } if (!(await this.isLoggedIn())) { await this.login(); } this._window?.setProgressBar(0, { mode: 'indeterminate' }); const bookUrls: string[] = []; for await (const wc of this.getFavoritePageWebContentsGenerator()) { bookUrls.push( ...((await wc.executeJavaScript( `Array.from(document.querySelectorAll('${coverLinkSelector}')).map((el) => el.href)` )) as string[]) ); } const readable = Readable.from( (async function* (thisArg): AsyncGenerator { for (let i = 0; i < bookUrls.length; i++) { const bookUrl = bookUrls[i]; yield await thisArg.getBookTorrent(bookUrl); thisArg._window?.setProgressBar(i / bookUrls.length); } return; })(this), { objectMode: true, } ); readable.once('end', () => { this.close(); release(); }); return readable; } catch (e) { this.close(); release(); throw e; } } public async getGallery(identifier: string): Promise { if (this.isClosed()) { await this.open(); } if (!this._window) { throw new WindowClosedError(); } const gallery: Nhentai.Gallery = { title: { pre: '', main: '', post: '', }, artists: [], groups: [], parodies: [], characters: [], tags: [], }; const release = await this.acquireLock(); const bookUrl = getBookUrl(identifier); try { await this.loadUrlSafe(bookUrl); await Promise.all([ this.getInnerHtml(preTitleSelector).then((preTitle) => { gallery.title.pre = preTitle.trim(); }), this.getInnerHtml(mainTitleSelector).then((mainTitle) => { gallery.title.main = mainTitle; }), this.getInnerHtml(postTitleSelector).then((postTitle) => { gallery.title.post = postTitle.trim(); }), this.getTags(tagLabelArtists).then((artists: string[]) => { gallery.artists = artists; }), this.getTags(tagLabelGroups).then((groups: string[]) => { gallery.groups = groups; }), this.getTags(tagLabelParodies).then((parodies: string[]) => { gallery.parodies = parodies; }), this.getTags(tagLabelCharacters).then((characters: string[]) => { gallery.characters = characters; }), this.getTags(tagLabelTags).then((tags: string[]) => { gallery.tags = tags; }), ]); this.close(); release(); } catch (e) { this.close(); release(); throw e; } return gallery; } protected getCsp(): Session.ContentSecurityPolicy { return { 'default-src': ['nhentai.net'], 'script-src': ['nhentai.net', "'unsafe-eval'"], 'script-src-elem': ['*.nhentai.net', "'unsafe-inline'", '*.google.com', '*.gstatic.com'], 'style-src': [ '*.nhentai.net', "'unsafe-inline'", 'fonts.googleapis.com', 'cdnjs.cloudflare.com', 'maxcdn.bootstrapcdn.com', '*.gstatic.com', ], 'img-src': ['*.nhentai.net', 'data:', '*.gstatic.com', '*.google.com'], 'font-src': ['fonts.gstatic.com', 'cdnjs.cloudflare.com', 'maxcdn.bootstrapcdn.com'], 'frame-src': ['*.google.com'], 'connect-src': ['nhentai.net', '*.google.com'], 'worker-src': ['*.google.com'], }; } protected onWillNavigate(event: Electron.Event, navigationUrl: string): void { const url = new URL(navigationUrl); if (url.hostname !== nhentaiHostname) { event.preventDefault(); } } private async isLoggedIn(): Promise { if (!this._window) { throw new WindowClosedError(); } return this._window.webContents .executeJavaScript( `fetch('${ nhentaiUrl + nhentaiPaths.favorites }', {credentials: 'include', redirect: 'manual'}).then((res) => res.status)` ) .then((status: number) => status === HttpCode.OK); } /** * @throws WindowClosedError when this._window is null */ private async login(): Promise { if (!this._window) { throw new WindowClosedError(); } await this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login); return new Promise((resolve, reject) => { const timeout = setInterval(() => { this.isLoggedIn() .then((loggedIn) => { if (loggedIn) { clearTimeout(timeout); resolve(); } }) .catch((reason) => reject(reason)); }, waitInterval); }); } private async *getFavoritePageWebContentsGenerator(): AsyncGenerator { if (!this._window) { throw new WindowClosedError(); } await this.loadUrlSafe(getFavoritePageUrl()); while (true) { yield this._window.webContents; const hasNextPage = (await this._window.webContents.executeJavaScript( `!!document.querySelector('${nextFavoritePageSelector}')` )) as boolean; if (hasNextPage) { const nextPageHref = (await this._window.webContents.executeJavaScript( `document.querySelector('${nextFavoritePageSelector}').href` )) as string; await this.loadUrlSafe(nextPageHref); } else { break; } } return; } private async getBookTorrent(bookUrl: string): Promise { if (!this._window) { throw new WindowClosedError(); } const galleryId = getGalleryId(bookUrl); const fileName = `${galleryId}.torrent`; const filePath = path.resolve(os.tmpdir(), fileName); await this.loadUrlSafe(bookUrl); const downloadLink: string = (await this._window.webContents.executeJavaScript( `document.getElementById('${downloadLinkId}').href` )) as string; await this.downloadUrlSafe(downloadLink, filePath); const readable = createReadStream(filePath, { emitClose: true }); readable.once('close', () => { void remove(filePath); }); return { name: fileName, torrentFile: readable, }; } private getTags(tagLabel: string): Promise { if (!this._window) { throw new WindowClosedError(); } return this._window.webContents.executeJavaScript( `Array.from( document.querySelectorAll('${labeledTagContainerSelector}') ).filter( (tagContainer) => tagContainer.textContent.includes('${tagLabel}:') ).map( (tagContainer) => Array.from(tagContainer.querySelectorAll('${tagSelector}')) ).flat().map( (tagElement) => tagElement.querySelector('${tagNameSelector}').innerHTML )` ) as Promise; } }