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, } from './nhentai-util'; const waitInterval = 2000; @injectable() export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowInterface { public constructor(@inject('session-helper') sessionHelper: SessionHelperInterface) { super(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) { release(); throw e; } } 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, }; } }