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

291 lines
8.6 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 { Service } from '../../core/container';
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 {
coverLinkSelector,
downloadLinkId,
favoritePageIsReady,
galleryPageIsReady,
getBookUrl,
getFavoritePageUrl,
getGalleryId,
hostname as nhentaiHostname,
labeledTagContainerSelector,
loginPageIsReady,
mainTitleSelector,
nextFavoritePageSelector,
pageIsReady,
paths as nhentaiPaths,
postTitleSelector,
preTitleSelector,
tagLabelArtists,
tagLabelCharacters,
tagLabelGroups,
tagLabelLanguages,
tagLabelParodies,
tagLabelTags,
tagNameSelector,
tagSelector,
timeSelector,
url as nhentaiUrl,
} from './nhentai-util';
const waitInterval = 2000;
@injectable()
export class NhentaiAppWindow extends CloudflareSiteAppWindow implements NhentaiAppWindowInterface {
protected readyCheck = pageIsReady;
public constructor(@inject(Service.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[]>;
}
}