feat: add multi-strategy version fetchers, disable auto-fetch by default
- Add 4 version-fetch strategies (UpToDateCheck API, Store Page Scrape, Community Hub Scrape, Store API Heuristic) — no Steam API key needed - Create orchestrator that tries all strategies, prefers named version over build ID - Add test page at /test-version-fetchers for comparing strategies - Add standalone /api/version-test endpoint (direct Steam App ID, no DB) - Update /api/games/:id/steamdb-version to use new strategies - Disable auto-fetch on submit page by default (NEXT_PUBLIC_VERSION_AUTO_FETCH=true to enable) - SteamDB scraping blocked by Cloudflare; strategies preserved for later enablement
This commit is contained in:
+5
-1
@@ -26,7 +26,8 @@ import {
|
||||
import { adminStorageRoutes } from "@/lib/api/admin-storage"
|
||||
import { adminAnalyticsRoutes } from "@/lib/api/admin-analytics"
|
||||
import { steamSearchRoutes } from "@/lib/api/steam-search"
|
||||
import { steamdbVersionRoutes } from "@/lib/api/steamdb-version"
|
||||
import { steamdbVersionRoutes, clientVersionRoutes } from "@/lib/api/steamdb-version"
|
||||
import { versionTestRoutes, standaloneVersionTestRoutes } from "@/lib/api/version-test"
|
||||
import { searchUnifiedRoutes } from "@/lib/api/search-unified"
|
||||
import { gameStubRoutes } from "@/lib/api/game-stub"
|
||||
import { gameStatsRoutes } from "@/lib/api/game-stats"
|
||||
@@ -182,6 +183,8 @@ export const app = new Elysia({ prefix: "/api" })
|
||||
.use(gameStubRoutes)
|
||||
.use(steamgridProxyRoutes)
|
||||
.use(steamdbVersionRoutes)
|
||||
.use(versionTestRoutes)
|
||||
.use(standaloneVersionTestRoutes)
|
||||
.use(gamesManualRoutes)
|
||||
.use(screenshotRoutes)
|
||||
.use(mobileRoutes)
|
||||
@@ -191,6 +194,7 @@ export const app = new Elysia({ prefix: "/api" })
|
||||
app
|
||||
.use(rateLimit("write"))
|
||||
.use(betterAuth)
|
||||
.use(clientVersionRoutes)
|
||||
.use(performanceVerifyRoutes)
|
||||
.use(performanceSubmitRoutes)
|
||||
.use(commentsRoutes)
|
||||
|
||||
+145
-23
@@ -2,7 +2,7 @@ import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { scrapeSteamDBVersion } from "@/lib/steamdb/scrape"
|
||||
import { fetchAllVersions, SERVER_STRATEGIES, CLIENT_STRATEGIES, type VersionFetchResult } from "@/lib/version-fetchers/index"
|
||||
|
||||
export const steamdbVersionRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
@@ -10,41 +10,163 @@ export const steamdbVersionRoutes = new Elysia({
|
||||
}).get(
|
||||
"/steamdb-version",
|
||||
async ({ params, set }) => {
|
||||
// Look up game's steamAppId
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
// Look up game — supports both DB UUID and numeric Steam App ID
|
||||
const isNumeric = /^\d+$/.test(params.gameId)
|
||||
let steamAppId: number | null = null
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
if (isNumeric) {
|
||||
// Already a Steam App ID — use directly
|
||||
steamAppId = Number(params.gameId)
|
||||
} else {
|
||||
// Look up by DB UUID
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
steamAppId = game.steamAppId
|
||||
}
|
||||
|
||||
if (game.steamAppId === null) {
|
||||
if (steamAppId === null) {
|
||||
return { unavailable: true, reason: "no_steam_app_id" }
|
||||
}
|
||||
|
||||
// Check if scraping is globally disabled
|
||||
if (process.env.STEAMDB_SCRAPING_ENABLED === "false") {
|
||||
return { unavailable: true, reason: "scraping_disabled" }
|
||||
}
|
||||
// Run ONLY server-safe strategies (no client-preferred ones)
|
||||
// Client-side strategies should be called from the browser
|
||||
const { best, all } = await fetchAllVersions(
|
||||
steamAppId,
|
||||
SERVER_STRATEGIES,
|
||||
)
|
||||
|
||||
const result = await scrapeSteamDBVersion(game.steamAppId)
|
||||
// If server strategies found nothing, tell the client which strategies to try
|
||||
const clientStrategies = best.versionString || best.buildId
|
||||
? [] // Server got something, client doesn't need to try more
|
||||
: CLIENT_STRATEGIES.map((s) => s.name) // Server got nothing, suggest client try these
|
||||
|
||||
if (result.versionString === null && result.buildId === null) {
|
||||
return { unavailable: true, reason: "not_found" }
|
||||
if (best.versionString === null && best.buildId === null && clientStrategies.length === 0) {
|
||||
return {
|
||||
unavailable: true,
|
||||
reason: "not_found",
|
||||
results: all.map((r) => ({
|
||||
source: r.source,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
steamAppId: game.steamAppId,
|
||||
source: "steamdb",
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
steamAppId,
|
||||
source: best.source,
|
||||
clientStrategies,
|
||||
// Include detailed results for debugging
|
||||
results: all.map((r) => ({
|
||||
source: r.source,
|
||||
versionString: r.versionString,
|
||||
buildId: r.buildId,
|
||||
success: r.success,
|
||||
error: r.error,
|
||||
})),
|
||||
// If server didn't find named version, tell client to try
|
||||
needsClientFetch: clientStrategies.length > 0,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Client-friendly version fetch endpoint.
|
||||
* POST /api/games/:gameId/fetch-version-client
|
||||
*
|
||||
* Accepts results from client-side strategies and merges with server results.
|
||||
* The client calls this after running client-side strategies in the browser.
|
||||
*/
|
||||
export const clientVersionRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).post(
|
||||
"/fetch-version-client",
|
||||
async ({ params, body, set }) => {
|
||||
// Look up game — supports both DB UUID and numeric Steam App ID
|
||||
const isNumeric = /^\d+$/.test(params.gameId)
|
||||
let steamAppId: number | null = null
|
||||
|
||||
if (isNumeric) {
|
||||
steamAppId = Number(params.gameId)
|
||||
} else {
|
||||
const [game] = await db
|
||||
.select({ steamAppId: games.steamAppId })
|
||||
.from(games)
|
||||
.where(eq(games.id, params.gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: "Game not found" }
|
||||
}
|
||||
steamAppId = game.steamAppId
|
||||
}
|
||||
|
||||
if (steamAppId === null) {
|
||||
return { unavailable: true, reason: "no_steam_app_id" }
|
||||
}
|
||||
|
||||
// Run server strategies
|
||||
const { best: serverBest } = await fetchAllVersions(
|
||||
steamAppId,
|
||||
SERVER_STRATEGIES,
|
||||
)
|
||||
|
||||
// Merge with client results
|
||||
const clientResults: VersionFetchResult[] = (body.clientResults ?? []).map(
|
||||
(r: { source: string; versionString: string | null; buildId: string | null; success: boolean; error?: string }) => ({
|
||||
...r,
|
||||
}),
|
||||
)
|
||||
|
||||
const allResults = [
|
||||
...SERVER_STRATEGIES.map((s) => {
|
||||
const existing = clientResults.find((r) => r.source === s.name)
|
||||
return existing ?? { versionString: null, buildId: null, source: s.name, success: false }
|
||||
}),
|
||||
...clientResults.filter((r) => !SERVER_STRATEGIES.some((s) => s.name === r.source)),
|
||||
]
|
||||
|
||||
// Find best: prefer versionString > buildId
|
||||
let best: VersionFetchResult = serverBest
|
||||
for (const r of allResults) {
|
||||
if (r.versionString && !best.versionString) best = r
|
||||
if (!best.versionString && !best.buildId && r.buildId) best = r
|
||||
}
|
||||
|
||||
return {
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
source: best.source,
|
||||
allResults,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
body: t.Object({
|
||||
clientResults: t.Array(
|
||||
t.Object({
|
||||
source: t.String(),
|
||||
versionString: t.Nullable(t.String()),
|
||||
buildId: t.Nullable(t.String()),
|
||||
success: t.Boolean(),
|
||||
error: t.Optional(t.String()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Version Fetcher Test API
|
||||
*
|
||||
* Two endpoints:
|
||||
* 1. GET /api/games/:gameId/test-version-fetchers
|
||||
* Uses DB game ID (UUID or numeric Steam App ID) to look up the game,
|
||||
* then runs all strategies.
|
||||
*
|
||||
* 2. GET /api/version-test?steamAppId=730
|
||||
* Runs all strategies directly against a Steam App ID — no DB lookup needed.
|
||||
* This is the preferred testing endpoint.
|
||||
*/
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games } from "@/lib/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ALL_STRATEGIES } from "@/lib/version-fetchers/index"
|
||||
|
||||
async function resolveGame(gameId: string): Promise<{
|
||||
steamAppId: number
|
||||
title: string | null
|
||||
dbId: string | null
|
||||
} | null> {
|
||||
// Try numeric (Steam App ID) first
|
||||
const isNumeric = /^\d+$/.test(gameId)
|
||||
const numId = isNumeric ? Number(gameId) : null
|
||||
|
||||
if (numId) {
|
||||
// Look up by Steam App ID in DB
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId, title: games.title })
|
||||
.from(games)
|
||||
.where(eq(games.steamAppId, numId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
steamAppId: numId,
|
||||
title: game?.title ?? null,
|
||||
dbId: game?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// Try UUID
|
||||
const [game] = await db
|
||||
.select({ id: games.id, steamAppId: games.steamAppId, title: games.title })
|
||||
.from(games)
|
||||
.where(eq(games.id, gameId))
|
||||
.limit(1)
|
||||
|
||||
if (!game || game.steamAppId === null) return null
|
||||
|
||||
return {
|
||||
steamAppId: game.steamAppId,
|
||||
title: game.title,
|
||||
dbId: game.id,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nested route: /api/games/:gameId/test-version-fetchers ──────
|
||||
export const versionTestRoutes = new Elysia({
|
||||
prefix: "/games/:gameId",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/test-version-fetchers",
|
||||
async ({ params, query, set }) => {
|
||||
// Support direct steamAppId override via query param
|
||||
let steamAppId: number
|
||||
let title: string | null = null
|
||||
let dbId: string | null = null
|
||||
|
||||
if (query.steamAppId) {
|
||||
// Use provided Steam App ID directly
|
||||
steamAppId = Number(query.steamAppId)
|
||||
} else {
|
||||
const resolved = await resolveGame(params.gameId)
|
||||
if (!resolved) {
|
||||
set.status = 404
|
||||
return { error: "Game not found. Try passing ?steamAppId=730 directly." }
|
||||
}
|
||||
steamAppId = resolved.steamAppId
|
||||
title = resolved.title
|
||||
dbId = resolved.dbId
|
||||
}
|
||||
|
||||
// Run all strategies
|
||||
const results = await Promise.all(
|
||||
ALL_STRATEGIES.map(async (s) => {
|
||||
const result = await s.fn(steamAppId)
|
||||
return {
|
||||
strategy: s.name,
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Determine best
|
||||
const withVersion = results.find((r) => r.versionString)
|
||||
const withBuild = results.find((r) => r.buildId)
|
||||
const best = withVersion ?? withBuild ?? null
|
||||
|
||||
return {
|
||||
game: {
|
||||
id: dbId ?? params.gameId,
|
||||
title: title ?? `Steam App ${steamAppId}`,
|
||||
steamAppId,
|
||||
},
|
||||
results,
|
||||
best: best
|
||||
? {
|
||||
strategy: best.strategy,
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
{
|
||||
params: t.Object({ gameId: t.String() }),
|
||||
query: t.Object({
|
||||
steamAppId: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
// ── Standalone route: /api/version-test?steamAppId=730 ──────────
|
||||
export const standaloneVersionTestRoutes = new Elysia({
|
||||
prefix: "/version-test",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/",
|
||||
async ({ query }) => {
|
||||
const steamAppId = Number(query.steamAppId)
|
||||
if (!steamAppId || isNaN(steamAppId)) {
|
||||
return { error: "steamAppId query parameter is required (e.g., ?steamAppId=730)" }
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
ALL_STRATEGIES.map(async (s) => {
|
||||
const result = await s.fn(steamAppId)
|
||||
return {
|
||||
strategy: s.name,
|
||||
versionString: result.versionString,
|
||||
buildId: result.buildId,
|
||||
success: result.success,
|
||||
error: result.error ?? null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const withVersion = results.find((r) => r.versionString)
|
||||
const withBuild = results.find((r) => r.buildId)
|
||||
const best = withVersion ?? withBuild ?? null
|
||||
|
||||
return {
|
||||
steamAppId,
|
||||
results,
|
||||
best: best
|
||||
? {
|
||||
strategy: best.strategy,
|
||||
versionString: best.versionString,
|
||||
buildId: best.buildId,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
steamAppId: t.String(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user