Format with prettier

pull/81/head
MillenniumEarl 2021-03-04 12:26:45 +01:00
parent 7bf5b18fd6
commit e0fd96ab78
34 changed files with 3083 additions and 2736 deletions

View File

@ -12,7 +12,8 @@ F95_PASSWORD = YOUR_PASSWORD
import dotenv from "dotenv";
// Modules from file
import { login,
import {
login,
getUserData,
getLatestUpdates,
LatestSearchQuery,
@ -28,22 +29,24 @@ main();
async function main() {
// Local variables
const gameList = [
"City of broken dreamers",
"Seeds of chaos",
"MIST"
];
const gameList = ["City of broken dreamers", "Seeds of chaos", "MIST"];
// Log in the platform
console.log("Authenticating...");
const result = await login(process.env.F95_USERNAME, process.env.F95_PASSWORD);
const result = await login(
process.env.F95_USERNAME,
process.env.F95_PASSWORD
);
console.log(`Authentication result: ${result.message}\n`);
// Get user data
console.log("Fetching user data...");
const userdata = await getUserData();
const gameThreads = userdata.watched.filter(e => e.forum === "Games").length;
console.log(`${userdata.name} follows ${userdata.watched.length} threads of which ${gameThreads} are games\n`);
const gameThreads = userdata.watched.filter((e) => e.forum === "Games")
.length;
console.log(
`${userdata.name} follows ${userdata.watched.length} threads of which ${gameThreads} are games\n`
);
// Get latest game update
const latestQuery: LatestSearchQuery = new LatestSearchQuery();
@ -51,7 +54,11 @@ async function main() {
latestQuery.includedTags = ["3d game"];
const latestUpdates = await getLatestUpdates<Game>(latestQuery, 1);
console.log(`"${latestUpdates.shift().name}" was the last "3d game" tagged game to be updated\n`);
console.log(
`"${
latestUpdates.shift().name
}" was the last "3d game" tagged game to be updated\n`
);
// Get game data
for (const gamename of gameList) {
@ -75,6 +82,8 @@ async function main() {
// Extract first game
const gamedata = searchResult.shift();
const authors = gamedata.authors.map((a, idx) => a.name).join(", ");
console.log(`Found: ${gamedata.name} (${gamedata.version}) by ${authors}\n`);
console.log(
`Found: ${gamedata.name} (${gamedata.version}) by ${authors}\n`
);
}
}

View File

@ -52,7 +52,9 @@ shared.logger.level = "warn"; // By default log only the warn messages
/**
* Indicates whether a user is logged in to the F95Zone platform or not.
*/
export function isLogged(): boolean { return shared.isLogged; };
export function isLogged(): boolean {
return shared.isLogged;
}
//#endregion Export properties
//#region Export methods
@ -62,7 +64,10 @@ export function isLogged(): boolean { return shared.isLogged; };
*
* This **must** be the first operation performed before accessing any other script functions.
*/
export async function login(username: string, password: string): Promise<LoginResult> {
export async function login(
username: string,
password: string
): Promise<LoginResult> {
// Try to load a previous session
await shared.session.load();
@ -98,14 +103,16 @@ export async function login(username: string, password: string): Promise<LoginRe
} else shared.logger.warn(`Error during authentication: ${result.message}`);
return result;
};
}
/**
* Chek if exists a new version of the handiwork.
*
* You **must** be logged in to the portal before calling this method.
*/
export async function checkIfHandiworkHasUpdate(hw: HandiWork): Promise<boolean> {
export async function checkIfHandiworkHasUpdate(
hw: HandiWork
): Promise<boolean> {
// Local variables
let hasUpdate = false;
@ -123,7 +130,7 @@ export async function checkIfHandiworkHasUpdate(hw: HandiWork): Promise<boolean>
}
return hasUpdate;
};
}
/**
* Search for one or more handiworks identified by a specific query.
@ -133,19 +140,24 @@ export async function checkIfHandiworkHasUpdate(hw: HandiWork): Promise<boolean>
* @param {HandiworkSearchQuery} query Parameters used for the search.
* @param {Number} limit Maximum number of results. Default: 10
*/
export async function searchHandiwork<T extends IBasic>(query: HandiworkSearchQuery, limit: number = 10): Promise<T[]> {
export async function searchHandiwork<T extends IBasic>(
query: HandiworkSearchQuery,
limit = 10
): Promise<T[]> {
// Check if the user is logged
if (!shared.isLogged) throw new UserNotLogged(USER_NOT_LOGGED);
return search<T>(query, limit);
};
}
/**
* Given the url, it gets all the information about the handiwork requested.
*
* You **must** be logged in to the portal before calling this method.
*/
export async function getHandiworkFromURL<T extends IBasic>(url: string): Promise<T> {
export async function getHandiworkFromURL<T extends IBasic>(
url: string
): Promise<T> {
// Check if the user is logged
if (!shared.isLogged) throw new UserNotLogged(USER_NOT_LOGGED);
@ -156,7 +168,7 @@ export async function getHandiworkFromURL<T extends IBasic>(url: string): Promis
// Get game data
return getHandiworkInformation<T>(url);
};
}
/**
* Gets the data of the currently logged in user.
@ -174,7 +186,7 @@ export async function getUserData(): Promise<UserProfile> {
await profile.fetch();
return profile;
};
}
/**
* Gets the latest updated games that match the specified parameters.
@ -184,7 +196,10 @@ export async function getUserData(): Promise<UserProfile> {
* @param {LatestSearchQuery} query Parameters used for the search.
* @param {Number} limit Maximum number of results. Default: 10
*/
export async function getLatestUpdates<T extends IBasic>(query: LatestSearchQuery, limit: number = 10): Promise<T[]> {
export async function getLatestUpdates<T extends IBasic>(
query: LatestSearchQuery,
limit = 10
): Promise<T[]> {
// Check limit value
if (limit <= 0) throw new Error("limit must be greater than 0");
@ -197,6 +212,6 @@ export async function getLatestUpdates<T extends IBasic>(query: LatestSearchQuer
// Get the data from urls
const promiseList = urls.map((u: string) => getHandiworkInformation<T>(u));
return Promise.all(promiseList);
};
}
//#endregion

View File

@ -4,15 +4,15 @@ interface IBaseError {
/**
* Unique identifier of the error.
*/
id: number,
id: number;
/**
* Error message.
*/
message: string,
message: string;
/**
* Error to report.
*/
error: Error,
error: Error;
}
export class GenericAxiosError extends Error implements IBaseError {

View File

@ -4,7 +4,6 @@
import { TAuthor, IAnimation, TRating, TCategory } from "../../interfaces";
export default class Animation implements IAnimation {
//#region Properties
censored: boolean;
genre: string[];
@ -27,5 +26,4 @@ export default class Animation implements IAnimation {
threadPublishingDate: Date;
url: string;
//#endregion Properties
}

View File

@ -4,7 +4,6 @@
import { TAuthor, IAsset, TRating, TCategory } from "../../interfaces";
export default class Asset implements IAsset {
//#region Properties
assetLink: string;
associatedAssets: string[];
@ -26,5 +25,4 @@ export default class Asset implements IAsset {
threadPublishingDate: Date;
url: string;
//#endregion Properties
}

View File

@ -4,7 +4,6 @@
import { TAuthor, IComic, TRating, TCategory } from "../../interfaces";
export default class Comic implements IComic {
//#region Properties
genre: string[];
pages: string;

View File

@ -1,10 +1,16 @@
"use strict";
// Modules from files
import { TAuthor, TEngine, IGame, TRating, TStatus, TCategory } from "../../interfaces";
import {
TAuthor,
TEngine,
IGame,
TRating,
TStatus,
TCategory
} from "../../interfaces";
export default class Game implements IGame {
//#region Properties
censored: boolean;
engine: TEngine;
@ -30,5 +36,4 @@ export default class Game implements IGame {
threadPublishingDate: Date;
url: string;
//#endregion Properties
}

View File

@ -1,13 +1,19 @@
"use strict";
// Modules from files
import { TAuthor, TRating, IHandiwork, TEngine, TCategory, TStatus } from "../../interfaces";
import {
TAuthor,
TRating,
IHandiwork,
TEngine,
TCategory,
TStatus
} from "../../interfaces";
/**
* It represents a generic work, be it a game, a comic, an animation or an asset.
*/
export default class HandiWork implements IHandiwork {
//#region Properties
censored: boolean;
engine: TEngine;
@ -42,5 +48,4 @@ export default class HandiWork implements IHandiwork {
officialLinks: string[];
sku: string;
//#endregion Properties
}

View File

@ -13,7 +13,6 @@ import { GENERIC, MEMBER } from "../../constants/css-selector.js";
* Represents a generic user registered on the platform.
*/
export default class PlatformUser {
//#region Fields
private _id: number;
@ -39,71 +38,105 @@ export default class PlatformUser {
/**
* Unique user ID.
*/
public get id() { return this._id; }
public get id() {
return this._id;
}
/**
* Username.
*/
public get name() { return this._name; }
public get name() {
return this._name;
}
/**
* Title assigned to the user by the platform.
*/
public get title() { return this._title; }
public get title() {
return this._title;
}
/**
* List of banners assigned by the platform.
*/
public get banners() { return this._banners; }
public get banners() {
return this._banners;
}
/**
* Number of messages written by the user.
*/
public get messages() { return this._messages; }
public get messages() {
return this._messages;
}
/**
* @todo Reaction score.
*/
public get reactionScore() { return this._reactionScore; }
public get reactionScore() {
return this._reactionScore;
}
/**
* @todo Points.
*/
public get points() { return this._points; }
public get points() {
return this._points;
}
/**
* Number of ratings received.
*/
public get ratingsReceived() { return this._ratingsReceived; }
public get ratingsReceived() {
return this._ratingsReceived;
}
/**
* Date of joining the platform.
*/
public get joined() { return this._joined; }
public get joined() {
return this._joined;
}
/**
* Date of the last connection to the platform.
*/
public get lastSeen() { return this._lastSeen; }
public get lastSeen() {
return this._lastSeen;
}
/**
* Indicates whether the user is followed by the currently logged in user.
*/
public get followed() { return this._followed; }
public get followed() {
return this._followed;
}
/**
* Indicates whether the user is ignored by the currently logged on user.
*/
public get ignored() { return this._ignored; }
public get ignored() {
return this._ignored;
}
/**
* Indicates that the profile is private and not viewable by the user.
*/
public get private() { return this._private; }
public get private() {
return this._private;
}
/**
* URL of the image used as the user's avatar.
*/
public get avatar() { return this._avatar; }
public get avatar() {
return this._avatar;
}
/**
* Value of donations made.
*/
public get donation() { return this._amountDonated; }
public get donation() {
return this._amountDonated;
}
//#endregion Getters
constructor(id?: number) { this._id = id; }
constructor(id?: number) {
this._id = id;
}
//#region Public methods
public setID(id: number) { this._id = id; }
public setID(id: number) {
this._id = id;
}
public async fetch() {
// Check ID
@ -120,15 +153,18 @@ export default class PlatformUser {
const $ = cheerio.load(htmlResponse.value);
// Check if the profile is private
this._private = $(GENERIC.ERROR_BANNER)
?.text()
.trim() === "This member limits who may view their full profile.";
this._private =
$(GENERIC.ERROR_BANNER)?.text().trim() ===
"This member limits who may view their full profile.";
if (!this._private) {
// Parse the elements
this._name = $(MEMBER.NAME).text();
this._title = $(MEMBER.TITLE).text();
this._banners = $(MEMBER.BANNERS).toArray().map((el, idx) => $(el).text().trim()).filter(el => el);
this._banners = $(MEMBER.BANNERS)
.toArray()
.map((el, idx) => $(el).text().trim())
.filter((el) => el);
this._avatar = $(MEMBER.AVATAR).attr("src");
this._followed = $(MEMBER.FOLLOWED).text() === "Unfollow";
this._ignored = $(MEMBER.IGNORED).text() === "Unignore";
@ -139,10 +175,12 @@ export default class PlatformUser {
// Parse date
const joined = $(MEMBER.JOINED)?.attr("datetime");
if (luxon.DateTime.fromISO(joined).isValid) this._joined = new Date(joined);
if (luxon.DateTime.fromISO(joined).isValid)
this._joined = new Date(joined);
const lastSeen = $(MEMBER.LAST_SEEN)?.attr("datetime");
if (luxon.DateTime.fromISO(lastSeen).isValid) this._joined = new Date(lastSeen);
if (luxon.DateTime.fromISO(lastSeen).isValid)
this._joined = new Date(lastSeen);
// Parse donation
const donation = $(MEMBER.AMOUNT_DONATED)?.text().replace("$", "");
@ -152,5 +190,4 @@ export default class PlatformUser {
}
//#endregion Public method
}

View File

@ -5,7 +5,10 @@ import cheerio from "cheerio";
// Modules from file
import PlatformUser from "./platform-user.js";
import { IPostElement, parseF95ThreadPost } from "../../scrape-data/post-parse.js";
import {
IPostElement,
parseF95ThreadPost
} from "../../scrape-data/post-parse.js";
import { POST, THREAD } from "../../constants/css-selector.js";
import { urls } from "../../constants/url.js";
import { fetchHTML } from "../../network-helper.js";
@ -14,7 +17,6 @@ import { fetchHTML } from "../../network-helper.js";
* Represents a post published by a user on the F95Zone platform.
*/
export default class Post {
//#region Fields
private _id: number;
@ -33,39 +35,57 @@ export default class Post {
/**
* Represents a post published by a user on the F95Zone platform.
*/
public get id() { return this._id; }
public get id() {
return this._id;
}
/**
* Unique ID of the post within the thread in which it is present.
*/
public get number() { return this._number; }
public get number() {
return this._number;
}
/**
* Date the post was first published.
*/
public get published() { return this._published; }
public get published() {
return this._published;
}
/**
* Date the post was last modified.
*/
public get lastEdit() { return this._lastEdit; }
public get lastEdit() {
return this._lastEdit;
}
/**
* User who owns the post.
*/
public get owner() { return this._owner; }
public get owner() {
return this._owner;
}
/**
* Indicates whether the post has been bookmarked.
*/
public get bookmarked() { return this._bookmarked; }
public get bookmarked() {
return this._bookmarked;
}
/**
* Post message text.
*/
public get message() { return this._message; }
public get message() {
return this._message;
}
/**
* Set of the elements that make up the body of the post.
*/
public get body() { return this._body; }
public get body() {
return this._body;
}
//#endregion Getters
constructor(id: number) { this._id = id; }
constructor(id: number) {
this._id = id;
}
//#region Public methods
@ -81,9 +101,14 @@ export default class Post {
// Load cheerio and find post
const $ = cheerio.load(htmlResponse.value);
const post = $(THREAD.POSTS_IN_PAGE).toArray().find((el, idx) => {
const post = $(THREAD.POSTS_IN_PAGE)
.toArray()
.find((el, idx) => {
// Fetch the ID and check if it is what we are searching
const sid: string = $(el).find(POST.ID).attr("id").replace("post-", "");
const sid: string = $(el)
.find(POST.ID)
.attr("id")
.replace("post-", "");
const id = parseInt(sid, 10);
if (id === this.id) return el;
@ -98,7 +123,10 @@ export default class Post {
//#region Private methods
private async parsePost($: cheerio.Root, post: cheerio.Cheerio): Promise<void> {
private async parsePost(
$: cheerio.Root,
post: cheerio.Cheerio
): Promise<void> {
// Find post's ID
const sid: string = post.find(POST.ID).attr("id").replace("post-", "");
this._id = parseInt(sid, 10);
@ -116,7 +144,10 @@ export default class Post {
this._lastEdit = new Date(sLastEdit);
// Find post's owner
const sOwnerID: string = post.find(POST.OWNER_ID).attr("data-user-id").trim();
const sOwnerID: string = post
.find(POST.OWNER_ID)
.attr("data-user-id")
.trim();
this._owner = new PlatformUser(parseInt(sOwnerID, 10));
await this._owner.fetch();

View File

@ -12,7 +12,11 @@ import { urls } from "../../constants/url.js";
import { POST, THREAD } from "../../constants/css-selector.js";
import { fetchHTML, fetchPOSTResponse } from "../../network-helper.js";
import Shared from "../../shared.js";
import { GenericAxiosError, ParameterError, UnexpectedResponseContentType } from "../errors.js";
import {
GenericAxiosError,
ParameterError,
UnexpectedResponseContentType
} from "../errors.js";
import { Result } from "../result.js";
import { getJSONLD, TJsonLD } from "../../scrape-data/json-ld.js";
@ -20,10 +24,9 @@ import { getJSONLD, TJsonLD } from "../../scrape-data/json-ld.js";
* Represents a generic F95Zone platform thread.
*/
export default class Thread {
//#region Fields
private POST_FOR_PAGE: number = 20;
private POST_FOR_PAGE = 20;
private _id: number;
private _url: string;
private _title: string;
@ -42,45 +45,65 @@ export default class Thread {
/**
* Unique ID of the thread on the platform.
*/
public get id() { return this._id; }
public get id() {
return this._id;
}
/**
* URL of the thread.
*
* It may vary depending on any versions of the contained product.
*/
public get url() { return this._url; }
public get url() {
return this._url;
}
/**
* Thread title.
*/
public get title() { return this._title; }
public get title() {
return this._title;
}
/**
* Tags associated with the thread.
*/
public get tags() { return this._tags; }
public get tags() {
return this._tags;
}
/**
* Prefixes associated with the thread
*/
public get prefixes() { return this._prefixes; }
public get prefixes() {
return this._prefixes;
}
/**
* Rating assigned to the thread.
*/
public get rating() { return this._rating; }
public get rating() {
return this._rating;
}
/**
* Owner of the thread.
*/
public get owner() { return this._owner; }
public get owner() {
return this._owner;
}
/**
* Date the thread was first published.
*/
public get publication() { return this._publication; }
public get publication() {
return this._publication;
}
/**
* Date the thread was last modified.
*/
public get modified() { return this._modified; }
public get modified() {
return this._modified;
}
/**
* Category to which the content of the thread belongs.
*/
public get category() { return this._category; }
public get category() {
return this._category;
}
//#endregion Getters
@ -89,7 +112,9 @@ export default class Thread {
*
* The unique ID of the thread must be specified.
*/
constructor(id: number) { this._id = id; }
constructor(id: number) {
this._id = id;
}
//#region Private methods
@ -99,13 +124,13 @@ export default class Thread {
private async setMaximumPostsForPage(n: 20 | 40 | 60 | 100): Promise<void> {
// Prepare the parameters to send via POST request
const params = {
"_xfResponseType": "json",
"_xfRequestUri": `/account/dpp-update?content_type=thread&content_id=${this.id}`,
"_xfToken": Shared.session.token,
"_xfWithData": "1",
"content_id": this.id.toString(),
"content_type": "thread",
"dpp_custom_config[posts]": n.toString(),
_xfResponseType: "json",
_xfRequestUri: `/account/dpp-update?content_type=thread&content_id=${this.id}`,
_xfToken: Shared.session.token,
_xfWithData: "1",
content_id: this.id.toString(),
content_type: "thread",
"dpp_custom_config[posts]": n.toString()
};
// Send POST request
@ -137,7 +162,9 @@ export default class Thread {
*/
private async fetchPosts(pages: number): Promise<Post[]> {
// Local variables
type TFetchResult = Promise<Result<GenericAxiosError | UnexpectedResponseContentType, string>>;
type TFetchResult = Promise<
Result<GenericAxiosError | UnexpectedResponseContentType, string>
>;
const htmlPromiseList: TFetchResult[] = [];
const fetchedPosts: Post[] = [];
@ -163,7 +190,9 @@ export default class Thread {
}
// Sorts the list of posts
return fetchedPosts.sort((a, b) => (a.id > b.id) ? 1 : ((b.id > a.id) ? -1 : 0));
return fetchedPosts.sort((a, b) =>
a.id > b.id ? 1 : b.id > a.id ? -1 : 0
);
}
/**
@ -175,7 +204,7 @@ export default class Thread {
const rating: TRating = {
average: ratingTree ? parseFloat(ratingTree["ratingValue"] as string) : 0,
best: ratingTree ? parseInt(ratingTree["bestRating"] as string, 10) : 0,
count: ratingTree ? parseInt(ratingTree["ratingCount"] as string, 10) : 0,
count: ratingTree ? parseInt(ratingTree["ratingCount"] as string, 10) : 0
};
return rating;
@ -193,7 +222,7 @@ export default class Thread {
// Get the title name
let name = headline;
if (matches) matches.forEach(e => name = name.replace(e, ""));
if (matches) matches.forEach((e) => (name = name.replace(e, "")));
return name.trim();
}
@ -225,17 +254,18 @@ export default class Thread {
// Parse the thread's data
this._title = this.cleanHeadline(JSONLD["headline"] as string);
this._tags = tagArray.map(el => $(el).text().trim());
this._prefixes = prefixArray.map(el => $(el).text().trim());
this._tags = tagArray.map((el) => $(el).text().trim());
this._prefixes = prefixArray.map((el) => $(el).text().trim());
this._owner = new PlatformUser(parseInt(ownerID, 10));
await this._owner.fetch();
this._rating = this.parseRating(JSONLD);
this._category = JSONLD["articleSection"] as TCategory;
// Validate the dates
if (luxon.DateTime.fromISO(modified).isValid) this._modified = new Date(modified);
if (luxon.DateTime.fromISO(published).isValid) this._publication = new Date(published);
if (luxon.DateTime.fromISO(modified).isValid)
this._modified = new Date(modified);
if (luxon.DateTime.fromISO(published).isValid)
this._publication = new Date(published);
} else throw htmlResponse.value;
}
@ -247,7 +277,8 @@ export default class Thread {
*/
public async getPost(index: number): Promise<Post | null> {
// Validate parameters
if (index < 1) throw new ParameterError("Index must be greater or equal than 1");
if (index < 1)
throw new ParameterError("Index must be greater or equal than 1");
// Local variables
let returnValue = null;
@ -278,5 +309,4 @@ export default class Thread {
}
//#endregion Public methods
}

View File

@ -21,21 +21,23 @@ interface IWatchedThread {
/**
* Indicates whether the thread has any unread posts.
*/
unread: boolean,
unread: boolean;
/**
* Specifies the forum to which the thread belongs.
*/
forum: string,
forum: string;
}
// Types
type TFetchResult = Result<GenericAxiosError | UnexpectedResponseContentType, string>;
type TFetchResult = Result<
GenericAxiosError | UnexpectedResponseContentType,
string
>;
/**
* Class containing the data of the user currently connected to the F95Zone platform.
*/
export default class UserProfile extends PlatformUser {
//#region Fields
private _watched: IWatchedThread[] = [];
@ -50,26 +52,36 @@ export default class UserProfile extends PlatformUser {
/**
* List of followed thread data.
*/
public get watched() { return this._watched; }
public get watched() {
return this._watched;
}
/**
* List of bookmarked posts.
* @todo
*/
public get bookmarks() { return this._bookmarks; }
public get bookmarks() {
return this._bookmarks;
}
/**
* List of alerts.
* @todo
*/
public get alerts() { return this._alerts; }
public get alerts() {
return this._alerts;
}
/**
* List of conversations.
* @todo
*/
public get conversation() { return this._conversations; }
public get conversation() {
return this._conversations;
}
//#endregion Getters
constructor() { super(); }
constructor() {
super();
}
//#region Public methods
@ -134,7 +146,11 @@ export default class UserProfile extends PlatformUser {
* @param n Total number of pages
* @param s Page to start from
*/
private async fetchPages(url: URL, n: number, s: number = 1): Promise<TFetchResult[]> {
private async fetchPages(
url: URL,
n: number,
s = 1
): Promise<TFetchResult[]> {
// Local variables
const responsePromiseList: Promise<TFetchResult>[] = [];
@ -144,7 +160,7 @@ export default class UserProfile extends PlatformUser {
url.searchParams.set("page", page.toString());
// Fetch HTML but not wait for it
const promise = fetchHTML(url.toString())
const promise = fetchHTML(url.toString());
responsePromiseList.push(promise);
}
@ -159,19 +175,23 @@ export default class UserProfile extends PlatformUser {
// Local variables
const $ = cheerio.load(html);
return $(WATCHED_THREAD.BODIES).map((idx, el) => {
return $(WATCHED_THREAD.BODIES)
.map((idx, el) => {
// Parse the URL
const partialURL = $(el).find(WATCHED_THREAD.URL).attr("href");
const url = new URL(partialURL.replace("unread", ""), `${urls.F95_BASE_URL}`).toString();
const url = new URL(
partialURL.replace("unread", ""),
`${urls.F95_BASE_URL}`
).toString();
return {
url: url.toString(),
unread: partialURL.endsWith("unread"),
forum: $(el).find(WATCHED_THREAD.FORUM).text().trim()
} as IWatchedThread;
}).get();
})
.get();
}
//#endregion Private methods
}

View File

@ -7,7 +7,6 @@ import shared, { TPrefixDict } from "../shared.js";
* Convert prefixes and platform tags from string to ID and vice versa.
*/
export default class PrefixParser {
//#region Private methods
/**
* Gets the key associated with a given value from a dictionary.
@ -15,8 +14,11 @@ export default class PrefixParser {
* @param {Any} value Value associated with the key
* @returns {String|undefined} Key found or undefined
*/
private getKeyByValue(object: TPrefixDict, value: string): string | undefined {
return Object.keys(object).find(key => object[key] === value);
private getKeyByValue(
object: TPrefixDict,
value: string
): string | undefined {
return Object.keys(object).find((key) => object[key] === value);
}
/**
@ -47,17 +49,23 @@ export default class PrefixParser {
* desired element and return the dictionary that contains it.
* @param element Element to search in the prefixes as a key or as a value
*/
private searchElementInPrefixes(element: string | number): TPrefixDict | null {
private searchElementInPrefixes(
element: string | number
): TPrefixDict | null {
// Local variables
let dictName = null;
// Iterate the key/value pairs in order to find the element
for (const [key, subdict] of Object.entries(shared.prefixes)) {
// Check if the element is a value in the sub-dict
const valueInDict = typeof element === "string" && this.valueInDict(subdict, element as string);
const valueInDict =
typeof element === "string" &&
this.valueInDict(subdict, element as string);
// Check if the element is a key in the subdict
const keyInDict = typeof element === "number" && Object.keys(subdict).includes(element.toString());
const keyInDict =
typeof element === "number" &&
Object.keys(subdict).includes(element.toString());
if (valueInDict || keyInDict) {
dictName = key;

View File

@ -1,15 +1,15 @@
"use strict";
import { AxiosResponse } from 'axios';
import { AxiosResponse } from "axios";
// Public modules from npm
import validator from 'class-validator';
import validator from "class-validator";
// Module from files
import { IQuery, TCategory, TQueryInterface } from "../../interfaces.js";
import { GenericAxiosError, UnexpectedResponseContentType } from '../errors.js';
import { Result } from '../result.js';
import LatestSearchQuery, { TLatestOrder } from './latest-search-query.js';
import ThreadSearchQuery, { TThreadOrder } from './thread-search-query.js';
import { GenericAxiosError, UnexpectedResponseContentType } from "../errors.js";
import { Result } from "../result.js";
import LatestSearchQuery, { TLatestOrder } from "./latest-search-query.js";
import ThreadSearchQuery, { TThreadOrder } from "./thread-search-query.js";
// Type definitions
/**
@ -30,11 +30,16 @@ import ThreadSearchQuery, { TThreadOrder } from './thread-search-query.js';
*
* `views`: Order based on the number of visits. Replacement: `replies`.
*/
type THandiworkOrder = "date" | "likes" | "relevance" | "replies" | "title" | "views";
type THandiworkOrder =
| "date"
| "likes"
| "relevance"
| "replies"
| "title"
| "views";
type TExecuteResult = Result<GenericAxiosError, AxiosResponse<any>>;
export default class HandiworkSearchQuery implements IQuery {
//#region Private fields
static MIN_PAGE = 1;
@ -46,7 +51,7 @@ export default class HandiworkSearchQuery implements IQuery {
/**
* Keywords to use in the search.
*/
public keywords: string = "";
public keywords = "";
/**
* The results must be more recent than the date indicated.
*/
@ -72,7 +77,7 @@ export default class HandiworkSearchQuery implements IQuery {
@validator.Min(HandiworkSearchQuery.MIN_PAGE, {
message: "The minimum $property value must be $constraint1, received $value"
})
public page: number = 1;
public page = 1;
itype: TQueryInterface = "HandiworkSearchQuery";
//#endregion Properties
@ -92,12 +97,15 @@ export default class HandiworkSearchQuery implements IQuery {
// If the keywords are set or the number
// of included tags is greather than 5,
// we must perform a thread search
if (this.keywords || this.includedTags.length > MAX_TAGS_LATEST_SEARCH) return "thread";
if (this.keywords || this.includedTags.length > MAX_TAGS_LATEST_SEARCH)
return "thread";
return DEFAULT_SEARCH_TYPE;
}
public validate(): boolean { return validator.validateSync(this).length === 0; }
public validate(): boolean {
return validator.validateSync(this).length === 0;
}
public async execute(): Promise<TExecuteResult> {
// Local variables
@ -105,12 +113,20 @@ export default class HandiworkSearchQuery implements IQuery {
// Check if the query is valid
if (!this.validate()) {
throw new Error(`Invalid query: ${validator.validateSync(this).join("\n")}`);
throw new Error(
`Invalid query: ${validator.validateSync(this).join("\n")}`
);
}
// Convert the query
if (this.selectSearchType() === "latest") response = await this.cast<LatestSearchQuery>("LatestSearchQuery").execute();
else response = await this.cast<ThreadSearchQuery>("ThreadSearchQuery").execute();
if (this.selectSearchType() === "latest")
response = await this.cast<LatestSearchQuery>(
"LatestSearchQuery"
).execute();
else
response = await this.cast<ThreadSearchQuery>(
"ThreadSearchQuery"
).execute();
return response;
}
@ -135,7 +151,7 @@ export default class HandiworkSearchQuery implements IQuery {
private castToLatest(): LatestSearchQuery {
// Cast the basic query object and copy common values
const query: LatestSearchQuery = new LatestSearchQuery();
Object.keys(this).forEach(key => {
Object.keys(this).forEach((key) => {
if (query.hasOwnProperty(key)) {
query[key] = this[key];
}
@ -156,7 +172,7 @@ export default class HandiworkSearchQuery implements IQuery {
private castToThread(): ThreadSearchQuery {
// Cast the basic query object and copy common values
const query: ThreadSearchQuery = new ThreadSearchQuery();
Object.keys(this).forEach(key => {
Object.keys(this).forEach((key) => {
if (query.hasOwnProperty(key)) {
query[key] = this[key];
}

View File

@ -1,13 +1,13 @@
"use strict";
// Public modules from npm
import validator from 'class-validator';
import validator from "class-validator";
// Modules from file
import { urls } from "../../constants/url.js";
import PrefixParser from '../prefix-parser.js';
import PrefixParser from "../prefix-parser.js";
import { IQuery, TCategory, TQueryInterface } from "../../interfaces.js";
import { fetchGETResponse } from '../../network-helper.js';
import { fetchGETResponse } from "../../network-helper.js";
// Type definitions
export type TLatestOrder = "date" | "likes" | "views" | "title" | "rating";
@ -17,7 +17,6 @@ type TDate = 365 | 180 | 90 | 30 | 14 | 7 | 3 | 1;
* Query used to search handiwork in the "Latest" tab.
*/
export default class LatestSearchQuery implements IQuery {
//#region Private fields
private static MAX_TAGS = 5;
@ -27,13 +26,13 @@ export default class LatestSearchQuery implements IQuery {
//#region Properties
public category: TCategory = 'games';
public category: TCategory = "games";
/**
* Ordering type.
*
* Default: `date`.
*/
public order: TLatestOrder = 'date';
public order: TLatestOrder = "date";
/**
* Date limit in days, to be understood as "less than".
* Use `1` to indicate "today" or `null` to indicate "anytime".
@ -61,12 +60,16 @@ export default class LatestSearchQuery implements IQuery {
//#region Public methods
public validate(): boolean { return validator.validateSync(this).length === 0; }
public validate(): boolean {
return validator.validateSync(this).length === 0;
}
public async execute() {
// Check if the query is valid
if (!this.validate()) {
throw new Error(`Invalid query: ${validator.validateSync(this).join("\n")}`);
throw new Error(
`Invalid query: ${validator.validateSync(this).join("\n")}`
);
}
// Prepare the URL
@ -85,8 +88,11 @@ export default class LatestSearchQuery implements IQuery {
const diff = this.dateDiffInDays(new Date(), d);
// Find the closest valid value in the array
const closest = [365, 180, 90, 30, 14, 7, 3, 1].reduce(function (prev, curr) {
return (Math.abs(curr - diff) < Math.abs(prev - diff) ? curr : prev);
const closest = [365, 180, 90, 30, 14, 7, 3, 1].reduce(function (
prev,
curr
) {
return Math.abs(curr - diff) < Math.abs(prev - diff) ? curr : prev;
});
return closest as TDate;
@ -140,5 +146,4 @@ export default class LatestSearchQuery implements IQuery {
}
//#endregion Private methodss
}

View File

@ -1,23 +1,22 @@
"use strict";
// Public modules from npm
import validator from 'class-validator';
import validator from "class-validator";
// Module from files
import { IQuery, TCategory, TQueryInterface } from "../../interfaces.js";
import { urls } from "../../constants/url.js";
import PrefixParser from "./../prefix-parser.js";
import { fetchPOSTResponse } from '../../network-helper.js';
import { AxiosResponse } from 'axios';
import { GenericAxiosError } from '../errors.js';
import { Result } from '../result.js';
import Shared from '../../shared.js';
import { fetchPOSTResponse } from "../../network-helper.js";
import { AxiosResponse } from "axios";
import { GenericAxiosError } from "../errors.js";
import { Result } from "../result.js";
import Shared from "../../shared.js";
// Type definitions
export type TThreadOrder = "relevance" | "date" | "last_update" | "replies";
export default class ThreadSearchQuery implements IQuery {
//#region Private fields
static MIN_PAGE = 1;
@ -29,11 +28,11 @@ export default class ThreadSearchQuery implements IQuery {
/**
* Keywords to use in the search.
*/
public keywords: string = "";
public keywords = "";
/**
* Indicates to search by checking only the thread titles and not the content.
*/
public onlyTitles: boolean = false;
public onlyTitles = false;
/**
* The results must be more recent than the date indicated.
*/
@ -50,7 +49,7 @@ export default class ThreadSearchQuery implements IQuery {
/**
* Minimum number of answers that the thread must possess.
*/
public minimumReplies: number = 0;
public minimumReplies = 0;
public includedPrefixes: string[] = [];
public category: TCategory = null;
/**
@ -63,19 +62,25 @@ export default class ThreadSearchQuery implements IQuery {
@validator.Min(ThreadSearchQuery.MIN_PAGE, {
message: "The minimum $property value must be $constraint1, received $value"
})
public page: number = 1;
public page = 1;
itype: TQueryInterface = "ThreadSearchQuery";
//#endregion Properties
//#region Public methods
public validate(): boolean { return validator.validateSync(this).length === 0; }
public validate(): boolean {
return validator.validateSync(this).length === 0;
}
public async execute(): Promise<Result<GenericAxiosError, AxiosResponse<any>>> {
public async execute(): Promise<
Result<GenericAxiosError, AxiosResponse<any>>
> {
// Check if the query is valid
if (!this.validate()) {
throw new Error(`Invalid query: ${validator.validateSync(this).join("\n")}`);
throw new Error(
`Invalid query: ${validator.validateSync(this).join("\n")}`
);
}
// Define the POST parameters
@ -121,10 +126,12 @@ export default class ThreadSearchQuery implements IQuery {
// Set included and excluded tags (joined with a comma)
if (this.includedTags) params["c[tags]"] = this.includedTags.join(",");
if (this.excludedTags) params["c[excludeTags]"] = this.excludedTags.join(",");
if (this.excludedTags)
params["c[excludeTags]"] = this.excludedTags.join(",");
// Set minimum reply number
if (this.minimumReplies > 0) params["c[min_reply_count]"] = this.minimumReplies.toString();
if (this.minimumReplies > 0)
params["c[min_reply_count]"] = this.minimumReplies.toString();
// Add prefixes
const parser = new PrefixParser();
@ -152,9 +159,9 @@ export default class ThreadSearchQuery implements IQuery {
* Convert a date in the YYYY-MM-DD format taking into account the time zone.
*/
private convertShortDate(d: Date): string {
const offset = d.getTimezoneOffset()
d = new Date(d.getTime() - (offset * 60 * 1000))
return d.toISOString().split('T')[0]
const offset = d.getTimezoneOffset();
d = new Date(d.getTime() - offset * 60 * 1000);
return d.toISOString().split("T")[0];
}
/**
@ -162,16 +169,15 @@ export default class ThreadSearchQuery implements IQuery {
*/
private categoryToID(category: TCategory): number {
const catMap = {
"games": 2,
"mods": 41,
"comics": 40,
"animations": 94,
"assets": 95,
}
games: 2,
mods: 41,
comics: 40,
animations: 94,
assets: 95
};
return catMap[category as string];
}
//#endregion Private methods
}

View File

@ -15,7 +15,6 @@ const awritefile = promisify(fs.writeFile);
const aunlinkfile = promisify(fs.unlink);
export default class Session {
//#region Fields
/**
@ -38,27 +37,39 @@ export default class Session {
/**
* Path of the session map file on disk.
*/
public get path() { return this._path; }
public get path() {
return this._path;
}
/**
* Indicates if the session is mapped on disk.
*/
public get isMapped() { return this._isMapped; }
public get isMapped() {
return this._isMapped;
}
/**
* Date of creation of the session.
*/
public get created() { return this._created; }
public get created() {
return this._created;
}
/**
* MD5 hash of the username and the password.
*/
public get hash() { return this._hash; }
public get hash() {
return this._hash;
}
/**
* Token used to login to F95Zone.
*/
public get token() { return this._token; }
public get token() {
return this._token;
}
/**
* Cookie holder.
*/
public get cookieJar() { return this._cookieJar; }
public get cookieJar() {
return this._cookieJar;
}
//#endregion Getters
@ -100,7 +111,7 @@ export default class Session {
return {
_created: this._created,
_hash: this._hash,
_token: this._token,
_token: this._token
};
}
@ -148,7 +159,7 @@ export default class Session {
async load(): Promise<void> {
if (this.isMapped) {
// Read data
const data = await areadfile(this.path, { encoding: 'utf-8', flag: 'r' });
const data = await areadfile(this.path, { encoding: "utf-8", flag: "r" });
const json = JSON.parse(data);
// Assign values
@ -157,7 +168,10 @@ export default class Session {
this._token = json._token;
// Load cookiejar
const serializedJar = await areadfile(this._cookieJarPath, { encoding: 'utf-8', flag: 'r' });
const serializedJar = await areadfile(this._cookieJarPath, {
encoding: "utf-8",
flag: "r"
});
this._cookieJar = await CookieJar.deserialize(JSON.parse(serializedJar));
}
}
@ -190,14 +204,13 @@ export default class Session {
const hashValid = sha256(value) === this._hash;
// Search for expired cookies
const jarValid = this._cookieJar
const jarValid =
this._cookieJar
.getCookiesSync("https://f95zone.to")
.filter(el => el.TTL() === 0)
.length === 0;
.filter((el) => el.TTL() === 0).length === 0;
return dateValid && hashValid && jarValid;
}
//#endregion Public Methods
}

View File

@ -1,16 +1,17 @@
export const selectors = {
WT_FILTER_POPUP_BUTTON: "a.filterBar-menuTrigger",
WT_NEXT_PAGE: "a.pageNav-jump--next",
WT_URLS: "a[href^=\"/threads/\"][data-tp-primary]",
WT_UNREAD_THREAD_CHECKBOX: "input[type=\"checkbox\"][name=\"unread\"]",
WT_URLS: 'a[href^="/threads/"][data-tp-primary]',
WT_UNREAD_THREAD_CHECKBOX: 'input[type="checkbox"][name="unread"]',
GS_POSTS: "article.message-body:first-child > div.bbWrapper:first-of-type",
GS_RESULT_THREAD_TITLE: "h3.contentRow-title > a",
GS_RESULT_BODY: "div.contentRow-main",
GS_MEMBERSHIP: "li > a:not(.username)",
GET_REQUEST_TOKEN: "input[name=\"_xfToken\"]",
UD_USERNAME_ELEMENT: "a[href=\"/account/\"] > span.p-navgroup-linkText",
UD_AVATAR_PIC: "a[href=\"/account/\"] > span.avatar > img[class^=\"avatar\"]",
LOGIN_MESSAGE_ERROR: "div.blockMessage.blockMessage--error.blockMessage--iconic",
GET_REQUEST_TOKEN: 'input[name="_xfToken"]',
UD_USERNAME_ELEMENT: 'a[href="/account/"] > span.p-navgroup-linkText',
UD_AVATAR_PIC: 'a[href="/account/"] > span.avatar > img[class^="avatar"]',
LOGIN_MESSAGE_ERROR:
"div.blockMessage.blockMessage--error.blockMessage--iconic",
LU_TAGS_SCRIPT: "script:contains('latestUpdates')",
BK_RESULTS: "ol.listPlain > * div.contentRow-main",
BK_POST_URL: "div.contentRow-title > a",
@ -20,7 +21,7 @@ export const selectors = {
/**
* Attribute `datetime` contains an ISO date.
*/
BK_TIME: "div.contentRow-minor > * time",
BK_TIME: "div.contentRow-minor > * time"
};
export const GENERIC = {
@ -32,8 +33,8 @@ export const GENERIC = {
/**
* Banner containing any error messages as text.
*/
ERROR_BANNER: "div.p-body-pageContent > div.blockMessage",
}
ERROR_BANNER: "div.p-body-pageContent > div.blockMessage"
};
export const WATCHED_THREAD = {
/**
@ -54,12 +55,13 @@ export const WATCHED_THREAD = {
*
* For use within a `WATCHED_THREAD.BODIES` selector.
*/
FORUM: "div.structItem-cell--main > div.structItem-minor > ul.structItem-parts > li:last-of-type > a",
FORUM:
"div.structItem-cell--main > div.structItem-minor > ul.structItem-parts > li:last-of-type > a",
/**
* Index of the last page available as text.
*/
LAST_PAGE: "ul.pageNav-main > li:last-child > a"
}
};
export const THREAD = {
/**
@ -87,7 +89,7 @@ export const THREAD = {
/**
* List of prefixes assigned to the thread.
*/
PREFIXES: "h1.p-title-value > a.labelLink > span[dir=\"auto\"]",
PREFIXES: 'h1.p-title-value > a.labelLink > span[dir="auto"]',
/**
* Thread title.
*/
@ -97,12 +99,12 @@ export const THREAD = {
*
* Two different elements are found.
*/
JSONLD: "script[type=\"application/ld+json\"]",
JSONLD: 'script[type="application/ld+json"]',
/**
* Posts on the current page.
*/
POSTS_IN_PAGE: "article.message",
}
POSTS_IN_PAGE: "article.message"
};
export const POST = {
/**
@ -110,13 +112,14 @@ export const POST = {
*
* For use within a `THREAD.POSTS_IN_PAGE` selector.
*/
NUMBER: "* ul.message-attribution-opposite > li > a:not([id])[rel=\"nofollow\"]",
NUMBER:
'* ul.message-attribution-opposite > li > a:not([id])[rel="nofollow"]',
/**
* Unique ID of the post in the F95Zone platform in the `id` attribute.
*
* For use within a `THREAD.POSTS_IN_PAGE` selector.
*/
ID: "span[id^=\"post\"]",
ID: 'span[id^="post"]',
/**
* Unique ID of the post author in the `data-user-id` attribute.
*
@ -146,7 +149,8 @@ export const POST = {
*
* For use within a `THREAD.POSTS_IN_PAGE` selector.
*/
BOOKMARKED: "* ul.message-attribution-opposite >li > a[title=\"Bookmark\"].is-bookmarked",
BOOKMARKED:
'* ul.message-attribution-opposite >li > a[title="Bookmark"].is-bookmarked'
};
export const MEMBER = {
@ -155,7 +159,7 @@ export const MEMBER = {
*
* It also contains the unique ID of the user in the `data-user-id` attribute.
*/
NAME: "span[class^=\"username\"]",
NAME: 'span[class^="username"]',
/**
* Title of the user in the platform.
*
@ -179,13 +183,15 @@ export const MEMBER = {
*
* The date is contained in the `datetime` attribute as an ISO string.
*/
JOINED: "div.uix_memberHeader__extra > div.memberHeader-blurb:nth-child(1) > * time",
JOINED:
"div.uix_memberHeader__extra > div.memberHeader-blurb:nth-child(1) > * time",
/**
* Last time the user connected to the platform.
*
* The date is contained in the `datetime` attribute as an ISO string.
*/
LAST_SEEN: "div.uix_memberHeader__extra > div.memberHeader-blurb:nth-child(2) > * time",
LAST_SEEN:
"div.uix_memberHeader__extra > div.memberHeader-blurb:nth-child(2) > * time",
MESSAGES: "div.pairJustifier > dl:nth-child(1) > * a",
REACTION_SCORE: "div.pairJustifier > dl:nth-child(2) > dd",
POINTS: "div.pairJustifier > dl:nth-child(3) > * a",
@ -197,12 +203,14 @@ export const MEMBER = {
* If the text is `Unfollow` then the user is followed.
* If the text is `Follow` then the user is not followed.
*/
FOLLOWED: "div.memberHeader-buttons > div.buttonGroup:first-child > a[data-sk-follow] > span",
FOLLOWED:
"div.memberHeader-buttons > div.buttonGroup:first-child > a[data-sk-follow] > span",
/**
* Button used to ignore/unignore the user.
*
* If the text is `Unignore` then the user is ignored.
* If the text is `Ignore` then the user is not ignored.
*/
IGNORED: "div.memberHeader-buttons > div.buttonGroup:first-child > a[data-sk-ignore]",
}
IGNORED:
"div.memberHeader-buttons > div.buttonGroup:first-child > a[data-sk-ignore]"
};

View File

@ -22,5 +22,5 @@ export const urls = {
*/
F95_ALERTS: "https://f95zone.to/account/alerts",
F95_POSTS_NUMBER: "https://f95zone.to/account/dpp-update",
F95_MEMBERS: "https://f95zone.to/members",
F95_MEMBERS: "https://f95zone.to/members"
};

View File

@ -16,7 +16,10 @@ import fetchThreadHandiworkURLs from "./fetch-thread.js";
* Maximum number of items to get. Default: 30
* @returns {Promise<String[]>} URLs of the handiworks
*/
export default async function fetchHandiworkURLs(query: HandiworkSearchQuery, limit: number = 30): Promise<string[]> {
export default async function fetchHandiworkURLs(
query: HandiworkSearchQuery,
limit = 30
): Promise<string[]> {
// Local variables
let urls: string[] = null;
const searchType = query.selectSearchType();
@ -28,8 +31,7 @@ export default async function fetchHandiworkURLs(query: HandiworkSearchQuery, li
// Fetch the urls
urls = await fetchLatestHandiworkURLs(castedQuery, limit);
}
else {
} else {
// Cast the query
const castedQuery = query.cast<ThreadSearchQuery>("ThreadSearchQuery");

View File

@ -4,7 +4,6 @@
import LatestSearchQuery from "../classes/query/latest-search-query.js";
import { urls } from "../constants/url.js";
/**
* Gets the URLs of the latest handiworks that match the passed parameters.
*
@ -15,9 +14,15 @@ import { urls } from "../constants/url.js";
* Maximum number of items to get. Default: 30
* @returns {Promise<String[]>} URLs of the handiworks
*/
export default async function fetchLatestHandiworkURLs(query: LatestSearchQuery, limit: number = 30): Promise<string[]> {
export default async function fetchLatestHandiworkURLs(
query: LatestSearchQuery,
limit = 30
): Promise<string[]> {
// Local variables
const shallowQuery: LatestSearchQuery = Object.assign(new LatestSearchQuery(), query);
const shallowQuery: LatestSearchQuery = Object.assign(
new LatestSearchQuery(),
query
);
const resultURLs = [];
let fetchedResults = 0;
let noMorePages = false;
@ -34,8 +39,8 @@ export default async function fetchLatestHandiworkURLs(query: LatestSearchQuery,
data.map((e, idx) => {
if (fetchedResults < limit) {
const gameURL = new URL(e.thread_id.toString(), urls.F95_THREADS).href;
const gameURL = new URL(e.thread_id.toString(), urls.F95_THREADS)
.href;
resultURLs.push(gameURL);
fetchedResults += 1;
@ -46,8 +51,7 @@ export default async function fetchLatestHandiworkURLs(query: LatestSearchQuery,
shallowQuery.page += 1;
noMorePages = shallowQuery.page > totalPages;
} else throw response.value;
}
while (fetchedResults < limit && !noMorePages);
} while (fetchedResults < limit && !noMorePages);
return resultURLs;
}

View File

@ -17,27 +17,27 @@ import { fetchHTML } from "../network-helper.js";
* Represents the single element contained in the data categories.
*/
interface ISingleOption {
id: number,
name: string,
class: string
id: number;
name: string;
class: string;
}
/**
* Represents the set of values associated with a specific category of data.
*/
interface ICategoryResource {
id: number,
name: string,
prefixes: ISingleOption[]
id: number;
name: string;
prefixes: ISingleOption[];
}
/**
* Represents the set of tags present on the platform-
*/
interface ILatestResource {
prefixes: { [s: string]: ICategoryResource[] },
tags: TPrefixDict,
options: string
prefixes: { [s: string]: ICategoryResource[] };
tags: TPrefixDict;
options: string;
}
//#endregion Interface definitions
@ -98,7 +98,7 @@ function saveCache(path: string): void {
engines: shared.prefixes["engines"],
statuses: shared.prefixes["statuses"],
tags: shared.prefixes["tags"],
others: shared.prefixes["others"],
others: shared.prefixes["others"]
};
const json = JSON.stringify(saveDict);
writeFileSync(path, json);

View File

@ -16,7 +16,10 @@ import { IQuery } from "../interfaces.js";
* @param limit Maximum number of items to get. Default: 30
* @returns URLs of the fetched games
*/
export default async function getURLsFromQuery(query: IQuery, limit: number = 30): Promise<string[]> {
export default async function getURLsFromQuery(
query: IQuery,
limit = 30
): Promise<string[]> {
switch (query.itype) {
case "HandiworkSearchQuery":
return fetchHandiworkURLs(query as HandiworkSearchQuery, limit);

View File

@ -21,13 +21,17 @@ import ThreadSearchQuery from "../classes/query/thread-search-query.js";
* Maximum number of items to get. Default: 30
* @returns {Promise<String[]>} URLs of the handiworks
*/
export default async function fetchThreadHandiworkURLs(query: ThreadSearchQuery, limit: number = 30): Promise<string[]> {
export default async function fetchThreadHandiworkURLs(
query: ThreadSearchQuery,
limit = 30
): Promise<string[]> {
// Execute the query
const response = await query.execute();
// Fetch the results from F95 and return the handiwork urls
if (response.isSuccess()) return fetchResultURLs(response.value.data as string, limit);
else throw response.value
if (response.isSuccess())
return fetchResultURLs(response.value.data as string, limit);
else throw response.value;
}
//#endregion Public methods
@ -39,7 +43,7 @@ export default async function fetchThreadHandiworkURLs(query: ThreadSearchQuery,
* @param {number} limit
* Maximum number of items to get. Default: 30
*/
async function fetchResultURLs(html: string, limit: number = 30): Promise<string[]> {
async function fetchResultURLs(html: string, limit = 30): Promise<string[]> {
// Prepare cheerio
const $ = cheerio.load(html);
@ -47,10 +51,13 @@ async function fetchResultURLs(html: string, limit: number = 30): Promise<string
const results = $("body").find(f95Selector.GS_RESULT_BODY);
// Than we extract the URLs
const urls = results.slice(0, limit).map((idx, el) => {
const urls = results
.slice(0, limit)
.map((idx, el) => {
const elementSelector = $(el);
return extractLinkFromResult(elementSelector);
}).get();
})
.get();
return urls;
}

View File

@ -5,12 +5,12 @@ export type TExternalPlatform = {
/**
* name of the platform.
*/
name: string,
name: string;
/**
* link to the platform.
*/
link: string
}
link: string;
};
/**
* Information about the author of a work.
@ -19,12 +19,12 @@ export type TAuthor = {
/**
* Plain name or username of the author.
*/
name: string,
name: string;
/**
*
*/
platforms: TExternalPlatform[],
}
platforms: TExternalPlatform[];
};
/**
* Information on the evaluation of a work.
@ -33,21 +33,35 @@ export type TRating = {
/**
* average value of evaluations.
*/
average: number,
average: number;
/**
* Best rating received.
*/
best: number,
best: number;
/**
* Number of ratings made by users.
*/
count: number,
}
count: number;
};
/**
* List of possible graphics engines used for game development.
*/
export type TEngine = "QSP" | "RPGM" | "Unity" | "HTML" | "RAGS" | "Java" | "Ren'Py" | "Flash" | "ADRIFT" | "Others" | "Tads" | "Wolf RPG" | "Unreal Engine" | "WebGL";
export type TEngine =
| "QSP"
| "RPGM"
| "Unity"
| "HTML"
| "RAGS"
| "Java"
| "Ren'Py"
| "Flash"
| "ADRIFT"
| "Others"
| "Tads"
| "Wolf RPG"
| "Unreal Engine"
| "WebGL";
/**
* List of possible progress states associated with a game.
@ -62,7 +76,10 @@ export type TCategory = "games" | "mods" | "comics" | "animations" | "assets";
/**
* Valid names of classes that implement the IQuery interface.
*/
export type TQueryInterface = "LatestSearchQuery" | "ThreadSearchQuery" | "HandiworkSearchQuery";
export type TQueryInterface =
| "LatestSearchQuery"
| "ThreadSearchQuery"
| "HandiworkSearchQuery";
/**
* Collection of values defined for each
@ -72,55 +89,55 @@ export interface IBasic {
/**
* Authors of the work.
*/
authors: TAuthor[],
authors: TAuthor[];
/**
* Category of the work..
*/
category: TCategory,
category: TCategory;
/**
* List of changes of the work for each version.
*/
changelog: string[],
changelog: string[];
/**
* link to the cover image of the work.
*/
cover: string,
cover: string;
/**
* Unique ID of the work on the platform.
*/
id: number,
id: number;
/**
* Last update of the opera thread.
*/
lastThreadUpdate: Date,
lastThreadUpdate: Date;
/**
* Plain name of the work (without tags and/or prefixes)
*/
name: string,
name: string;
/**
* Work description
*/
overview: string,
overview: string;
/**
* List of prefixes associated with the work.
*/
prefixes: string[],
prefixes: string[];
/**
* Evaluation of the work by the users of the platform.
*/
rating: TRating,
rating: TRating;
/**
* List of tags associated with the work.
*/
tags: string[],
tags: string[];
/**
* Date of publication of the thread associated with the work.
*/
threadPublishingDate: Date,
threadPublishingDate: Date;
/**
* URL to the work's official conversation on the F95Zone portal.
*/
url: string,
url: string;
}
/**
@ -131,43 +148,43 @@ export interface IGame extends IBasic {
* Specify whether the work has censorship
* measures regarding NSFW scenes
*/
censored: boolean,
censored: boolean;
/**
* Graphics engine used for game development.
*/
engine: TEngine,
engine: TEngine;
/**
* List of genres associated with the work.
*/
genre: string[],
genre: string[];
/**
* Author's Guide to Installation.
*/
installation: string,
installation: string;
/**
* List of available languages.
*/
language: string[],
language: string[];
/**
* Last time the work underwent updates.
*/
lastRelease: Date,
lastRelease: Date;
/**
* Indicates that this item represents a mod.
*/
mod: boolean,
mod: boolean;
/**
* List of OS for which the work is compatible.
*/
os: string[],
os: string[];
/**
* Indicates the progress of a game.
*/
status: TStatus,
status: TStatus;
/**
* Version of the work.
*/
version: string,
version: string;
}
/**
@ -177,15 +194,15 @@ export interface IComic extends IBasic {
/**
* List of genres associated with the work.
*/
genre: string[],
genre: string[];
/**
* Number of pages or elements that make up the work.
*/
pages: string,
pages: string;
/**
* List of resolutions available for the work.
*/
resolution: string[],
resolution: string[];
}
/**
@ -196,31 +213,31 @@ export interface IAnimation extends IBasic {
* Specify whether the work has censorship
* measures regarding NSFW scenes
*/
censored: boolean,
censored: boolean;
/**
* List of genres associated with the work.
*/
genre: string[],
genre: string[];
/**
* Author's Guide to Installation.
*/
installation: string,
installation: string;
/**
* List of available languages.
*/
language: string[],
language: string[];
/**
* Length of the animation.
*/
lenght: string,
lenght: string;
/**
* Number of pages or elements that make up the work.
*/
pages: string,
pages: string;
/**
* List of resolutions available for the work.
*/
resolution: string[],
resolution: string[];
}
/**
@ -230,28 +247,28 @@ export interface IAsset extends IBasic {
/**
* External URL of the asset.
*/
assetLink: string,
assetLink: string;
/**
* List of URLs of assets associated with the work
* (for example same collection).
*/
associatedAssets: string[],
associatedAssets: string[];
/**
* Software compatible with the work.
*/
compatibleSoftware: string,
compatibleSoftware: string;
/**
* List of assets url included in the work or used to develop it.
*/
includedAssets: string[],
includedAssets: string[];
/**
* List of official links of the work, external to the platform.
*/
officialLinks: string[],
officialLinks: string[];
/**
* Unique SKU value of the work.
*/
sku: string,
sku: string;
}
/**
@ -264,33 +281,33 @@ export interface IQuery {
/**
* Name of the implemented interface.
*/
itype: TQueryInterface,
itype: TQueryInterface;
/**
* Category of items to search among.
*/
category: TCategory,
category: TCategory;
/**
* Tags to be include in the search.
* Max. 5 tags
*/
includedTags: string[],
includedTags: string[];
/**
* Prefixes to include in the search.
*/
includedPrefixes: string[],
includedPrefixes: string[];
/**
* Index of the page to be obtained.
* Between 1 and infinity.
*/
page: number,
page: number;
/**
* Verify that the query values are valid.
*/
validate(): boolean,
validate(): boolean;
/**
* Search with the data in the query and returns the result.
*
* If the query is invalid it throws an exception.
*/
execute(): any,
execute(): any;
}

View File

@ -12,10 +12,15 @@ import { selectors as f95selector } from "./constants/css-selector.js";
import LoginResult from "./classes/login-result.js";
import credentials from "./classes/credentials.js";
import { failure, Result, success } from "./classes/result.js";
import { GenericAxiosError, InvalidF95Token, UnexpectedResponseContentType } from "./classes/errors.js";
import {
GenericAxiosError,
InvalidF95Token,
UnexpectedResponseContentType
} from "./classes/errors.js";
// Global variables
const userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) " +
const userAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) " +
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0 Safari/605.1.15";
// @ts-ignore
axiosCookieJarSupport.default(axios);
@ -29,7 +34,7 @@ const commonConfig = {
*/
headers: {
"User-Agent": userAgent,
"Connection": "keep-alive"
Connection: "keep-alive"
},
/**
* Specify if send credentials along the request.
@ -47,7 +52,9 @@ const commonConfig = {
/**
* Gets the HTML code of a page.
*/
export async function fetchHTML(url: string): Promise<Result<GenericAxiosError | UnexpectedResponseContentType, string>> {
export async function fetchHTML(
url: string
): Promise<Result<GenericAxiosError | UnexpectedResponseContentType, string>> {
// Fetch the response of the platform
const response = await fetchGETResponse(url);
@ -61,9 +68,9 @@ export async function fetchHTML(url: string): Promise<Result<GenericAxiosError |
error: null
});
return isHTML ?
success(response.value.data as string) :
failure(unexpectedResponseError);
return isHTML
? success(response.value.data as string)
: failure(unexpectedResponseError);
} else return failure(response.value as GenericAxiosError);
}
@ -75,48 +82,60 @@ export async function fetchHTML(url: string): Promise<Result<GenericAxiosError |
* @param {Boolean} force Specifies whether the request should be forced, ignoring any saved cookies
* @returns {Promise<LoginResult>} Result of the operation
*/
export async function authenticate(credentials: credentials, force: boolean = false): Promise<LoginResult> {
export async function authenticate(
credentials: credentials,
force = false
): Promise<LoginResult> {
shared.logger.info(`Authenticating with user ${credentials.username}`);
if (!credentials.token) throw new InvalidF95Token(`Invalid token for auth: ${credentials.token}`);
if (!credentials.token)
throw new InvalidF95Token(`Invalid token for auth: ${credentials.token}`);
// Secure the URL
const secureURL = enforceHttpsUrl(f95url.F95_LOGIN_URL);
// Prepare the parameters to send to the platform to authenticate
const params = {
"login": credentials.username,
"url": "",
"password": credentials.password,
"password_confirm": "",
"additional_security": "",
"remember": "1",
"_xfRedirect": "https://f95zone.to/",
"website_code": "",
"_xfToken": credentials.token,
login: credentials.username,
url: "",
password: credentials.password,
password_confirm: "",
additional_security: "",
remember: "1",
_xfRedirect: "https://f95zone.to/",
website_code: "",
_xfToken: credentials.token
};
try {
// Try to log-in
const response = await fetchPOSTResponse(f95url.F95_LOGIN_URL, params, force);
const response = await fetchPOSTResponse(
f95url.F95_LOGIN_URL,
params,
force
);
if (response.isSuccess()) {
// Parse the response HTML
const $ = cheerio.load(response.value.data as string);
// Get the error message (if any) and remove the new line chars
const errorMessage = $("body").find(f95selector.LOGIN_MESSAGE_ERROR).text().replace(/\n/g, "");
const errorMessage = $("body")
.find(f95selector.LOGIN_MESSAGE_ERROR)
.text()
.replace(/\n/g, "");
// Return the result of the authentication
const result = errorMessage.trim() === "";
const message = result ? "Authentication successful" : errorMessage;
return new LoginResult(result, message);
}
else throw response.value;
} else throw response.value;
} catch (e) {
shared.logger.error(`Error ${e.message} occurred while authenticating to ${secureURL}`);
shared.logger.error(
`Error ${e.message} occurred while authenticating to ${secureURL}`
);
return new LoginResult(false, `Error ${e.message} while authenticating`);
}
};
}
/**
* Obtain the token used to authenticate the user to the platform.
@ -136,7 +155,9 @@ export async function getF95Token() {
/**
* Performs a GET request to a specific URL and returns the response.
*/
export async function fetchGETResponse(url: string): Promise<Result<GenericAxiosError, AxiosResponse<any>>> {
export async function fetchGETResponse(
url: string
): Promise<Result<GenericAxiosError, AxiosResponse<any>>> {
// Secure the URL
const secureURL = enforceHttpsUrl(url);
@ -147,7 +168,9 @@ export async function fetchGETResponse(url: string): Promise<Result<GenericAxios
return success(response);
} catch (e) {
console.log(e.response);
shared.logger.error(`(GET) Error ${e.message} occurred while trying to fetch ${secureURL}`);
shared.logger.error(
`(GET) Error ${e.message} occurred while trying to fetch ${secureURL}`
);
const genericError = new GenericAxiosError({
id: 1,
message: `(GET) Error ${e.message} occurred while trying to fetch ${secureURL}`,
@ -163,14 +186,14 @@ export async function fetchGETResponse(url: string): Promise<Result<GenericAxios
export function enforceHttpsUrl(url: string): string {
if (isStringAValidURL(url)) return url.replace(/^(https?:)?\/\//, "https://");
else throw new Error(`${url} is not a valid URL`);
};
}
/**
* Check if the url belongs to the domain of the F95 platform.
*/
export function isF95URL(url: string): boolean {
return url.toString().startsWith(f95url.F95_BASE_URL);
};
}
/**
* Checks if the string passed by parameter has a
@ -182,7 +205,7 @@ export function isStringAValidURL(url: string): boolean {
const expression = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
const regex = new RegExp(expression);
return url.match(regex).length > 0;
};
}
/**
* Check if a particular URL is valid and reachable on the web.
@ -192,7 +215,10 @@ export function isStringAValidURL(url: string): boolean {
* Default: false
* @returns {Promise<Boolean>} true if the URL exists, false otherwise
*/
export async function urlExists(url: string, checkRedirect: boolean = false): Promise<boolean> {
export async function urlExists(
url: string,
checkRedirect = false
): Promise<boolean> {
// Local variables
let valid = false;
@ -224,13 +250,18 @@ export async function getUrlRedirect(url: string): Promise<string> {
* @param params List of value pairs to send with the request
* @param force If `true`, the request ignores the sending of cookies already present on the device.
*/
export async function fetchPOSTResponse(url: string, params: { [s: string]: string }, force: boolean = false): Promise<Result<GenericAxiosError, AxiosResponse<any>>> {
export async function fetchPOSTResponse(
url: string,
params: { [s: string]: string },
force = false
): Promise<Result<GenericAxiosError, AxiosResponse<any>>> {
// Secure the URL
const secureURL = enforceHttpsUrl(url);
// Prepare the parameters for the POST request
const urlParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) urlParams.append(key, value);
for (const [key, value] of Object.entries(params))
urlParams.append(key, value);
// Shallow copy of the common configuration object
commonConfig.jar = shared.session.cookieJar;
@ -244,7 +275,9 @@ export async function fetchPOSTResponse(url: string, params: { [s: string]: stri
const response = await axios.post(secureURL, urlParams, config);
return success(response);
} catch (e) {
shared.logger.error(`(POST) Error ${e.message} occurred while trying to fetch ${secureURL}`);
shared.logger.error(
`(POST) Error ${e.message} occurred while trying to fetch ${secureURL}`
);
const genericError = new GenericAxiosError({
id: 3,
message: `(POST) Error ${e.message} occurred while trying to fetch ${secureURL}`,

View File

@ -6,13 +6,23 @@ import luxon from "luxon";
// Modules from files
import HandiWork from "../classes/handiwork/handiwork.js";
import Thread from "../classes/mapping/thread.js";
import { IBasic, TAuthor, TEngine, TExternalPlatform, TStatus } from "../interfaces.js";
import {
IBasic,
TAuthor,
TEngine,
TExternalPlatform,
TStatus
} from "../interfaces.js";
import shared, { TPrefixDict } from "../shared.js";
import { ILink, IPostElement } from "./post-parse.js";
export async function getHandiworkInformation<T extends IBasic>(url: string): Promise<T>
export async function getHandiworkInformation<T extends IBasic>(
url: string
): Promise<T>;
export async function getHandiworkInformation<T extends IBasic>(url: string): Promise<T>
export async function getHandiworkInformation<T extends IBasic>(
url: string
): Promise<T>;
/**
* Gets information of a particular handiwork from its thread.
@ -21,7 +31,9 @@ export async function getHandiworkInformation<T extends IBasic>(url: string): Pr
*
* @todo It does not currently support assets.
*/
export default async function getHandiworkInformation<T extends IBasic>(arg: string | Thread): Promise<T> {
export default async function getHandiworkInformation<T extends IBasic>(
arg: string | Thread
): Promise<T> {
// Local variables
let thread: Thread = null;
@ -50,7 +62,7 @@ export default async function getHandiworkInformation<T extends IBasic>(arg: str
const post = await thread.getPost(1);
fillWithPostData(hw, post.body);
return <T><unknown>hw;
return <T>(<unknown>hw);
}
//#region Private methods
@ -119,8 +131,11 @@ function stringToBoolean(s: string): boolean {
*
* Case-insensitive.
*/
function getPostElementByName(elements: IPostElement[], name: string): IPostElement | undefined {
return elements.find(el => el.name.toUpperCase() === name.toUpperCase());
function getPostElementByName(
elements: IPostElement[],
name: string
): IPostElement | undefined {
return elements.find((el) => el.name.toUpperCase() === name.toUpperCase());
}
//#endregion Utilities
@ -144,8 +159,8 @@ function fillWithPrefixes(hw: HandiWork, prefixes: string[]) {
*/
const fakeModDict: TPrefixDict = {
0: "MOD",
1: "CHEAT MOD",
}
1: "CHEAT MOD"
};
// Initialize the array
hw.prefixes = [];
@ -155,8 +170,10 @@ function fillWithPrefixes(hw: HandiWork, prefixes: string[]) {
const prefix = item.replace("[", "").replace("]", "");
// Check what the prefix indicates
if (stringInDict(prefix, shared.prefixes["engines"])) engine = prefix as TEngine;
else if (stringInDict(prefix, shared.prefixes["statuses"])) status = prefix as TStatus;
if (stringInDict(prefix, shared.prefixes["engines"]))
engine = prefix as TEngine;
else if (stringInDict(prefix, shared.prefixes["statuses"]))
status = prefix as TStatus;
else if (stringInDict(prefix, fakeModDict)) mod = true;
// Anyway add the prefix to list
@ -164,7 +181,7 @@ function fillWithPrefixes(hw: HandiWork, prefixes: string[]) {
});
// If the status is not set, then the game is in development (Ongoing)
status = (!status && hw.category === "games") ? status : "Ongoing";
status = !status && hw.category === "games" ? status : "Ongoing";
hw.engine = engine;
hw.status = status;
@ -183,32 +200,47 @@ function fillWithPrefixes(hw: HandiWork, prefixes: string[]) {
function fillWithPostData(hw: HandiWork, elements: IPostElement[]) {
// First fill the "simple" elements
hw.overview = getPostElementByName(elements, "overview")?.text;
hw.os = getPostElementByName(elements, "os")?.text?.split(",").map(s => s.trim());
hw.language = getPostElementByName(elements, "language")?.text?.split(",").map(s => s.trim());
hw.os = getPostElementByName(elements, "os")
?.text?.split(",")
.map((s) => s.trim());
hw.language = getPostElementByName(elements, "language")
?.text?.split(",")
.map((s) => s.trim());
hw.version = getPostElementByName(elements, "version")?.text;
hw.installation = getPostElementByName(elements, "installation")?.content.shift()?.text;
hw.installation = getPostElementByName(
elements,
"installation"
)?.content.shift()?.text;
hw.pages = getPostElementByName(elements, "pages")?.text;
hw.resolution = getPostElementByName(elements, "resolution")?.text?.split(",").map(s => s.trim());
hw.resolution = getPostElementByName(elements, "resolution")
?.text?.split(",")
.map((s) => s.trim());
hw.lenght = getPostElementByName(elements, "lenght")?.text;
// Parse the censorship
const censored = getPostElementByName(elements, "censored") || getPostElementByName(elements, "censorship");
const censored =
getPostElementByName(elements, "censored") ||
getPostElementByName(elements, "censorship");
if (censored) hw.censored = stringToBoolean(censored.text);
// Get the genres
const genre = getPostElementByName(elements, "genre")?.content.shift()?.text;
hw.genre = genre?.split(",").map(s => s.trim());
hw.genre = genre?.split(",").map((s) => s.trim());
// Get the cover
const cover = getPostElementByName(elements, "overview")?.content.find(el => el.type === "Image") as ILink;
const cover = getPostElementByName(elements, "overview")?.content.find(
(el) => el.type === "Image"
) as ILink;
hw.cover = cover?.href;
// Fill the dates
const releaseDate = getPostElementByName(elements, "release date")?.text;
if (luxon.DateTime.fromISO(releaseDate).isValid) hw.lastRelease = new Date(releaseDate);
if (luxon.DateTime.fromISO(releaseDate).isValid)
hw.lastRelease = new Date(releaseDate);
//#region Convert the author
const authorElement = getPostElementByName(elements, "developer") ||
const authorElement =
getPostElementByName(elements, "developer") ||
getPostElementByName(elements, "developer/publisher") ||
getPostElementByName(elements, "artist");
const author: TAuthor = {
@ -220,7 +252,7 @@ function fillWithPostData(hw: HandiWork, elements: IPostElement[]) {
authorElement?.content.forEach((el: ILink, idx) => {
const platform: TExternalPlatform = {
name: el.text,
link: el.href,
link: el.href
};
author.platforms.push(platform);
@ -230,14 +262,16 @@ function fillWithPostData(hw: HandiWork, elements: IPostElement[]) {
//#region Get the changelog
hw.changelog = [];
const changelogElement = getPostElementByName(elements, "changelog") || getPostElementByName(elements, "change-log");
const changelogElement =
getPostElementByName(elements, "changelog") ||
getPostElementByName(elements, "change-log");
if (changelogElement) {
const changelogSpoiler = changelogElement?.content.find(el => {
const changelogSpoiler = changelogElement?.content.find((el) => {
return el.type === "Spoiler" && el.content.length > 0;
});
// Add to the changelog the single spoilers
changelogSpoiler?.content.forEach(el => {
changelogSpoiler?.content.forEach((el) => {
if (el.text.trim()) hw.changelog.push(el.text);
});

View File

@ -10,7 +10,7 @@ import { THREAD } from "../constants/css-selector.js";
/**
* Represents information contained in a JSON+LD tag.
*/
export type TJsonLD = { [s: string]: string | TJsonLD }
export type TJsonLD = { [s: string]: string | TJsonLD };
/**
* Extracts and processes the JSON-LD values of the page.
@ -55,7 +55,7 @@ function parseJSONLD(element: cheerio.Element): TJsonLD {
// Obtain the JSON-LD
const data = html
.replace("<script type=\"application/ld+json\">", "")
.replace('<script type="application/ld+json">', "")
.replace("</script>", "");
// Convert the string to an object

View File

@ -3,15 +3,15 @@
//#region Interfaces
export interface IPostElement {
type: "Empty" | "Text" | "Link" | "Image" | "Spoiler",
name: string,
text: string,
content: IPostElement[]
type: "Empty" | "Text" | "Link" | "Image" | "Spoiler";
name: string;
text: string;
content: IPostElement[];
}
export interface ILink extends IPostElement {
type: "Image" | "Link",
href: string,
type: "Image" | "Link";
href: string;
}
//#endregion Interfaces
@ -20,7 +20,10 @@ export interface ILink extends IPostElement {
/**
* Given a post of a thread page it extracts the information contained in the body.
*/
export function parseF95ThreadPost($: cheerio.Root, post: cheerio.Cheerio): IPostElement[] {
export function parseF95ThreadPost(
$: cheerio.Root,
post: cheerio.Cheerio
): IPostElement[] {
// The data is divided between "tag" and "text" elements.
// Simple data is composed of a "tag" element followed
// by a "text" element, while more complex data (contained
@ -29,12 +32,16 @@ export function parseF95ThreadPost($: cheerio.Root, post: cheerio.Cheerio): IPos
// "tag" element having as the first term "Spoiler"
// First fetch all the elements in the post
const elements = post.contents().toArray().map(el => {
const elements = post
.contents()
.toArray()
.map((el) => {
const node = parseCheerioNode($, el);
if (node.name || node.text || node.content.length != 0) {
return node;
}
}).filter(el => el);
})
.filter((el) => el);
// ... then parse the elements to create the pairs of title/data
return parsePostElements(elements);
@ -47,7 +54,10 @@ export function parseF95ThreadPost($: cheerio.Root, post: cheerio.Cheerio): IPos
* Process a spoiler element by getting its text broken
* down by any other spoiler elements present.
*/
function parseCheerioSpoilerNode($: cheerio.Root, spoiler: cheerio.Cheerio): IPostElement {
function parseCheerioSpoilerNode(
$: cheerio.Root,
spoiler: cheerio.Cheerio
): IPostElement {
// A spoiler block is composed of a div with class "bbCodeSpoiler",
// containing a div "bbCodeSpoiler-content" containing, in cascade,
// a div with class "bbCodeBlock--spoiler" and a div with class "bbCodeBlock-content".
@ -55,7 +65,8 @@ function parseCheerioSpoilerNode($: cheerio.Root, spoiler: cheerio.Cheerio): IPo
// Local variables
const BUTTON_CLASS = "button.bbCodeSpoiler-button";
const SPOILER_CONTENT_CLASS = "div.bbCodeSpoiler-content > div.bbCodeBlock--spoiler > div.bbCodeBlock-content";
const SPOILER_CONTENT_CLASS =
"div.bbCodeSpoiler-content > div.bbCodeBlock--spoiler > div.bbCodeBlock-content";
const content: IPostElement = {
type: "Spoiler",
name: "",
@ -68,7 +79,10 @@ function parseCheerioSpoilerNode($: cheerio.Root, spoiler: cheerio.Cheerio): IPo
content.name = $(button).text().trim();
// Parse the content of the spoiler
spoiler.find(SPOILER_CONTENT_CLASS).contents().map((idx, el) => {
spoiler
.find(SPOILER_CONTENT_CLASS)
.contents()
.map((idx, el) => {
// Convert the element
const element = $(el);
@ -89,7 +103,7 @@ function parseCheerioSpoilerNode($: cheerio.Root, spoiler: cheerio.Cheerio): IPo
});
// Clean text
content.text = content.text.replace(/\s\s+/g, ' ').trim();
content.text = content.text.replace(/\s\s+/g, " ").trim();
return content;
}
@ -98,7 +112,7 @@ function parseCheerioSpoilerNode($: cheerio.Root, spoiler: cheerio.Cheerio): IPo
* This also includes formatted nodes (i.e. `<b>`).
*/
function isTextNode(node: cheerio.Element): boolean {
const formattedTags = ["b", "i"]
const formattedTags = ["b", "i"];
const isText = node.type === "text";
const isFormatted = node.type === "tag" && formattedTags.includes(node.name);
@ -111,12 +125,16 @@ function isTextNode(node: cheerio.Element): boolean {
*/
function getCheerioNonChildrenText(node: cheerio.Cheerio): string {
// Find all the text nodes in the node
const text = node.first().contents().filter((idx, el) => {
const text = node
.first()
.contents()
.filter((idx, el) => {
return isTextNode(el);
}).text();
})
.text();
// Clean and return the text
return text.replace(/\s\s+/g, ' ').trim();
return text.replace(/\s\s+/g, " ").trim();
}
/**
@ -138,10 +156,9 @@ function parseCheerioLinkNode(element: cheerio.Cheerio): ILink | null {
link.type = "Image";
link.text = element.attr("alt");
link.href = element.attr("data-src");
}
else if (name === "a") {
} else if (name === "a") {
link.type = "Link";
link.text = element.text().replace(/\s\s+/g, ' ').trim();
link.text = element.text().replace(/\s\s+/g, " ").trim();
link.href = element.attr("href");
}
@ -155,8 +172,10 @@ function parseCheerioLinkNode(element: cheerio.Cheerio): ILink | null {
function reducePostElement(element: IPostElement): IPostElement {
if (element.content.length === 1) {
const content = element.content[0] as IPostElement;
const nullValues = (!element.name || !content.name) && (!element.text || !content.text);
const sameValues = (element.name === content.name) || (element.text === content.text)
const nullValues =
(!element.name || !content.name) && (!element.text || !content.text);
const sameValues =
element.name === content.name || element.text === content.text;
if (nullValues || sameValues) {
element.name = element.name || content.name;
@ -178,9 +197,13 @@ function reducePostElement(element: IPostElement): IPostElement {
* Transform a `cheerio.Cheerio` node into an `IPostElement` element with its subnodes.
* @param reduce Compress subsequent subnodes if they contain no information. Default: `true`.
*/
function parseCheerioNode($: cheerio.Root, node: cheerio.Element, reduce = true): IPostElement {
function parseCheerioNode(
$: cheerio.Root,
node: cheerio.Element,
reduce = true
): IPostElement {
// Local variables
let content: IPostElement = {
const content: IPostElement = {
type: "Empty",
name: "",
text: "",
@ -189,7 +212,7 @@ function parseCheerioNode($: cheerio.Root, node: cheerio.Element, reduce = true)
const cheerioNode = $(node);
if (isTextNode(node)) {
content.text = cheerioNode.text().replace(/\s\s+/g, ' ').trim();
content.text = cheerioNode.text().replace(/\s\s+/g, " ").trim();
content.type = "Text";
} else {
// Get the number of children that the element own
@ -223,7 +246,10 @@ function parseCheerioNode($: cheerio.Root, node: cheerio.Element, reduce = true)
const childElement = parseCheerioNode($, el);
// If the children is valid (not empty) push it
if ((childElement.text || childElement.content.length !== 0) && !isTextNode(el)) {
if (
(childElement.text || childElement.content.length !== 0) &&
!isTextNode(el)
) {
content.content.push(childElement);
}
});

View File

@ -11,7 +11,10 @@ import getURLsFromQuery from "./fetch-data/fetch-query.js";
* @param {Number} limit
* Maximum number of items to get. Default: 30
*/
export default async function search<T extends IBasic>(query: IQuery, limit: number = 30): Promise<T[]> {
export default async function search<T extends IBasic>(
query: IQuery,
limit = 30
): Promise<T[]> {
// Fetch the URLs
const urls: string[] = await getURLsFromQuery(query, limit);

View File

@ -12,18 +12,19 @@ import log4js from "log4js";
import Session from "./classes/session.js";
// Types declaration
export type TPrefixDict = { [n: number]: string; };
export type TPrefixDict = { [n: number]: string };
type TPrefixKey = "engines" | "statuses" | "tags" | "others";
/**
* Class containing variables shared between modules.
*/
export default abstract class Shared {
//#region Fields
private static _isLogged = false;
private static _prefixes: { [key in TPrefixKey]: TPrefixDict } = {} as { [key in TPrefixKey]: TPrefixDict };
private static _prefixes: { [key in TPrefixKey]: TPrefixDict } = {} as {
[key in TPrefixKey]: TPrefixDict;
};
private static _logger: log4js.Logger = log4js.getLogger();
private static _session = new Session(join(tmpdir(), "f95session.json"));
@ -34,31 +35,45 @@ export default abstract class Shared {
/**
* Indicates whether a user is logged in to the F95Zone platform or not.
*/
static get isLogged(): boolean { return this._isLogged; }
static get isLogged(): boolean {
return this._isLogged;
}
/**
* List of platform prefixes and tags.
*/
static get prefixes(): { [s: string]: TPrefixDict } { return this._prefixes; }
static get prefixes(): { [s: string]: TPrefixDict } {
return this._prefixes;
}
/**
* Logger object used to write to both file and console.
*/
static get logger(): log4js.Logger { return this._logger; }
static get logger(): log4js.Logger {
return this._logger;
}
/**
* Path to the cache used by this module wich contains engines, statuses, tags...
*/
static get cachePath(): string { return join(tmpdir(), "f95cache.json"); }
static get cachePath(): string {
return join(tmpdir(), "f95cache.json");
}
/**
* Session on the F95Zone platform.
*/
static get session(): Session { return this._session; }
static get session(): Session {
return this._session;
}
//#endregion Getters
//#region Setters
static setPrefixPair(key: TPrefixKey, val: TPrefixDict): void { this._prefixes[key] = val; }
static setPrefixPair(key: TPrefixKey, val: TPrefixDict): void {
this._prefixes[key] = val;
}
static setIsLogged(val: boolean): void { this._isLogged = val; }
static setIsLogged(val: boolean): void {
this._isLogged = val;
}
//#endregion Setters
}