From bea26a9a6da1066cf86bceb132dd6f171b544505 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:40:25 -0400 Subject: [PATCH] feat: game metadata rating support --- pages/store/[id]/index.vue | 7 +- prisma/models/content.prisma | 23 +++- server/internal/metadata/giantbomb.ts | 3 +- server/internal/metadata/igdb.ts | 11 +- server/internal/metadata/index.ts | 37 +++++- server/internal/metadata/manual.ts | 3 +- server/internal/metadata/pcgamingwiki.ts | 138 ++++++++++++++++++++--- server/internal/metadata/types.d.ts | 14 ++- 8 files changed, 203 insertions(+), 33 deletions(-) diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index cc6943f..48f2ec7 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -103,9 +103,7 @@ 'w-4 h-4', ]" /> - ({{ game.mReviewCount }} reviews) + ({{ 0 }} reviews) @@ -220,7 +218,8 @@ const platforms = game.versions .flat() .filter((e, i, u) => u.indexOf(e) === i); -const rating = Math.round(game.mReviewRating * 5); +// const rating = Math.round(game.mReviewRating * 5); +const rating = Math.round(0 * 5); const ratingArray = Array(5) .fill(null) .map((_, i) => i + 1 <= rating); diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index ee3b267..4631b06 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -3,6 +3,8 @@ enum MetadataSource { GiantBomb PCGamingWiki IGDB + Metacritic + OpenCritic } model Game { @@ -19,8 +21,7 @@ model Game { mDescription String // Supports markdown mReleased DateTime // When the game was released - mReviewCount Int - mReviewRating Float // 0 to 1 + ratings GameRating[] mIconObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3 @@ -42,6 +43,24 @@ model Game { @@unique([metadataSource, metadataId], name: "metadataKey") } +model GameRating { + id String @id @default(uuid()) + + metadataSource MetadataSource + metadataId String + created DateTime @default(now()) + + mReviewCount Int + mReviewRating Float // 0 to 1 + + mReviewHref String? + + Game Game? @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId String? + + @@unique([metadataSource, metadataId], name: "metadataKey") +} + // A particular set of files that relate to the version model GameVersion { gameId String diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 2441aa4..312fab5 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -207,8 +207,7 @@ export class GiantBombProvider implements MetadataProvider { tags: [], - reviewCount: 0, - reviewRating: 0, + reviews: [], publishers, developers, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 075ad38..57656d7 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -390,8 +390,15 @@ export class IGDBProvider implements MetadataProvider { ? DateTime.now().toJSDate() : DateTime.fromSeconds(firstReleaseDate).toJSDate(), - reviewCount: response[i]?.total_rating_count ?? 0, - reviewRating: (response[i]?.total_rating ?? 0) / 100, + reviews: [ + { + metadataId: "" + response[i].id, + metadataSource: MetadataSource.IGDB, + mReviewCount: response[i]?.total_rating_count ?? 0, + mReviewRating: (response[i]?.total_rating ?? 0) / 100, + mReviewHref: response[i].url, + }, + ], publishers: [], developers: [], diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 8721459..b62137c 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -1,4 +1,4 @@ -import { MetadataSource } from "~/prisma/client"; +import { MetadataSource, type GameRating } from "~/prisma/client"; import prisma from "../db/database"; import type { _FetchGameMetadataParams, @@ -7,6 +7,7 @@ import type { GameMetadataSearchResult, InternalGameMetadataResult, CompanyMetadata, + GameMetadataRating, } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; @@ -135,6 +136,34 @@ export class MetadataHandler { return results; } + private parseRatings(ratings: GameMetadataRating[]) { + const results: { + where: { + metadataKey: { + metadataId: string; + metadataSource: MetadataSource; + }; + }; + create: Omit; + }[] = []; + + ratings.forEach((r) => { + results.push({ + where: { + metadataKey: { + metadataId: r.metadataId, + metadataSource: r.metadataSource, + }, + }, + create: { + ...r, + }, + }); + }); + + return results; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -181,9 +210,6 @@ export class MetadataHandler { mName: metadata.name, mShortDescription: metadata.shortDescription, mDescription: metadata.description, - - mReviewCount: metadata.reviewCount, - mReviewRating: metadata.reviewRating, mReleased: metadata.released, mIconObjectId: metadata.icon, @@ -198,6 +224,9 @@ export class MetadataHandler { connect: metadata.developers, }, + ratings: { + connectOrCreate: this.parseRatings(metadata.reviews), + }, tags: { connectOrCreate: this.parseTags(metadata.tags), }, diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index d7b00dc..551faf3 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -34,8 +34,7 @@ export class ManualMetadataProvider implements MetadataProvider { publishers: [], developers: [], tags: [], - reviewCount: 0, - reviewRating: 0, + reviews: [], icon: iconId, coverId: iconId, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 489e41a..251b0fe 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -7,12 +7,14 @@ import type { GameMetadata, _FetchCompanyMetadataParams, CompanyMetadata, + GameMetadataRating, } from "./types"; import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import * as jdenticon from "jdenticon"; import { DateTime } from "luxon"; import * as cheerio from "cheerio"; +import { type } from "arktype"; interface PCGamingWikiParseRawPage { parse: { @@ -31,11 +33,6 @@ interface PCGamingWikiParseRawPage { }; } -interface PCGamingWikiParsedPage { - shortIntro: string; - introduction: string; -} - interface PCGamingWikiPage { PageID: string; PageName: string; @@ -89,6 +86,10 @@ type StringArrayKeys = { [K in keyof T]: T[K] extends string | string[] | null ? K : never; }[keyof T]; +const ratingProviderReview = type({ + rating: "string.integer.parse", +}); + // 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 { @@ -115,7 +116,7 @@ export class PCGamingWikiProvider implements MetadataProvider { if (response.status !== 200) throw new Error( - `Error in pcgamingwiki \nStatus Code: ${response.status}`, + `Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`, ); return response; @@ -134,9 +135,13 @@ export class PCGamingWikiProvider implements MetadataProvider { return response; } - private async getPageContent( - pageID: string, - ): Promise { + /** + * Gets the raw wiki page for parsing, + * requested values are to be considered unstable as compared to cargo queries + * @param pageID + * @returns + */ + private async getPageContent(pageID: string) { const searchParams = new URLSearchParams({ action: "parse", format: "json", @@ -149,9 +154,116 @@ export class PCGamingWikiProvider implements MetadataProvider { // remove citations from intro introductionEle.find("sup").remove(); + const infoBoxEle = $(".template-infobox").first(); + const receptionEle = infoBoxEle + .find(".template-infobox-header") + .filter((_, el) => $(el).text().trim() === "Reception"); + + const receptionResults: (GameMetadataRating | undefined)[] = []; + if (receptionEle.length > 0) { + // we have a match! + + const ratingElements = infoBoxEle.find(".template-infobox-type"); + + // TODO: cleanup this ratnest + const parseIdFromHref = (href: string): string | undefined => { + const url = new URL(href); + const opencriticRegex = /^\/game\/(\d+)\/.+$/; + switch (url.hostname.toLocaleLowerCase()) { + case "www.metacritic.com": { + // https://www.metacritic.com/game/elden-ring/critic-reviews/?platform=pc + return url.pathname + .replace("/game/", "") + .replace("/critic-reviews", "") + .replace(/\/$/, ""); + } + case "opencritic.com": { + // https://opencritic.com/game/12090/elden-ring + let id = "unknown"; + let matches; + if ((matches = opencriticRegex.exec(url.pathname)) !== null) { + matches.forEach((match, _groupIndex) => { + // console.log(`Found match, group ${_groupIndex}: ${match}`); + id = match; + }); + } + + if (id === "unknown") { + return undefined; + } + return id; + } + case "www.igdb.com": { + // https://www.igdb.com/games/elden-ring + return url.pathname.replace("/games/", "").replace(/\/$/, ""); + } + default: { + console.warn("Pcgamingwiki, unknown host", url.hostname); + return undefined; + } + } + }; + const getRating = ( + source: MetadataSource, + ): GameMetadataRating | undefined => { + const providerEle = ratingElements.filter( + (_, el) => + $(el).text().trim().toLocaleLowerCase() === + source.toLocaleLowerCase(), + ); + if (providerEle.length > 0) { + // get info associated with provider + const reviewEle = providerEle + .first() + .parent() + .find(".template-infobox-info") + .find("a") + .first(); + + const href = reviewEle.attr("href"); + if (!href) { + console.log( + `pcgamingwiki: failed to properly get review href for ${source}`, + ); + return undefined; + } + const ratingObj = ratingProviderReview({ + rating: reviewEle.text().trim(), + }); + if (ratingObj instanceof type.errors) { + console.log( + "pcgamingwiki: failed to properly get review rating", + ratingObj.summary, + ); + return undefined; + } + + const id = parseIdFromHref(href); + if (!id) return undefined; + + return { + mReviewHref: href, + metadataId: id, + metadataSource: source, + mReviewCount: 0, + // make float within 0 to 1 + mReviewRating: ratingObj.rating / 100, + }; + } + + return undefined; + }; + receptionResults.push(getRating(MetadataSource.Metacritic)); + receptionResults.push(getRating(MetadataSource.IGDB)); + receptionResults.push(getRating(MetadataSource.OpenCritic)); + } + + console.log(res.data.parse.title, receptionResults); + return { - shortIntro: introductionEle.find("p").first().text(), - introduction: introductionEle.text(), + shortIntro: introductionEle.find("p").first().text().trim(), + introduction: introductionEle.text().trim(), + reception: receptionResults, }; } @@ -318,9 +430,7 @@ export class PCGamingWikiProvider implements MetadataProvider { tags: this.compileTags(game), - reviewCount: 0, - reviewRating: 0, - + reviews: pageContent.reception.filter((v) => typeof v !== "undefined"), publishers, developers, diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index b627041..b5286a7 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -1,4 +1,4 @@ -import type { Company } from "~/prisma/client"; +import type { Company, GameRating } from "~/prisma/client"; import type { TransactionDataType } from "../objects/transactional"; import type { ObjectReference } from "../objects/objectHandler"; @@ -18,6 +18,15 @@ export interface GameMetadataSource { export type InternalGameMetadataResult = GameMetadataSearchResult & GameMetadataSource; +export type GameMetadataRating = Pick< + GameRating, + | "metadataSource" + | "metadataId" + | "mReviewCount" + | "mReviewHref" + | "mReviewRating" +>; + export interface GameMetadata { id: string; name: string; @@ -32,8 +41,7 @@ export interface GameMetadata { tags: string[]; - reviewCount: number; - reviewRating: number; + reviews: GameMetadataRating[]; // Created with another utility function icon: ObjectReference;