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

230 lines
6.5 KiB
TypeScript

import { 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 { UrlAppWindow } from '../app-window/url-app-window';
import { WindowClosedError } from '../app-window/window-closed-error';
import { ISessionHelper } from '../session/i-session-helper';
import { INhentaiAppWindow } from './i-nhentai-app-window';
import { IFavorite } from './nhentai';
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 UrlAppWindow implements INhentaiAppWindow {
public constructor(@inject('session-helper') sessionHelper: ISessionHelper) {
super(sessionHelper, nhentaiUrl);
}
/**
* @throws WindowClosedError when this._window is null
*/
public async getFavorites(): Promise<NodeJS.ReadableStream> {
const error = new WindowClosedError();
if (this.isClosed()) {
await this.open();
}
if (!(await this.isLoggedIn())) {
await this.login();
}
if (!this._window) {
throw error;
}
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<IFavorite> {
for (const bookUrl of bookUrls) {
yield await thisArg.getBookTorrent(bookUrl);
}
})(this),
{
objectMode: true,
}
);
readable.once('close', this.close);
return readable;
}
protected getCsp(): IContentSecurityPolicy {
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();
}
}
/**
* @throws WindowClosedError when this._window is null
*/
private async isLoggedIn(): Promise<boolean> {
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<void> {
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<WebContents, undefined> {
const error = new WindowClosedError();
if (!this._window) {
throw error;
}
await this.loadUrlSafe(getFavoritePageUrl(1));
yield this._window.webContents;
do {
try {
const hasNextPage = (await this._window.webContents.executeJavaScript(
`!!document.querySelector('${nextFavoritePageSelector}')`
)) as boolean;
if (hasNextPage) {
yield new Promise<WebContents>((resolve) => {
if (this._window) {
this._window.webContents.once('did-finish-load', () => {
if (this._window) {
resolve(this._window.webContents);
} else {
throw error;
}
});
void this._window.webContents.executeJavaScript(
`document.querySelector('${nextFavoritePageSelector}').click()`
);
} else {
throw error;
}
});
} else {
break;
}
} catch {
break;
}
} while (true);
return undefined;
}
private async getBookTorrent(bookUrl: string): Promise<IFavorite> {
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 new Promise<void>((resolve, reject) => {
if (!this._window) {
throw new WindowClosedError();
}
this._window.webContents.session.once('will-download', (event, item) => {
item.setSavePath(filePath);
item.once('done', (doneEvent, state) => {
switch (state) {
case 'completed':
resolve();
break;
case 'cancelled':
case 'interrupted':
default:
reject(new Error(state));
break;
}
});
item.on('updated', () => {
item.resume();
});
});
void this.loadUrlSafe(downloadLink);
});
const readable = createReadStream(filePath, { emitClose: true });
readable.once('close', () => {
void remove(filePath);
});
return {
name: fileName,
torrentFile: readable,
};
}
}