Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"ryanluker.vscode-coverage-gutters"
],
"unwantedRecommendations": []
}
63 changes: 48 additions & 15 deletions src/lib/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { parseHTML } from "linkedom/worker";
import type {
InferColumns,
InferFields,
InferItem,
InferList,
InferQuery,
Expand Down Expand Up @@ -32,7 +33,7 @@ export type EndpointOptions = { headers?: Record<string, string>; locale?: strin
export class Endpoint<R extends Registry> {
/**
* A generic Lodestone endpoint used to search and get items from Lodestone.
* @param {T} registry The provided endpoint registry containing field selectors to obtain
* @param {R} registry The provided endpoint registry containing field selectors to obtain
* @param {EndpointOptions<R>} [options] Default method options to use when fetching from Lodestone.
* @since 0.1.0
*/
Expand All @@ -41,6 +42,7 @@ export class Endpoint<R extends Registry> {
protected readonly options?: EndpointOptions
) {}

/* v8 ignore next */
private check(response: Response): void {
const { status } = response;
if (status === 200) return;
Expand All @@ -50,6 +52,7 @@ export class Endpoint<R extends Registry> {
if (status >= 400) throw new LodestoneError(`Request failed with status ${status}.`);
}

/* v8 ignore next */
private async req(path: string, options?: EndpointOptions): Promise<Response> {
const { headers: rawHeaders = {}, locale = "na" } = options ?? this.options!;
const headers = Object.fromEntries(
Expand Down Expand Up @@ -184,6 +187,18 @@ export class Endpoint<R extends Registry> {
}
}

private pickSelectors<T extends Selectors, K extends keyof T>(
selectors: T,
keys: K[]
): Pick<T, K> {
const out: Partial<Pick<T, K>> = {};
for (const key of keys) {
out[key] = selectors[key];
}
return out as Pick<T, K>;
}

/* v8 ignore next */
private extract<T extends Selectors>(dom: Document | Element, selectors: T): InferSelectors<T> {
const out: Record<string, unknown> = {};

Expand All @@ -202,11 +217,12 @@ export class Endpoint<R extends Registry> {

if (isArray) {
const nodes = [...dom.querySelectorAll(sel.selector)];
out[key] = nodes.map((n) => {
const array = nodes.map((n) => {
const raw = this.getRawValue(n, sel);
const extracted = this.applyRegex(raw, sel);
return this.coerce(extracted, base);
});
out[key] = array.length > 0 ? array : undefined;
continue;
}

Expand All @@ -230,10 +246,11 @@ export class Endpoint<R extends Registry> {
* @returns {Promise<InferList<R>[] | null>}
* @since 0.1.0
*/
public async find(
public async find<F extends Array<Extract<keyof InferFields<R>, string>> = []>(
query: InferQuery<R>,
options: EndpointOptions = {}
options: EndpointOptions & { fields?: F } = {}
): Promise<InferList<R>[] | null> {
const { fields: filteredFields, ...rest } = options;
this.validate(query);

const parameters = new URLSearchParams(
Expand All @@ -249,13 +266,17 @@ export class Endpoint<R extends Registry> {

const document = await this.fetchDocument(
`?${parameters}`,
Object.assign(this.options ?? {}, options)
Object.assign(this.options ?? {}, rest)
);
if (!document) return null;

const selectedFields = filteredFields?.length
? this.pickSelectors(this.registry.item.fields, filteredFields)
: this.registry.item.fields;

const entries = [...document.querySelectorAll("div.entry")];
const results = entries
.map((element) => this.extract(element, this.registry.list.fields))
.map((element) => this.extract(element, selectedFields))
.filter((v) => v.id !== null || v.id !== undefined);

return results as InferList<R>[];
Expand All @@ -264,26 +285,38 @@ export class Endpoint<R extends Registry> {
/**
* @param {NumberResolvable} id The unique identifier for the Lodestone item.
* @param {EndpointOptions & { columns?: Array<keyof InferColumns<R>> }} [options] Optional method overrides.
* @returns {Promise<(InferItem<R> & Partial<InferColumns<R>) | null>}
* @returns {Promise<(InferItem<R>) | null>}
* @since 0.1.0
*/
public async get(
public async get<
F extends Array<Extract<keyof InferFields<R>, string>> | undefined = undefined,
C extends Array<Extract<keyof InferColumns<R>, string>> | undefined = undefined,
>(
id: NumberResolvable,
options: EndpointOptions & { columns?: Array<keyof InferColumns<R>> } = {}
): Promise<(InferItem<R> & Partial<InferColumns<R>>) | null> {
const { columns, ...rest } = Object.assign(this.options ?? {}, options);
options: EndpointOptions & { fields?: F; columns?: C } = {}
): Promise<InferItem<R, F, C> | null> {
const { columns, fields: filteredFields, ...rest } = options;

const document = await this.fetchDocument(id.toString(), rest);
const document = await this.fetchDocument(
id.toString(),
Object.assign(this.options ?? {}, rest)
);
if (!document) return null;

const fields = this.extract(document, this.registry.item.fields);
const selectedFields = filteredFields?.length
? this.pickSelectors(this.registry.item.fields, filteredFields)
: this.registry.item.fields;

const fields = this.extract(document, selectedFields);
if (columns && this.registry.item.columns) {
for (const key of columns) {
const value = await this.fetchColumn(id, String(key), rest);
if (value !== undefined) fields[key as string] = value as Primitives;
if (value !== undefined) {
fields[key as string] = value as Primitives;
}
}
}

return fields as InferItem<R> & Partial<InferColumns<R>>;
return fields as InferItem<R, F, C>;
}
}
78 changes: 58 additions & 20 deletions src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// type definitions
export type Primitives = { boolean: boolean; number: number; string: string; date: Date; url: URL };
export type Primitives = {
boolean: boolean;
number: number;
string: string;
date: Date;
url: URL;
};
export type Primitive = keyof Primitives;

// selectors type definitions
Expand Down Expand Up @@ -29,15 +35,20 @@ export type Registry = {
export type ExtractPrimitive<T extends Primitive> = Primitives[T];
export type ExtractQuery<T extends QueryShape> = T["type"];

type RequiredKeys<T> = { [K in keyof T]: T[K] extends { required: true } ? K : never }[keyof T];
type RequiredKeys<T> = {
[K in keyof T]: T[K] extends { required: true } ? K : never;
}[keyof T];

export type InferQuery<R extends Registry> = Partial<{
[K in keyof R["list"]["query"]]: ExtractPrimitive<ExtractQuery<R["list"]["query"][K]>>;
}> & {
[K in RequiredKeys<R["list"]["query"]>]: ExtractPrimitive<ExtractQuery<R["list"]["query"][K]>>;
};

export type InferSelectors<T extends Selectors> = { [K in keyof T]: InferSelector<T[K]> };
export type InferSelectors<T extends Selectors> = {
[K in keyof T]: InferSelector<T[K]>;
};

export type InferSelector<T> = T extends { type: infer U }
? U extends keyof Primitives
? Primitives[U]
Expand All @@ -52,11 +63,38 @@ export type InferSelector<T> = T extends { type: infer U }
: never
: never;

export type InferItem<R extends Registry> = InferSelectors<R["item"]["fields"]>;
export type InferList<R extends Registry> = InferSelectors<R["list"]["fields"]>;
export type InferColumns<R extends Registry> = R["item"]["columns"] extends Selectors
export type InferFields<R extends Registry> = InferSelectors<R["item"]["fields"]>;

export type InferColumns<R extends Registry> = R["item"] extends { columns: Selectors }
? { [K in keyof R["item"]["columns"]]: InferSelector<R["item"]["columns"][K]> }
: never;
: object;

// selection helpers
export type InferSelectedFields<R extends Registry, F extends Array<keyof InferFields<R>>> = {
[K in F[number]]: InferFields<R>[K];
};

export type InferSelectedColumns<R extends Registry, C extends Array<keyof InferColumns<R>>> = {
[K in C[number]]: InferColumns<R>[K];
};

// item inference with optional selection
type InferItemFields<R extends Registry, F> = F extends undefined
? InferFields<R>
: F extends Array<keyof InferFields<R>>
? InferSelectedFields<R, F>
: never;

type InferItemColumns<R extends Registry, C> = C extends undefined
? InferColumns<R>
: C extends Array<keyof InferColumns<R>>
? InferSelectedColumns<R, C>
: never;

export type InferItem<R extends Registry, F = undefined, C = undefined> = InferItemFields<R, F> &
InferItemColumns<R, C>;

export type InferList<R extends Registry> = InferSelectors<R["list"]["fields"]>;

// endpoint registry definitions
export const character = {
Expand All @@ -80,7 +118,7 @@ export const character = {
avatar: {
attribute: "src",
selector: ".frame__chara__face > img:nth-child(1)",
type: "string",
type: "url",
},
bio: { selector: ".character__selfintroduction", type: "string" },
data_center: {
Expand All @@ -93,7 +131,7 @@ export const character = {
crest: {
attribute: "src",
selector: "div.character__freecompany__crest > div > img",
type: "string[]",
type: "url[]",
},
id: {
attribute: "href",
Expand Down Expand Up @@ -136,14 +174,14 @@ export const character = {
portrait: {
attribute: "src",
selector: ".js__image_popup > img:nth-child(1)",
type: "string",
type: "url",
},
pvp_team: {
shape: {
crest: {
attribute: "src",
selector: ".character__pvpteam__crest__image > img",
type: "string[]",
type: "url[]",
},
id: {
attribute: "href",
Expand All @@ -161,7 +199,7 @@ export const character = {
},
list: {
fields: {
avatar: { attribute: "src", selector: ".entry__chara__face > img", type: "string" },
avatar: { attribute: "src", selector: ".entry__chara__face > img", type: "url" },
data_center: { regex: /\[(?<datacenter>\w+)]/, selector: ".entry__world", type: "string" },
grand_company: {
shape: {
Expand Down Expand Up @@ -212,7 +250,7 @@ export const cwls = {
formed: {
regex: /ldst_strftime\((\d+),/,
selector: ".heading__cwls__formed > script",
type: "string",
type: "date",
},
members: {
regex: /(?<total>\d+)/,
Expand Down Expand Up @@ -252,7 +290,7 @@ export const freecompany = {
attribute: "src",
selector:
"div.ldst__window:nth-child(1) > div:nth-child(2) > a:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > img",
type: "string[]",
type: "url[]",
},
data_center: {
regex: /\[(?<datacenter>\w+)]/,
Expand All @@ -270,7 +308,7 @@ export const freecompany = {
formed: {
regex: /ldst_strftime\((\d+),/,
selector: "p.freecompany__text:nth-of-type(5) > script",
type: "string",
type: "date",
},
grand_company: {
shape: {
Expand Down Expand Up @@ -324,7 +362,7 @@ export const freecompany = {
crest: {
attribute: "src",
selector: ".entry__freecompany__crest__image > img",
type: "string[]",
type: "url[]",
},
data_center: {
regex: /\[(?<datacenter>\w+)]/,
Expand All @@ -334,7 +372,7 @@ export const freecompany = {
formed: {
regex: /ldst_strftime\((\d+),/,
selector: ".entry__freecompany__fc-day > script",
type: "string",
type: "date",
},
grand_company: {
shape: {
Expand Down Expand Up @@ -425,13 +463,13 @@ export const pvpteam = {
crest: {
attribute: "src",
selector: ".entry__pvpteam__crest__image > img",
type: "string[]",
type: "url[]",
},
data_center: { selector: ".entry__pvpteam__name--dc", type: "string" },
formed: {
regex: /ldst_strftime\((\d+),/,
selector: ".entry__pvpteam__data--formed > script",
type: "string",
type: "date",
},
name: { selector: ".entry__pvpteam__name--team", type: "string" },
},
Expand All @@ -441,7 +479,7 @@ export const pvpteam = {
crest: {
attribute: "src",
selector: ".entry__pvpteam__search__crest__image > img",
type: "string[]",
type: "url[]",
},
data_center: { selector: ".entry__world", type: "string" },
id: {
Expand Down
Loading
Loading