-
Notifications
You must be signed in to change notification settings - Fork 18
Proposal for parser API #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
8102c95
b799c65
123e981
f370cf1
20350cc
f599e12
7e57a07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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', | ||
| }; | ||
|
|
||
| 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>> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure making it Maybe this means that we should have an
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was too narrow with arguments of the As discussed on Discord already, we should probably use an options object to provide more flexibility for the subclasses.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
Supamiu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private handleColumn(definition: CssSelectorRegistry | CssSelectorDefinition | null, document: Document): { isPatch: boolean, data: any } { | ||
|
|
||
| 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"; | ||
|
|
@@ -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> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The original purpose of passing the
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I would strongly advise against using a type from an unrelated library ( 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||
| } | ||
|
|
||
| } | ||
| 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)); | ||
| } | ||
| } |
| 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, | ||
| }; | ||
| } | ||
| } |
| 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, | ||
| }; | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.