feat: add functionality to get a work entity from a nhentai gallery id

This is more of a vertical slice of the intended functionality and needs to be extended.
This commit is contained in:
Xymorot 2021-01-07 04:51:07 +01:00
parent 799b7271e3
commit 0a2a266176
23 changed files with 347 additions and 10 deletions

31
package-lock.json generated
View File

@ -1535,6 +1535,12 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true
},
"@types/deep-equal-in-any-order": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/deep-equal-in-any-order/-/deep-equal-in-any-order-1.0.1.tgz",
"integrity": "sha512-hUWUUE53WjKfcCncSmWmNXVNNT+0Iz7gYFnov3zdCXrX3Thxp1Cnmfd5LwWOeCVUV5LhpiFgS05vaAG72doo9w==",
"dev": true
},
"@types/eslint": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.2.6.tgz",
@ -3447,6 +3453,16 @@
"type-detect": "^4.0.0"
}
},
"deep-equal-in-any-order": {
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/deep-equal-in-any-order/-/deep-equal-in-any-order-1.0.28.tgz",
"integrity": "sha512-qq3jffpGmAG9kGpZGKusjRwoGxmFgIqNW076HQmV9rNdrFsgTcpuCyp6dBhzdVCWgQDkgRmvZLYAilV4u2BsfQ==",
"dev": true,
"requires": {
"lodash.mapvalues": "^4.6.0",
"sort-any": "^1.1.21"
}
},
"deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@ -6547,6 +6563,12 @@
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
"dev": true
},
"lodash.mapvalues": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz",
"integrity": "sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=",
"dev": true
},
"lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
@ -9109,6 +9131,15 @@
}
}
},
"sort-any": {
"version": "1.1.23",
"resolved": "https://registry.npmjs.org/sort-any/-/sort-any-1.1.23.tgz",
"integrity": "sha512-aY92w1RkjIyJd1l+O4btCwfAIfZm2r+zA6+cfKbKUO5D5MEZlqY27B7QyHHIsEShBsvx+Ur1Oq3v/gfR6wxD/w==",
"dev": true,
"requires": {
"lodash": "^4.17.15"
}
},
"source-list-map": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",

View File

@ -61,6 +61,7 @@
"@types/better-sqlite3": "^5.4.1",
"@types/chai": "^4.2.14",
"@types/chai-fs": "^2.0.2",
"@types/deep-equal-in-any-order": "^1.0.1",
"@types/fs-extra": "^9.0.6",
"@types/glob": "^7.1.3",
"@types/minimist": "^1.2.1",
@ -76,6 +77,7 @@
"chai-fs": "^2.0.0",
"chokidar": "^3.4.3",
"concurrently": "^5.3.0",
"deep-equal-in-any-order": "^1.0.28",
"electron": "^10.2.0",
"electron-mocha": "^10.0.0",
"electron-rebuild": "^2.3.4",

View File

@ -7,6 +7,7 @@ import { Logger } from '../modules/logger/logger';
import { NhentaiApi } from '../modules/nhentai/nhentai-api';
import '../modules/nhentai/nhentai-ipc-controller';
import { NhentaiAppWindow } from '../modules/nhentai/nhentai-app-window';
import { NhentaiSourceGetter } from '../modules/nhentai/nhentai-source-getter';
import { SessionHelper } from '../modules/session/session-helper';
import { Store } from '../modules/store/store';
import BindingToSyntax = interfaces.BindingToSyntax;
@ -34,7 +35,8 @@ container.bind('store').to(Store);
container.bind('session-helper').to(SessionHelper);
container.bind('nhentai-api').to(NhentaiApi);
container.bind('nhentai-app-window').to(NhentaiAppWindow);
container.bind('nhentai-api').to(NhentaiApi);
container.bind('nhentai-source-getter').to(NhentaiSourceGetter);
container.bind('app-window-main').to(MainAppWindow);

View File

