290 lines
8.5 KiB
TypeScript
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[]>;
|
|
}
|
|
}
|