RenaiApp/src/main/modules/nhentai/nhentai-app-window.ts

290 lines
8.5 KiB
TypeScript

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 { CloudflareSiteAppWindow } from '../cloudflare/cloudflare-site-app-window';
import { mergeContentSecurityPolicy } from '../session/session-util';
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,
galleryPageIsReady,
loginPageIsReady,
favoritePageIsReady,
pageIsReady,
timeSelector,
tagLabelLanguages,
} from './nhentai-util';
const waitInterval = 2000;
@injectable()
export class NhentaiAppWindow extends CloudflareSiteAppWindow implements NhentaiAppWindowInterface {
protected readyCheck = pageIsReady;
public constructor(@inject('logger') logger: LoggerInterface) {
super(logger, nhentaiUrl);
}
public async getFavorites(): Promise<NodeJS.ReadableStream> {
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<Nhentai.Favorite, undefined> {
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<Nhentai.Gallery> {
if (this.isClosed()) {
await this.open();
}
const bookUrl = getBookUrl(identifier);
const gallery: Nhentai.Gallery = {
url: bookUrl,
title: {
pre: '',
main: '',
post: '',
},
artists: [],
groups: [],
parodies: [],
characters: [],
tags: [],
languages: [],
uploadTime: undefined,
};
const release = await this.acquireLock();
try {
await this.loadGalleryPageSafe(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.getTags(tagLabelLanguages).then((languages: string[]) => {
gallery.languages = languages;
}),
this.getTime(timeSelector).then((time?: number) => {
gallery.uploadTime = time;
}),
]);
this.close();
release();
} catch (e) {
this.close();
release();
throw e;
}
return gallery;
}
protected getCsp(): Session.ContentSecurityPolicy {
return mergeContentSecurityPolicy(super.getCsp(), {
'default-src': ['nhentai.net'],
'script-src': ['nhentai.net', "'unsafe-eval'"],
'script-src-elem': ['*.nhentai.net', 'nhentai.net', "'unsafe-inline'", '*.google.com', '*.gstatic.com'],
'style-src': [
'*.nhentai.net',
'nhentai.net',
"'unsafe-inline'",
'fonts.googleapis.com',
'maxcdn.bootstrapcdn.com',
'*.gstatic.com',
],
'img-src': ['*.nhentai.net', '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<boolean> {
return this.getWindow()
.webContents.executeJavaScript(
`fetch('${
nhentaiUrl + nhentaiPaths.favorites
}', {credentials: 'include', redirect: 'manual'}).then((res) => res.status)`
)
.then((status: number) => status === HttpCode.OK);
}
private async login(): Promise<void> {
await this.loadLoginPageSafe();
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<WebContents, undefined> {
await this.loadFavoritesPageSafe(getFavoritePageUrl());
while (true) {
yield this.getWindow().webContents;
const hasNextPage = (await this.getWindow().webContents.executeJavaScript(
`!!document.querySelector('${nextFavoritePageSelector}')`
)) as boolean;
if (hasNextPage) {
const nextPageHref = (await this.getWindow().webContents.executeJavaScript(
`document.querySelector('${nextFavoritePageSelector}').href`
)) as string;
await this.loadFavoritesPageSafe(nextPageHref);
} else {
break;
}
}
return;
}
private async getBookTorrent(bookUrl: string): Promise<Nhentai.Favorite> {
const galleryId = getGalleryId(bookUrl);
const fileName = `${galleryId}.torrent`;
const filePath = path.resolve(os.tmpdir(), fileName);
await this.loadGalleryPageSafe(bookUrl);
const downloadLink: string = (await this.getWindow().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 loadGalleryPageSafe(url: string): Promise<void> {
return this.loadUrlSafe(url, galleryPageIsReady);
}
private loadLoginPageSafe(): Promise<void> {
return this.loadUrlSafe(nhentaiUrl + nhentaiPaths.login, loginPageIsReady);
}
private loadFavoritesPageSafe(url: string): Promise<void> {
return this.loadUrlSafe(url, favoritePageIsReady);
}
private getTags(tagLabel: string): Promise<string[]> {
return this.getWindow().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<string[]>;
}
}