Format with prettier
							parent
							
								
									7bf5b18fd6
								
							
						
					
					
						commit
						e0fd96ab78
					
				| 
						 | 
				
			
			@ -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,10 +54,14 @@ 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) {
 | 
			
		||||
  for (const gamename of gameList) {
 | 
			
		||||
    console.log(`Searching '${gamename}'...`);
 | 
			
		||||
 | 
			
		||||
    // Prepare the query
 | 
			
		||||
| 
						 | 
				
			
			@ -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`
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										39
									
								
								src/index.ts
								
								
								
								
							
							
						
						
									
										39
									
								
								src/index.ts
								
								
								
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -4,7 +4,6 @@
 | 
			
		|||
import { TAuthor, IComic, TRating, TCategory } from "../../interfaces";
 | 
			
		||||
 | 
			
		||||
export default class Comic implements IComic {
 | 
			
		||||
    
 | 
			
		||||
  //#region Properties
 | 
			
		||||
  genre: string[];
 | 
			
		||||
  pages: string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -245,9 +275,10 @@ export default class Thread {
 | 
			
		|||
   * `index` must be greater or equal to 1.
 | 
			
		||||
   * If the post is not found, `null` is returned.
 | 
			
		||||
   */
 | 
			
		||||
    public async getPost(index: number): Promise<Post|null> {
 | 
			
		||||
  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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -72,10 +80,10 @@ export default class PrefixParser {
 | 
			
		|||
  /**
 | 
			
		||||
   * Convert a list of prefixes to their respective IDs.
 | 
			
		||||
   */
 | 
			
		||||
    public prefixesToIDs(prefixes: string[]) : number[] {
 | 
			
		||||
  public prefixesToIDs(prefixes: string[]): number[] {
 | 
			
		||||
    const ids: number[] = [];
 | 
			
		||||
 | 
			
		||||
        for(const p of prefixes) {
 | 
			
		||||
    for (const p of prefixes) {
 | 
			
		||||
      // Check what dict contains the value
 | 
			
		||||
      const dict = this.searchElementInPrefixes(p);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -92,9 +100,9 @@ export default class PrefixParser {
 | 
			
		|||
   * It converts a list of IDs into their respective prefixes.
 | 
			
		||||
   */
 | 
			
		||||
  public idsToPrefixes(ids: number[]): string[] {
 | 
			
		||||
        const prefixes:string[] = [];
 | 
			
		||||
    const prefixes: string[] = [];
 | 
			
		||||
 | 
			
		||||
        for(const id of ids) {
 | 
			
		||||
    for (const id of ids) {
 | 
			
		||||
      // Check what dict contains the key
 | 
			
		||||
      const dict = this.searchElementInPrefixes(id);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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];
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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]"
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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");
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,13 +34,13 @@ export default async function fetchLatestHandiworkURLs(query: LatestSearchQuery,
 | 
			
		|||
    // Save the URLs
 | 
			
		||||
    if (response.isSuccess()) {
 | 
			
		||||
      // In-loop variables
 | 
			
		||||
            const data: [{ thread_id: number}] = response.value.data.msg.data;
 | 
			
		||||
      const data: [{ thread_id: number }] = response.value.data.msg.data;
 | 
			
		||||
      const totalPages: number = response.value.data.msg.pagination.total;
 | 
			
		||||
 | 
			
		||||
      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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import cheerio from "cheerio";
 | 
			
		|||
// Modules from file
 | 
			
		||||
import shared, { TPrefixDict } from "../shared.js";
 | 
			
		||||
import { urls as f95url } from "../constants/url.js";
 | 
			
		||||
import { selectors as f95selector} from "../constants/css-selector.js";
 | 
			
		||||
import { selectors as f95selector } from "../constants/css-selector.js";
 | 
			
		||||
import { fetchHTML } from "../network-helper.js";
 | 
			
		||||
 | 
			
		||||
//#region Interface definitions
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -76,7 +76,7 @@ function readCache(path: string) {
 | 
			
		|||
  let returnValue = false;
 | 
			
		||||
 | 
			
		||||
  if (existsSync(path)) {
 | 
			
		||||
        const data = readFileSync(path, {encoding: "utf-8", flag: "r"});
 | 
			
		||||
    const data = readFileSync(path, { encoding: "utf-8", flag: "r" });
 | 
			
		||||
    const json: { [s: string]: TPrefixDict } = JSON.parse(data);
 | 
			
		||||
 | 
			
		||||
    shared.setPrefixPair("engines", json.engines);
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			@ -109,7 +109,7 @@ function saveCache(path: string): void {
 | 
			
		|||
 * Given the HTML code of the response from the F95Zone,
 | 
			
		||||
 * parse it and return the result.
 | 
			
		||||
 */
 | 
			
		||||
function parseLatestPlatformHTML(html: string): ILatestResource{
 | 
			
		||||
function parseLatestPlatformHTML(html: string): ILatestResource {
 | 
			
		||||
  const $ = cheerio.load(html);
 | 
			
		||||
 | 
			
		||||
  // Clean the JSON string
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,67 +247,67 @@ 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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Collection of values extrapolated from the
 | 
			
		||||
 * F95 platform representing a particular work.
 | 
			
		||||
 */
 | 
			
		||||
export interface IHandiwork extends IGame, IComic, IAnimation, IAsset { }
 | 
			
		||||
export interface IHandiwork extends IGame, IComic, IAnimation, IAsset {}
 | 
			
		||||
 | 
			
		||||
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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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,10 +168,12 @@ 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}`,
 | 
			
		||||
      message: `(GET) Error ${e.message} occurred while trying to fetch ${secureURL}`,
 | 
			
		||||
      error: e
 | 
			
		||||
    });
 | 
			
		||||
    return failure(genericError);
 | 
			
		||||
| 
						 | 
				
			
			@ -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}`,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue