Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@
"release": "rimraf dist && tsc -p tsconfig.json && yarn run copyfiles && npm publish ./dist"
},
"dependencies": {
"@types/express": "^4.17.13",
"axios": "^0.21.0",
"axios": "^0.24.0",
"express": "^4.17.1",
"jsdom": "^16.4.0",
"jsdom": "^18.0.1",
"lodash": "^4.17.21",
"regex-translator": "^0.2.7",
"typescript": "^4.4.4"
},
"devDependencies": {
"@types/express": "^4.17.13",
"@types/jsdom": "^16.2.13",
"@types/lodash": "^4.14.176",
"@types/node": "^16.11.6",
"copyfiles": "^2.4.1",
"rimraf": "^2.6.2"
"rimraf": "^3.0.2"
}
}
119 changes: 74 additions & 45 deletions src/core/page-parser.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,96 @@
import {JSDOM} from "jsdom";
import {Request} from "express";
import {CssSelectorDefinition, CssSelectorRegistry} from "./css-selector-registry";
import {JSDOM} from 'jsdom';
import {snakeCase} from 'lodash';
// @ts-ignore
import * as RegexTranslator from 'regex-translator';
import axios from 'axios';

import {snakeCase} from 'lodash';
import {CssSelectorDefinition, CssSelectorRegistry} from "./css-selector-registry";

const axios = require('axios').default;
export type Language = 'en' | 'de' | 'ja' | 'fr';
export type RequestFunction = (url: string, headers?: Record<string, string>) => Promise<string>;

const lodestoneBaseUrls: Record<Language, string> = {
'en': 'https://na.finalfantasyxiv.com/lodestone',
'de': 'https://de.finalfantasyxiv.com/lodestone',
'ja': 'https://jp.finalfantasyxiv.com/lodestone',
'fr': 'https://fr.finalfantasyxiv.com/lodestone',
};
Comment on lines +12 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the language support would have to offer, since we're getting a lot of things in english so we can convert to ids that can then be used to get the name of the element in any language (including ko/zh) using the ID itself (and it's lighter for the reply, too)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see any of the "convert to ID" in the functionality of the codebase yet, sorry!
I assumed it would be more light-weight in terms of requests to the Lodestone (which is the main limiting factor for this lib AFAIK) to request data in the language needed, rather than requesting IDs and then later resolving them with a new request.
An alternative would be to resolve the IDs locally, but that would involve some kind of DB, maybe saved locally alongside the lib, which would produce a lot of complexity in code and keeping the DB up to date.
What would be your alternative approach to language support?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think Nodestone should provide language support at all, using IDs will make sure that the data can be used in any language, as it becomes the consumer's responsability to convert to string for their own display.

It also makes the response payload way lighter by not including strings.

Also, every XIVAPI entity that has a name has the name in 4 languages, having nodestone only provide one language based on the selected language would push people into making 4 requests just for localization purpose, which would be a bad thing overall.