@ -4,6 +4,7 @@ import path from 'path';
import { isDev } from '../../core/env';
import type { SessionHelperInterface } from '../session/session-helper-interface';
import type { AppWindowInterface } from './app-window-interface';
import { WindowClosedError } from './window-closed-error';
import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions;
let defaultOptions: BrowserWindowConstructorOptions = {
@ -35,6 +36,8 @@ export abstract class AppWindow implements AppWindowInterface {
protected _window: BrowserWindow | null = null;
protected readonly logger: LoggerInterface;
protected readonly sessionHelper: SessionHelperInterface;
protected options: BrowserWindowConstructorOptions;
@ -44,10 +47,12 @@ export abstract class AppWindow implements AppWindowInterface {
protected abstract loadOptions: LoadFileOptions | LoadURLOptions;
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {}
) {
this.logger = logger;
this.sessionHelper = sessionHelper;
this.options = { ...defaultOptions, ...options };
this.uri = uri;
@ -104,5 +109,22 @@ export abstract class AppWindow implements AppWindowInterface {
protected onClosed(): void {}
protected getInnerHtml(selector: string): Promise<string> {
return new Promise<string>((resolve) => {
if (!this._window) {
throw new WindowClosedError();
}
this._window.webContents
.executeJavaScript(`document.querySelector('${selector}').innerHTML`)
.then((innerHtml) => {
resolve(innerHtml);
})
.catch(() => {
void this.logger.warning(`Could not get the inner HTML of an element with the selector '${selector}'.`);
resolve('');
});
});
}
protected abstract load(window: BrowserWindow): Promise<void>;
}

View File

@ -6,12 +6,13 @@ export abstract class FileAppWindow extends AppWindow {
protected loadOptions: LoadFileOptions;
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {},
loadOptions: LoadFileOptions = {}
) {
super(sessionHelper, uri, options);
super(logger, sessionHelper, uri, options);
this.loadOptions = loadOptions;
}

View File

@ -5,8 +5,11 @@ import { FileAppWindow } from './file-app-window';
@injectable()
export class MainAppWindow extends FileAppWindow {
public constructor(@inject('session-helper') sessionHelper: SessionHelperInterface) {
super(sessionHelper, 'frontend/index.html', {
public constructor(
@inject('logger') logger: LoggerInterface,
@inject('session-helper') sessionHelper: SessionHelperInterface
) {
super(logger, sessionHelper, 'frontend/index.html', {
webPreferences: {
nodeIntegration: true,
},

View File

@ -12,12 +12,13 @@ export abstract class SiteAppWindow extends UrlAppWindow implements SiteAppWindo
private windowLock: MutexInterface;
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {},
loadOptions: LoadURLOptions = {}
) {
super(sessionHelper, uri, options, loadOptions);
super(logger, sessionHelper, uri, options, loadOptions);
this.windowLock = new SimpleMutex();
}

View File

@ -32,12 +32,13 @@ export abstract class UrlAppWindow extends AppWindow implements UrlAppWindowInte
private loadWait: Promise<void> = Promise.resolve();
protected constructor(
logger: LoggerInterface,
sessionHelper: SessionHelperInterface,
uri: string,
options: BrowserWindowConstructorOptions = {},
loadOptions: LoadURLOptions = {}
) {
super(sessionHelper, uri, {
super(logger, sessionHelper, uri, {
...options,
...{
webPreferences: {

View File

@ -1,3 +1,5 @@
interface NhentaiApiInterface {
getFavorites(): Promise<NodeJS.ReadableStream>;
getGallery(identifier: string): Promise<Nhentai.Gallery>;
}

View File

@ -13,4 +13,8 @@ export class NhentaiApi implements NhentaiApiInterface {
public getFavorites(): Promise<NodeJS.ReadableStream> {
return this.appWindow.getFavorites();
}
public getGallery(identifier: string): Promise<Nhentai.Gallery> {
return this.appWindow.getGallery(identifier);
}
}

View File

@ -2,4 +2,6 @@ import type { SiteAppWindowInterface } from '../app-window/site-app-window-inter
interface NhentaiAppWindowInterface extends SiteAppWindowInterface {
getFavorites(): Promise<NodeJS.ReadableStream>;
getGallery(identifier: string): Promise<Nhentai.Gallery>;
}

View File

@ -0,0 +1,65 @@
import chai, { expect } from 'chai';
import deepEqualInAnyOrder from 'deep-equal-in-any-order';
import { describe, it, before } from 'mocha';
import { container } from '../../core/container';
import { LoggerMock } from '../logger/logger.mock';
import type { NhentaiAppWindowInterface } from './nhentai-app-window-interface';
chai.use(deepEqualInAnyOrder);
describe('Nhentai App Window', () => {
before(() => {
container.unbind('logger');
container.bind('logger').to(LoggerMock);
});
it('gets the gallery information from an identifier @slow', async () => {
const nhentaiAppWindow: NhentaiAppWindowInterface = container.get('nhentai-app-window');
let expectedGallery: Nhentai.Gallery = {
title: {
pre: '[Homunculus]',
main: 'Renai Sample',
post: '[English] [Decensored]',
},
artists: ['homunculus'],
groups: [],
parodies: [],
characters: [],
tags: [
'group',
'stockings',
'schoolgirl uniform',
'glasses',
'nakadashi',
'incest',
'tankoubon',
'defloration',
'swimsuit',
'ffm threesome',
'sister',
'schoolboy uniform',
'bikini',
'uncensored',
'small breasts',
],
};
let gallery = await nhentaiAppWindow.getGallery('117300');
expect(gallery).deep.equalInAnyOrder(expectedGallery, 'Renai Sample is not got correctly');
expectedGallery = {
title: {
pre: '(COMIC1☆12) [MOSQUITONE. (Great Mosu)]',
main: 'Koisuru Dai Akuma | The Archdemon In Love',
post: '(Gabriel DropOut) [English] {Tanjoubi + Hennojin} [Decensored]',
},
artists: ['great mosu'],
groups: ['mosquitone.'],
parodies: ['gabriel dropout'],
characters: ['satanichia kurumizawa mcdowell'],
tags: ['sole female', 'sole male', 'defloration', 'uncensored', 'kissing'],
};
gallery = await nhentaiAppWindow.getGallery('273405');
expect(gallery).deep.equalInAnyOrder(expectedGallery, 'The Archdemon in Love is not got correctly!');
}).timeout(5000);
});

View File

@ -19,14 +19,29 @@ import {
coverLinkSelector,
downloadLinkId,
getGalleryId,
getBookUrl,
preTitleSelector,
tagLabelArtists,
labeledTagContainerSelector,
tagNameSelector,
tagSelector,
tagLabelGroups,
tagLabelParodies,
tagLabelCharacters,
tagLabelTags,
mainTitleSelector,
postTitleSelector,
} 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 constructor(
@inject('logger') logger: LoggerInterface,
@inject('session-helper') sessionHelper: SessionHelperInterface
) {
super(logger, sessionHelper, nhentaiUrl);
}
public async getFavorites(): Promise<NodeJS.ReadableStream> {
@ -70,11 +85,75 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
return readable;
} catch (e) {
this.close();
release();
throw e;
}
}
public async getGallery(identifier: string): Promise<Nhentai.Gallery> {
if (this.isClosed()) {
await this.open();
}
if (!this._window) {
throw new WindowClosedError();
}
const gallery: Nhentai.Gallery = {
title: {
pre: '',
main: '',
post: '',
},
artists: [],
groups: [],
parodies: [],
characters: [],
tags: [],
};
const release = await this.acquireLock();
const bookUrl = getBookUrl(identifier);
try {
await this.loadUrlSafe(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.close();
release();
} catch (e) {
this.close();
release();
throw e;
}
return gallery;
}
protected getCsp(): Session.ContentSecurityPolicy {
return {
'default-src': ['nhentai.net'],
@ -186,4 +265,21 @@ export class NhentaiAppWindow extends SiteAppWindow implements NhentaiAppWindowI
torrentFile: readable,
};
}
private getTags(tagLabel: string): Promise<string[]> {
if (!this._window) {
throw new WindowClosedError();
}
return this._window.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[]>;
}
}

View File

@ -1,18 +1,29 @@
import path from 'path';
import { createWriteStream } from 'fs-extra';
import { container } from '../../core/container';
import { Database, getConnection } from '../../core/database';
import type { Work } from '../../entities/library/work';
import type { DialogInterface } from '../dialog/dialog-interface';
import { answer } from '../ipc/annotations/answer';
import type { SourceGetterInterface } from '../source/source-getter-interface';
export class NhentaiIpcController implements IpcController {
private readonly nhentaiApi: NhentaiApiInterface;
private readonly nhentaiSourceGetter: SourceGetterInterface;
private readonly translator: I18nTranslatorInterface;
private readonly dialog: DialogInterface;
private constructor(nhentaiApi: NhentaiApiInterface, translator: I18nTranslatorInterface, dialog: DialogInterface) {
private constructor(
nhentaiApi: NhentaiApiInterface,
nhentaiSourceGetter: SourceGetterInterface,
translator: I18nTranslatorInterface,
dialog: DialogInterface
) {
this.nhentaiApi = nhentaiApi;
this.nhentaiSourceGetter = nhentaiSourceGetter;
this.translator = translator;
this.dialog = dialog;
}
@ -39,10 +50,19 @@ export class NhentaiIpcController implements IpcController {
});
}
@answer(IpcChannel.NHENTAI_GET_WORK)
public async nhentaiGetWork({ galleryId }: { galleryId: string }): Promise<Work> {
const work = await this.nhentaiSourceGetter.find(galleryId);
const { manager } = await getConnection(Database.LIBRARY);
return manager.save(work);
}
public get(): NhentaiIpcController {
const nhentaiApi: NhentaiApiInterface = container.get('nhentai-api');
const nhentaiSourceGetter: SourceGetterInterface = container.get('nhentai-source-getter');
const translator: I18nTranslatorInterface = container.get('i18n-translator');
const dialog: DialogInterface = container.get('dialog');
return new NhentaiIpcController(nhentaiApi, translator, dialog);
return new NhentaiIpcController(nhentaiApi, nhentaiSourceGetter, translator, dialog);
}
}

View File

@ -0,0 +1,23 @@
import { injectable } from 'inversify';
import { inject } from '../../core/inject';
import { Work } from '../../entities/library/work';
import type { SourceGetterInterface } from '../source/source-getter-interface';
@injectable()
export class NhentaiSourceGetter implements SourceGetterInterface {
private nhentaiApi: NhentaiApiInterface;
public constructor(@inject('nhentai-api') nhentaiApi: NhentaiApiInterface) {
this.nhentaiApi = nhentaiApi;
}
public async find(identifier: string): Promise<Work> {
const gallery = await this.nhentaiApi.getGallery(identifier);
const work = new Work();
work.nameCanonical = gallery.title.main;
return work;
}
}

View File

@ -11,11 +11,27 @@ export const downloadLinkId = 'download';
export const nextFavoritePageSelector = 'a.next';
export const coverLinkSelector = 'a.cover';
export const preTitleSelector = 'h1.title .before';
export const mainTitleSelector = 'h1.title .pretty';
export const postTitleSelector = 'h1.title .after';
export const labeledTagContainerSelector = '.tag-container.field-name';
export const tagSelector = '.tag';
export const tagNameSelector = 'span.name';
export const tagLabelParodies = 'Parodies';
export const tagLabelCharacters = 'Characters';
export const tagLabelTags = 'Tags';
export const tagLabelArtists = 'Artists';
export const tagLabelGroups = 'Groups';
export function getFavoritePageUrl(page?: number): string {
return `${url + paths.favorites}${page ? `?page=${page}` : ''}`;
}
export function getBookUrl(galleryId: string): string {
return `${url}g/${galleryId}/`;
}
export function getGalleryId(bookUrl: string): string {
const regExpExecArray = /https:\/\/nhentai\.net\/g\/(\d+)/.exec(bookUrl);
if (regExpExecArray && regExpExecArray[1]) {

View File

@ -3,4 +3,17 @@ declare namespace Nhentai {
name: string;
torrentFile: NodeJS.ReadableStream;
};
type Gallery = {
title: {
pre: string;
main: string;
post: string;
};
artists: string[];
groups: string[];
parodies: string[];
characters: string[];
tags: string[];
};
}

View File

@ -0,0 +1,5 @@
import { Work } from '../../entities/library/work';
interface SourceGetterInterface {
find(identifier: string): Promise<Work>;
}

View File

@ -1,4 +1,5 @@
<script>
import NhentaiGetWork from './components/3-polymers/NhentaiGetWork.svelte';
import NhentaiLogin from './components/3-polymers/NhentaiLogin.svelte';
</script>
@ -45,4 +46,5 @@
<main>
<NhentaiLogin></NhentaiLogin>
<NhentaiGetWork />
</main>

View File

@ -0,0 +1,18 @@
<script>
import { nhentaiGetWork } from '../../services/api';
import SvelteButton from '../1-atoms/SvelteButton.svelte';
import { t } from '../../services/utils';
let galleryId;
let work;
async function handleClick() {
work = await nhentaiGetWork(galleryId);
}
</script>
<div class="nhentai-get-work">
<label><input type="text" placeholder="177013" bind:value="{galleryId}" /></label
><SvelteButton on:click="{handleClick}">{t('Get')}</SvelteButton>
<div>{JSON.stringify(work)}</div>
</div>

View File

@ -30,3 +30,7 @@ const ipcClient: IpcClient = {
export function nhentaiSaveFavorites(): Promise<void> {
return ipcClient.ask(IpcChannel.NHENTAI_SAVE_FAVORITES) as Promise<void>;
}
export function nhentaiGetWork(galleryId: string): Promise<Work> {
return ipcClient.ask(IpcChannel.NHENTAI_GET_WORK, { galleryId }) as Promise<Work>;
}

3
types/entities/work.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
type Work = {
nameCanonical: string;
};

1
types/ipc.d.ts vendored
View File

@ -1,5 +1,6 @@
declare const enum IpcChannel {
NHENTAI_SAVE_FAVORITES = 'NHENTAI_SAVE_FAVORITES',
NHENTAI_GET_WORK = 'NHENTAI_GET_WORK',
}
type IpcPayload = {