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
frontend

View File

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

View File

@ -1,22 +1,41 @@
import { app, BrowserWindow } from 'electron';
import os from 'os';
import './main/controllers/api';
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;
async function createWindow(): Promise<void> {
session.init();
session.setHeaders();
// Create the browser window.
mainWindow = new BrowserWindow({
// universal options
let options: BrowserWindowConstructorOptions = {
width: 1600,
height: 900,
webPreferences: {
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.
await mainWindow.loadFile('index.html');

View File

@ -1,13 +1,32 @@
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) => {
nhentai
.login(args.name, args.password)
.then(() => {
console.log('success');
})
.catch(() => {
console.log('fail');
const ipcServer: IIpcServer = {
answer: (channel: IpcChannels, handler: (data?: any) => Promise<any>): void => {
ipcMain.on(channel, (event: IpcEvent, payload: IIpcPayload) => {
handler(payload.data)
.then((result: any) => {
const response: IIpcResponse = {
success: true,
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 { Connection, createConnection } from 'typeorm';
export let library: Connection;
function init(): void {
initConnection();
export const enum Databases {
LIBRARY = 'library',
}
function initConnection(): void {
// createConnection method will automatically read connection options
// from your ormconfig file or environment variables
createConnection('library')
.then((c: Connection) => {
library = c;
})
.catch((reason: any) => {
throw reason;
const connections: {
[key in Databases]?: Connection;
} = {};
export function getConnection(database: Databases): Promise<Connection> {
if (connections[database] === undefined) {
return createConnection(database).then((connection: Connection) => {
connections[database] = connection;
return connection;
});
} else {
return Promise.resolve(connections[database]);
}
}
init();

View File

@ -1,9 +1,10 @@
import { JSDOM } from 'jsdom';
import { RequestInit, Response } from 'node-fetch';
import RenaiError, { Errors } from '../../types/error';
import fetch from './web-crawler';
import { Errors, RenaiError } from '../../types/error';
import { fetch } from './web-crawler';
const url = 'https://nhentai.net/';
const domain = 'nhentai.net';
const url = `https://${domain}/`;
const paths = {
books: 'g/',
@ -25,33 +26,6 @@ interface 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> {
return fetch(`${url}${path}`)
.then((res: Response) => {
@ -63,8 +37,21 @@ function getNHentai(path: string): Promise<Document> {
});
}
function postNHentai(path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`${url}${path}`, { ...init, ...{ method: 'post' } });
function postNHentai(path: string, requestInit: RequestInit = {}): Promise<Response> {
const postUrl = `${url}${path}`;
return fetch(postUrl, {
...requestInit,
...{
headers: {
...requestInit.headers,
...{
Host: domain,
Referer: postUrl,
},
},
},
method: 'post',
});
}
function getLoginMeta(): Promise<ILoginMeta> {
@ -93,6 +80,36 @@ function getLoginMeta(): Promise<ILoginMeta> {
});
}
export default {
login,
};
export function isLoggedIn(): Promise<boolean> {
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 OnHeadersReceivedDetails = Electron.OnHeadersReceivedDetails;
function init(): void {
// these headers only work on webrequests, file:// protocol is handled via meta tags in index.html
export function setHeaders(): void {
// these headers only work on web requests, file:// protocol is handled via meta tags in index.html
session.defaultSession.webRequest.onHeadersReceived(
(details: OnHeadersReceivedDetails, callback: (response: {}) => void) => {
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 { 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> {
const headers: HeadersInit = {};
cookieJar.getCookiesSync(url).forEach((cookie: Cookie) => {
headers[cookie.key] = cookie.value;
});
let error: Error;
function init(): void {
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 = {
...init,
...{ headers: { ...init.headers, ...headers } },
...requestInit,
...{
headers: {
...requestInit.headers,
...{
Cookie: cookieJar.getCookieStringSync(url),
},
},
},
};
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;
});
}
function setCookies(header: string[], url: string): void {
header.forEach((cookie: string) => {
cookieJar.setCookieSync(cookie, url);
});
function setCookies(header: string[], url: string): Promise<void> {
if (header) {
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>
import Bttn from 'atoms/Bttn.svelte';
import Divide from 'molecules/Divide.svelte';
import api from 'services/api';
let form = {
name: '',
password: '',
};
function handleClick() {
api.sendCredentials(form);
}
import NhentaiLogin from 'polymers/NhentaiLogin.svelte';
</script>
<style>
@ -57,18 +48,7 @@
<main>
<Divide>
<div slot="1">
<h1>Login</h1>
<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>
<NhentaiLogin></NhentaiLogin>
</div>
<div slot="2">
<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 IpcMessageEvent = Electron.IpcMessageEvent;
function sendCredentials(credentials: ICredentials): void {
ipcRenderer.send(IpcChannels.Credentials, credentials);
const ipcClient: IIpcClient = {
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 {
sendCredentials,
};
export function isLoggedIn(): Promise<boolean> {
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]}`)
.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',
ENOLOGIN = 'ENOLOGIN',
ELOGINFAIL = 'ELOGINFAIL',
EINITFAIL = 'EINITFAIL',
}
const messages = {
[Errors.ERROR]: 'error',
[Errors.ERROR]: 'generic error',
[Errors.ENOLOGIN]: 'no login form found',
[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 = '') {
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 {
Credentials = 'CREDENTIALS',
LOGIN = 'LOGIN',
LOGGED_IN = 'LOGGED_IN',
}
interface IIpcPayload {
data: any;
}
interface IIpcResponse {
success: boolean;
data?: any;
error?: any;
}
interface ICredentials {
name: 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-magic-numbers": true,
"no-parameter-reassignment": true,
"arrow-return-shorthand": true
"arrow-return-shorthand": true,
"no-default-export": true
},
"jsRules": true
}

View File

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