diff --git a/prisma/schema/content.prisma b/prisma/schema/content.prisma index f64fdac..386a3b0 100644 --- a/prisma/schema/content.prisma +++ b/prisma/schema/content.prisma @@ -1,6 +1,8 @@ enum MetadataSource { Manual GiantBomb + PCGamingWiki + IGDB } model Game { diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index f97d83f..4c18a27 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -19,6 +19,7 @@ import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityList, PriorityListIndexed } from "../utils/prioritylist"; import { GiantBombProvider } from "./giantbomb"; import { ManualMetadataProvider } from "./manual"; +import { PCGamingWikiProvider } from "./pcgamingwiki"; export class MissingMetadataProviderApiKey extends Error { private providerName: string; @@ -251,7 +252,11 @@ export const metadataHandler = new MetadataHandler(); export default metadataHandler; export const enabledMedadataProviders: string[] = []; -const metadataProviders = [GiantBombProvider, ManualMetadataProvider]; +const metadataProviders = [ + GiantBombProvider, + ManualMetadataProvider, + PCGamingWikiProvider, +]; for (const provider of metadataProviders) { try { diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts new file mode 100644 index 0000000..be92c03 --- /dev/null +++ b/server/internal/metadata/pcgamingwiki.ts @@ -0,0 +1,272 @@ +import { Developer, MetadataSource, Publisher } from "@prisma/client"; +import { MetadataProvider, MissingMetadataProviderApiKey } from "."; +import { + GameMetadataSearchResult, + _FetchGameMetadataParams, + GameMetadata, + _FetchPublisherMetadataParams, + PublisherMetadata, + _FetchDeveloperMetadataParams, + DeveloperMetadata, +} from "./types"; +import axios, { AxiosRequestConfig } from "axios"; +import moment from "moment"; +import * as jdenticon from "jdenticon"; + +interface PCGamingWikiPage { + PageID: string; + PageName: string; +} + +interface PCGamingWikiSearchStub extends PCGamingWikiPage { + "Cover URL"?: string; + Released?: string; + Released__precision?: string; +} + +interface PCGamingWikiGame extends PCGamingWikiSearchStub { + Developers?: string; + Genres?: string; + Publishers?: string; + Themes?: string; + Series?: string; + Modes?: string; +} + +interface PCGamingWikiCompany extends PCGamingWikiPage { + Parent?: string; + Founded?: string; + Website?: string; + Founded__precision?: string; + Defunct__precision?: string; +} + +interface PCGamingWikiCargoResult { + cargoquery: [ + { + title: T; + } + ]; + error?: { + code?: string; + info?: string; + errorclass?: string; + "*"?: string; + }; +} + +// Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API +// Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery +export class PCGamingWikiProvider implements MetadataProvider { + constructor() {} + + id() { + return "pcgamingwiki"; + } + name() { + return "PCGamingWiki"; + } + source() { + return MetadataSource.PCGamingWiki; + } + + private async request( + query: URLSearchParams, + options?: AxiosRequestConfig + ) { + const finalURL = `https://www.pcgamingwiki.com/w/api.php?${query.toString()}`; + + const overlay: AxiosRequestConfig = { + url: finalURL, + baseURL: "", + }; + const response = await axios.request>( + Object.assign({}, options, overlay) + ); + + if (response.status !== 200) + throw new Error( + `Error in pcgamingwiki \nStatus Code: ${response.status}` + ); + else if (response.data.error !== undefined) + throw new Error(`Error in pcgamingwiki, malformed query`); + + return response; + } + + private markNullUndefined>(obj: T): T { + // TODO: fix types above and make everything nullable instead of possibly undefined, better then this, maybe? + + const result = structuredClone(obj); + for (const [key, value] of Object.entries(result)) { + if (value === null) delete result[key]; + } + return result; + } + + async search(query: string) { + const searchParams = new URLSearchParams({ + action: "cargoquery", + tables: "Infobox_game", + fields: + "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Released", + where: `Infobox_game._pageName="${query}"`, + format: "json", + }); + + const res = await this.request(searchParams); + + const mapped = res.data.cargoquery.map((result) => { + const game = this.markNullUndefined(result.title); + + const metadata: GameMetadataSearchResult = { + id: game.PageID, + name: game.PageName, + icon: game["Cover URL"] ?? "", + description: "", // TODO: need to render the `Introduction` template somehow (or we could just hardcode it) + year: + game.Released !== undefined && game.Released.length > 0 + ? moment(game.Released).year() + : 0, + }; + return metadata; + }); + + return mapped; + } + + /** + * Parses the specific format that the wiki returns when specifying a company + * @param companyStr + * @returns + */ + private parseCompanyStr(companyStr: string): string[] { + const results: string[] = []; + // provides the string as a list of companies + // ie: "Company:Digerati Distribution,Company:Greylock Studio" + const items = companyStr.split(","); + + items.forEach((item) => { + // remove the `Company:` and trim and whitespace + results.push(item.replace("Company:", "").trim()); + }); + + return results; + } + + async fetchGame({ + id, + name, + publisher, + developer, + createObject, + }: _FetchGameMetadataParams): Promise { + const searchParams = new URLSearchParams({ + action: "cargoquery", + tables: "Infobox_game", + fields: + "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes", + where: `Infobox_game._pageID="${id}"`, + format: "json", + }); + + const res = await this.request(searchParams); + if (res.data.cargoquery.length < 1) + throw new Error("Error in pcgamingwiki, no game"); + + const game = res.data.cargoquery[0].title; + + const publishers: Publisher[] = []; + if (game.Publishers !== undefined) { + const pubListClean = this.parseCompanyStr(game.Publishers); + for (const pub of pubListClean) { + console.log("Found publisher: ", pub); + publishers.push(await publisher(pub)); + } + } + + const developers: Developer[] = []; + if (game.Developers !== undefined) { + const devListClean = this.parseCompanyStr(game.Developers); + for (const dev of devListClean) { + console.log("Found dev: ", dev); + developers.push(await developer(dev.replace("Company:", ""))); + } + } + + const icon = + game["Cover URL"] !== undefined + ? createObject(game["Cover URL"]) + : createObject(jdenticon.toPng(name, 512)); + + const metadata: GameMetadata = { + id: game.PageID, + name: game.PageName, + shortDescription: "", // TODO: (again) need to render the `Introduction` template somehow (or we could just hardcode it) + description: "", + released: + game.Released !== undefined + ? moment(game.Released).toDate() + : new Date(), + + reviewCount: 0, + reviewRating: 0, + + publishers, + developers, + + icon: icon, + bannerId: icon, + coverId: icon, + images: [icon], + }; + + return metadata; + } + + async fetchPublisher({ + query, + createObject, + }: _FetchPublisherMetadataParams): Promise { + const searchParams = new URLSearchParams({ + action: "cargoquery", + tables: "Company", + fields: + "Company.Parent,Company.Founded,Company.Defunct,Company.Website,Company._pageName=PageName,Company._pageID=pageID", + where: `Company._pageName="Company:${query}"`, + format: "json", + }); + + console.log("Searching for: " + query); + const res = await this.request(searchParams); + + // TODO: replace + const icon = createObject(jdenticon.toPng(query, 512)); + + console.log("Found: ", res.data.cargoquery); + + for (let i = 0; i < res.data.cargoquery.length; i++) { + const company = this.markNullUndefined(res.data.cargoquery[i].title); + + const metadata: PublisherMetadata = { + id: company.PageID, + name: company.PageName, + shortDescription: "", + description: "", + website: company?.Website ?? "", + + logo: icon, + banner: icon, + }; + return metadata; + } + + throw new Error("Error in pcgamingwiki, no publisher"); + } + + async fetchDeveloper( + params: _FetchDeveloperMetadataParams + ): Promise { + return await this.fetchPublisher(params); + } +}