Merge pull request #44 from MillenniumEarl/Use-plain-HTML-scraper
Switch to plain HTML scraperpull/45/head
commit
151a0e7d3b
|
@ -3,18 +3,36 @@
|
|||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
"node": true,
|
||||
"mocha": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parser": "./node_modules/babel-eslint",
|
||||
"parser": "babel-eslint",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 12
|
||||
},
|
||||
"rules": {
|
||||
"indent": ["error", 4],
|
||||
"linebreak-style": ["error", "windows"],
|
||||
"quotes": ["error", "double"],
|
||||
"semi": ["error", "always"],
|
||||
"no-unused-vars": ["error", "after-used"]
|
||||
"indent": [
|
||||
"error",
|
||||
4
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"windows"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"args": "after-used"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
69
README.md
69
README.md
|
@ -8,6 +8,75 @@
|
|||
|
||||
Unofficial Node JS module for scraping F95Zone platform
|
||||
|
||||
These APIs have been developed to support this application and allow you to obtain data on games and mods on the platform [F95zone](https://f95zone.to/) (**NSFW**)
|
||||
|
||||
A simple usage example can be found in [app/example.js](https://github.com/MillenniumEarl/F95API/blob/master/app/example.js)
|
||||
|
||||
**Attention**: Two-factor authentication is not supported
|
||||
|
||||
# Data scraping
|
||||
Games/mods can be obtained by name or URL
|
||||
|
||||
```javascript
|
||||
// The name is case insensitive
|
||||
let listOfFoundGames = await F95API.getGameData("your game name");
|
||||
let listOfFoundMods = await F95API.getGameData("your mod name", true);
|
||||
|
||||
let specificGame = await F95API.getGameDataFromURL("the URL of your game");
|
||||
```
|
||||
|
||||
While user data (after authenticating) with
|
||||
|
||||
```javascript
|
||||
let authResult = await F95API.login(username, password);
|
||||
|
||||
let loggedUserData = await F95API.getUserData();
|
||||
```
|
||||
|
||||
# Classes
|
||||
## Games and mods
|
||||
Information about games and mods is stored in a GameInfo object with the following fields:
|
||||
|
||||
```
|
||||
name: The game name
|
||||
author: The game developer
|
||||
url: The URL that leads to the game thread on F95Zone
|
||||
overview: Description of the game
|
||||
language: Main language of the game
|
||||
supportedOS: List of supported OS (Windows/Linux/Mac/Android...)
|
||||
censored: Are the NSFW parts censored?
|
||||
engine: Game engine (Unity, Ren'Py, RPGM...)
|
||||
status: Completed/Abandoned/Ongoing/Onhold
|
||||
tags: List of tags
|
||||
previewSrc: Source URL of the game description image
|
||||
version: Version of the game
|
||||
lastUpdate: Date of the last update (it's a Date object)
|
||||
isMod: Is it a game or a mod?
|
||||
changelog: Latest changelog available
|
||||
```
|
||||
|
||||
The serialization in JSON format of this object is possible through `JSON.stringfy()` while the deserialization must happen through the static method `GameInfo.fromJSON()`.
|
||||
|
||||
## User data
|
||||
User data (after authentication) can be stored in a UserData object, consisting of the following fields:
|
||||
|
||||
```
|
||||
username: Name of the logged in user
|
||||
avatarSrc: Source URL of the user's profile picture
|
||||
watchedThreads: List of URLs of threads followed by the user
|
||||
```
|
||||
|
||||
## Login results
|
||||
The outcome of the authentication process is represented by the LoginResult object:
|
||||
|
||||
```
|
||||
success: Was the authentication successful?;
|
||||
message: Possible error message (unrecognized user, wrong password ...) or authentication successful message
|
||||
```
|
||||
|
||||
# Logging
|
||||
To log the behavior of the application [log4js](https://github.com/log4js-node/log4js-node) is used with a default level of "warn". This option can be changed with the `loggerLevel` property.
|
||||
|
||||
# Guidelines for errors
|
||||
|
||||
- If you can, return a meaningful value
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
to use this example, create an .env file
|
||||
in the project root with the following values:
|
||||
|
||||
F95_USERNAME = YOUR_USERNAME
|
||||
F95_PASSWORD = YOUR_PASSWORD
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
// Modules from file
|
||||
const F95API = require("./index.js");
|
||||
|
||||
// Configure the .env reader
|
||||
dotenv.config();
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
// Local variables
|
||||
const gameList = [
|
||||
"kingdom of deception",
|
||||
"perverted education",
|
||||
"corrupted kingdoms",
|
||||
"summertime saga",
|
||||
"brothel king"
|
||||
];
|
||||
|
||||
// Log in the platform
|
||||
console.log("Authenticating...");
|
||||
const result = await F95API.login(process.env.F95_USERNAME, process.env.F95_PASSWORD);
|
||||
console.log(`Authentication result: ${result.message}`);
|
||||
|
||||
// Get user data
|
||||
console.log("Fetching user data...");
|
||||
const userdata = await F95API.getUserData();
|
||||
console.log(`${userdata.username} follows ${userdata.watchedThreads.length} threads`);
|
||||
|
||||
for(const gamename of gameList) {
|
||||
console.log(`Searching '${gamename}'...`);
|
||||
const found = await F95API.getGameData(gamename, false);
|
||||
|
||||
// If no game is found
|
||||
if (found.length === 0) {
|
||||
console.log(`No data found for '${gamename}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract first game
|
||||
const gamedata = found[0];
|
||||
console.log(`Found ${gamedata.name} (${gamedata.version}) by ${gamedata.author}`);
|
||||
}
|
||||
}
|
544
app/index.js
544
app/index.js
|
@ -1,21 +1,14 @@
|
|||
"use strict";
|
||||
|
||||
// Core modules
|
||||
const fs = require("fs");
|
||||
|
||||
// Modules from file
|
||||
const shared = require("./scripts/shared.js");
|
||||
const urlK = require("./scripts/constants/url.js");
|
||||
const selectorK = require("./scripts/constants/css-selector.js");
|
||||
const urlHelper = require("./scripts/url-helper.js");
|
||||
const scraper = require("./scripts/game-scraper.js");
|
||||
const {
|
||||
prepareBrowser,
|
||||
preparePage,
|
||||
} = require("./scripts/puppeteer-helper.js");
|
||||
const searcher = require("./scripts/game-searcher.js");
|
||||
const networkHelper = require("./scripts/network-helper.js");
|
||||
const scraper = require("./scripts/scraper.js");
|
||||
const searcher = require("./scripts/searcher.js");
|
||||
const uScraper = require("./scripts/user-scraper.js");
|
||||
|
||||
// Classes from file
|
||||
const Credentials = require("./scripts/classes/credentials.js");
|
||||
const GameInfo = require("./scripts/classes/game-info.js");
|
||||
const LoginResult = require("./scripts/classes/login-result.js");
|
||||
const UserData = require("./scripts/classes/user-data.js");
|
||||
|
@ -28,63 +21,24 @@ module.exports.UserData = UserData;
|
|||
|
||||
//#region Export properties
|
||||
/**
|
||||
* Shows log messages and other useful functions for module debugging.
|
||||
* @param {Boolean} value
|
||||
* @public
|
||||
* Set the logger level for module debugging.
|
||||
*/
|
||||
module.exports.debug = function (value) {
|
||||
shared.debug = value;
|
||||
|
||||
// Configure logger
|
||||
shared.logger.level = value ? "debug" : "warn";
|
||||
};
|
||||
/* istambul ignore next */
|
||||
module.exports.loggerLevel = shared.logger.level;
|
||||
exports.loggerLevel = "warn"; // By default log only the warn messages
|
||||
/**
|
||||
* @public
|
||||
* Indicates whether a user is logged in to the F95Zone platform or not.
|
||||
* @returns {String}
|
||||
*/
|
||||
module.exports.isLogged = function () {
|
||||
/* istambul ignore next */
|
||||
module.exports.isLogged = function isLogged() {
|
||||
return shared.isLogged;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* If true, it opens a new browser for each request
|
||||
* to the F95Zone platform, otherwise it reuses the same.
|
||||
* @returns {String}
|
||||
*/
|
||||
module.exports.setIsolation = function (value) {
|
||||
shared.isolation = value;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* Path to the cache directory
|
||||
* @returns {String}
|
||||
*/
|
||||
module.exports.getCacheDir = function () {
|
||||
return shared.cacheDir;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* Set path to the cache directory
|
||||
* @returns {String}
|
||||
*/
|
||||
module.exports.setCacheDir = function (value) {
|
||||
shared.cacheDir = value;
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(shared.cacheDir)) fs.mkdirSync(shared.cacheDir);
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* Set local chromium path.
|
||||
* @returns {String}
|
||||
*/
|
||||
module.exports.setChromiumPath = function (value) {
|
||||
shared.chromiumLocalPath = value;
|
||||
};
|
||||
//#endregion Export properties
|
||||
|
||||
//#region Global variables
|
||||
var _browser = null;
|
||||
const USER_NOT_LOGGED = "User not authenticated, unable to continue";
|
||||
//#endregion
|
||||
|
||||
|
@ -99,91 +53,24 @@ const USER_NOT_LOGGED = "User not authenticated, unable to continue";
|
|||
*/
|
||||
module.exports.login = async function (username, password) {
|
||||
if (shared.isLogged) {
|
||||
shared.logger.info("Already logged in");
|
||||
const result = new LoginResult(true, "Already logged in");
|
||||
return result;
|
||||
shared.logger.info(`${username} already authenticated`);
|
||||
return new LoginResult(true, `${username} already authenticated`);
|
||||
}
|
||||
|
||||
// If cookies are loaded, use them to authenticate
|
||||
shared.cookies = loadCookies();
|
||||
if (shared.cookies !== null) {
|
||||
shared.logger.info("Valid session, no need to re-authenticate");
|
||||
shared.isLogged = true;
|
||||
const result = new LoginResult(true, "Logged with cookies");
|
||||
return result;
|
||||
}
|
||||
shared.logger.trace("Fetching token...");
|
||||
const creds = new Credentials(username, password);
|
||||
await creds.fetchToken();
|
||||
|
||||
// Else, log in throught browser
|
||||
shared.logger.info(
|
||||
"No saved sessions or expired session, login on the platform"
|
||||
);
|
||||
|
||||
if (_browser === null && !shared.isolation) _browser = await prepareBrowser();
|
||||
const browser = shared.isolation ? await prepareBrowser() : _browser;
|
||||
|
||||
const result = await loginF95(browser, username, password);
|
||||
shared.logger.trace(`Authentication for ${username}`);
|
||||
const result = await networkHelper.authenticate(creds);
|
||||
shared.isLogged = result.success;
|
||||
|
||||
if (result.success) {
|
||||
// Reload cookies
|
||||
shared.cookies = loadCookies();
|
||||
shared.logger.info("User logged in through the platform");
|
||||
} else {
|
||||
shared.logger.warn("Error during authentication: " + result.message);
|
||||
}
|
||||
if (shared.isolation) await browser.close();
|
||||
if (result.success) shared.logger.info("User logged in through the platform");
|
||||
else shared.logger.warn(`Error during authentication: ${result.message}`);
|
||||
|
||||
return result;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* This method loads the main data from the F95 portal
|
||||
* used to provide game information. You **must** be logged
|
||||
* in to the portal before calling this method.
|
||||
* @returns {Promise<Boolean>} Result of the operation
|
||||
*/
|
||||
module.exports.loadF95BaseData = async function () {
|
||||
if (!shared.isLogged || !shared.cookies) {
|
||||
shared.logger.warn(USER_NOT_LOGGED);
|
||||
return false;
|
||||
}
|
||||
|
||||
shared.logger.info("Loading base data...");
|
||||
|
||||
// Prepare a new web page
|
||||
if (_browser === null && !shared.isolation) _browser = await prepareBrowser();
|
||||
const browser = shared.isolation ? await prepareBrowser() : _browser;
|
||||
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.setCookie(...shared.cookies); // Set cookies to avoid login
|
||||
|
||||
// Go to latest update page and wait for it to load
|
||||
await page.goto(urlK.F95_LATEST_UPDATES, {
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
});
|
||||
|
||||
// Obtain engines (disk/online)
|
||||
await page.waitForSelector(selectorK.ENGINE_ID_SELECTOR);
|
||||
shared.engines = await loadValuesFromLatestPage(
|
||||
page,
|
||||
shared.enginesCachePath,
|
||||
selectorK.ENGINE_ID_SELECTOR,
|
||||
"engines"
|
||||
);
|
||||
|
||||
// Obtain statuses (disk/online)
|
||||
await page.waitForSelector(selectorK.STATUS_ID_SELECTOR);
|
||||
shared.statuses = await loadValuesFromLatestPage(
|
||||
page,
|
||||
shared.statusesCachePath,
|
||||
selectorK.STATUS_ID_SELECTOR,
|
||||
"statuses"
|
||||
);
|
||||
|
||||
await page.close();
|
||||
if (shared.isolation) await browser.close();
|
||||
shared.logger.info("Base data loaded");
|
||||
return true;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* Chek if exists a new version of the game.
|
||||
|
@ -191,68 +78,54 @@ module.exports.loadF95BaseData = async function () {
|
|||
* @param {GameInfo} info Information about the game to get the version for
|
||||
* @returns {Promise<Boolean>} true if an update is available, false otherwise
|
||||
*/
|
||||
module.exports.chekIfGameHasUpdate = async function (info) {
|
||||
if (!shared.isLogged || !shared.cookies) {
|
||||
module.exports.checkIfGameHasUpdate = async function (info) {
|
||||
if (!shared.isLogged) {
|
||||
shared.logger.warn(USER_NOT_LOGGED);
|
||||
return false;
|
||||
}
|
||||
|
||||
// F95 change URL at every game update,
|
||||
// so if the URL is different an update is available
|
||||
const exists = await urlHelper.urlExists(info.f95url, true);
|
||||
const exists = await networkHelper.urlExists(info.url, true);
|
||||
if (!exists) return true;
|
||||
|
||||
// Parse version from title
|
||||
if (_browser === null && !shared.isolation) _browser = await prepareBrowser();
|
||||
const browser = shared.isolation ? await prepareBrowser() : _browser;
|
||||
|
||||
const onlineVersion = await scraper.getGameVersionFromTitle(browser, info);
|
||||
|
||||
if (shared.isolation) await browser.close();
|
||||
const onlineVersion = await scraper.getGameInfo(info.url).version;
|
||||
|
||||
// Compare the versions
|
||||
return onlineVersion.toUpperCase() !== info.version.toUpperCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Starting from the name, it gets all the information about the game you are looking for.
|
||||
* You **must** be logged in to the portal before calling this method.
|
||||
* @param {String} name Name of the game searched
|
||||
* @param {Boolean} includeMods Indicates whether to also take mods into account when searching
|
||||
* @param {Boolean} mod Indicate if you are looking for mods or games
|
||||
* @returns {Promise<GameInfo[]>} List of information obtained where each item corresponds to
|
||||
* an identified game (in the case of homonymy of titles)
|
||||
*/
|
||||
module.exports.getGameData = async function (name, includeMods) {
|
||||
if (!shared.isLogged || !shared.cookies) {
|
||||
module.exports.getGameData = async function (name, mod) {
|
||||
if (!shared.isLogged) {
|
||||
shared.logger.warn(USER_NOT_LOGGED);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gets the search results of the game being searched for
|
||||
if (_browser === null && !shared.isolation) _browser = await prepareBrowser();
|
||||
const browser = shared.isolation ? await prepareBrowser() : _browser;
|
||||
const urlList = await searcher.getSearchGameResults(browser, name);
|
||||
// Gets the search results of the game/mod being searched for
|
||||
let urls = [];
|
||||
if(mod) urls = await searcher.searchMod(name);
|
||||
else urls = await searcher.searchGame(name);
|
||||
|
||||
// Process previous partial results
|
||||
const promiseList = [];
|
||||
for (const url of urlList) {
|
||||
const results = [];
|
||||
for (const url of urls) {
|
||||
// Start looking for information
|
||||
promiseList.push(scraper.getGameInfo(browser, url));
|
||||
const info = await scraper.getGameInfo(url);
|
||||
results.push(info);
|
||||
}
|
||||
|
||||
// Filter for mods
|
||||
const result = [];
|
||||
for (const info of await Promise.all(promiseList)) {
|
||||
// Ignore empty results
|
||||
if (!info) continue;
|
||||
// Skip mods if not required
|
||||
if (info.isMod && !includeMods) continue;
|
||||
// Else save data
|
||||
result.push(info);
|
||||
}
|
||||
|
||||
if (shared.isolation) await browser.close();
|
||||
return result;
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Starting from the url, it gets all the information about the game you are looking for.
|
||||
|
@ -261,27 +134,20 @@ module.exports.getGameData = async function (name, includeMods) {
|
|||
* @returns {Promise<GameInfo>} Information about the game. If no game was found, null is returned
|
||||
*/
|
||||
module.exports.getGameDataFromURL = async function (url) {
|
||||
if (!shared.isLogged || !shared.cookies) {
|
||||
if (!shared.isLogged) {
|
||||
shared.logger.warn(USER_NOT_LOGGED);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check URL
|
||||
const exists = await urlHelper.urlExists(url);
|
||||
if (!exists) throw new URIError(url + " is not a valid URL");
|
||||
if (!urlHelper.isF95URL(url))
|
||||
throw new Error(url + " is not a valid F95Zone URL");
|
||||
|
||||
// Gets the search results of the game being searched for
|
||||
if (_browser === null && !shared.isolation) _browser = await prepareBrowser();
|
||||
const browser = shared.isolation ? await prepareBrowser() : _browser;
|
||||
// Check URL validity
|
||||
const exists = await networkHelper.urlExists(url);
|
||||
if (!exists) throw new URIError(`${url} is not a valid URL`);
|
||||
if (!networkHelper.isF95URL(url)) throw new Error(`${url} is not a valid F95Zone URL`);
|
||||
|
||||
// Get game data
|
||||
const result = await scraper.getGameInfo(browser, url);
|
||||
|
||||
if (shared.isolation) await browser.close();
|
||||
return result;
|
||||
return await scraper.getGameInfo(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Gets the data of the currently logged in user.
|
||||
|
@ -289,321 +155,11 @@ module.exports.getGameDataFromURL = async function (url) {
|
|||
* @returns {Promise<UserData>} Data of the user currently logged in
|
||||
*/
|
||||
module.exports.getUserData = async function () {
|
||||
if (!shared.isLogged || !shared.cookies) {
|
||||
if (!shared.isLogged) {
|
||||
shared.logger.warn(USER_NOT_LOGGED);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepare a new web page
|
||||
if (_browser === null && !shared.isolation) _browser = await prepareBrowser();
|
||||
const browser = shared.isolation ? await prepareBrowser() : _browser;
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.setCookie(...shared.cookies); // Set cookies to avoid login
|
||||
await page.goto(urlK.F95_BASE_URL); // Go to base page
|
||||
|
||||
// Explicitly wait for the required items to load
|
||||
await Promise.all([
|
||||
page.waitForSelector(selectorK.USERNAME_ELEMENT),
|
||||
page.waitForSelector(selectorK.AVATAR_PIC),
|
||||
]);
|
||||
|
||||
const threads = getUserWatchedGameThreads(browser);
|
||||
|
||||
const username = await page.evaluate(
|
||||
/* istanbul ignore next */ (selector) =>
|
||||
document.querySelector(selector).innerText,
|
||||
selectorK.USERNAME_ELEMENT
|
||||
);
|
||||
|
||||
const avatarSrc = await page.evaluate(
|
||||
/* istanbul ignore next */ (selector) =>
|
||||
document.querySelector(selector).getAttribute("src"),
|
||||
selectorK.AVATAR_PIC
|
||||
);
|
||||
|
||||
const ud = new UserData();
|
||||
ud.username = username;
|
||||
ud.avatarSrc = urlHelper.isStringAValidURL(avatarSrc) ? avatarSrc : null;
|
||||
ud.watchedThreads = await threads;
|
||||
|
||||
await page.close();
|
||||
if (shared.isolation) await browser.close();
|
||||
|
||||
return ud;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* Logout from the current user and gracefully close shared browser.
|
||||
* You **must** be logged in to the portal before calling this method.
|
||||
*/
|
||||
module.exports.logout = async function () {
|
||||
if (!shared.isLogged || !shared.cookies) {
|
||||
shared.logger.warn(USER_NOT_LOGGED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Logout
|
||||
shared.isLogged = false;
|
||||
|
||||
// Gracefully close shared browser
|
||||
if (!shared.isolation && _browser !== null) {
|
||||
await _browser.close();
|
||||
_browser = null;
|
||||
}
|
||||
return await uScraper.getUserData();
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region Private methods
|
||||
|
||||
//#region Cookies functions
|
||||
/**
|
||||
* @private
|
||||
* Loads and verifies the expiration of previously stored cookies from disk
|
||||
* if they exist, otherwise it returns null.
|
||||
* @return {object[]} List of dictionaries or null if cookies don't exist
|
||||
*/
|
||||
function loadCookies() {
|
||||
// Check the existence of the cookie file
|
||||
if (fs.existsSync(shared.cookiesCachePath)) {
|
||||
// Read cookies
|
||||
const cookiesJSON = fs.readFileSync(shared.cookiesCachePath);
|
||||
const cookies = JSON.parse(cookiesJSON);
|
||||
|
||||
// Check if the cookies have expired
|
||||
for (const cookie of cookies) {
|
||||
if (isCookieExpired(cookie)) return null;
|
||||
}
|
||||
|
||||
// Cookies loaded and verified
|
||||
return cookies;
|
||||
} else return null;
|
||||
}
|
||||
/**
|
||||
* @private
|
||||
* Check the validity of a cookie.
|
||||
* @param {object} cookie Cookies to verify the validity. It's a dictionary
|
||||
* @returns {Boolean} true if the cookie has expired, false otherwise
|
||||
*/
|
||||
function isCookieExpired(cookie) {
|
||||
// Local variables
|
||||
let expiredCookies = false;
|
||||
|
||||
// Ignore cookies that never expire
|
||||
const expirationUnixTimestamp = cookie.expire;
|
||||
|
||||
if (expirationUnixTimestamp !== "-1") {
|
||||
// Convert UNIX epoch timestamp to normal Date
|
||||
const expirationDate = new Date(expirationUnixTimestamp * 1000);
|
||||
|
||||
if (expirationDate < Date.now()) {
|
||||
shared.logger.warn(
|
||||
"Cookie " + cookie.name + " expired, you need to re-authenticate"
|
||||
);
|
||||
expiredCookies = true;
|
||||
}
|
||||
}
|
||||
|
||||
return expiredCookies;
|
||||
}
|
||||
//#endregion Cookies functions
|
||||
|
||||
//#region Latest Updates page parserer
|
||||
/**
|
||||
* @private
|
||||
* If present, it reads the file containing the searched values (engines or states)
|
||||
* from the disk, otherwise it connects to the F95 portal (at the page
|
||||
* https://f95zone.to/latest) and downloads them.
|
||||
* @param {puppeteer.Page} page Page used to locate the required elements
|
||||
* @param {String} path Path to disk of the JSON file containing the data to read / write
|
||||
* @param {String} selector CSS selector of the required elements
|
||||
* @param {String} elementRequested Required element (engines or states) used to detail log messages
|
||||
* @returns {Promise<String[]>} List of required values in uppercase
|
||||
*/
|
||||
async function loadValuesFromLatestPage(
|
||||
page,
|
||||
path,
|
||||
selector,
|
||||
elementRequested
|
||||
) {
|
||||
// If the values already exist they are loaded from disk without having to connect to F95
|
||||
shared.logger.info("Load " + elementRequested + " from disk...");
|
||||
if (fs.existsSync(path)) {
|
||||
const valueJSON = fs.readFileSync(path);
|
||||
return JSON.parse(valueJSON);
|
||||
}
|
||||
|
||||
// Otherwise, connect and download the data from the portal
|
||||
shared.logger.info("No " + elementRequested + " cached, downloading...");
|
||||
const values = await getValuesFromLatestPage(
|
||||
page,
|
||||
selector,
|
||||
"Getting " + elementRequested + " from page"
|
||||
);
|
||||
fs.writeFileSync(path, JSON.stringify(values));
|
||||
return values;
|
||||
}
|
||||
/**
|
||||
* @private
|
||||
* Gets all the textual values of the elements present
|
||||
* in the F95 portal page and identified by the selector
|
||||
* passed by parameter
|
||||
* @param {puppeteer.Page} page Page used to locate items specified by the selector
|
||||
* @param {String} selector CSS selector
|
||||
* @param {String} logMessage Log message indicating which items the selector is requesting
|
||||
* @return {Promise<String[]>} List of uppercase strings indicating the textual values of the elements identified by the selector
|
||||
*/
|
||||
async function getValuesFromLatestPage(page, selector, logMessage) {
|
||||
shared.logger.info(logMessage);
|
||||
|
||||
const result = [];
|
||||
const elements = await page.$$(selector);
|
||||
|
||||
for (const element of elements) {
|
||||
const text = await element.evaluate(
|
||||
/* istanbul ignore next */ (e) => e.innerText
|
||||
);
|
||||
|
||||
// Save as upper text for better match if used in query
|
||||
result.push(text.toUpperCase());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region User
|
||||
/**
|
||||
* @private
|
||||
* Log in to the F95Zone portal and, if successful, save the cookies.
|
||||
* @param {puppeteer.Browser} browser Browser object used for navigation
|
||||
* @param {String} username Username to use during login
|
||||
* @param {String} password Password to use during login
|
||||
* @returns {Promise<LoginResult>} Result of the operation
|
||||
*/
|
||||
async function loginF95(browser, username, password) {
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.goto(urlK.F95_LOGIN_URL); // Go to login page
|
||||
|
||||
// Explicitly wait for the required items to load
|
||||
await Promise.all([
|
||||
page.waitForSelector(selectorK.USERNAME_INPUT),
|
||||
page.waitForSelector(selectorK.PASSWORD_INPUT),
|
||||
page.waitForSelector(selectorK.LOGIN_BUTTON),
|
||||
]);
|
||||
|
||||
await page.type(selectorK.USERNAME_INPUT, username); // Insert username
|
||||
await page.type(selectorK.PASSWORD_INPUT, password); // Insert password
|
||||
await Promise.all([
|
||||
page.click(selectorK.LOGIN_BUTTON), // Click on the login button
|
||||
page.waitForNavigation({
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
}), // Wait for page to load
|
||||
]);
|
||||
|
||||
// Prepare result
|
||||
let message = "";
|
||||
|
||||
// Check if the user is logged in
|
||||
const success = await page.evaluate(
|
||||
/* istanbul ignore next */ (selector) =>
|
||||
document.querySelector(selector) !== null,
|
||||
selectorK.AVATAR_INFO
|
||||
);
|
||||
|
||||
const errorMessageExists = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(selector) => document.querySelector(selector) !== null,
|
||||
selectorK.LOGIN_MESSAGE_ERROR
|
||||
);
|
||||
|
||||
// Save cookies to avoid re-auth
|
||||
if (success) {
|
||||
const c = await page.cookies();
|
||||
fs.writeFileSync(shared.cookiesCachePath, JSON.stringify(c));
|
||||
message = "Authentication successful";
|
||||
} else if (errorMessageExists) {
|
||||
const errorMessage = await page.evaluate(
|
||||
/* istanbul ignore next */ (selector) =>
|
||||
document.querySelector(selector).innerText,
|
||||
selectorK.LOGIN_MESSAGE_ERROR
|
||||
);
|
||||
|
||||
if (errorMessage === "Incorrect password. Please try again.") {
|
||||
message = "Incorrect password";
|
||||
} else if (
|
||||
errorMessage ===
|
||||
"The requested user '" + username + "' could not be found."
|
||||
) {
|
||||
// The escaped quotes are important!
|
||||
message = "Incorrect username";
|
||||
} else message = errorMessage;
|
||||
} else message = "Unknown error";
|
||||
|
||||
await page.close(); // Close the page
|
||||
return new LoginResult(success, message);
|
||||
}
|
||||
/**
|
||||
* @private
|
||||
* Gets the list of URLs of threads the user follows.
|
||||
* @param {puppeteer.Browser} browser Browser object used for navigation
|
||||
* @returns {Promise<String[]>} URL list
|
||||
*/
|
||||
async function getUserWatchedGameThreads(browser) {
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.goto(urlK.F95_WATCHED_THREADS); // Go to the thread page
|
||||
|
||||
// Explicitly wait for the required items to load
|
||||
await page.waitForSelector(selectorK.WATCHED_THREAD_FILTER_POPUP_BUTTON);
|
||||
|
||||
// Show the popup
|
||||
await Promise.all([
|
||||
page.click(selectorK.WATCHED_THREAD_FILTER_POPUP_BUTTON),
|
||||
page.waitForSelector(selectorK.UNREAD_THREAD_CHECKBOX),
|
||||
page.waitForSelector(selectorK.ONLY_GAMES_THREAD_OPTION),
|
||||
page.waitForSelector(selectorK.FILTER_THREADS_BUTTON),
|
||||
]);
|
||||
|
||||
// Set the filters
|
||||
await page.evaluate(
|
||||
/* istanbul ignore next */ (selector) =>
|
||||
document.querySelector(selector).removeAttribute("checked"),
|
||||
selectorK.UNREAD_THREAD_CHECKBOX
|
||||
); // Also read the threads already read
|
||||
|
||||
// Filter the threads
|
||||
await page.click(selectorK.ONLY_GAMES_THREAD_OPTION);
|
||||
await page.click(selectorK.FILTER_THREADS_BUTTON);
|
||||
await page.waitForSelector(selectorK.WATCHED_THREAD_URLS);
|
||||
|
||||
// Get the threads urls
|
||||
const urls = [];
|
||||
let nextPageExists = false;
|
||||
do {
|
||||
// Get all the URLs
|
||||
for (const handle of await page.$$(selectorK.WATCHED_THREAD_URLS)) {
|
||||
const src = await page.evaluate(
|
||||
/* istanbul ignore next */ (element) => element.href,
|
||||
handle
|
||||
);
|
||||
// If 'unread' is left, it will redirect to the last unread post
|
||||
const url = src.replace("/unread", "");
|
||||
urls.push(url);
|
||||
}
|
||||
|
||||
nextPageExists = await page.evaluate(
|
||||
/* istanbul ignore next */ (selector) => document.querySelector(selector),
|
||||
selectorK.WATCHED_THREAD_NEXT_PAGE
|
||||
);
|
||||
|
||||
// Click to next page
|
||||
if (nextPageExists) {
|
||||
await page.click(selectorK.WATCHED_THREAD_NEXT_PAGE);
|
||||
await page.waitForSelector(selectorK.WATCHED_THREAD_URLS);
|
||||
}
|
||||
} while (nextPageExists);
|
||||
|
||||
await page.close();
|
||||
return urls;
|
||||
}
|
||||
//#endregion User
|
||||
|
||||
//#endregion Private methods
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
"use strict";
|
||||
|
||||
// Modules from file
|
||||
const { getF95Token } = require("../network-helper.js");
|
||||
|
||||
class Credentials {
|
||||
constructor(username, password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
async fetchToken() {
|
||||
this.token = await getF95Token();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Credentials;
|
|
@ -1,110 +0,0 @@
|
|||
/* istanbul ignore file */
|
||||
|
||||
"use strict";
|
||||
|
||||
// Core modules
|
||||
const fs = require("fs");
|
||||
|
||||
// Public modules from npm
|
||||
// const { File } = require('megajs');
|
||||
|
||||
// Modules from file
|
||||
const { prepareBrowser, preparePage } = require("../puppeteer-helper.js");
|
||||
const shared = require("../shared.js");
|
||||
|
||||
class GameDownload {
|
||||
constructor() {
|
||||
/**
|
||||
* @public
|
||||
* Platform that hosts game files
|
||||
* @type String
|
||||
*/
|
||||
this.hosting = "";
|
||||
/**
|
||||
* @public
|
||||
* Link to game files
|
||||
* @type String
|
||||
*/
|
||||
this.link = null;
|
||||
/**
|
||||
* @public
|
||||
* Operating systems supported by the game version indicated in this class.
|
||||
* Can be *WINDOWS/LINUX/MACOS*
|
||||
* @type String[]
|
||||
*/
|
||||
this.supportedOS = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
* Download the game data in the indicated path.
|
||||
* Supported hosting platforms: MEGA, NOPY
|
||||
* @param {String} path Save path
|
||||
* @return {Promise<Boolean>} Result of the operation
|
||||
*/
|
||||
async download(path) {
|
||||
if (this.link.includes("mega.nz"))
|
||||
return await downloadMEGA(this.link, path);
|
||||
else if (this.link.includes("nopy.to"))
|
||||
return await downloadNOPY(this.link, path);
|
||||
}
|
||||
}
|
||||
module.exports = GameDownload;
|
||||
|
||||
async function downloadMEGA(url, savepath) {
|
||||
// The URL is masked
|
||||
const browser = await prepareBrowser();
|
||||
const page = await preparePage(browser);
|
||||
await page.setCookie(...shared.cookies); // Set cookies to avoid login
|
||||
await page.goto(url);
|
||||
await page.waitForSelector("a.host_link");
|
||||
|
||||
// Obtain the link for the unmasked page and click it
|
||||
const link = await page.$("a.host_link");
|
||||
await link.click();
|
||||
await page.goto(url, {
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
}); // Go to the game page and wait until it loads
|
||||
|
||||
// Obtain the URL after the redirect
|
||||
const downloadURL = page.url();
|
||||
|
||||
// Close browser and page
|
||||
await page.close();
|
||||
await browser.close();
|
||||
|
||||
const stream = fs.createWriteStream(savepath);
|
||||
const file = File.fromURL(downloadURL);
|
||||
file.download().pipe(stream);
|
||||
return fs.existsSync(savepath);
|
||||
}
|
||||
|
||||
async function downloadNOPY(url, savepath) {
|
||||
// Prepare browser
|
||||
const browser = await prepareBrowser();
|
||||
const page = await preparePage(browser);
|
||||
await page.goto(url);
|
||||
await page.waitForSelector("#download");
|
||||
|
||||
// Set the save path
|
||||
await page._client.send("Page.setDownloadBehavior", {
|
||||
behavior: "allow",
|
||||
downloadPath: path.basename(path.dirname(savepath)), // It's a directory
|
||||
});
|
||||
|
||||
// Obtain the download button and click it
|
||||
const downloadButton = await page.$("#download");
|
||||
await downloadButton.click();
|
||||
|
||||
// Await for all the connections to close
|
||||
await page.waitForNavigation({
|
||||
waitUntil: "networkidle0",
|
||||
timeout: 0, // Disable timeout
|
||||
});
|
||||
|
||||
// Close browser and page
|
||||
await page.close();
|
||||
await browser.close();
|
||||
|
||||
return fs.existsSync(savepath);
|
||||
}
|
|
@ -17,12 +17,28 @@ class GameInfo {
|
|||
* URL to the game's official conversation on the F95Zone portal
|
||||
* @type String
|
||||
*/
|
||||
this.f95url = null;
|
||||
this.url = null;
|
||||
/**
|
||||
* Game description
|
||||
* @type String
|
||||
*/
|
||||
this.overview = null;
|
||||
/**
|
||||
* Game language.
|
||||
* @type String
|
||||
*/
|
||||
this.language = null;
|
||||
/**
|
||||
* List of supported OS.
|
||||
* @type
|
||||
*/
|
||||
this.supportedOS = [];
|
||||
/**
|
||||
* Specify whether the game has censorship
|
||||
* measures regarding NSFW scenes.
|
||||
* @type Boolean
|
||||
*/
|
||||
this.censored = null;
|
||||
/**
|
||||
* List of tags associated with the game
|
||||
* @type String[]
|
||||
|
@ -42,7 +58,7 @@ class GameInfo {
|
|||
* Game description image URL
|
||||
* @type String
|
||||
*/
|
||||
this.previewSource = null;
|
||||
this.previewSrc = null;
|
||||
/**
|
||||
* Game version
|
||||
* @type String
|
||||
|
@ -73,47 +89,47 @@ class GameInfo {
|
|||
* @type String
|
||||
*/
|
||||
this.gameDir = null;
|
||||
/**
|
||||
* Information on game file download links,
|
||||
* including information on hosting platforms
|
||||
* and operating system supported by the specific link
|
||||
* @type GameDownload[]
|
||||
*/
|
||||
this.downloadInfo = [];
|
||||
//#endregion Properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to a dictionary used for JSON serialization
|
||||
* Converts the object to a dictionary used for JSON serialization.
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
author: this.author,
|
||||
f95url: this.f95url,
|
||||
url: this.url,
|
||||
overview: this.overview,
|
||||
language: this.language,
|
||||
supportedOS: this.supportedOS,
|
||||
censored: this.censored,
|
||||
engine: this.engine,
|
||||
status: this.status,
|
||||
previewSource: this.previewSource,
|
||||
tags: this.tags,
|
||||
previewSrc: this.previewSrc,
|
||||
version: this.version,
|
||||
lastUpdate: this.lastUpdate,
|
||||
lastPlayed: this.lastPlayed,
|
||||
isMod: this.isMod,
|
||||
changelog: this.changelog,
|
||||
gameDir: this.gameDir,
|
||||
downloadInfo: this.downloadInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new GameInfo from a JSON string
|
||||
* Return a new GameInfo from a JSON string.
|
||||
* @param {String} json JSON string used to create the new object
|
||||
* @returns {GameInfo}
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
static fromJSON(json) {
|
||||
return Object.assign(new GameInfo(), json);
|
||||
// Convert string
|
||||
const temp = Object.assign(new GameInfo(), JSON.parse(json));
|
||||
|
||||
// JSON cannot transform a string to a date implicitly
|
||||
temp.lastUpdate = new Date(temp.lastUpdate);
|
||||
return temp;
|
||||
}
|
||||
}
|
||||
module.exports = GameInfo;
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
class UserData {
|
||||
constructor() {
|
||||
/**
|
||||
* User username.
|
||||
* User name.
|
||||
* @type String
|
||||
*/
|
||||
this.username = "";
|
||||
|
@ -17,7 +17,7 @@ class UserData {
|
|||
this.avatarSrc = null;
|
||||
/**
|
||||
* List of followed thread URLs.
|
||||
* @type URL[]
|
||||
* @type String[]
|
||||
*/
|
||||
this.watchedThreads = [];
|
||||
}
|
||||
|
|
|
@ -1,33 +1,23 @@
|
|||
module.exports = Object.freeze({
|
||||
AVATAR_INFO: "span.avatar",
|
||||
AVATAR_PIC: 'a[href="/account/"] > span.avatar > img[class^="avatar"]',
|
||||
ENGINE_ID_SELECTOR: 'div[id^="btn-prefix_1_"]>span',
|
||||
FILTER_THREADS_BUTTON: 'button[class="button--primary button"]',
|
||||
GAME_IMAGES: 'img[src^="https://attachments.f95zone.to"]',
|
||||
GAME_TAGS: "a.tagItem",
|
||||
GAME_TITLE: "h1.p-title-value",
|
||||
GAME_TITLE_PREFIXES: 'h1.p-title-value > a.labelLink > span[dir="auto"]',
|
||||
LOGIN_BUTTON: "button.button--icon--login",
|
||||
LOGIN_MESSAGE_ERROR:
|
||||
"div.blockMessage.blockMessage--error.blockMessage--iconic",
|
||||
ONLY_GAMES_THREAD_OPTION: 'select[name="nodes[]"] > option[value="2"]',
|
||||
PASSWORD_INPUT: 'input[name="password"]',
|
||||
SEARCH_BUTTON: "form.block > * button.button--icon--search",
|
||||
SEARCH_FORM_TEXTBOX: 'input[name="keywords"][type="search"]',
|
||||
SEARCH_ONLY_GAMES_OPTION: 'select[name="c[nodes][]"] > option[value="1"]',
|
||||
STATUS_ID_SELECTOR: 'div[id^="btn-prefix_4_"]>span',
|
||||
THREAD_POSTS:
|
||||
"article.message-body:first-child > div.bbWrapper:first-of-type",
|
||||
THREAD_TITLE: "h3.contentRow-title",
|
||||
TITLE_ONLY_CHECKBOX: 'form.block > * input[name="c[title_only]"]',
|
||||
UNREAD_THREAD_CHECKBOX: 'input[type="checkbox"][name="unread"]',
|
||||
USERNAME_ELEMENT: 'a[href="/account/"] > span.p-navgroup-linkText',
|
||||
USERNAME_INPUT: 'input[name="login"]',
|
||||
WATCHED_THREAD_FILTER_POPUP_BUTTON: "a.filterBar-menuTrigger",
|
||||
WATCHED_THREAD_NEXT_PAGE: "a.pageNav-jump--next",
|
||||
WATCHED_THREAD_URLS: 'a[href^="/threads/"][data-tp-primary]',
|
||||
DOWNLOAD_LINKS_CONTAINER: 'span[style="font-size: 18px"]',
|
||||
SEARCH_THREADS_RESULTS_BODY: "div.contentRow-main",
|
||||
SEARCH_THREADS_MEMBERSHIP: "li > a:not(.username)",
|
||||
THREAD_LAST_CHANGELOG: "div.bbCodeBlock-content > div:first-of-type",
|
||||
BD_ENGINE_ID_SELECTOR: "div[id^=\"btn-prefix_1_\"]>span",
|
||||
BD_STATUS_ID_SELECTOR: "div[id^=\"btn-prefix_4_\"]>span",
|
||||
|
||||
GT_IMAGES: "img:not([title])[data-src^=\"https://attachments.f95zone.to\"][data-url=\"\"]",
|
||||
GT_TAGS: "a.tagItem",
|
||||
GT_TITLE: "h1.p-title-value",
|
||||
GT_TITLE_PREFIXES: "h1.p-title-value > a.labelLink > span[dir=\"auto\"]",
|
||||
GT_LAST_CHANGELOG: "div.bbCodeBlock-content > div:first-of-type",
|
||||
GT_JSONLD: "script[type=\"application/ld+json\"]",
|
||||
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\"]",
|
||||
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",
|
||||
});
|
||||
|
|
|
@ -2,6 +2,6 @@ module.exports = Object.freeze({
|
|||
F95_BASE_URL: "https://f95zone.to",
|
||||
F95_SEARCH_URL: "https://f95zone.to/search/?type=post",
|
||||
F95_LATEST_UPDATES: "https://f95zone.to/latest",
|
||||
F95_LOGIN_URL: "https://f95zone.to/login",
|
||||
F95_LOGIN_URL: "https://f95zone.to/login/login",
|
||||
F95_WATCHED_THREADS: "https://f95zone.to/watched/threads",
|
||||
});
|
||||
|
|
|
@ -1,467 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const HTMLParser = require("node-html-parser");
|
||||
const puppeteer = require("puppeteer"); // skipcq: JS-0128
|
||||
|
||||
// Modules from file
|
||||
const shared = require("./shared.js");
|
||||
const selectorK = require("./constants/css-selector.js");
|
||||
const { preparePage } = require("./puppeteer-helper.js");
|
||||
const GameDownload = require("./classes/game-download.js");
|
||||
const GameInfo = require("./classes/game-info.js");
|
||||
const urlHelper = require("./url-helper.js");
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Get information from the game's main page.
|
||||
* @param {puppeteer.Browser} browser Browser object used for navigation
|
||||
* @param {String} url URL (String) of the game/mod to extract data from
|
||||
* @return {Promise<GameInfo>} Complete information about the game you are
|
||||
* looking for
|
||||
*/
|
||||
module.exports.getGameInfo = async function (browser, url) {
|
||||
shared.logger.info("Obtaining game info");
|
||||
|
||||
// Verify the correctness of the URL
|
||||
const exists = await urlHelper.urlExists(url);
|
||||
if (!exists) throw new URIError(`${url} is not a valid URL`);
|
||||
if (!urlHelper.isF95URL(url))
|
||||
throw new Error(`${url} is not a valid F95Zone URL`);
|
||||
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.setCookie(...shared.cookies); // Set cookies to avoid login
|
||||
await page.goto(url, {
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
}); // Go to the game page and wait until it loads
|
||||
|
||||
// It asynchronously searches for the elements and
|
||||
// then waits at the end to compile the object to be returned
|
||||
let info = new GameInfo();
|
||||
const title = getGameTitle(page);
|
||||
const author = getGameAuthor(page);
|
||||
const tags = getGameTags(page);
|
||||
const redirectUrl = urlHelper.getUrlRedirect(url);
|
||||
info = await parsePrefixes(page, info); // Fill status/engines/isMod
|
||||
const structuredText = await getMainPostStructuredText(page);
|
||||
const overview = getOverview(structuredText, info.isMod);
|
||||
const parsedInfos = parseConversationPage(structuredText);
|
||||
const previewSource = getGamePreviewSource(page);
|
||||
const changelog = getLastChangelog(page);
|
||||
|
||||
// Fill in the GameInfo element with the information obtained
|
||||
info.name = await title;
|
||||
info.author = await author;
|
||||
info.tags = await tags;
|
||||
info.f95url = await redirectUrl;
|
||||
info.overview = overview;
|
||||
info.lastUpdate = info.isMod
|
||||
? parsedInfos.UPDATED
|
||||
: parsedInfos.THREAD_UPDATED;
|
||||
info.previewSource = await previewSource;
|
||||
info.changelog = await changelog;
|
||||
info.version = await exports.getGameVersionFromTitle(browser, info);
|
||||
|
||||
//let downloadData = getGameDownloadLink(page);
|
||||
//info.downloadInfo = await downloadData;
|
||||
/* Downloading games without going directly to
|
||||
* the platform appears to be prohibited by
|
||||
* the guidelines. It is therefore useless to
|
||||
* keep the links for downloading the games. */
|
||||
|
||||
await page.close(); // Close the page
|
||||
shared.logger.info("Founded data for " + info.name);
|
||||
return info;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtain the game version without parsing again all the data of the game.
|
||||
* @param {puppeteer.Browser} browser Browser object used for navigation
|
||||
* @param {GameInfo} info Information about the game
|
||||
* @returns {Promise<String>} Online version of the game
|
||||
*/
|
||||
module.exports.getGameVersionFromTitle = async function (browser, info) {
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.setCookie(...shared.cookies); // Set cookies to avoid login
|
||||
await page.goto(info.f95url, {
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
}); // Go to the game page and wait until it loads
|
||||
|
||||
// Get the title
|
||||
const titleHTML = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(selector) => document.querySelector(selector).innerHTML,
|
||||
selectorK.GAME_TITLE
|
||||
);
|
||||
const title = HTMLParser.parse(titleHTML).childNodes.pop().rawText;
|
||||
|
||||
// The title is in the following format: [PREFIXES] NAME GAME [VERSION] [AUTHOR]
|
||||
const startIndex = title.indexOf("[") + 1;
|
||||
const endIndex = title.indexOf("]", startIndex);
|
||||
let version = title.substring(startIndex, endIndex).trim().toUpperCase();
|
||||
if (version.startsWith("V")) version = version.replace("V", ""); // Replace only the first occurrence
|
||||
await page.close();
|
||||
return cleanFSString(version);
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* Clean a string from invalid File System chars.
|
||||
* @param {String} s
|
||||
* @returns {String}
|
||||
*/
|
||||
function cleanFSString(s) {
|
||||
const rx = /[/\\?%*:|"<>]/g;
|
||||
return s.replace(rx, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Get the game description from its web page.
|
||||
* Different processing depending on whether the game is a mod or not.
|
||||
* @param {String} text Structured text extracted from the game's web page
|
||||
* @param {Boolean} isMod Specify if it is a game or a mod
|
||||
* @returns {Promise<String>} Game description
|
||||
*/
|
||||
function getOverview(text, isMod) {
|
||||
// Get overview (different parsing for game and mod)
|
||||
let overviewEndIndex;
|
||||
if (isMod) overviewEndIndex = text.indexOf("Updated");
|
||||
else overviewEndIndex = text.indexOf("Thread Updated");
|
||||
return text.substring(0, overviewEndIndex).replace("Overview:\n", "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Extrapolate the page structure by removing the element tags
|
||||
* and leaving only the text and its spacing.
|
||||
* @param {puppeteer.Page} page Page containing the text
|
||||
* @returns {Promise<String>} Structured text
|
||||
*/
|
||||
async function getMainPostStructuredText(page) {
|
||||
// Gets the first post, where are listed all the game's informations
|
||||
const post = (await page.$$(selectorK.THREAD_POSTS))[0];
|
||||
|
||||
// The info are plain text so we need to parse the HTML code
|
||||
const bodyHTML = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(mainPost) => mainPost.innerHTML,
|
||||
post
|
||||
);
|
||||
return HTMLParser.parse(bodyHTML).structuredText;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Extrapolates and cleans the author from the page passed by parameter.
|
||||
* @param {puppeteer.Page} page Page containing the author to be extrapolated
|
||||
* @returns {Promise<String>} Game author
|
||||
*/
|
||||
async function getGameAuthor(page) {
|
||||
// Get the game/mod name (without square brackets)
|
||||
const titleHTML = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(selector) => document.querySelector(selector).innerHTML,
|
||||
selectorK.GAME_TITLE
|
||||
);
|
||||
const structuredTitle = HTMLParser.parse(titleHTML);
|
||||
|
||||
// The last element **shoud be** the title without prefixes (engines, status, other...)
|
||||
const gameTitle = structuredTitle.childNodes.pop().rawText;
|
||||
|
||||
// The last square brackets contain the author
|
||||
const startTitleIndex = gameTitle.lastIndexOf("[") + 1;
|
||||
return gameTitle.substring(startTitleIndex, gameTitle.length - 1).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Process the post text to get all the useful
|
||||
* information in the format *DESCRIPTOR : VALUE*.
|
||||
* @param {String} text Structured text of the post
|
||||
* @returns {Object} Dictionary of information
|
||||
*/
|
||||
function parseConversationPage(text) {
|
||||
const dataPairs = {};
|
||||
|
||||
// The information searched in the game post are one per line
|
||||
const splittedText = text.split("\n");
|
||||
for (const line of splittedText) {
|
||||
if (!line.includes(":")) continue;
|
||||
|
||||
// Create pair key/value
|
||||
const splitted = line.split(":");
|
||||
const key = splitted[0].trim().toUpperCase().replace(/ /g, "_"); // Uppercase to avoid mismatch
|
||||
const value = splitted[1].trim();
|
||||
|
||||
// Add pair to the dict if valid
|
||||
if (value !== "") dataPairs[key] = value;
|
||||
}
|
||||
|
||||
return dataPairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the URL of the image used as a preview for the game in the conversation.
|
||||
* @param {puppeteer.Page} page Page containing the URL to be extrapolated
|
||||
* @returns {Promise<String>} URL (String) of the image or null if failed to get it
|
||||
*/
|
||||
async function getGamePreviewSource(page) {
|
||||
// Wait for the selector or return an empty value
|
||||
try {
|
||||
await page.waitForSelector(selectorK.GAME_IMAGES);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const src = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(selector) => {
|
||||
// Get the firs image available
|
||||
const img = document.querySelector(selector);
|
||||
|
||||
if (img) return img.getAttribute("src");
|
||||
else return null;
|
||||
},
|
||||
selectorK.GAME_IMAGES
|
||||
);
|
||||
|
||||
// Check if the URL is valid
|
||||
return urlHelper.isStringAValidURL(src) ? src : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Extrapolates and cleans the title from the page passed by parameter.
|
||||
* @param {puppeteer.Page} page Page containing the title to be extrapolated
|
||||
* @returns {Promise<String>} Game title
|
||||
*/
|
||||
async function getGameTitle(page) {
|
||||
// Get the game/mod name (without square brackets)
|
||||
const titleHTML = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(selector) => document.querySelector(selector).innerHTML,
|
||||
selectorK.GAME_TITLE
|
||||
);
|
||||
const structuredTitle = HTMLParser.parse(titleHTML);
|
||||
|
||||
// The last element **shoud be** the title without prefixes (engines, status, other...)
|
||||
const gameTitle = structuredTitle.childNodes.pop().rawText;
|
||||
const endTitleIndex = gameTitle.indexOf("[");
|
||||
return gameTitle.substring(0, endTitleIndex).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Get the alphabetically sorted list of tags associated with the game.
|
||||
* @param {puppeteer.Page} page Page containing the tags to be extrapolated
|
||||
* @returns {Promise<String[]>} List of uppercase tags
|
||||
*/
|
||||
async function getGameTags(page) {
|
||||
const tags = [];
|
||||
|
||||
// Get the game tags
|
||||
for (const handle of await page.$$(selectorK.GAME_TAGS)) {
|
||||
const tag = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(element) => element.innerText,
|
||||
handle
|
||||
);
|
||||
tags.push(tag.toUpperCase());
|
||||
}
|
||||
return tags.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Process the game title prefixes to extract information such as game status,
|
||||
* graphics engine used, and whether it is a mod or original game.
|
||||
* @param {puppeteer.Page} page Page containing the prefixes to be extrapolated
|
||||
* @param {GameInfo} info Object to assign the identified information to
|
||||
* @returns {Promise<GameInfo>} GameInfo object passed in to which the identified information has been added
|
||||
*/
|
||||
async function parsePrefixes(page, info) {
|
||||
// The 'Ongoing' status is not specified, only 'Abandoned'/'OnHold'/'Complete'
|
||||
info.status = "ONGOING";
|
||||
for (const handle of await page.$$(selectorK.GAME_TITLE_PREFIXES)) {
|
||||
const value = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(element) => element.innerText,
|
||||
handle
|
||||
);
|
||||
|
||||
// Clean the prefix
|
||||
const prefix = value.toUpperCase().replace("[", "").replace("]", "").trim();
|
||||
|
||||
// Getting infos...
|
||||
if (shared.statuses.includes(prefix)) info.status = prefix;
|
||||
else if (shared.engines.includes(prefix)) info.engine = prefix;
|
||||
// This is not a game but a mod
|
||||
else if (prefix === "MOD" || prefix === "CHEAT MOD") info.isMod = true;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Get the last changelog available for the game.
|
||||
* @param {puppeteer.Page} page Page containing the changelog
|
||||
* @returns {Promise<String>} Changelog for the last version or a empty string if no changelog is found
|
||||
*/
|
||||
async function getLastChangelog(page) {
|
||||
// Gets the first post, where are listed all the game's informations
|
||||
const post = (await page.$$(selectorK.THREAD_POSTS))[0];
|
||||
|
||||
const spoiler = await post.$(selectorK.THREAD_LAST_CHANGELOG);
|
||||
if (!spoiler) return "";
|
||||
|
||||
const changelogHTML = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(e) => e.innerText,
|
||||
spoiler
|
||||
);
|
||||
let parsedText = HTMLParser.parse(changelogHTML).structuredText;
|
||||
|
||||
// Clean the text
|
||||
if (parsedText.startsWith("Spoiler"))
|
||||
parsedText = parsedText.replace("Spoiler", "");
|
||||
if (parsedText.startsWith(":")) parsedText = parsedText.replace(":", "");
|
||||
return parsedText.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Get game download links for different platforms.
|
||||
* @param {puppeteer.Page} page Page containing the links to be extrapolated
|
||||
* @returns {Promise<GameDownload[]>} List of objects used for game download
|
||||
* @deprecated
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
// skipcq: JS-0128
|
||||
async function getGameDownloadLink(page) {
|
||||
// Most used hosting platforms
|
||||
const hostingPlatforms = [
|
||||
"MEGA",
|
||||
"NOPY",
|
||||
"FILESUPLOAD",
|
||||
"MIXDROP",
|
||||
"UPLOADHAVEN",
|
||||
"PIXELDRAIN",
|
||||
"FILESFM",
|
||||
];
|
||||
|
||||
// Supported OS platforms
|
||||
const platformOS = ["WIN", "LINUX", "MAC", "ALL"];
|
||||
|
||||
// Gets the <span> which contains the download links
|
||||
const temp = await page.$$(selectorK.DOWNLOAD_LINKS_CONTAINER);
|
||||
if (temp.length === 0) return [];
|
||||
|
||||
// Look for the container that contains the links
|
||||
// It is necessary because the same css selector
|
||||
// also identifies other elements on the page
|
||||
let container = null;
|
||||
for (const candidate of temp) {
|
||||
if (container !== null) break;
|
||||
const upperText = (
|
||||
await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(e) => e.innerText,
|
||||
candidate
|
||||
)
|
||||
).toUpperCase();
|
||||
|
||||
// Search if the container contains the name of a hosting platform
|
||||
for (const p of hostingPlatforms) {
|
||||
if (upperText.includes(p)) {
|
||||
container = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (container === null) return [];
|
||||
|
||||
// Extract the HTML text from the container
|
||||
const searchText = (
|
||||
await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(e) => e.innerHTML,
|
||||
container
|
||||
)
|
||||
).toLowerCase();
|
||||
|
||||
// Parse the download links
|
||||
const downloadData = [];
|
||||
for (const platform of platformOS) {
|
||||
const data = extractGameHostingData(platform, searchText);
|
||||
downloadData.push(...data);
|
||||
}
|
||||
return downloadData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* From the HTML text it extracts the game download links for the specified operating system.
|
||||
* @param {String} platform Name of the operating system to look for a compatible link to.
|
||||
* It can only be *WIN/LINUX/MAC/ALL*
|
||||
* @param {String} text HTML string to extract links from
|
||||
* @returns {GameDownload[]} List of game download links for the selected platform
|
||||
* @deprecated
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
function extractGameHostingData(platform, text) {
|
||||
const PLATFORM_BOLD_OPEN = "<b>";
|
||||
const CONTAINER_SPAN_CLOSE = "</span>";
|
||||
const LINK_OPEN = "<a";
|
||||
const LINK_CLOSE = "</a>";
|
||||
const HREF_START = "href='";
|
||||
const HREF_END = "'";
|
||||
const TAG_CLOSE = ">";
|
||||
|
||||
// Identify the individual platforms
|
||||
let startIndex = text.indexOf(platform.toLowerCase());
|
||||
if (startIndex === -1) return [];
|
||||
else startIndex += platform.length;
|
||||
|
||||
// Find the <b>platform</b>
|
||||
let endIndex =
|
||||
text.indexOf(PLATFORM_BOLD_OPEN, startIndex) + PLATFORM_BOLD_OPEN.length;
|
||||
|
||||
// Find the end of the container
|
||||
if (endIndex === -1)
|
||||
endIndex =
|
||||
text.indexOf(CONTAINER_SPAN_CLOSE, startIndex) +
|
||||
CONTAINER_SPAN_CLOSE.length;
|
||||
|
||||
text = text.substring(startIndex, endIndex);
|
||||
|
||||
const downloadData = [];
|
||||
const linkTags = text.split(LINK_OPEN);
|
||||
for (const tag of linkTags) {
|
||||
// Ignore non-link string
|
||||
if (!tag.includes(HREF_START)) continue;
|
||||
|
||||
// Find the hosting platform name
|
||||
startIndex = tag.indexOf(TAG_CLOSE) + TAG_CLOSE.length;
|
||||
endIndex = tag.indexOf(LINK_CLOSE, startIndex);
|
||||
const hosting = tag.substring(startIndex, endIndex);
|
||||
|
||||
// Find the 'href' attribute
|
||||
startIndex = tag.indexOf(HREF_START) + HREF_START.length;
|
||||
endIndex = tag.indexOf(HREF_END, startIndex);
|
||||
const link = tag.substring(startIndex, endIndex);
|
||||
|
||||
if (urlHelper.isStringAValidURL(link)) {
|
||||
const gd = new GameDownload();
|
||||
gd.hosting = hosting.toUpperCase();
|
||||
gd.link = link;
|
||||
gd.supportedOS = platform.toUpperCase();
|
||||
|
||||
downloadData.push(gd);
|
||||
}
|
||||
}
|
||||
return downloadData;
|
||||
}
|
||||
|
||||
//#endregion Private methods
|
|
@ -1,132 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const puppeteer = require("puppeteer"); // skipcq: JS-0128
|
||||
|
||||
// Modules from file
|
||||
const shared = require("./shared.js");
|
||||
const urlK = require("./constants/url.js");
|
||||
const selectorK = require("./constants/css-selector.js");
|
||||
const { preparePage } = require("./puppeteer-helper.js");
|
||||
const { isF95URL } = require("./url-helper.js");
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Search the F95Zone portal to find possible conversations regarding the game you are looking for.
|
||||
* @param {puppeteer.Browser} browser Browser object used for navigation
|
||||
* @param {String} gamename Name of the game to search for
|
||||
* @returns {Promise<String[]>} List of URL of possible games obtained from the preliminary research on the F95 portal
|
||||
*/
|
||||
module.exports.getSearchGameResults = async function (browser, gamename) {
|
||||
shared.logger.info(`Searching ${gamename} on F95Zone`);
|
||||
|
||||
const page = await preparePage(browser); // Set new isolated page
|
||||
await page.setCookie(...shared.cookies); // Set cookies to avoid login
|
||||
await page.goto(urlK.F95_SEARCH_URL, {
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
}); // Go to the search form and wait for it
|
||||
|
||||
// Explicitly wait for the required items to load
|
||||
await Promise.all([
|
||||
page.waitForSelector(selectorK.SEARCH_FORM_TEXTBOX),
|
||||
page.waitForSelector(selectorK.TITLE_ONLY_CHECKBOX),
|
||||
page.waitForSelector(selectorK.SEARCH_ONLY_GAMES_OPTION),
|
||||
page.waitForSelector(selectorK.SEARCH_BUTTON),
|
||||
]);
|
||||
|
||||
await page.type(selectorK.SEARCH_FORM_TEXTBOX, gamename); // Type the game we desire
|
||||
await page.click(selectorK.TITLE_ONLY_CHECKBOX); // Select only the thread with the game in the titles
|
||||
await page.click(selectorK.SEARCH_ONLY_GAMES_OPTION); // Search only games and mod
|
||||
await Promise.all([
|
||||
page.click(selectorK.SEARCH_BUTTON), // Execute search
|
||||
page.waitForNavigation({
|
||||
waitUntil: shared.WAIT_STATEMENT,
|
||||
}), // Wait for page to load
|
||||
]);
|
||||
|
||||
// Select all conversation titles
|
||||
const resultsThread = await page.$$(selectorK.SEARCH_THREADS_RESULTS_BODY);
|
||||
|
||||
// For each element found extract the info about the conversation
|
||||
shared.logger.info("Extracting info from conversations");
|
||||
const results = [];
|
||||
for (const element of resultsThread) {
|
||||
const gameUrl = await getOnlyGameThreads(page, element);
|
||||
if (gameUrl !== null) results.push(gameUrl);
|
||||
}
|
||||
shared.logger.info(`Find ${results.length} conversations`);
|
||||
await page.close(); // Close the page
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* Return the link of a conversation if it is a game or a mod.
|
||||
* @param {puppeteer.Page} page Page containing the conversation to be analyzed
|
||||
* @param {puppeteer.ElementHandle} divHandle Element of the conversation to be analyzed
|
||||
* @return {Promise<String>} URL of the game/mod or null if the URL is not of a game
|
||||
*/
|
||||
async function getOnlyGameThreads(page, divHandle) {
|
||||
// Obtain the elements containing the basic information
|
||||
const titleHandle = await divHandle.$(selectorK.THREAD_TITLE);
|
||||
const forumHandle = await divHandle.$(selectorK.SEARCH_THREADS_MEMBERSHIP);
|
||||
|
||||
// Get the forum where the thread was posted
|
||||
const forum = await getMembershipForum(page, forumHandle);
|
||||
if (forum !== "GAMES" && forum !== "MODS") return null;
|
||||
|
||||
// Get the URL of the thread from the title
|
||||
return await getThreadURL(page, titleHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Obtain the membership forum of the thread passed throught 'handle'.
|
||||
* @param {puppeteer.Page} page Page containing the conversation to be analyzed
|
||||
* @param {puppeteer.ElementHandle} handle Handle containing the forum membership
|
||||
* @returns {Promise<String>} Uppercase membership category
|
||||
*/
|
||||
async function getMembershipForum(page, handle) {
|
||||
// The link can be something like:
|
||||
// + /forums/request.NUMBER/
|
||||
// + /forums/game-recommendations-identification.NUMBER/
|
||||
// + /forums/games.NUMBER/ <-- We need this
|
||||
|
||||
let link = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(e) => e.getAttribute("href"),
|
||||
handle
|
||||
);
|
||||
|
||||
// Parse link
|
||||
link = link.replace("/forums/", "");
|
||||
const endIndex = link.indexOf(".");
|
||||
const forum = link.substring(0, endIndex);
|
||||
|
||||
return forum.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Obtain the URL of the thread passed through 'handle'.
|
||||
* @param {puppeteer.Page} page Page containing the conversation to be analyzed
|
||||
* @param {puppeteer.ElementHandle} handle Handle containing the thread title
|
||||
* @returns {Promise<String>} URL of the thread
|
||||
*/
|
||||
async function getThreadURL(page, handle) {
|
||||
const relativeURLThread = await page.evaluate(
|
||||
/* istanbul ignore next */
|
||||
(e) => e.querySelector("a").href,
|
||||
handle
|
||||
);
|
||||
|
||||
// Some game already have a full URL...
|
||||
if (isF95URL(relativeURLThread)) return relativeURLThread;
|
||||
|
||||
// ... else compose the URL and return
|
||||
const urlThread = new URL(relativeURLThread, urlK.F95_BASE_URL).toString();
|
||||
return urlThread;
|
||||
}
|
||||
//#endregion Private methods
|
|
@ -0,0 +1,243 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const axios = require("axios").default;
|
||||
const ky = require("ky-universal").create({
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
const cheerio = require("cheerio");
|
||||
const axiosCookieJarSupport = require("axios-cookiejar-support").default;
|
||||
const tough = require("tough-cookie");
|
||||
|
||||
// Modules from file
|
||||
const shared = require("./shared.js");
|
||||
const f95url = require("./constants/url.js");
|
||||
const f95selector = require("./constants/css-selector.js");
|
||||
const LoginResult = require("./classes/login-result.js");
|
||||
|
||||
// Global variables
|
||||
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";
|
||||
axiosCookieJarSupport(axios);
|
||||
const cookieJar = new tough.CookieJar();
|
||||
|
||||
const commonConfig = {
|
||||
headers: {
|
||||
"User-Agent": userAgent,
|
||||
"Connection": "keep-alive"
|
||||
},
|
||||
withCredentials: true,
|
||||
jar: cookieJar // Used to store the token in the PC
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Gets the HTML code of a page.
|
||||
* @param {String} url URL to fetch
|
||||
* @returns {Promise<String>} HTML code or `null` if an error arise
|
||||
*/
|
||||
module.exports.fetchHTML = async function (url) {
|
||||
// Fetch the response of the platform
|
||||
const response = await exports.fetchGETResponse(url);
|
||||
if (!response) {
|
||||
shared.logger.warn(`Unable to fetch HTML for ${url}`);
|
||||
return null;
|
||||
}
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* It authenticates to the platform using the credentials
|
||||
* and token obtained previously. Save cookies on your
|
||||
* device after authentication.
|
||||
* @param {Credentials} credentials Platform access credentials
|
||||
* @param {Boolea} force Specifies whether the request should be forced, ignoring any saved cookies
|
||||
* @returns {Promise<LoginResult>} Result of the operation
|
||||
*/
|
||||
module.exports.authenticate = async function (credentials, force) {
|
||||
shared.logger.info(`Authenticating with user ${credentials.username}`);
|
||||
if (!credentials.token) throw new Error(`Invalid token for auth: ${credentials.token}`);
|
||||
|
||||
// Secure the URL
|
||||
const secureURL = exports.enforceHttpsUrl(f95url.F95_LOGIN_URL);
|
||||
|
||||
// Prepare the parameters to send to the platform to authenticate
|
||||
const params = new URLSearchParams();
|
||||
params.append("login", credentials.username);
|
||||
params.append("url", "");
|
||||
params.append("password", credentials.password);
|
||||
params.append("password_confirm", "");
|
||||
params.append("additional_security", "");
|
||||
params.append("remember", "1");
|
||||
params.append("_xfRedirect", "https://f95zone.to/");
|
||||
params.append("website_code", "");
|
||||
params.append("_xfToken", credentials.token);
|
||||
|
||||
try {
|
||||
// Try to log-in
|
||||
let config = Object.assign({}, commonConfig);
|
||||
if (force) delete config.jar;
|
||||
const response = await axios.post(secureURL, params, config);
|
||||
|
||||
// Parse the response HTML
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Get the error message (if any) and remove the new line chars
|
||||
const errorMessage = $("body").find(f95selector.LOGIN_MESSAGE_ERROR).text().replace(/\n/g, "");
|
||||
|
||||
// Return the result of the authentication
|
||||
if (errorMessage === "") return new LoginResult(true, "Authentication successful");
|
||||
else return new LoginResult(false, errorMessage);
|
||||
} catch (e) {
|
||||
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.
|
||||
* @returns {Promise<String>} Token or `null` if an error arise
|
||||
*/
|
||||
module.exports.getF95Token = async function() {
|
||||
// Fetch the response of the platform
|
||||
const response = await exports.fetchGETResponse(f95url.F95_LOGIN_URL);
|
||||
if (!response) {
|
||||
shared.logger.warn("Unable to get the token for the session");
|
||||
return null;
|
||||
}
|
||||
|
||||
// The response is a HTML page, we need to find the <input> with name "_xfToken"
|
||||
const $ = cheerio.load(response.data);
|
||||
const token = $("body").find(f95selector.GET_REQUEST_TOKEN).attr("value");
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Gets the basic data used for game data processing
|
||||
* (such as graphics engines and progress statuses)
|
||||
* @deprecated
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
module.exports.fetchPlatformData = async function() {
|
||||
// Fetch the response of the platform
|
||||
const response = await exports.fetchGETResponse(f95url.F95_LATEST_UPDATES);
|
||||
if (!response) {
|
||||
shared.logger.warn("Unable to get the token for the session");
|
||||
return;
|
||||
}
|
||||
|
||||
// The response is a HTML page, we need to find
|
||||
// the base data, used when scraping the games
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
// Extract the elements
|
||||
const engineElements = $("body").find(f95selector.BD_ENGINE_ID_SELECTOR);
|
||||
const statusesElements = $("body").find(f95selector.BD_STATUS_ID_SELECTOR);
|
||||
|
||||
// Extract the raw text
|
||||
engineElements.each(function extractEngineNames(idx, el) {
|
||||
const engine = cheerio.load(el).text().trim();
|
||||
shared.engines.push(engine);
|
||||
});
|
||||
|
||||
statusesElements.each(function extractEngineNames(idx, el) {
|
||||
const status = cheerio.load(el).text().trim();
|
||||
shared.statuses.push(status);
|
||||
});
|
||||
};
|
||||
|
||||
//#region Utility methods
|
||||
/**
|
||||
* @protected
|
||||
* Performs a GET request to a specific URL and returns the response.
|
||||
* If the request generates an error (for example 400) `null` is returned.
|
||||
* @param {String} url
|
||||
*/
|
||||
module.exports.fetchGETResponse = async function(url) {
|
||||
// Secure the URL
|
||||
const secureURL = exports.enforceHttpsUrl(url);
|
||||
|
||||
try {
|
||||
// Fetch and return the response
|
||||
return await axios.get(secureURL, commonConfig);
|
||||
} catch (e) {
|
||||
shared.logger.error(`Error ${e.message} occurred while trying to fetch ${secureURL}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Enforces the scheme of the URL is https and returns the new URL.
|
||||
* @param {String} url
|
||||
* @returns {String} Secure URL or `null` if the argument is not a string
|
||||
*/
|
||||
module.exports.enforceHttpsUrl = function (url) {
|
||||
return exports.isStringAValidURL(url) ? url.replace(/^(https?:)?\/\//, "https://") : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Check if the url belongs to the domain of the F95 platform.
|
||||
* @param {String} url URL to check
|
||||
* @returns {Boolean} true if the url belongs to the domain, false otherwise
|
||||
*/
|
||||
module.exports.isF95URL = function (url) {
|
||||
if (url.toString().startsWith(f95url.F95_BASE_URL)) return true;
|
||||
else return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Checks if the string passed by parameter has a
|
||||
* properly formatted and valid path to a URL (HTTP/HTTPS).
|
||||
* @param {String} url String to check for correctness
|
||||
* @returns {Boolean} true if the string is a valid URL, false otherwise
|
||||
*/
|
||||
module.exports.isStringAValidURL = function (url) {
|
||||
// Many thanks to Daveo at StackOverflow (https://preview.tinyurl.com/y2f2e2pc)
|
||||
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);
|
||||
if (url.match(regex)) return true;
|
||||
else return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Check if a particular URL is valid and reachable on the web.
|
||||
* @param {String} url URL to check
|
||||
* @param {Boolean} checkRedirect If true, the function will consider redirects a violation and return false
|
||||
* @returns {Promise<Boolean>} true if the URL exists, false otherwise
|
||||
*/
|
||||
module.exports.urlExists = async function (url, checkRedirect) {
|
||||
if (!exports.isStringAValidURL(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await ky.head(url);
|
||||
let valid = response !== undefined && !/4\d\d/.test(response.status);
|
||||
|
||||
if (!valid) return false;
|
||||
|
||||
if (checkRedirect) {
|
||||
const redirectUrl = await exports.getUrlRedirect(url);
|
||||
if (redirectUrl === url) valid = true;
|
||||
else valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Check if the URL has a redirect to another page.
|
||||
* @param {String} url URL to check for redirect
|
||||
* @returns {Promise<String>} Redirect URL or the passed URL
|
||||
*/
|
||||
module.exports.getUrlRedirect = async function (url) {
|
||||
const response = await ky.head(url);
|
||||
return response.url;
|
||||
};
|
||||
//#endregion Utility methods
|
|
@ -1,60 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const puppeteer = require("puppeteer");
|
||||
|
||||
// Modules from file
|
||||
const shared = require("./shared.js");
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Create a Chromium instance used to navigate with Puppeteer.
|
||||
* By default the browser is headless.
|
||||
* @returns {Promise<puppeteer.Browser>} Created browser
|
||||
*/
|
||||
module.exports.prepareBrowser = async function () {
|
||||
// Create a headless browser
|
||||
let browser = null;
|
||||
if (shared.chromiumLocalPath) {
|
||||
browser = await puppeteer.launch({
|
||||
executablePath: shared.chromiumLocalPath,
|
||||
headless: !shared.debug, // Use GUI when debug = true
|
||||
});
|
||||
} else {
|
||||
browser = await puppeteer.launch({
|
||||
headless: !shared.debug, // Use GUI when debug = true
|
||||
});
|
||||
}
|
||||
|
||||
return browser;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Prepare a page used to navigate the browser.
|
||||
* The page is set up to reject image download requests. The user agent is also changed.
|
||||
* @param {puppeteer.Browser} browser Browser to use when navigating where the page will be created
|
||||
* @returns {Promise<puppeteer.Page>} New page
|
||||
*/
|
||||
module.exports.preparePage = async function (browser) {
|
||||
// Create new page in the browser argument
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Block image download
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (request) => {
|
||||
if (request.resourceType() === "image") request.abort();
|
||||
else if (request.resourceType === "font") request.abort();
|
||||
// else if (request.resourceType() == 'stylesheet') request.abort();
|
||||
// else if(request.resourceType == 'media') request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
|
||||
// Set custom user-agent
|
||||
const userAgent =
|
||||
"Mozilla/5.0 (X11; Linux x86_64)" +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.39 Safari/537.36";
|
||||
await page.setUserAgent(userAgent);
|
||||
|
||||
return page;
|
||||
};
|
|
@ -0,0 +1,371 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const cheerio = require("cheerio");
|
||||
|
||||
// Modules from file
|
||||
const { fetchHTML, getUrlRedirect } = require("./network-helper.js");
|
||||
const shared = require("./shared.js");
|
||||
const GameInfo = require("./classes/game-info.js");
|
||||
const f95Selector = require("./constants/css-selector.js");
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Get information from the game's main page.
|
||||
* @param {String} url URL of the game/mod to extract data from
|
||||
* @return {Promise<GameInfo>} Complete information about the game you are
|
||||
* looking for
|
||||
*/
|
||||
module.exports.getGameInfo = async function (url) {
|
||||
shared.logger.info("Obtaining game info");
|
||||
|
||||
// Fetch HTML and prepare Cheerio
|
||||
const html = await fetchHTML(url);
|
||||
const $ = cheerio.load(html);
|
||||
const body = $("body");
|
||||
const mainPost = $(f95Selector.GS_POSTS).first();
|
||||
|
||||
// Extract data
|
||||
const titleData = extractInfoFromTitle(body);
|
||||
const tags = extractTags(body);
|
||||
const prefixesData = parseGamePrefixes(body);
|
||||
const src = extractPreviewSource(body);
|
||||
const changelog = extractChangelog(mainPost);
|
||||
const structuredData = extractStructuredData(body);
|
||||
const parsedInfos = parseMainPostText(structuredData["description"]);
|
||||
const overview = getOverview(structuredData["description"], prefixesData.mod);
|
||||
|
||||
// Obtain the updated URL
|
||||
const redirectUrl = await getUrlRedirect(url);
|
||||
|
||||
// Fill in the GameInfo element with the information obtained
|
||||
const info = new GameInfo();
|
||||
info.name = titleData.name;
|
||||
info.author = titleData.author;
|
||||
info.isMod = prefixesData.mod;
|
||||
info.engine = prefixesData.engine;
|
||||
info.status = prefixesData.status;
|
||||
info.tags = tags;
|
||||
info.url = redirectUrl;
|
||||
info.language = parsedInfos.Language;
|
||||
info.overview = overview;
|
||||
info.supportedOS = parsedInfos.SupportedOS;
|
||||
info.censored = parsedInfos.Censored;
|
||||
info.lastUpdate = parsedInfos.LastUpdate;
|
||||
info.previewSrc = src;
|
||||
info.changelog = changelog;
|
||||
info.version = titleData.version;
|
||||
|
||||
shared.logger.info(`Founded data for ${info.name}`);
|
||||
return info;
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* Parse the game prefixes obtaining the engine used,
|
||||
* the advancement status and if the game is actually a game or a mod.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {Object.<string, object>} Dictionary of values with keys `engine`, `status`, `mod`
|
||||
*/
|
||||
function parseGamePrefixes(body) {
|
||||
shared.logger.trace("Parsing prefixes...");
|
||||
|
||||
// Local variables
|
||||
let mod = false,
|
||||
engine = null,
|
||||
status = null;
|
||||
|
||||
// Obtain the title prefixes
|
||||
const prefixeElements = body.find(f95Selector.GT_TITLE_PREFIXES);
|
||||
|
||||
prefixeElements.each(function parseGamePrefix(idx, el) {
|
||||
// Obtain the prefix text
|
||||
let prefix = cheerio.load(el).text().trim();
|
||||
|
||||
// Remove the square brackets
|
||||
prefix = prefix.replace("[", "").replace("]", "");
|
||||
|
||||
// Check what the prefix indicates
|
||||
if (isEngine(prefix)) engine = prefix;
|
||||
else if (isStatus(prefix)) status = prefix;
|
||||
else if (isMod(prefix)) mod = true;
|
||||
});
|
||||
|
||||
// If the status is not set, then the game in in development (Ongoing)
|
||||
if (!status) status = "Ongoing";
|
||||
|
||||
return {
|
||||
engine,
|
||||
status,
|
||||
mod
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Extracts all the possible informations from the title.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {Object.<string, string>} Dictionary of values with keys `name`, `author`, `version`
|
||||
*/
|
||||
function extractInfoFromTitle(body) {
|
||||
shared.logger.trace("Extracting information from title...");
|
||||
const title = body
|
||||
.find(f95Selector.GT_TITLE)
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
// From the title we can extract: Name, author and version
|
||||
// [PREFIXES] TITLE [VERSION] [AUTHOR]
|
||||
const matches = title.match(/\[(.*?)\]/g);
|
||||
|
||||
// Get the title name
|
||||
let name = title;
|
||||
matches.forEach(function replaceElementsInTitle(e) {
|
||||
name = name.replace(e, "");
|
||||
});
|
||||
name = name.trim();
|
||||
|
||||
// The regex [[\]]+ remove the square brackets
|
||||
|
||||
// The version is the penultimate element.
|
||||
// If the matches are less than 2, than the title
|
||||
// is malformes and only the author is fetched
|
||||
// (usually the author is always present)
|
||||
let version = null;
|
||||
if (matches.length >= 2) version = matches[matches.length - 2].replace(/[[\]]+/g, "").trim();
|
||||
else shared.logger.trace(`Malformed title: ${title}`);
|
||||
|
||||
// Last element
|
||||
const author = matches[matches.length - 1].replace(/[[\]]+/g, "").trim();
|
||||
|
||||
return {
|
||||
name,
|
||||
version,
|
||||
author,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the tags used to classify the game.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {String[]} List of tags
|
||||
*/
|
||||
function extractTags(body) {
|
||||
shared.logger.trace("Extracting tags...");
|
||||
|
||||
// Get the game tags
|
||||
const tagResults = body.find(f95Selector.GT_TAGS);
|
||||
return tagResults.map(function parseGameTags(idx, el) {
|
||||
return cheerio.load(el).text().trim();
|
||||
}).get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the URL of the image used as a preview.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {String} URL of the image
|
||||
*/
|
||||
function extractPreviewSource(body) {
|
||||
shared.logger.trace("Extracting image preview source...");
|
||||
const image = body.find(f95Selector.GT_IMAGES);
|
||||
|
||||
// The "src" attribute is rendered only in a second moment,
|
||||
// we need the "static" src value saved in the attribute "data-src"
|
||||
const source = image ? image.attr("data-src") : null;
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the changelog of the latest version.
|
||||
* @param {cheerio.Cheerio} mainPost main post selector
|
||||
* @returns {String} Changelog of the last version or `null` if no changelog is fetched
|
||||
*/
|
||||
function extractChangelog(mainPost) {
|
||||
shared.logger.trace("Extracting last changelog...");
|
||||
|
||||
// Obtain changelog
|
||||
let changelog = mainPost.find(f95Selector.GT_LAST_CHANGELOG).text().trim();
|
||||
|
||||
// Clean changelog
|
||||
changelog = changelog.replace("Spoiler", "");
|
||||
changelog = changelog.replace(/\n+/g, "\n");
|
||||
|
||||
// Return changelog
|
||||
return changelog ? changelog : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Process the main post text to get all the useful
|
||||
* information in the format *DESCRIPTOR : VALUE*.
|
||||
* Gets "standard" values such as: `Language`, `SupportedOS`, `Censored`, and `LastUpdate`.
|
||||
* All non-canonical values are instead grouped together as a dictionary with the key `Various`.
|
||||
* @param {String} text Structured text of the post
|
||||
* @returns {Object.<string, object>} Dictionary of information
|
||||
*/
|
||||
function parseMainPostText(text) {
|
||||
shared.logger.trace("Parsing main post raw text...");
|
||||
|
||||
const data = {};
|
||||
|
||||
// The information searched in the game post are one per line
|
||||
const splittedText = text.split("\n");
|
||||
for (const line of splittedText) {
|
||||
if (!line.includes(":")) continue;
|
||||
|
||||
// Create pair key/value
|
||||
const splitted = line.split(":");
|
||||
const key = splitted[0].trim().toUpperCase().replace(/ /g, "_"); // Uppercase to avoid mismatch
|
||||
const value = splitted[1].trim();
|
||||
|
||||
// Add pair to the dict if valid
|
||||
if (value !== "") data[key] = value;
|
||||
}
|
||||
|
||||
// Parse the standard pairs
|
||||
const parsedDict = {};
|
||||
|
||||
// Check if the game is censored
|
||||
if (data.CENSORED) {
|
||||
const censored = data.CENSORED.toUpperCase() === "NO" ? false : true;
|
||||
parsedDict["Censored"] = censored;
|
||||
delete data.CENSORED;
|
||||
}
|
||||
|
||||
// Last update of the main post
|
||||
if (data.UPDATED) {
|
||||
parsedDict["LastUpdate"] = new Date(data.UPDATED);
|
||||
delete data.UPDATED;
|
||||
}
|
||||
else if (data.THREAD_UPDATED) {
|
||||
parsedDict["LastUpdate"] = new Date(data.THREAD_UPDATED);
|
||||
delete data.THREAD_UPDATED;
|
||||
}
|
||||
|
||||
// Parse the supported OS
|
||||
if (data.OS) {
|
||||
const listOS = [];
|
||||
|
||||
// Usually the string is something like "Windows, Linux, Mac"
|
||||
const splitted = data.OS.split(",");
|
||||
splitted.forEach(function (os) {
|
||||
listOS.push(os.trim());
|
||||
});
|
||||
|
||||
parsedDict["SupportedOS"] = listOS;
|
||||
delete data.OS;
|
||||
}
|
||||
|
||||
// Rename the key for the language
|
||||
if (data.LANGUAGE) {
|
||||
parsedDict["Language"] = data.LANGUAGE;
|
||||
delete data.LANGUAGE;
|
||||
}
|
||||
|
||||
// What remains is added to a sub dictionary
|
||||
parsedDict["Various"] = data;
|
||||
|
||||
return parsedDict;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Extracts and processes the JSON-LD values found at the bottom of the page.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {Object.<string, string>} JSON-LD or `null` if no valid JSON is found
|
||||
*/
|
||||
function extractStructuredData(body) {
|
||||
shared.logger.trace("Extracting JSON-LD data...");
|
||||
const structuredDataElements = body.find(f95Selector.GT_JSONLD);
|
||||
const json = structuredDataElements.map(function parseScriptTag(idx, el) {
|
||||
// Get the element HTML
|
||||
const html = cheerio.load(el).html().trim();
|
||||
|
||||
// Obtain the JSON-LD
|
||||
const data = html
|
||||
.replace("<script type=\"application/ld+json\">", "")
|
||||
.replace("</script>", "");
|
||||
|
||||
// Convert the string to an object
|
||||
const json = JSON.parse(data);
|
||||
|
||||
// Return only the data of the game
|
||||
if (json["@type"] === "Book") return json;
|
||||
}).get();
|
||||
return json[0] ? json[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Get the game description from its web page.
|
||||
* Different processing depending on whether the game is a mod or not.
|
||||
* @param {String} text Structured text extracted from the game's web page
|
||||
* @param {Boolean} mod Specify if it is a game or a mod
|
||||
* @returns {String} Game description
|
||||
*/
|
||||
function getOverview(text, mod) {
|
||||
shared.logger.trace("Extracting game overview...");
|
||||
|
||||
// Get overview (different parsing for game and mod)
|
||||
const overviewEndIndex = mod ? text.indexOf("Updated") : text.indexOf("Thread Updated");
|
||||
return text.substring(0, overviewEndIndex).replace("Overview:\n", "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Check if the prefix is a game's engine.
|
||||
* @param {String} prefix Prefix to check
|
||||
* @return {Boolean}
|
||||
*/
|
||||
function isEngine(prefix) {
|
||||
const engines = toUpperCaseArray(shared.engines);
|
||||
return engines.includes(prefix.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Check if the prefix is a game's status.
|
||||
* @param {String} prefix Prefix to check
|
||||
* @return {Boolean}
|
||||
*/
|
||||
function isStatus(prefix) {
|
||||
const statuses = toUpperCaseArray(shared.statuses);
|
||||
return statuses.includes(prefix.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Check if the prefix indicates a mod.
|
||||
* @param {String} prefix Prefix to check
|
||||
* @return {Boolean}
|
||||
*/
|
||||
function isMod(prefix) {
|
||||
const modPrefixes = ["MOD", "CHEAT MOD"];
|
||||
return modPrefixes.includes(prefix.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Makes an array of strings uppercase.
|
||||
* @param {String[]} a
|
||||
* @returns {String[]}
|
||||
*/
|
||||
function toUpperCaseArray(a) {
|
||||
// If the array is empty, return
|
||||
if(a.length === 0) return [];
|
||||
|
||||
/**
|
||||
* Makes a string uppercase.
|
||||
* @param {String} s
|
||||
* @returns {String}
|
||||
*/
|
||||
function toUpper(s) {
|
||||
return s.toUpperCase();
|
||||
}
|
||||
return a.map(toUpper);
|
||||
}
|
||||
//#endregion Private methods
|
|
@ -0,0 +1,95 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const cheerio = require("cheerio");
|
||||
|
||||
// Modules from file
|
||||
const { fetchHTML } = require("./network-helper.js");
|
||||
const shared = require("./shared.js");
|
||||
const f95Selector = require("./constants/css-selector.js");
|
||||
const { F95_BASE_URL } = require("./constants/url.js");
|
||||
|
||||
//#region Public methods
|
||||
/**
|
||||
* @protected
|
||||
* Search for a game on F95Zone and return a list of URLs, one for each search result.
|
||||
* @param {String} name Game name
|
||||
* @returns {Promise<String[]>} URLs of results
|
||||
*/
|
||||
module.exports.searchGame = async function (name) {
|
||||
shared.logger.info(`Searching games with name ${name}`);
|
||||
|
||||
// Replace the whitespaces with +
|
||||
const searchName = encodeURIComponent(name.toUpperCase());
|
||||
|
||||
// Prepare the URL (only title, search in the "Games" section, order by relevance)
|
||||
const url = `https://f95zone.to/search/83456043/?q=${searchName}&t=post&c[child_nodes]=1&c[nodes][0]=2&c[title_only]=1&o=relevance`;
|
||||
|
||||
// Fetch and parse the result URLs
|
||||
return await fetchResultURLs(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Search for a mod on F95Zone and return a list of URLs, one for each search result.
|
||||
* @param {String} name Mod name
|
||||
* @returns {Promise<String[]>} URLs of results
|
||||
*/
|
||||
module.exports.searchMod = async function (name) {
|
||||
shared.logger.info(`Searching mods with name ${name}`);
|
||||
|
||||
// Replace the whitespaces with +
|
||||
const searchName = encodeURIComponent(name.toUpperCase());
|
||||
|
||||
// Prepare the URL (only title, search in the "Mods" section, order by relevance)
|
||||
const url = `https://f95zone.to/search/83459796/?q=${searchName}&t=post&c[child_nodes]=1&c[nodes][0]=41&c[title_only]=1&o=relevance`;
|
||||
|
||||
// Fetch and parse the result URLs
|
||||
return await fetchResultURLs(url);
|
||||
};
|
||||
//#endregion Public methods
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* Gets the URLs of the threads resulting from the F95Zone search.
|
||||
* @param {String} url Search URL
|
||||
* @return {Promise<String[]>} List of URLs
|
||||
*/
|
||||
async function fetchResultURLs(url) {
|
||||
shared.logger.trace(`Fetching ${url}...`);
|
||||
|
||||
// Fetch HTML and prepare Cheerio
|
||||
const html = await fetchHTML(url);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Here we get all the DIV that are the body of the various query results
|
||||
const results = $("body").find(f95Selector.GS_RESULT_BODY);
|
||||
|
||||
// Than we extract the URLs
|
||||
const urls = results.map((idx, el) => {
|
||||
const elementSelector = $(el);
|
||||
return extractLinkFromResult(elementSelector);
|
||||
}).get();
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Look for the URL to the thread referenced by the item.
|
||||
* @param {cheerio.Cheerio} selector Element to search
|
||||
* @returns {String} URL to thread
|
||||
*/
|
||||
function extractLinkFromResult(selector) {
|
||||
shared.logger.trace("Extracting thread link from result...");
|
||||
|
||||
const partialLink = selector
|
||||
.find(f95Selector.GS_RESULT_THREAD_TITLE)
|
||||
.attr("href")
|
||||
.trim();
|
||||
|
||||
// Compose and return the URL
|
||||
return new URL(partialLink, F95_BASE_URL).toString();
|
||||
}
|
||||
//#endregion Private methods
|
|
@ -1,8 +1,7 @@
|
|||
/* istanbul ignore file */
|
||||
"use strict";
|
||||
|
||||
// Core modules
|
||||
const { join } = require("path");
|
||||
|
||||
// Public modules from npm
|
||||
const log4js = require("log4js");
|
||||
|
||||
/**
|
||||
|
@ -10,47 +9,21 @@ const log4js = require("log4js");
|
|||
*/
|
||||
class Shared {
|
||||
//#region Properties
|
||||
/**
|
||||
* Shows log messages and other useful functions for module debugging.
|
||||
* @type Boolean
|
||||
*/
|
||||
static #_debug = false;
|
||||
/**
|
||||
* Indicates whether a user is logged in to the F95Zone platform or not.
|
||||
* @type Boolean
|
||||
*/
|
||||
static #_isLogged = false;
|
||||
/**
|
||||
* List of cookies obtained from the F95Zone platform.
|
||||
* @type Object[]
|
||||
*/
|
||||
static #_cookies = null;
|
||||
/**
|
||||
* List of possible game engines used for development.
|
||||
* @type String[]
|
||||
*/
|
||||
static #_engines = null;
|
||||
static #_engines = ["ADRIFT", "Flash", "HTML", "Java", "Others", "QSP", "RAGS", "RPGM", "Ren'Py", "Tads", "Unity", "Unreal Engine", "WebGL", "Wolf RPG"];
|
||||
/**
|
||||
* List of possible development statuses that a game can assume.
|
||||
* @type String[]
|
||||
*/
|
||||
static #_statuses = null;
|
||||
/**
|
||||
* Wait instruction for the browser created by puppeteer.
|
||||
* @type String
|
||||
*/
|
||||
static WAIT_STATEMENT = "domcontentloaded";
|
||||
/**
|
||||
* Path to the directory to save the cache generated by the API.
|
||||
* @type String
|
||||
*/
|
||||
static #_cacheDir = "./f95cache";
|
||||
/**
|
||||
* If true, it opens a new browser for each request to
|
||||
* the F95Zone platform, otherwise it reuses the same.
|
||||
* @type Boolean
|
||||
*/
|
||||
static #_isolation = false;
|
||||
static #_statuses = ["Completed", "Onhold", "Abandoned"];
|
||||
/**
|
||||
* Logger object used to write to both file and console.
|
||||
* @type log4js.Logger
|
||||
|
@ -59,13 +32,6 @@ class Shared {
|
|||
//#endregion Properties
|
||||
|
||||
//#region Getters
|
||||
/**
|
||||
* Shows log messages and other useful functions for module debugging.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
static get debug() {
|
||||
return this.#_debug;
|
||||
}
|
||||
/**
|
||||
* Indicates whether a user is logged in to the F95Zone platform or not.
|
||||
* @returns {Boolean}
|
||||
|
@ -73,13 +39,6 @@ class Shared {
|
|||
static get isLogged() {
|
||||
return this.#_isLogged;
|
||||
}
|
||||
/**
|
||||
* List of cookies obtained from the F95Zone platform.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
static get cookies() {
|
||||
return this.#_cookies;
|
||||
}
|
||||
/**
|
||||
* List of possible game engines used for development.
|
||||
* @returns {String[]}
|
||||
|
@ -94,42 +53,6 @@ class Shared {
|
|||
static get statuses() {
|
||||
return this.#_statuses;
|
||||
}
|
||||
/**
|
||||
* Directory to save the API cache.
|
||||
* @returns {String}
|
||||
*/
|
||||
static get cacheDir() {
|
||||
return this.#_cacheDir;
|
||||
}
|
||||
/**
|
||||
* Path to the F95 platform cache.
|
||||
* @returns {String}
|
||||
*/
|
||||
static get cookiesCachePath() {
|
||||
return join(this.#_cacheDir, "cookies.json");
|
||||
}
|
||||
/**
|
||||
* Path to the game engine cache.
|
||||
* @returns {String}
|
||||
*/
|
||||
static get enginesCachePath() {
|
||||
return join(this.#_cacheDir, "engines.json");
|
||||
}
|
||||
/**
|
||||
* Path to the cache of possible game states.
|
||||
* @returns {String}
|
||||
*/
|
||||
static get statusesCachePath() {
|
||||
return join(this.#_cacheDir, "statuses.json");
|
||||
}
|
||||
/**
|
||||
* If true, it opens a new browser for each request
|
||||
* to the F95Zone platform, otherwise it reuses the same.
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
static get isolation() {
|
||||
return this.#_isolation;
|
||||
}
|
||||
/**
|
||||
* Logger object used to write to both file and console.
|
||||
* @returns {log4js.Logger}
|
||||
|
@ -140,10 +63,6 @@ class Shared {
|
|||
//#endregion Getters
|
||||
|
||||
//#region Setters
|
||||
static set cookies(val) {
|
||||
this.#_cookies = val;
|
||||
}
|
||||
|
||||
static set engines(val) {
|
||||
this.#_engines = val;
|
||||
}
|
||||
|
@ -152,21 +71,9 @@ class Shared {
|
|||
this.#_statuses = val;
|
||||
}
|
||||
|
||||
static set cacheDir(val) {
|
||||
this.#_cacheDir = val;
|
||||
}
|
||||
|
||||
static set debug(val) {
|
||||
this.#_debug = val;
|
||||
}
|
||||
|
||||
static set isLogged(val) {
|
||||
this.#_isLogged = val;
|
||||
}
|
||||
|
||||
static set isolation(val) {
|
||||
this.#_isolation = val;
|
||||
}
|
||||
//#endregion Setters
|
||||
}
|
||||
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const ky = require("ky-universal").create({
|
||||
throwHttpErrors: false,
|
||||
});
|
||||
|
||||
// Modules from file
|
||||
const { F95_BASE_URL } = require("./constants/url.js");
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Check if the url belongs to the domain of the F95 platform.
|
||||
* @param {String} url URL to check
|
||||
* @returns {Boolean} true if the url belongs to the domain, false otherwise
|
||||
*/
|
||||
module.exports.isF95URL = function (url) {
|
||||
if (url.toString().startsWith(F95_BASE_URL)) return true;
|
||||
else return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Checks if the string passed by parameter has a properly formatted and valid path to a URL.
|
||||
* @param {String} url String to check for correctness
|
||||
* @returns {Boolean} true if the string is a valid URL, false otherwise
|
||||
*/
|
||||
module.exports.isStringAValidURL = function (url) {
|
||||
try {
|
||||
new URL(url); // skipcq: JS-0078
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Check if a particular URL is valid and reachable on the web.
|
||||
* @param {String} url URL to check
|
||||
* @param {Boolean} checkRedirect If true, the function will consider redirects a violation and return false
|
||||
* @returns {Promise<Boolean>} true if the URL exists, false otherwise
|
||||
*/
|
||||
module.exports.urlExists = async function (url, checkRedirect) {
|
||||
if (!exports.isStringAValidURL(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await ky.head(url);
|
||||
let valid = response !== undefined && !/4\d\d/.test(response.status);
|
||||
|
||||
if (!valid) return false;
|
||||
|
||||
if (checkRedirect) {
|
||||
const redirectUrl = await exports.getUrlRedirect(url);
|
||||
if (redirectUrl === url) valid = true;
|
||||
else valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Check if the URL has a redirect to another page.
|
||||
* @param {String} url URL to check for redirect
|
||||
* @returns {Promise<String>} Redirect URL or the passed URL
|
||||
*/
|
||||
module.exports.getUrlRedirect = async function (url) {
|
||||
const response = await ky.head(url);
|
||||
return response.url;
|
||||
};
|
|
@ -0,0 +1,123 @@
|
|||
"use strict";
|
||||
|
||||
// Public modules from npm
|
||||
const cheerio = require("cheerio");
|
||||
|
||||
// Modules from file
|
||||
const networkHelper = require("./network-helper.js");
|
||||
const f95Selector = require("./constants/css-selector.js");
|
||||
const f95url = require("./constants/url.js");
|
||||
const UserData = require("./classes/user-data.js");
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Gets user data, such as username, url of watched threads, and profile picture url.
|
||||
* @return {Promise<UserData>} User data
|
||||
*/
|
||||
module.exports.getUserData = async function() {
|
||||
// Fetch data
|
||||
const data = await fetchUsernameAndAvatar();
|
||||
const urls = await fetchWatchedThreadURLs();
|
||||
|
||||
// Create object
|
||||
const ud = new UserData();
|
||||
ud.username = data.username;
|
||||
ud.avatarSrc = data.source;
|
||||
ud.watchedThreads = urls;
|
||||
|
||||
return ud;
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* It connects to the page and extracts the name
|
||||
* of the currently logged in user and the URL
|
||||
* of their profile picture.
|
||||
* @return {Promise<Object.<string, string>>}
|
||||
*/
|
||||
async function fetchUsernameAndAvatar() {
|
||||
// Fetch page
|
||||
const html = await networkHelper.fetchHTML(f95url.F95_BASE_URL);
|
||||
|
||||
// Load HTML response
|
||||
const $ = cheerio.load(html);
|
||||
const body = $("body");
|
||||
|
||||
// Fetch username
|
||||
const username = body.find(f95Selector.UD_USERNAME_ELEMENT).first().text().trim();
|
||||
|
||||
// Fetch user avatar image source
|
||||
const source = body.find(f95Selector.UD_AVATAR_PIC).first().attr("src");
|
||||
|
||||
return {
|
||||
username,
|
||||
source
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the list of URLs of threads watched by the user.
|
||||
* @returns {Promise<String[]>} List of URLs
|
||||
*/
|
||||
async function fetchWatchedThreadURLs() {
|
||||
// Local variables
|
||||
let currentURL = f95url.F95_WATCHED_THREADS;
|
||||
const wathcedThreadURLs = [];
|
||||
|
||||
do {
|
||||
// Fetch page
|
||||
const html = await networkHelper.fetchHTML(currentURL);
|
||||
|
||||
// Load HTML response
|
||||
const $ = cheerio.load(html);
|
||||
const body = $("body");
|
||||
|
||||
// Find the URLs
|
||||
const urls = fetchPageURLs(body);
|
||||
wathcedThreadURLs.push(...urls);
|
||||
|
||||
// Find the next page (if any)
|
||||
currentURL = fetchNextPageURL(body);
|
||||
}
|
||||
while (currentURL);
|
||||
|
||||
return wathcedThreadURLs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the URLs of the watched threads on the page.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {String[]}
|
||||
*/
|
||||
function fetchPageURLs(body) {
|
||||
const elements = body.find(f95Selector.WT_URLS);
|
||||
|
||||
return elements.map(function extractURLs(idx, e) {
|
||||
// Obtain the link (replace "unread" only for the unread threads)
|
||||
const partialLink = e.attribs.href.replace("unread", "");
|
||||
|
||||
// Compose and return the URL
|
||||
return new URL(partialLink, f95url.F95_BASE_URL).toString();
|
||||
}).get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Gets the URL of the next page containing the watched threads
|
||||
* or `null` if that page does not exist.
|
||||
* @param {cheerio.Cheerio} body Page `body` selector
|
||||
* @returns {String}
|
||||
*/
|
||||
function fetchNextPageURL(body) {
|
||||
const element = body.find(f95Selector.WT_NEXT_PAGE).first();
|
||||
|
||||
// No element found
|
||||
if(element.length === 0) return null;
|
||||
|
||||
// Compose and return the URL
|
||||
return new URL(element.attr("href"), f95url.F95_BASE_URL).toString();
|
||||
}
|
||||
//#endregion Private methods
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "f95api",
|
||||
"version": "1.3.5",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
@ -285,17 +285,7 @@
|
|||
"@types/node": {
|
||||
"version": "14.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.2.tgz",
|
||||
"integrity": "sha512-jiE3QIxJ8JLNcb1Ps6rDbysDhN4xa8DJJvuC9prr6w+1tIh+QAbYyNF3tyiZNLDBIuBCf4KEcV2UvQm/V60xfA==",
|
||||
"optional": true
|
||||
},
|
||||
"@types/yauzl": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.1.tgz",
|
||||
"integrity": "sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
"integrity": "sha512-jiE3QIxJ8JLNcb1Ps6rDbysDhN4xa8DJJvuC9prr6w+1tIh+QAbYyNF3tyiZNLDBIuBCf4KEcV2UvQm/V60xfA=="
|
||||
},
|
||||
"abort-controller": {
|
||||
"version": "3.0.0",
|
||||
|
@ -317,11 +307,6 @@
|
|||
"integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==",
|
||||
"dev": true
|
||||
},
|
||||
"agent-base": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz",
|
||||
"integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g=="
|
||||
},
|
||||
"aggregate-error": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
|
||||
|
@ -423,6 +408,23 @@
|
|||
"integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==",
|
||||
"dev": true
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
|
||||
"integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"axios-cookiejar-support": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-1.0.1.tgz",
|
||||
"integrity": "sha512-IZJxnAJ99XxiLqNeMOqrPbfR7fRyIfaoSLdPUf4AMQEGkH8URs0ghJK/xtqBsD+KsSr3pKl4DEQjCn834pHMig==",
|
||||
"requires": {
|
||||
"is-redirect": "^1.0.0",
|
||||
"pify": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"babel-eslint": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz",
|
||||
|
@ -435,17 +437,21 @@
|
|||
"@babel/types": "^7.7.0",
|
||||
"eslint-visitor-keys": "^1.0.0",
|
||||
"resolve": "^1.12.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
|
||||
"integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
},
|
||||
"base64-js": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
|
||||
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
|
||||
"dev": true
|
||||
},
|
||||
"binary-extensions": {
|
||||
"version": "2.1.0",
|
||||
|
@ -453,20 +459,16 @@
|
|||
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
||||
"dev": true
|
||||
},
|
||||
"bl": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.0.3.tgz",
|
||||
"integrity": "sha512-fs4G6/Hu4/EE+F75J8DuN/0IpQqNjAdC7aEQv7Qt8MHGUH7Ckv2MwTEEeN9QehD0pfIDkMI1bkHYkKy7xHyKIg==",
|
||||
"requires": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
"boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
|
@ -487,20 +489,6 @@
|
|||
"integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
|
||||
"dev": true
|
||||
},
|
||||
"buffer": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
|
||||
"integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
|
||||
"requires": {
|
||||
"base64-js": "^1.0.2",
|
||||
"ieee754": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI="
|
||||
},
|
||||
"caching-transform": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
|
||||
|
@ -556,6 +544,19 @@
|
|||
"integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=",
|
||||
"dev": true
|
||||
},
|
||||
"cheerio": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.3.tgz",
|
||||
"integrity": "sha512-0td5ijfUPuubwLUu0OBoe98gZj8C/AA+RW3v67GPlGOrvxWjZmBXiBCRU+I8VEiNyJzjth40POfHiz2RB3gImA==",
|
||||
"requires": {
|
||||
"css-select": "~1.2.0",
|
||||
"dom-serializer": "~0.1.1",
|
||||
"entities": "~1.1.1",
|
||||
"htmlparser2": "^3.9.1",
|
||||
"lodash": "^4.15.0",
|
||||
"parse5": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"chokidar": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.4.2.tgz",
|
||||
|
@ -572,11 +573,6 @@
|
|||
"readdirp": "~3.4.0"
|
||||
}
|
||||
},
|
||||
"chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
|
||||
},
|
||||
"clean-stack": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||
|
@ -618,7 +614,8 @@
|
|||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
|
||||
"dev": true
|
||||
},
|
||||
"convert-source-map": {
|
||||
"version": "1.7.0",
|
||||
|
@ -640,6 +637,22 @@
|
|||
"which": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"css-select": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
|
||||
"integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
|
||||
"requires": {
|
||||
"boolbase": "~1.0.0",
|
||||
"css-what": "2.1",
|
||||
"domutils": "1.5.1",
|
||||
"nth-check": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"css-what": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz",
|
||||
"integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg=="
|
||||
},
|
||||
"data-uri-to-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz",
|
||||
|
@ -697,11 +710,6 @@
|
|||
"object-keys": "^1.0.12"
|
||||
}
|
||||
},
|
||||
"devtools-protocol": {
|
||||
"version": "0.0.799653",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.799653.tgz",
|
||||
"integrity": "sha512-t1CcaZbvm8pOlikqrsIM9GOa7Ipp07+4h/q9u0JXBWjPCjHdBl9KkddX87Vv9vBHoBGtwV79sYQNGnQM6iS5gg=="
|
||||
},
|
||||
"diff": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
|
@ -717,6 +725,37 @@
|
|||
"esutils": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"dom-serializer": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz",
|
||||
"integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==",
|
||||
"requires": {
|
||||
"domelementtype": "^1.3.0",
|
||||
"entities": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"domelementtype": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
|
||||
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
|
||||
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
|
||||
"requires": {
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
|
||||
"integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
|
||||
"requires": {
|
||||
"dom-serializer": "0",
|
||||
"domelementtype": "1"
|
||||
}
|
||||
},
|
||||
"dotenv": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
|
||||
|
@ -729,14 +768,6 @@
|
|||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
|
||||
"requires": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"enquirer": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
|
||||
|
@ -746,6 +777,11 @@
|
|||
"ansi-colors": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
|
||||
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="
|
||||
},
|
||||
"es-abstract": {
|
||||
"version": "1.17.6",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.6.tgz",
|
||||
|
@ -888,12 +924,6 @@
|
|||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz",
|
||||
"integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==",
|
||||
"dev": true
|
||||
},
|
||||
"globals": {
|
||||
"version": "12.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz",
|
||||
|
@ -949,13 +979,21 @@
|
|||
"dev": true,
|
||||
"requires": {
|
||||
"eslint-visitor-keys": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
|
||||
"integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz",
|
||||
"integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==",
|
||||
"dev": true
|
||||
},
|
||||
"espree": {
|
||||
"version": "7.3.0",
|
||||
|
@ -966,6 +1004,14 @@
|
|||
"acorn": "^7.4.0",
|
||||
"acorn-jsx": "^5.2.0",
|
||||
"eslint-visitor-keys": "^1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-visitor-keys": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
|
||||
"integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"esprima": {
|
||||
|
@ -1025,17 +1071,6 @@
|
|||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
|
||||
},
|
||||
"extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"requires": {
|
||||
"@types/yauzl": "^2.9.1",
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
}
|
||||
},
|
||||
"fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
@ -1054,14 +1089,6 @@
|
|||
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
|
||||
"dev": true
|
||||
},
|
||||
"fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
|
||||
"requires": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"fetch-blob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-2.1.1.tgz",
|
||||
|
@ -1100,6 +1127,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
|
@ -1141,6 +1169,11 @@
|
|||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz",
|
||||
"integrity": "sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA=="
|
||||
},
|
||||
"follow-redirects": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
|
||||
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
|
||||
},
|
||||
"foreground-child": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
|
||||
|
@ -1157,11 +1190,6 @@
|
|||
"integrity": "sha512-Xu2Qh8yqYuDhQGOhD5iJGninErSfI9A3FrriD3tjUgV5VbJFeH8vfgZ9HnC6jWN80QDVNQK5vmxRAmEAp7Mevw==",
|
||||
"dev": true
|
||||
},
|
||||
"fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
|
||||
},
|
||||
"fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
|
@ -1175,7 +1203,8 @@
|
|||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
|
||||
"dev": true
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.1.3",
|
||||
|
@ -1220,18 +1249,11 @@
|
|||
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"requires": {
|
||||
"pump": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"glob": {
|
||||
"version": "7.1.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
|
||||
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
|
@ -1301,7 +1323,8 @@
|
|||
"he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true
|
||||
},
|
||||
"html-escaper": {
|
||||
"version": "2.0.2",
|
||||
|
@ -1309,19 +1332,42 @@
|
|||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true
|
||||
},
|
||||
"https-proxy-agent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz",
|
||||
"integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==",
|
||||
"htmlparser2": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
|
||||
"requires": {
|
||||
"agent-base": "5",
|
||||
"debug": "4"
|
||||
"domelementtype": "^1.3.1",
|
||||
"domhandler": "^2.3.0",
|
||||
"domutils": "^1.5.1",
|
||||
"entities": "^1.1.1",
|
||||
"inherits": "^2.0.1",
|
||||
"readable-stream": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"ieee754": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
|
||||
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
|
||||
"ignore": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
|
||||
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
|
||||
"dev": true
|
||||
},
|
||||
"import-fresh": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz",
|
||||
"integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"parent-module": "^1.0.0",
|
||||
"resolve-from": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ignore": {
|
||||
"version": "4.0.6",
|
||||
|
@ -1363,6 +1409,7 @@
|
|||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
|
@ -1445,6 +1492,11 @@
|
|||
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
|
||||
"dev": true
|
||||
},
|
||||
"is-redirect": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz",
|
||||
"integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ="
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.1.tgz",
|
||||
|
@ -1699,6 +1751,7 @@
|
|||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-locate": "^4.1.0"
|
||||
}
|
||||
|
@ -1706,8 +1759,7 @@
|
|||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
||||
},
|
||||
"lodash.flattendeep": {
|
||||
"version": "4.4.0",
|
||||
|
@ -1801,6 +1853,7 @@
|
|||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
|
@ -1820,11 +1873,6 @@
|
|||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
|
||||
},
|
||||
"mocha": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/mocha/-/mocha-8.1.3.tgz",
|
||||
|
@ -2065,10 +2113,10 @@
|
|||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"nan": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
|
||||
"integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==",
|
||||
"natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
|
||||
"dev": true
|
||||
},
|
||||
"natural-compare": {
|
||||
|
@ -2086,14 +2134,6 @@
|
|||
"fetch-blob": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node-html-parser": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.3.1.tgz",
|
||||
"integrity": "sha512-AwYVI6GyEKj9NGoyMfSx4j5l7Axf7obQgLWGxtasLjED6RggTTQoq5ZRzjwSUfgSZ+Mv8Nzbi3pID0gFGqNUsA==",
|
||||
"requires": {
|
||||
"he": "1.2.0"
|
||||
}
|
||||
},
|
||||
"node-preload": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
|
||||
|
@ -2109,6 +2149,14 @@
|
|||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
|
||||
"integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==",
|
||||
"requires": {
|
||||
"boolbase": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"nyc": {
|
||||
"version": "15.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
|
||||
|
@ -2172,6 +2220,7 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
|
@ -2194,6 +2243,7 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-try": "^2.0.0"
|
||||
}
|
||||
|
@ -2202,6 +2252,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"p-limit": "^2.2.0"
|
||||
}
|
||||
|
@ -2218,7 +2269,8 @@
|
|||
"p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true
|
||||
},
|
||||
"package-hash": {
|
||||
"version": "4.0.0",
|
||||
|
@ -2241,15 +2293,25 @@
|
|||
"callsites": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"parse5": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
|
||||
"integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
|
||||
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
|
||||
"dev": true
|
||||
},
|
||||
"path-key": {
|
||||
"version": "3.1.1",
|
||||
|
@ -2269,21 +2331,22 @@
|
|||
"integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=",
|
||||
"dev": true
|
||||
},
|
||||
"pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA="
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
||||
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
|
||||
"dev": true
|
||||
},
|
||||
"pify": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
|
||||
"integrity": "sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA=="
|
||||
},
|
||||
"pkg-dir": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
|
||||
"integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"find-up": "^4.0.0"
|
||||
}
|
||||
|
@ -2306,7 +2369,8 @@
|
|||
"progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true
|
||||
},
|
||||
"promise.allsettled": {
|
||||
"version": "1.0.2",
|
||||
|
@ -2321,43 +2385,15 @@
|
|||
"iterate-value": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"pump": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
"integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
|
||||
"requires": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
|
||||
},
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true
|
||||
},
|
||||
"puppeteer": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-5.3.1.tgz",
|
||||
"integrity": "sha512-YTM1RaBeYrj6n7IlRXRYLqJHF+GM7tasbvrNFx6w1S16G76NrPq7oYFKLDO+BQsXNtS8kW2GxWCXjIMPvfDyaQ==",
|
||||
"requires": {
|
||||
"debug": "^4.1.0",
|
||||
"devtools-protocol": "0.0.799653",
|
||||
"extract-zip": "^2.0.0",
|
||||
"https-proxy-agent": "^4.0.0",
|
||||
"pkg-dir": "^4.2.0",
|
||||
"progress": "^2.0.1",
|
||||
"proxy-from-env": "^1.0.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"tar-fs": "^2.0.0",
|
||||
"unbzip2-stream": "^1.3.3",
|
||||
"ws": "^7.2.3"
|
||||
}
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
|
@ -2438,6 +2474,7 @@
|
|||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
|
@ -2490,13 +2527,23 @@
|
|||
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
|
||||
"dev": true
|
||||
},
|
||||
"sleep": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/sleep/-/sleep-6.3.0.tgz",
|
||||
"integrity": "sha512-+WgYl951qdUlb1iS97UvQ01pkauoBK9ML9I/CMPg41v0Ze4EyMlTgFTDDo32iYj98IYqxIjDMRd+L71lawFfpQ==",
|
||||
"slice-ansi": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz",
|
||||
"integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"nan": "^2.14.1"
|
||||
"ansi-styles": "^3.2.0",
|
||||
"astral-regex": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
|
||||
"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"slice-ansi": {
|
||||
|
@ -2689,29 +2736,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.0.tgz",
|
||||
"integrity": "sha512-9uW5iDvrIMCVpvasdFHW0wJPez0K4JnMZtsuIeDI7HyMGJNxmDZDOCQROr7lXyS+iL/QMpj07qcjGYTSdRFXUg==",
|
||||
"requires": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"tar-stream": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.4.tgz",
|
||||
"integrity": "sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==",
|
||||
"requires": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"test-exclude": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||
|
@ -2729,11 +2753,6 @@
|
|||
"integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
|
||||
"dev": true
|
||||
},
|
||||
"through": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||
"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
|
||||
},
|
||||
"to-fast-properties": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
||||
|
@ -2749,6 +2768,16 @@
|
|||
"is-number": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
|
||||
"integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==",
|
||||
"requires": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
@ -2779,15 +2808,6 @@
|
|||
"is-typedarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"unbzip2-stream": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
||||
"integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==",
|
||||
"requires": {
|
||||
"buffer": "^5.2.1",
|
||||
"through": "^2.3.8"
|
||||
}
|
||||
},
|
||||
"universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
|
@ -2814,9 +2834,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"v8-compile-cache": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz",
|
||||
"integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz",
|
||||
"integrity": "sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==",
|
||||
"dev": true
|
||||
},
|
||||
"which": {
|
||||
|
@ -2929,7 +2949,17 @@
|
|||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"write": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/write/-/write-1.0.3.tgz",
|
||||
"integrity": "sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"mkdirp": "^0.5.1"
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"version": "1.0.3",
|
||||
|
@ -2952,11 +2982,6 @@
|
|||
"typedarray-to-buffer": "^3.1.5"
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
|
||||
"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA=="
|
||||
},
|
||||
"y18n": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
|
||||
|
@ -3129,15 +3154,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
|
||||
"requires": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
16
package.json
16
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"main": "./app/index.js",
|
||||
"name": "f95api",
|
||||
"version": "1.3.5",
|
||||
"version": "1.5.0",
|
||||
"author": {
|
||||
"name": "Millennium Earl"
|
||||
},
|
||||
|
@ -19,7 +19,10 @@
|
|||
"scraping",
|
||||
"login",
|
||||
"game",
|
||||
"games"
|
||||
"games",
|
||||
"data",
|
||||
"userdata",
|
||||
"user data"
|
||||
],
|
||||
"scripts": {
|
||||
"unit-test-mocha": "nyc --reporter=text mocha './test/index-test.js'",
|
||||
|
@ -31,11 +34,13 @@
|
|||
"node": ">=10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.0",
|
||||
"axios-cookiejar-support": "^1.0.1",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"ky": "^0.24.0",
|
||||
"ky-universal": "^0.8.2",
|
||||
"log4js": "^6.3.0",
|
||||
"node-html-parser": "^1.3.1",
|
||||
"puppeteer": "^5.3.1"
|
||||
"tough-cookie": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-eslint": "^10.1.0",
|
||||
|
@ -44,7 +49,6 @@
|
|||
"eslint": "^7.12.1",
|
||||
"lodash": "^4.17.20",
|
||||
"mocha": "^8.1.3",
|
||||
"nyc": "^15.1.0",
|
||||
"sleep": "^6.3.0"
|
||||
"nyc": "^15.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,290 +1,36 @@
|
|||
"use strict";
|
||||
|
||||
// Core modules
|
||||
const fs = require("fs");
|
||||
// Test suite
|
||||
const api = require("./suites/api-test.js").suite;
|
||||
const credentials = require("./suites/credentials-test.js").suite;
|
||||
const network = require("./suites/network-helper-test.js").suite;
|
||||
const scraper = require("./suites/scraper-test.js").suite;
|
||||
const searcher = require("./suites/searcher-test.js").suite;
|
||||
const uScraper = require("./suites/user-scraper-test.js").suite;
|
||||
|
||||
// Public modules from npm
|
||||
const _ = require("lodash");
|
||||
const expect = require("chai").expect;
|
||||
const sleep = require("sleep");
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
// Modules from file
|
||||
const urlHelper = require("../app/scripts/url-helper.js");
|
||||
const F95API = require("../app/index.js");
|
||||
|
||||
// Configure the .env reader
|
||||
dotenv.config();
|
||||
|
||||
const COOKIES_SAVE_PATH = "./f95cache/cookies.json";
|
||||
const ENGINES_SAVE_PATH = "./f95cache/engines.json";
|
||||
const STATUSES_SAVE_PATH = "./f95cache/statuses.json";
|
||||
const USERNAME = process.env.F95_USERNAME;
|
||||
const PASSWORD = process.env.F95_PASSWORD;
|
||||
const FAKE_USERNAME = "FakeUsername091276";
|
||||
const FAKE_PASSWORD = "fake_password";
|
||||
|
||||
//F95API.debug(false);
|
||||
|
||||
function randomSleep() {
|
||||
const random = Math.floor(Math.random() * 500) + 50;
|
||||
sleep.msleep(500 + random);
|
||||
}
|
||||
|
||||
describe("Login without cookies", function () {
|
||||
describe("Test basic function", function testBasic() {
|
||||
//#region Set-up
|
||||
this.timeout(30000); // All tests in this suite get 30 seconds before timeout
|
||||
|
||||
before("Set isolation", function () {
|
||||
F95API.setIsolation(true);
|
||||
});
|
||||
|
||||
beforeEach("Remove all cookies", function () {
|
||||
// Runs before each test in this block
|
||||
if (fs.existsSync(COOKIES_SAVE_PATH)) fs.unlinkSync(COOKIES_SAVE_PATH);
|
||||
if (F95API.isLogged()) F95API.logout();
|
||||
});
|
||||
this.timeout(15000); // All tests in this suite get 15 seconds before timeout
|
||||
//#endregion Set-up
|
||||
|
||||
let testOrder = 0;
|
||||
|
||||
it("Test with valid credentials", async function () {
|
||||
// Gain exclusive use of the cookies
|
||||
while (testOrder !== 0) randomSleep();
|
||||
|
||||
const result = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(result.success).to.be.true;
|
||||
expect(result.message).equal("Authentication successful");
|
||||
|
||||
testOrder = 1;
|
||||
});
|
||||
it("Test with invalid username", async function () {
|
||||
// Gain exclusive use of the cookies
|
||||
while (testOrder !== 1) randomSleep();
|
||||
|
||||
const result = await F95API.login(FAKE_USERNAME, FAKE_PASSWORD);
|
||||
expect(result.success).to.be.false;
|
||||
expect(result.message).to.equal("Incorrect username");
|
||||
|
||||
testOrder = 2;
|
||||
});
|
||||
it("Test with invalid password", async function () {
|
||||
// Gain exclusive use of the cookies
|
||||
while (testOrder !== 2) randomSleep();
|
||||
|
||||
const result = await F95API.login(USERNAME, FAKE_PASSWORD);
|
||||
expect(result.success).to.be.false;
|
||||
expect(result.message).to.equal("Incorrect password");
|
||||
|
||||
testOrder = 3;
|
||||
});
|
||||
it("Test with invalid credentials", async function () {
|
||||
// Gain exclusive use of the cookies
|
||||
while (testOrder !== 3) randomSleep();
|
||||
|
||||
const result = await F95API.login(FAKE_USERNAME, FAKE_PASSWORD);
|
||||
expect(result.success).to.be.false;
|
||||
expect(result.message).to.equal("Incorrect username"); // It should first check the username
|
||||
|
||||
testOrder = 4;
|
||||
});
|
||||
describe("Test credentials class", credentials.bind(this));
|
||||
describe("Test network helper", network.bind(this));
|
||||
});
|
||||
|
||||
describe("Login with cookies", function () {
|
||||
describe("Test F95 modules", function testF95Modules() {
|
||||
//#region Set-up
|
||||
this.timeout(30000); // All tests in this suite get 30 seconds before timeout
|
||||
|
||||
before("Log in to create cookies then logout", async function () {
|
||||
// Runs once before the first test in this block
|
||||
if (!fs.existsSync(COOKIES_SAVE_PATH))
|
||||
await F95API.login(USERNAME, PASSWORD); // Download cookies
|
||||
if (F95API.isLogged()) F95API.logout();
|
||||
});
|
||||
this.timeout(15000); // All tests in this suite get 15 seconds before timeout
|
||||
//#endregion Set-up
|
||||
|
||||
it("Test with valid credentials", async function () {
|
||||
const result = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(result.success).to.be.true;
|
||||
expect(result.message).equal("Logged with cookies");
|
||||
});
|
||||
describe("Test scraper methods", scraper.bind(this));
|
||||
describe("Test searcher methods", searcher.bind(this));
|
||||
describe("Test user scraper methods", uScraper.bind(this));
|
||||
});
|
||||
|
||||
describe("Load base data without cookies", function () {
|
||||
describe("Test complete API", function testAPI() {
|
||||
//#region Set-up
|
||||
this.timeout(30000); // All tests in this suite get 30 seconds before timeout
|
||||
|
||||
before("Delete cache if exists", function () {
|
||||
// Runs once before the first test in this block
|
||||
if (fs.existsSync(ENGINES_SAVE_PATH)) fs.unlinkSync(ENGINES_SAVE_PATH);
|
||||
if (fs.existsSync(STATUSES_SAVE_PATH)) fs.unlinkSync(STATUSES_SAVE_PATH);
|
||||
});
|
||||
this.timeout(15000); // All tests in this suite get 15 seconds before timeout
|
||||
//#endregion Set-up
|
||||
|
||||
it("With login", async function () {
|
||||
const loginResult = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(loginResult.success).to.be.true;
|
||||
|
||||
const result = await F95API.loadF95BaseData();
|
||||
|
||||
const enginesCacheExists = fs.existsSync(ENGINES_SAVE_PATH);
|
||||
const statusesCacheExists = fs.existsSync(STATUSES_SAVE_PATH);
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(enginesCacheExists).to.be.true;
|
||||
expect(statusesCacheExists).to.be.true;
|
||||
});
|
||||
|
||||
it("Without login", async function () {
|
||||
if (F95API.isLogged()) F95API.logout();
|
||||
const result = await F95API.loadF95BaseData();
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search game data", function () {
|
||||
//#region Set-up
|
||||
this.timeout(60000); // All tests in this suite get 60 seconds before timeout
|
||||
|
||||
beforeEach("Prepare API", function () {
|
||||
// Runs once before the first test in this block
|
||||
if (F95API.isLogged()) F95API.logout();
|
||||
});
|
||||
//#endregion Set-up
|
||||
|
||||
let testGame = null;
|
||||
|
||||
it("Search game when logged", async function () {
|
||||
const loginResult = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(loginResult.success).to.be.true;
|
||||
|
||||
const loadResult = await F95API.loadF95BaseData();
|
||||
expect(loadResult).to.be.true;
|
||||
|
||||
// This test depend on the data on F95Zone at
|
||||
// https://f95zone.to/threads/kingdom-of-deception-v0-10-8-hreinn-games.2733/
|
||||
const gamesList = await F95API.getGameData("Kingdom of Deception", false);
|
||||
expect(gamesList.length, "Should find only the game").to.equal(1);
|
||||
const result = gamesList[0];
|
||||
const src = "https://attachments.f95zone.to/2018/09/162821_f9nXfwF.png";
|
||||
|
||||
// Test only the main information
|
||||
expect(result.name).to.equal("Kingdom of Deception");
|
||||
expect(result.author).to.equal("Hreinn Games");
|
||||
expect(result.isMod, "Should be false").to.be.false;
|
||||
expect(result.engine).to.equal("REN'PY");
|
||||
expect(result.previewSource).to.equal(src); // Could be null -> Why sometimes doesn't get the image?
|
||||
testGame = Object.assign({}, result);
|
||||
});
|
||||
it("Search game when not logged", async function () {
|
||||
const result = await F95API.getGameData("Kingdom of Deception", false);
|
||||
expect(result, "Without being logged should return null").to.be.null;
|
||||
});
|
||||
it("Test game serialization", function () {
|
||||
const json = JSON.stringify(testGame);
|
||||
const parsedGameInfo = JSON.parse(json);
|
||||
const result = _.isEqual(parsedGameInfo, testGame);
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Load user data", function () {
|
||||
//#region Set-up
|
||||
this.timeout(30000); // All tests in this suite get 30 seconds before timeout
|
||||
//#endregion Set-up
|
||||
|
||||
it("Retrieve when logged", async function () {
|
||||
// Login
|
||||
await F95API.login(USERNAME, PASSWORD);
|
||||
|
||||
// Then retrieve user data
|
||||
const data = await F95API.getUserData();
|
||||
|
||||
expect(data).to.exist;
|
||||
expect(data.username).to.equal(USERNAME);
|
||||
});
|
||||
it("Retrieve when not logged", async function () {
|
||||
// Logout
|
||||
if (F95API.isLogged()) F95API.logout();
|
||||
|
||||
// Try to retrieve user data
|
||||
const data = await F95API.getUserData();
|
||||
|
||||
expect(data).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Check game update", function () {
|
||||
//#region Set-up
|
||||
this.timeout(30000); // All tests in this suite get 30 seconds before timeout
|
||||
//#endregion Set-up
|
||||
|
||||
it("Get online game and verify that no update exists", async function () {
|
||||
const loginResult = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(loginResult.success).to.be.true;
|
||||
|
||||
const loadResult = await F95API.loadF95BaseData();
|
||||
expect(loadResult).to.be.true;
|
||||
|
||||
// This test depend on the data on F95Zone at
|
||||
// https://f95zone.to/threads/kingdom-of-deception-v0-10-8-hreinn-games.2733/
|
||||
const result = (await F95API.getGameData("Kingdom of Deception", false))[0];
|
||||
|
||||
const update = await F95API.chekIfGameHasUpdate(result);
|
||||
expect(update).to.be.false;
|
||||
});
|
||||
|
||||
it("Verify that update exists from old URL", async function () {
|
||||
const loginResult = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(loginResult.success).to.be.true;
|
||||
|
||||
// This test depend on the data on F95Zone at
|
||||
// https://f95zone.to/threads/perverted-education-v0-9701-april-ryan.1854/
|
||||
const url =
|
||||
"https://f95zone.to/threads/perverted-education-v0-9701-april-ryan.1854/";
|
||||
const result = await F95API.getGameDataFromURL(url);
|
||||
result.version = "0.9600";
|
||||
|
||||
const update = await F95API.chekIfGameHasUpdate(result);
|
||||
expect(update).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test url-helper", function () {
|
||||
//#region Set-up
|
||||
this.timeout(30000); // All tests in this suite get 30 seconds before timeout
|
||||
//#endregion Set-up
|
||||
|
||||
it("Check if URL exists", async function () {
|
||||
// Check generic URLs...
|
||||
let exists = await urlHelper.urlExists("https://www.google.com/");
|
||||
expect(exists, "Complete valid URL").to.be.true;
|
||||
|
||||
exists = await urlHelper.urlExists("www.google.com");
|
||||
expect(exists, "URl without protocol prefix").to.be.false;
|
||||
|
||||
exists = await urlHelper.urlExists("https://www.google/");
|
||||
expect(exists, "URL without third level domain").to.be.false;
|
||||
|
||||
// Now check for more specific URLs (with redirect)...
|
||||
exists = await urlHelper.urlExists(
|
||||
"https://f95zone.to/threads/perverted-education-v0-9601-april-ryan.1854/"
|
||||
);
|
||||
expect(exists, "URL with redirect without check").to.be.true;
|
||||
|
||||
exists = await urlHelper.urlExists(
|
||||
"https://f95zone.to/threads/perverted-education-v0-9601-april-ryan.1854/",
|
||||
true
|
||||
);
|
||||
expect(exists, "URL with redirect with check").to.be.false;
|
||||
});
|
||||
|
||||
it("Check if URL belong to the platform", async function () {
|
||||
let belong = urlHelper.isF95URL(
|
||||
"https://f95zone.to/threads/perverted-education-v0-9601-april-ryan.1854/"
|
||||
);
|
||||
expect(belong).to.be.true;
|
||||
|
||||
belong = urlHelper.isF95URL("https://www.google/");
|
||||
expect(belong).to.be.false;
|
||||
});
|
||||
describe("Test API", api.bind(this));
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
"use strict";
|
||||
|
||||
// Public module from npm
|
||||
const expect = require("chai").expect;
|
||||
const dotenv = require("dotenv");
|
||||
const {
|
||||
isEqual
|
||||
} = require("lodash");
|
||||
|
||||
// Modules from file
|
||||
const F95API = require("../../app/index.js");
|
||||
|
||||
// Configure the .env reader
|
||||
dotenv.config();
|
||||
|
||||
// Global variables
|
||||
const USERNAME = process.env.F95_USERNAME;
|
||||
const PASSWORD = process.env.F95_PASSWORD;
|
||||
|
||||
module.exports.suite = function suite() {
|
||||
// Global suite variables
|
||||
const gameURL = "https://f95zone.to/threads/perverted-education-v0-9601-april-ryan.1854/";
|
||||
|
||||
it("Test login", async function testLogin() {
|
||||
const result = await F95API.login(USERNAME, PASSWORD);
|
||||
expect(result.success).to.be.true;
|
||||
expect(F95API.isLogged()).to.be.true;
|
||||
});
|
||||
|
||||
it("Test user data fetching", async function testUserDataFetch() {
|
||||
const userdata = await F95API.getUserData();
|
||||
expect(userdata.username).to.be.equal(USERNAME);
|
||||
});
|
||||
|
||||
it("Test game update checking", async function testGameUpdateCheck() {
|
||||
// We force the creation of a GameInfo object,
|
||||
// knowing that the checkIfGameHasUpdate() function
|
||||
// only needs the game URL
|
||||
const info = new F95API.GameInfo();
|
||||
|
||||
// The gameURL identifies a game for which we know there is an update
|
||||
info.url = gameURL;
|
||||
|
||||
// Check for updates
|
||||
const update = await F95API.checkIfGameHasUpdate(info);
|
||||
expect(update).to.be.true;
|
||||
});
|
||||
|
||||
it("Test game data fetching", async function testGameDataFetch() {
|
||||
// Search a game by name
|
||||
const gameList = await F95API.getGameData("perverted education", false);
|
||||
|
||||
// We know that there is only one game with the selected name
|
||||
expect(gameList.length).to.be.equal(1, `There should be only one game, not ${gameList.length}`);
|
||||
const game = gameList[0];
|
||||
|
||||
// Than we fetch a game from URL
|
||||
const gameFromURL = await F95API.getGameDataFromURL(game.url);
|
||||
|
||||
// The two games must be equal
|
||||
const equal = isEqual(game, gameFromURL);
|
||||
expect(equal).to.be.true;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
"use strict";
|
||||
|
||||
// Public module from npm
|
||||
const expect = require("chai").expect;
|
||||
|
||||
// Modules from file
|
||||
const Credentials = require("../../app/scripts/classes/credentials.js");
|
||||
|
||||
module.exports.suite = function suite() {
|
||||
it("Check token formatting", async function testValidToken() {
|
||||
// Token example:
|
||||
// 1604309951,0338213c00fcbd894fd9415e6ba08403
|
||||
// 1604309986,ebdb75502337699381f0f55c86353555
|
||||
// 1604310008,2d50d55808e5ec3a157ec01953da9d26
|
||||
|
||||
// Fetch token (is a GET request, we don't need the credentials)
|
||||
const cred = new Credentials(null, null);
|
||||
await cred.fetchToken();
|
||||
|
||||
// Parse token for assert
|
||||
const splitted = cred.token.split(",");
|
||||
const unique = splitted[0];
|
||||
const hash = splitted[1];
|
||||
expect(splitted.length).to.be.equal(2, "The token consists of two parts");
|
||||
|
||||
// Check type of parts
|
||||
expect(isNumeric(unique)).to.be.true;
|
||||
expect(isNumeric(hash)).to.be.false;
|
||||
|
||||
// The second part is most probably the MD5 hash of something
|
||||
expect(hash.length).to.be.equal(32, "Hash should have 32 hex chars");
|
||||
});
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* Check if a string is a number
|
||||
* @param {String} str
|
||||
* @author Dan, Ben Aston
|
||||
* @see https://preview.tinyurl.com/y46jqwkt
|
||||
*/
|
||||
function isNumeric(str) {
|
||||
// We only process strings!
|
||||
if (typeof str != "string") return false;
|
||||
|
||||
// Use type coercion to parse the _entirety_ of the string
|
||||
// (`parseFloat` alone does not do this) and ensure strings
|
||||
// of whitespace fail
|
||||
return !isNaN(str) && !isNaN(parseFloat(str));
|
||||
}
|
||||
//#endregion
|
|
@ -0,0 +1,107 @@
|
|||
"use strict";
|
||||
|
||||
// Public module from npm
|
||||
const expect = require("chai").expect;
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
// Modules from file
|
||||
const Credentials = require("../../app/scripts/classes/credentials.js");
|
||||
const networkHelper = require("../../app/scripts/network-helper.js");
|
||||
const {
|
||||
F95_SEARCH_URL
|
||||
} = require("../../app/scripts/constants/url.js");
|
||||
|
||||
// Configure the .env reader
|
||||
dotenv.config();
|
||||
|
||||
// Global variables
|
||||
const USERNAME = process.env.F95_USERNAME;
|
||||
const PASSWORD = process.env.F95_PASSWORD;
|
||||
const FAKE_USERNAME = "Fake_Username091276";
|
||||
const FAKE_PASSWORD = "fake_password";
|
||||
|
||||
module.exports.suite = function suite() {
|
||||
// Global suite variables
|
||||
const gameURL = "https://f95zone.to/threads/perverted-education-v0-9601-april-ryan.1854/";
|
||||
|
||||
it("Check if URL exists", async function checkURLExistence() {
|
||||
// Check generic URLs...
|
||||
let exists = await networkHelper.urlExists("https://www.google.com/");
|
||||
expect(exists, "Complete valid URL").to.be.true;
|
||||
|
||||
exists = await networkHelper.urlExists("www.google.com");
|
||||
expect(exists, "URl without protocol prefix").to.be.false;
|
||||
|
||||
exists = await networkHelper.urlExists("https://www.google/");
|
||||
expect(exists, "URL without third level domain").to.be.false;
|
||||
|
||||
// Now check for more specific URLs (with redirect)...
|
||||
exists = await networkHelper.urlExists(gameURL);
|
||||
expect(exists, "URL with redirect without check").to.be.true;
|
||||
|
||||
exists = await networkHelper.urlExists(gameURL, true);
|
||||
expect(exists, "URL with redirect with check").to.be.false;
|
||||
});
|
||||
|
||||
it("Check if URL belong to the platform", function checkIfURLIsF95() {
|
||||
let belong = networkHelper.isF95URL(gameURL);
|
||||
expect(belong).to.be.true;
|
||||
|
||||
belong = networkHelper.isF95URL("https://www.google/");
|
||||
expect(belong).to.be.false;
|
||||
});
|
||||
|
||||
it("Enforce secure URLs", function testSecureURLEnforcement() {
|
||||
// This URL is already secure, should remain the same
|
||||
let enforced = networkHelper.enforceHttpsUrl(gameURL);
|
||||
expect(enforced).to.be.equal(gameURL, "The game URL is already secure");
|
||||
|
||||
// This URL is not secure
|
||||
enforced = networkHelper.enforceHttpsUrl("http://www.google.com");
|
||||
expect(enforced).to.be.equal("https://www.google.com", "The URL was without SSL/TLS (HTTPs)");
|
||||
|
||||
// Finally, we check when we pass a invalid URL
|
||||
enforced = networkHelper.enforceHttpsUrl("http://invalidurl");
|
||||
expect(enforced).to.be.null;
|
||||
});
|
||||
|
||||
it("Check URL redirect", async function checkURLRedirect() {
|
||||
// gameURL is an old URL it has been verified that it generates a redirect
|
||||
const redirectURL = await networkHelper.getUrlRedirect(gameURL);
|
||||
expect(redirectURL).to.not.be.equal(gameURL, "The original URL has redirect");
|
||||
|
||||
// If we recheck the new URL, we find that no redirect happens
|
||||
const secondRedirectURL = await networkHelper.getUrlRedirect(redirectURL);
|
||||
expect(secondRedirectURL).to.be.equal(redirectURL, "The URL has no redirect");
|
||||
});
|
||||
|
||||
it("Check response to GET request", async function testGETResponse() {
|
||||
// We should be able to fetch a game page
|
||||
let response = await networkHelper.fetchGETResponse(gameURL);
|
||||
expect(response.status).to.be.equal(200, "The operation must be successful");
|
||||
|
||||
// We should NOT be able to fetch the search page (we must be logged)
|
||||
response = await networkHelper.fetchGETResponse(F95_SEARCH_URL);
|
||||
expect(response).to.be.null;
|
||||
});
|
||||
|
||||
it("Test for authentication to platform", async function testAuthentication() {
|
||||
// Try to authenticate with valid credentials
|
||||
const creds = new Credentials(USERNAME, PASSWORD);
|
||||
await creds.fetchToken();
|
||||
const validResult = await networkHelper.authenticate(creds);
|
||||
expect(validResult.success).to.be.true;
|
||||
|
||||
// Now we use fake credentials
|
||||
const fakeCreds = new Credentials(FAKE_USERNAME, FAKE_PASSWORD);
|
||||
await fakeCreds.fetchToken();
|
||||
const invalidResult = await networkHelper.authenticate(fakeCreds, true);
|
||||
expect(invalidResult.success).to.be.false;
|
||||
});
|
||||
|
||||
it("Test fetching HTML", async function testFetchHTML() {
|
||||
// This should return the HTML code of the page
|
||||
const html = await networkHelper.fetchHTML(gameURL);
|
||||
expect(html.startsWith("<!DOCTYPE html>")).to.be.true;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
"use strict";
|
||||
|
||||
// Public module from npm
|
||||
const expect = require("chai").expect;
|
||||
const { isEqual } = require("lodash");
|
||||
const GameInfo = require("../../app/scripts/classes/game-info.js");
|
||||
|
||||
// Modules from file
|
||||
const scraper = require("../../app/scripts/scraper.js");
|
||||
|
||||
module.exports.suite = function suite() {
|
||||
// Global suite variables
|
||||
const gameURL = "https://f95zone.to/threads/kingdom-of-deception-v0-10-8-hreinn-games.2733/";
|
||||
const previewSrc = "https://attachments.f95zone.to/2018/09/162821_f9nXfwF.png";
|
||||
|
||||
it("Search game", async function () {
|
||||
// This test depend on the data on F95Zone at gameURL
|
||||
const result = await scraper.getGameInfo(gameURL);
|
||||
|
||||
// Test only the main information
|
||||
expect(result.name).to.equal("Kingdom of Deception");
|
||||
expect(result.author).to.equal("Hreinn Games");
|
||||
expect(result.isMod, "Should be false").to.be.false;
|
||||
expect(result.engine).to.equal("Ren'Py");
|
||||
expect(result.previewSrc).to.equal(previewSrc, "Preview not equals");
|
||||
});
|
||||
|
||||
it("Test game serialization", async function testGameSerialization() {
|
||||
// This test depend on the data on F95Zone at gameURL
|
||||
const testGame = await scraper.getGameInfo(gameURL);
|
||||
|
||||
// Serialize...
|
||||
const json = JSON.stringify(testGame);
|
||||
|
||||
// Deserialize...
|
||||
const parsedGameInfo = GameInfo.fromJSON(json);
|
||||
|
||||
// Compare with lodash
|
||||
const result = isEqual(parsedGameInfo, testGame);
|
||||
expect(result).to.be.true;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,64 @@
|
|||
"use strict";
|
||||
|
||||
// Public module from npm
|
||||
const expect = require("chai").expect;
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
// Modules from file
|
||||
const Credentials = require("../../app/scripts/classes/credentials.js");
|
||||
const searcher = require("../../app/scripts/searcher.js");
|
||||
const {
|
||||
authenticate
|
||||
} = require("../../app/scripts/network-helper.js");
|
||||
|
||||
// Configure the .env reader
|
||||
dotenv.config();
|
||||
|
||||
// Global variables
|
||||
const USERNAME = process.env.F95_USERNAME;
|
||||
const PASSWORD = process.env.F95_PASSWORD;
|
||||
|
||||
module.exports.suite = function suite() {
|
||||
// TODO:
|
||||
// This method should delete the store F95Zone cookies,
|
||||
// but what if the other tests require them?
|
||||
|
||||
// it("Search game when not logged", async function searchGameWhenNotLogged() {
|
||||
// // Search for a game that we know has only one result
|
||||
// // but without logging in first
|
||||
// const urls = await searcher.searchGame("kingdom of deception");
|
||||
// expect(urls.lenght).to.be.equal(0, "There should not be any URL");
|
||||
// });
|
||||
|
||||
it("Search game", async function searchGame() {
|
||||
// Authenticate
|
||||
const result = await auth();
|
||||
expect(result.success, "Authentication should be successful").to.be.true;
|
||||
|
||||
// Search for a game that we know has only one result
|
||||
const urls = await searcher.searchGame("kingdom of deception");
|
||||
expect(urls.length).to.be.equal(1, `There should be only one game result instead of ${urls.length}`);
|
||||
});
|
||||
|
||||
it("Search mod", async function searchMod() {
|
||||
// Authenticate
|
||||
const result = await auth();
|
||||
expect(result.success, "Authentication should be successful").to.be.true;
|
||||
|
||||
// Search for a mod that we know has only one result
|
||||
const urls = await searcher.searchMod("kingdom of deception jdmod");
|
||||
expect(urls.length).to.be.equal(1, `There should be only one mod result instead of ${urls.length}`);
|
||||
});
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* Simple wrapper for authentication.
|
||||
*/
|
||||
async function auth() {
|
||||
const creds = new Credentials(USERNAME, PASSWORD);
|
||||
await creds.fetchToken();
|
||||
return await authenticate(creds);
|
||||
}
|
||||
//#endregion Private methods
|
|
@ -0,0 +1,54 @@
|
|||
"use strict";
|
||||
|
||||
// Public module from npm
|
||||
const expect = require("chai").expect;
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
// Modules from file
|
||||
const Credentials = require("../../app/scripts/classes/credentials.js");
|
||||
const uScraper = require("../../app/scripts/user-scraper.js");
|
||||
const {
|
||||
authenticate
|
||||
} = require("../../app/scripts/network-helper.js");
|
||||
|
||||
// Configure the .env reader
|
||||
dotenv.config();
|
||||
|
||||
// Global variables
|
||||
const USERNAME = process.env.F95_USERNAME;
|
||||
const PASSWORD = process.env.F95_PASSWORD;
|
||||
|
||||
module.exports.suite = function suite() {
|
||||
// TODO:
|
||||
// This method should delete the store F95Zone cookies,
|
||||
// but what if the other tests require them?
|
||||
|
||||
// it("Fetch data when not logged", async function fetchUserDataWhenLogged() {
|
||||
// const data = await uScraper.getUserData();
|
||||
// expect(data.username).to.be.equal("");
|
||||
// expect(data.avatarSrc).to.be.equal("");
|
||||
// expect(data.watchedThreads.length).to.be.equal(0);
|
||||
// });
|
||||
|
||||
it("Fetch data when logged", async function fetchUserDataWhenNotLogged() {
|
||||
// Authenticate
|
||||
const result = await auth();
|
||||
expect(result.success, "Authentication should be successful").to.be.true;
|
||||
|
||||
// We test only for the username, the other test data depends on the user logged
|
||||
const data = await uScraper.getUserData();
|
||||
expect(data.username).to.be.equal(USERNAME);
|
||||
});
|
||||
};
|
||||
|
||||
//#region Private methods
|
||||
/**
|
||||
* @private
|
||||
* Simple wrapper for authentication.
|
||||
*/
|
||||
async function auth() {
|
||||
const creds = new Credentials(USERNAME, PASSWORD);
|
||||
await creds.fetchToken();
|
||||
return await authenticate(creds);
|
||||
}
|
||||
//#endregion Private methods
|
|
@ -1,15 +0,0 @@
|
|||
const F95API = require("../app/index.js");
|
||||
|
||||
F95API.debug(true);
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
const loginResult = await F95API.login("MillenniumEarl", "f9vTcRNuvxj4YpK");
|
||||
|
||||
if (loginResult.success) {
|
||||
await F95API.loadF95BaseData();
|
||||
const gameData = await F95API.getGameData("a struggle with sin", false);
|
||||
console.log(gameData);
|
||||
}
|
||||
F95API.logout();
|
||||
}
|
Loading…
Reference in New Issue