feat: implement nhentai login and refactor large pieces of code in the process

This commit is contained in:
Xymorot 2019-07-26 23:05:29 +02:00
parent 6d82b16d5c
commit 389df67605
18 changed files with 365 additions and 118 deletions

View File

@ -1,2 +1,3 @@
node_modules
dist dist
frontend frontend

View File

@ -13,5 +13,13 @@
"no-shadow": "warn", "no-shadow": "warn",
"no-magic-numbers": ["warn", { "ignore": [0, 1, -1] }], "no-magic-numbers": ["warn", { "ignore": [0, 1, -1] }],
"no-param-reassign": "warn" "no-param-reassign": "warn"
} },
"overrides": [
{
"files": ["src/renderer/**/*.*"],
"parserOptions": {
"sourceType": "module"
}
}
]
} }

View File

@ -1,22 +1,41 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
import os from 'os';
import './main/controllers/api'; import './main/controllers/api';
import './main/services/database'; import './main/services/database';
import session from './main/services/session'; import * as session from './main/services/session';
import BrowserWindowConstructorOptions = Electron.BrowserWindowConstructorOptions;
let mainWindow: Electron.BrowserWindow; let mainWindow: Electron.BrowserWindow;
async function createWindow(): Promise<void> { async function createWindow(): Promise<void> {
session.init(); session.setHeaders();
// Create the browser window. // universal options
mainWindow = new BrowserWindow({ let options: BrowserWindowConstructorOptions = {
width: 1600, width: 1600,
height: 900, height: 900,
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
}, },
}); };
// platform specifics
switch (os.platform()) {
case 'win32':
options = {
...options,
...{
icon: 'resources/icon.ico',
},
};
break;
default:
break;
}
// Create the browser window.
mainWindow = new BrowserWindow(options);
// and load the index.html of the app. // and load the index.html of the app.
await mainWindow.loadFile('index.html'); await mainWindow.loadFile('index.html');

View File

@ -1,13 +1,32 @@
import { ipcMain } from 'electron'; import { ipcMain } from 'electron';
import nhentai from './../services/nhentai-crawler'; import { isLoggedIn, login } from '../services/nhentai-crawler';
ipcMain.on(IpcChannels.Credentials, (event: IpcEvent, args: ICredentials) => { const ipcServer: IIpcServer = {
nhentai answer: (channel: IpcChannels, handler: (data?: any) => Promise<any>): void => {
.login(args.name, args.password) ipcMain.on(channel, (event: IpcEvent, payload: IIpcPayload) => {
.then(() => { handler(payload.data)
console.log('success'); .then((result: any) => {
}) const response: IIpcResponse = {
.catch(() => { success: true,
console.log('fail'); data: result,
};
event.reply(channel, response);
})
.catch((reason: any) => {
const response: IIpcResponse = {
success: false,
error: reason.toString(),
};
event.reply(channel, response);
});
}); });
},
};
ipcServer.answer(IpcChannels.LOGIN, (credentials: ICredentials) => {
return login(credentials.name, credentials.password);
});
ipcServer.answer(IpcChannels.LOGGED_IN, () => {
return isLoggedIn();
}); });

View File

@ -1,22 +1,21 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Connection, createConnection } from 'typeorm'; import { Connection, createConnection } from 'typeorm';
export let library: Connection; export const enum Databases {
LIBRARY = 'library',
function init(): void {
initConnection();
} }
function initConnection(): void { const connections: {
// createConnection method will automatically read connection options [key in Databases]?: Connection;
// from your ormconfig file or environment variables } = {};
createConnection('library')
.then((c: Connection) => { export function getConnection(database: Databases): Promise<Connection> {
library = c; if (connections[database] === undefined) {
}) return createConnection(database).then((connection: Connection) => {
.catch((reason: any) => { connections[database] = connection;
throw reason; return connection;
}); });
} else {
return Promise.resolve(connections[database]);
}
} }
init();

View File

@ -1,9 +1,10 @@
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { RequestInit, Response } from 'node-fetch'; import { RequestInit, Response } from 'node-fetch';
import RenaiError, { Errors } from '../../types/error'; import { Errors, RenaiError } from '../../types/error';
import fetch from './web-crawler'; import { fetch } from './web-crawler';
const url = 'https://nhentai.net/'; const domain = 'nhentai.net';
const url = `https://${domain}/`;
const paths = { const paths = {
books: 'g/', books: 'g/',
@ -25,33 +26,6 @@ interface ILoginAuth {
interface ILoginParams extends ILoginMeta, ILoginAuth {} interface ILoginParams extends ILoginMeta, ILoginAuth {}
function login(name: string, password: string): Promise<void> {
return getLoginMeta()
.then((meta: ILoginMeta) => {
const loginParams: ILoginParams = {
...meta,
...{
// tslint:disable-next-line: object-literal-sort-keys
username_or_email: name,
password,
},
};
return postNHentai(paths.login, {
body: encodeURI(
Object.keys(loginParams)
.map((key: keyof ILoginParams) => `${key}=${loginParams[key]}`)
.join('&')
),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
})
.then(() => {})
.catch(() => Promise.reject(new RenaiError(Errors.ELOGINFAIL)));
}
function getNHentai(path: string): Promise<Document> { function getNHentai(path: string): Promise<Document> {
return fetch(`${url}${path}`) return fetch(`${url}${path}`)
.then((res: Response) => { .then((res: Response) => {
@ -63,8 +37,21 @@ function getNHentai(path: string): Promise<Document> {
}); });
} }
function postNHentai(path: string, init: RequestInit = {}): Promise<Response> { function postNHentai(path: string, requestInit: RequestInit = {}): Promise<Response> {
return fetch(`${url}${path}`, { ...init, ...{ method: 'post' } }); const postUrl = `${url}${path}`;
return fetch(postUrl, {
...requestInit,
...{
headers: {
...requestInit.headers,
...{
Host: domain,
Referer: postUrl,
},
},
},
method: 'post',
});
} }
function getLoginMeta(): Promise<ILoginMeta> { function getLoginMeta(): Promise<ILoginMeta> {
@ -93,6 +80,36 @@ function getLoginMeta(): Promise<ILoginMeta> {
}); });
} }
export default { export function isLoggedIn(): Promise<boolean> {
login, return fetch(`${url}${paths.favorites}`, { redirect: 'manual' }).then((res: Response) => {
}; return res.status === HttpCode.OK;
});
}
export function login(name: string, password: string): Promise<void> {
return getLoginMeta()
.then((meta: ILoginMeta) => {
const loginParams: ILoginParams = {
...meta,
...{
// tslint:disable-next-line: object-literal-sort-keys
username_or_email: name,
password,
},
};
return postNHentai(paths.login, {
body: encodeURI(
Object.keys(loginParams)
.map((key: keyof ILoginParams) => `${key}=${loginParams[key]}`)
.join('&')
),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
redirect: 'manual',
});
})
.then(() => {})
.catch(() => Promise.reject(new RenaiError(Errors.ELOGINFAIL)));
}

View File

@ -1,8 +1,8 @@
import { session } from 'electron'; import { session } from 'electron';
import OnHeadersReceivedDetails = Electron.OnHeadersReceivedDetails; import OnHeadersReceivedDetails = Electron.OnHeadersReceivedDetails;
function init(): void { export function setHeaders(): void {
// these headers only work on webrequests, file:// protocol is handled via meta tags in index.html // these headers only work on web requests, file:// protocol is handled via meta tags in index.html
session.defaultSession.webRequest.onHeadersReceived( session.defaultSession.webRequest.onHeadersReceived(
(details: OnHeadersReceivedDetails, callback: (response: {}) => void) => { (details: OnHeadersReceivedDetails, callback: (response: {}) => void) => {
callback({ callback({
@ -14,5 +14,3 @@ function init(): void {
} }
); );
} }
export default { init };

View File

@ -1,27 +1,55 @@
import { CookieJar } from 'jsdom';
import nodeFetch, { RequestInit, Response } from 'node-fetch'; import nodeFetch, { RequestInit, Response } from 'node-fetch';
import { Cookie, CookieJar } from 'tough-cookie'; import { Errors, RenaiError } from '../../types/error';
import { load, save, StoreKeys } from './store';
const cookieJar: CookieJar = new CookieJar(); export let cookieJar: CookieJar = new CookieJar();
function fetch(url: string, init: RequestInit = {}): Promise<Response> { let error: Error;
const headers: HeadersInit = {};
cookieJar.getCookiesSync(url).forEach((cookie: Cookie) => { function init(): void {
headers[cookie.key] = cookie.value; load(StoreKeys.COOKIES)
}); .then((cookies: any) => {
if (cookies !== undefined) {
cookieJar = CookieJar.deserializeSync(cookies);
}
})
.catch((reason: any) => {
error = new RenaiError(Errors.EINITFAIL, reason);
});
}
export function fetch(url: string, requestInit: RequestInit = {}): Promise<Response> {
if (error !== undefined) {
return Promise.reject(error);
}
const cookiedInit = { const cookiedInit = {
...init, ...requestInit,
...{ headers: { ...init.headers, ...headers } }, ...{
headers: {
...requestInit.headers,
...{
Cookie: cookieJar.getCookieStringSync(url),
},
},
},
}; };
return nodeFetch(url, cookiedInit).then((res: Response) => { return nodeFetch(url, cookiedInit).then((res: Response) => {
setCookies(res.headers.raw()['set-cookie'], url); setCookies(res.headers.raw()['set-cookie'], url).catch((reason: any) => {
error = new Error(reason);
});
return res; return res;
}); });
} }
function setCookies(header: string[], url: string): void { function setCookies(header: string[], url: string): Promise<void> {
header.forEach((cookie: string) => { if (header) {
cookieJar.setCookieSync(cookie, url); header.forEach((cookie: string) => {
}); cookieJar.setCookieSync(cookie, url);
});
return save(StoreKeys.COOKIES, cookieJar.serializeSync());
}
return Promise.resolve();
} }
export default fetch; init();

View File

@ -1,16 +1,7 @@
{Divide,NhentaiLogin;}
<script> <script>
import Bttn from 'atoms/Bttn.svelte';
import Divide from 'molecules/Divide.svelte'; import Divide from 'molecules/Divide.svelte';
import api from 'services/api'; import NhentaiLogin from 'polymers/NhentaiLogin.svelte';
let form = {
name: '',
password: '',
};
function handleClick() {
api.sendCredentials(form);
}
</script> </script>
<style> <style>
@ -57,18 +48,7 @@
<main> <main>
<Divide> <Divide>
<div slot="1"> <div slot="1">
<h1>Login</h1> <NhentaiLogin></NhentaiLogin>
<form>
<label>
<span>Username/Email</span>
<input bind:value="{form.name}" />
</label>
<label>
<span>Password</span>
<input bind:value="{form.password}" type="password" />
</label>
<Bttn on:click="{handleClick}">submit</Bttn>
</form>
</div> </div>
<div slot="2"> <div slot="2">
<Divide mode="v"> <Divide mode="v">

View File

@ -0,0 +1,41 @@
<script>
import { onMount } from 'svelte/internal';
import { t } from 'services/utils';
import Bttn from 'atoms/Bttn.svelte';
import { loggedIn } from 'services/store';
let form = {
name: '',
password: '',
};
function handleClick() {
loggedIn.fetchLogin(form).catch((reason) => {
console.log(reason);
});
}
onMount(() => {
loggedIn.fetchIsLoggedIn();
});
</script>
<style></style>
<div class="nhentai-login">
{#if $loggedIn}
<div>logged in!</div>
{:else}
<form class="nhentai-login">
<label>
<span>{ t('Username/Email') }</span>
<input bind:value="{form.name}" />
</label>
<label>
<span>{ t('Password') }</span>
<input bind:value="{form.password}" type="password" />
</label>
<Bttn on:click="{handleClick}">submit</Bttn>
</form>
{/if}
</div>

View File

@ -1,9 +1,31 @@
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import IpcMessageEvent = Electron.IpcMessageEvent;
function sendCredentials(credentials: ICredentials): void { const ipcClient: IIpcClient = {
ipcRenderer.send(IpcChannels.Credentials, credentials); ask: (channel: IpcChannels, data?: any): Promise<any> => {
const payload: IIpcPayload = {
data,
};
return new Promise((resolve: (value?: any) => void, reject: (reason?: any) => void): void => {
const listener = (event: IpcMessageEvent, response: IIpcResponse): void => {
if (response.success) {
resolve(response.data);
} else {
reject(response.error);
}
ipcRenderer.removeListener(channel, listener);
};
ipcRenderer.send(channel, payload);
ipcRenderer.on(channel, listener);
});
},
};
export function login(credentials: ICredentials): Promise<void> {
return ipcClient.ask(IpcChannels.LOGIN, credentials);
} }
export default { export function isLoggedIn(): Promise<boolean> {
sendCredentials, return ipcClient.ask(IpcChannels.LOGGED_IN);
}; }

View File

@ -0,0 +1,18 @@
import { writable } from 'svelte/store';
import * as api from './api';
const { subscribe, set } = writable<boolean>(false);
export const loggedIn = {
subscribe,
fetchIsLoggedIn(): Promise<void> {
return api.isLoggedIn().then((isLoggedIn: boolean) => {
set(isLoggedIn);
});
},
fetchLogin(credentials: ICredentials): Promise<void> {
return api.login(credentials).then(() => {
return this.fetchIsLoggedIn();
});
},
};

View File

@ -11,3 +11,8 @@ export function s(styles: object): string {
.map((key: keyof object) => `${key}:${styles[key]}`) .map((key: keyof object) => `${key}:${styles[key]}`)
.join(';'); .join(';');
} }
export function t(text: string): string {
// If you want to implement frontend translation, begin here.
return text;
}

View File

@ -2,16 +2,18 @@ export const enum Errors {
ERROR = 'ERROR', ERROR = 'ERROR',
ENOLOGIN = 'ENOLOGIN', ENOLOGIN = 'ENOLOGIN',
ELOGINFAIL = 'ELOGINFAIL', ELOGINFAIL = 'ELOGINFAIL',
EINITFAIL = 'EINITFAIL',
} }
const messages = { const messages = {
[Errors.ERROR]: 'error', [Errors.ERROR]: 'generic error',
[Errors.ENOLOGIN]: 'no login form found', [Errors.ENOLOGIN]: 'no login form found',
[Errors.ELOGINFAIL]: 'login failed', [Errors.ELOGINFAIL]: 'login failed',
[Errors.EINITFAIL]: 'initialization failed',
}; };
export default class RenaiError extends Error { export class RenaiError extends Error {
constructor(eno: Errors = Errors.ERROR, msg: string = '') { constructor(eno: Errors = Errors.ERROR, msg: string = '') {
super(`Error ${eno}: ${messages[eno]}.${msg ? ` ${msg}` : ''}`); super(`${messages[eno]}.${msg ? ` ${msg}` : ''}`);
} }
} }

65
src/types/http.ts Normal file
View File

@ -0,0 +1,65 @@
const enum HttpCode {
// 100
'CONTINUE' = 100,
'SWITCHING_PROTOCOLS' = 101,
'PROCESSING' = 102,
// 200
'OK' = 200,
'CREATED' = 201,
'ACCEPTED' = 202,
'NON_AUTHORITATIVE_INFORMATION' = 203,
'NO_CONTENT' = 204,
'RESET_CONTENT' = 205,
'PARTIAL_CONTENT' = 206,
'MULTI_STATUS' = 207,
// 300
'MULTIPLE_CHOICES' = 300,
'MOVED_PERMANENTLY' = 301,
'MOVED_TEMPORARILY' = 302,
'SEE_OTHER' = 303,
'NOT_MODIFIED' = 304,
'USE_PROXY' = 305,
'TEMPORARY_REDIRECT' = 307,
'PERMANENT_REDIRECT' = 308,
// 400
'BAD_REQUEST' = 400,
'UNAUTHORIZED' = 401,
'PAYMENT_REQUIRED' = 402,
'FORBIDDEN' = 403,
'NOT_FOUND' = 404,
'METHOD_NOT_ALLOWED' = 405,
'NOT_ACCEPTABLE' = 406,
'PROXY_AUTHENTICATION_REQUIRED' = 407,
'REQUEST_TIMEOUT' = 408,
'CONFLICT' = 409,
'GONE' = 410,
'LENGTH_REQUIRED' = 411,
'PRECONDITION_FAILED' = 412,
'REQUEST_TOO_LONG' = 413,
'REQUEST_URI_TOO_LONG' = 414,
'UNSUPPORTED_MEDIA_TYPE' = 415,
'REQUESTED_RANGE_NOT_SATISFIABLE' = 416,
'EXPECTATION_FAILED' = 417,
'IM_A_TEAPOT' = 418,
'INSUFFICIENT_SPACE_ON_RESOURCE' = 419,
'METHOD_FAILURE' = 420, // nice
'UNPROCESSABLE_ENTITY' = 422,
'LOCKED' = 423,
'FAILED_DEPENDENCY' = 424,
'PRECONDITION_REQUIRED' = 428,
'TOO_MANY_REQUESTS' = 429,
'REQUEST_HEADER_FIELDS_TOO_LARGE' = 431,
// 500
'INTERNAL_SERVER_ERROR' = 500,
'NOT_IMPLEMENTED' = 501,
'BAD_GATEWAY' = 502,
'SERVICE_UNAVAILABLE' = 503,
'GATEWAY_TIMEOUT' = 504,
'HTTP_VERSION_NOT_SUPPORTED' = 505,
'INSUFFICIENT_STORAGE' = 507,
'NETWORK_AUTHENTICATION_REQUIRED' = 511,
}

View File

@ -1,8 +1,27 @@
const enum IpcChannels { const enum IpcChannels {
Credentials = 'CREDENTIALS', LOGIN = 'LOGIN',
LOGGED_IN = 'LOGGED_IN',
}
interface IIpcPayload {
data: any;
}
interface IIpcResponse {
success: boolean;
data?: any;
error?: any;
} }
interface ICredentials { interface ICredentials {
name: string; name: string;
password: string; password: string;
} }
interface IIpcClient {
ask: (channel: IpcChannels, data?: any) => Promise<any>;
}
interface IIpcServer {
answer: (channel: IpcChannels, handler: (data?: any) => Promise<any>) => void;
}

View File

@ -25,7 +25,8 @@
"no-empty": [true, "allow-empty-functions"], "no-empty": [true, "allow-empty-functions"],
"no-magic-numbers": true, "no-magic-numbers": true,
"no-parameter-reassignment": true, "no-parameter-reassignment": true,
"arrow-return-shorthand": true "arrow-return-shorthand": true,
"no-default-export": true
}, },
"jsRules": true "jsRules": true
} }

View File

@ -27,10 +27,15 @@ module.exports = {
}, },
], ],
}, },
{
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
},
], ],
}, },
resolve: { resolve: {
extensions: ['.js', '.ts'], extensions: ['.js', '.ts', '.mjs'],
alias: { alias: {
atoms: path.resolve(__dirname, 'src/renderer/components/1-atoms'), atoms: path.resolve(__dirname, 'src/renderer/components/1-atoms'),
molecules: path.resolve(__dirname, 'src/renderer/components/2-molecules'), molecules: path.resolve(__dirname, 'src/renderer/components/2-molecules'),