export abstract class PageParser {

protected abstract getURL(req: Request): string;
private _language: Language;
protected requestFunction: RequestFunction;
protected baseUrl: string;

public get language() {
return this._language;
}

public set language(newLanguage: Language) {
this._language = Object.keys(lodestoneBaseUrls).includes(newLanguage) ? newLanguage : 'en';
this.baseUrl = lodestoneBaseUrls[this._language];
}

public constructor(language: Language = 'en', requestFunction?: RequestFunction) {
this._language = Object.keys(lodestoneBaseUrls).includes(language) ? language : 'en';
this.baseUrl = lodestoneBaseUrls[this._language];

if (typeof requestFunction === 'function') {
this.requestFunction = requestFunction;
} else {
const axiosInstance = axios.create();
this.requestFunction = (url, headers) => {
return axiosInstance.get(url, {headers})
.then(response => response.data);
};
}
}

protected abstract getLodestonePage(characterId: string): Promise<string>;

protected abstract getCSSSelectors(): CssSelectorRegistry;

public async parse(req: Request, columnsPrefix = ''): Promise<Object> {
const {data} = await axios.get(this.getURL(req)).catch((err: any) => {
throw new Error(err.response.status);
protected modifyParseResult(data: Record<string, unknown>): Record<string, unknown> {
return data;
}

public async get(characterId: string, columns: string[] = []): Promise<Record<string, unknown>> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure making it get with only one parameter is the way to go for the API. While it serves the purpose for character data, having an ID as parameter makes no sense for the search endpoints, naming it characterId also makes it "character-oriented" which is not the goal of the PageParser class, since it's here to carry all the parsing logic for anyone to use.

Maybe this means that we should have an abstract class CharacterPareParser extends PageParser that provides common character logic without making PageParser too character oriented.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was too narrow with arguments of the PageParser here, since I did not see the full scope of the project yet, sorry!
I was too focused on the character/profile pages, since that was what I needed for my use case.

As discussed on Discord already, we should probably use an options object to provide more flexibility for the subclasses.
I also agree with your idea of specializing a "character page parser" class and will update the PR accordingly.
Since the "character" pages are called "profile" in the lodestone-css-selectors, do we want to name it ProfilePageParser?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the additions in #6 , I don't think a Character parser abstract class is needed anymore, since in the end, it's just one parser per request, and everything about a character is handled by the same parser, except minions and mounts but they'll work fine with the new options-based API 👍

const lodestonePage = await this.getLodestonePage(characterId).catch(error => {
if (axios.isAxiosError(error) && error.response?.status) {
throw new Error(String(error.response.status));
}
throw error;
});
const dom = new JSDOM(data);
let {document} = dom.window;
const columnsQuery = req.query['columns'];

const dom = new JSDOM(lodestonePage);
const selectors = this.getCSSSelectors();
let columns: string[];
if (columnsQuery && !Array.isArray(columnsQuery)) {
columns = [columnsQuery.toString()]
.filter(column => {
return column.startsWith(columnsPrefix)
})
.map(column => column.replace(columnsPrefix, ''));
} else if (columnsQuery && Array.isArray(columnsQuery)) {
columns = columnsQuery.map(c => c.toString())
.filter(column => {
return column.startsWith(columnsPrefix)
})
.map(column => column.replace(columnsPrefix, ''));
} else {
columns = Object.keys(selectors).map(key => {
return this.definitionNameToColumnName(key);
}).filter(column => column !== 'default');
}
return columns.reduce((acc, column) => {
const columnsToParse = columns.length > 0 ? columns : Object.keys(selectors).map(this.definitionNameToColumnName).filter(column => column !== 'default');

let {document} = dom.window;
let data: Record<string, unknown> = {};

for (const column of columnsToParse) {
const definition = this.getDefinition(selectors, column);

if (column === 'Root') {
const context = this.handleColumn(definition, document)?.data;
const contextDOM = new JSDOM(context);
document = contextDOM.window.document;
return {
...acc
}
}
const parsed = this.handleColumn(definition, document);
if (parsed.isPatch || column === 'Entry') {
return {
...acc,
...(parsed.data || {})
} else {
const parsed = this.handleColumn(definition, document);

if (parsed.isPatch || column === 'Entry') {
data = {
...data,
...(parsed.data || {}),
};
} else {
data[column] = parsed.data;
}
}
return {
...acc,
[column]: parsed.data
}
}, {});
}

return this.modifyParseResult(data);
}

private handleColumn(definition: CssSelectorRegistry | CssSelectorDefinition | null, document: Document): { isPatch: boolean, data: any } {
Expand Down
6 changes: 2 additions & 4 deletions src/profile/achievements.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {Request} from "express";
import {CssSelectorRegistry} from "../core/css-selector-registry";
import * as achievements from '../lib/lodestone-css-selectors/profile/achievements.json';
import {PageParser} from "../core/page-parser";
Expand All @@ -8,8 +7,7 @@ export class Achievements extends PageParser {
return achievements;
}

protected getURL(req: Request): string {
return "https://na.finalfantasyxiv.com/lodestone/character/" + req.params.characterId + "/achievement";
protected getLodestonePage(characterId: string): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original purpose of passing the Request object was to give the class access to query string and params at the same time, since query string could also contain paging information for when it'll be implemented, what are your plans regarding that?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should use a similar approach like the options object parameter in the get method here.

I would strongly advise against using a type from an unrelated library (Request is from express here) as users of nodestone might not actually use express in their code (e.g. for Discord bots or just random local scripts).

I don't think there is any better way than just extending the options object with everything needed for a parser (e.g. paging information) and then passing it through to the getURL/getLodestonePage function, which might sound like a lot of work. But with the current Request solution you would want to document all needed parameters of the Request object anyway, so people who don't use express can mock it without running into errors, which is basically the same amount of work. Then this would just eliminate the Request type from the nodestone lib and make the interface more explicit, since you have the expected parameters as types/generated docs rather than manually maintained docs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I strongly agree with that, moving to our own Option object will greatly help with that :)

return this.requestFunction(this.baseUrl + '/character/' + encodeURIComponent(characterId) + '/achievement');
}

}
9 changes: 4 additions & 5 deletions src/profile/character.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import {Request} from "express";
import {PageParser} from "../core/page-parser";
import * as character from '../lib/lodestone-css-selectors/profile/character.json'
import {CssSelectorRegistry} from "../core/css-selector-registry";

export class Character extends PageParser {
protected getURL(req: Request): string {
return "https://na.finalfantasyxiv.com/lodestone/character/" + req.params.characterId;
}

protected getCSSSelectors(): CssSelectorRegistry {
return character;
}

protected getLodestonePage(characterId: string): Promise<string> {
return this.requestFunction(this.baseUrl + '/character/' + encodeURIComponent(characterId));
}
}
23 changes: 23 additions & 0 deletions src/profile/minions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {CssSelectorRegistry} from "../core/css-selector-registry";
import * as minions from '../lib/lodestone-css-selectors/profile/minion.json';
import {PageParser} from "../core/page-parser";

export class Minions extends PageParser {
protected getCSSSelectors(): CssSelectorRegistry {
return minions;
}

protected getLodestonePage(characterId: string): Promise<string> {
return this.requestFunction(this.baseUrl + '/character/' + encodeURIComponent(characterId) + '/minion', {
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; SCH-I535 Build/KOT49H) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
});
}

protected modifyParseResult(data: Record<string, unknown>): Record<string, unknown> {
const { Minions, ...rest } = data;
return {
...(Minions as object),
...rest,
};
}
}
23 changes: 23 additions & 0 deletions src/profile/mounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {CssSelectorRegistry} from "../core/css-selector-registry";
import * as mounts from '../lib/lodestone-css-selectors/profile/mount.json';
import {PageParser} from "../core/page-parser";

export class Mounts extends PageParser {
protected getCSSSelectors(): CssSelectorRegistry {
return mounts;
}

protected getLodestonePage(characterId: string): Promise<string> {
return this.requestFunction(this.baseUrl + '/character/' + encodeURIComponent(characterId) + '/mount', {
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; SCH-I535 Build/KOT49H) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
});
}

protected modifyParseResult(data: Record<string, unknown>): Record<string, unknown> {
const { Mounts, ...rest } = data;
return {
...(Mounts as object),
...rest,
};
}
}
60 changes: 42 additions & 18 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import express from 'express';
import * as express from 'express';
import {Character} from "./profile/character";
import {Achievements} from "./profile/achievements";
import {Minions} from './profile/minions';
import {Mounts} from './profile/mounts';

const app = express();

const characterParser = new Character();
const achievementsParser = new Achievements();
const minionsParser = new Minions();
const mountsParser = new Mounts();

function getEntriesFromQuery(query: unknown | unknown[]): string[] {
const entries = Array.isArray(query) ? query as unknown[] : [query];
return (entries.filter(entry => typeof entry === 'string') as string[])
.map(entry => entry.split(','))
.flat();
}

function getColumnsForParser(columns: string | string[], columnsPrefix: string = ''): string[] {
const columnsArray = Array.isArray(columns) ? columns : [columns];

return columnsArray.filter(column => column.startsWith(columnsPrefix)).map(column => column.replace(columnsPrefix, ''));
}

app.get('/Character/:characterId', async (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
Expand All @@ -16,25 +32,33 @@ app.get('/Character/:characterId', async (req, res) => {
return res.sendStatus(200);
}
try {
const character = await characterParser.parse(req, 'Character.');
const parsed: any = {
Character: {
ID: +req.params.characterId,
...character
}
}
const additionalData = Array.isArray(req.query.data) ? req.query.data : [req.query.data].filter(d => !!d);
if (additionalData.includes('AC')) {
parsed.Achievements = await achievementsParser.parse(req, 'Achievements.');
}
return res.status(200).send(parsed);
} catch (err: any) {
if (err.message === '404') {
const characterId = req.params.characterId;
const stringColumns = getEntriesFromQuery(req.query.columns);
const additionalData = getEntriesFromQuery(req.query.data);

const [character, achievements, minions, mounts] = await Promise.all([
characterParser.get(characterId, getColumnsForParser(stringColumns, 'Character.')),
additionalData.includes('AC') ? achievementsParser.get(characterId, getColumnsForParser(stringColumns, 'Achievements.')) : Promise.resolve(),
additionalData.includes('MIMO') ? minionsParser.get(characterId, getColumnsForParser(stringColumns, 'Minions.')) : Promise.resolve(),
additionalData.includes('MIMO') ? mountsParser.get(characterId, getColumnsForParser(stringColumns, 'Mounts.')) : Promise.resolve(),
] as Promise<(Record<string, unknown> | undefined)>[]);

const result: Record<string, unknown> = {};
if (character != null) result.Character = {
ID: Number.isNaN(Number.parseInt(characterId, 10)) ? characterId : Number.parseInt(characterId, 10),
...character,
};
if (achievements != null) result.Achievements = achievements
if (minions != null) result.Minions = minions;
if (mounts != null) result.Mounts = mounts;

return res.status(200).send(result);
} catch (error) {
if (error instanceof Error && error.message === '404') {
return res.sendStatus(404)
}
return res.status(500).send(err);
return res.status(500).send(error);
}

});

const port = process.env.PORT || 8080;
Expand Down
Loading