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:
2026-05-16 20:13:23 +08:00
parent 88a098f387
commit e5f671a992
13 changed files with 1356 additions and 37 deletions
+215
View File
@@ -0,0 +1,215 @@
"use client"
import { useState, useCallback } from "react"
import { RefreshCwIcon, CheckCircleIcon, XCircleIcon, AlertTriangleIcon } from "lucide-react"
interface StrategyResult {
strategy: string
versionString: string | null
buildId: string | null
success: boolean
error: string | null
}
export default function TestVersionFetchersPage() {
const [appId, setAppId] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<StrategyResult[] | null>(null)
const [best, setBest] = useState<StrategyResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runTest = useCallback(async (id?: string) => {
const targetId = id ?? appId
if (!targetId.trim()) return
setLoading(true)
setError(null)
setResults(null)
setBest(null)
try {
// Use the standalone endpoint — no DB lookup needed
const testRes = await fetch(`/api/version-test?steamAppId=${encodeURIComponent(targetId)}`)
if (!testRes.ok) {
const errData = await testRes.json().catch(() => ({}))
setError(errData.error || `API error: ${testRes.status}`)
setLoading(false)
return
}
const testData = await testRes.json()
if (testData.error) {
setError(testData.error)
setLoading(false)
return
}
setResults(testData.results)
setBest(testData.best)
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error")
} finally {
setLoading(false)
}
}, [appId])
const handleQuickTest = useCallback((id: string) => {
setAppId(id)
runTest(id)
}, [runTest])
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-2">Version Fetcher Strategy Comparison</h1>
<p className="text-sm text-text/60 mb-8">
Enter a <strong>Steam App ID</strong> to test all version-fetching strategies.
The best result (priority: named version &gt; build ID) will be highlighted.
</p>
{/* Input */}
<div className="flex gap-3 mb-8">
<input
type="text"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="Steam App ID (e.g., 730 for CS2)"
className="flex-1 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
onKeyDown={(e) => e.key === "Enter" && runTest()}
/>
<button
onClick={() => runTest()}
disabled={loading || !appId.trim()}
className="px-6 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<RefreshCwIcon className="h-4 w-4 animate-spin" />
) : (
"Test All"
)}
</button>
</div>
{/* Error */}
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 mb-8">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Results */}
{results && (
<div className="space-y-4">
{/* Best Result */}
{best ? (
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2 mb-2">
<CheckCircleIcon className="h-5 w-5 text-green-400" />
<h3 className="text-sm font-semibold text-green-400">
Best Result: {best.strategy}
</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-text/40">Version String</p>
<p className="text-lg font-mono text-text">
{best.versionString || "—"}
</p>
</div>
<div>
<p className="text-xs text-text/40">Build ID</p>
<p className="text-lg font-mono text-text">
{best.buildId || "—"}
</p>
</div>
</div>
</div>
) : (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-yellow-400" />
<p className="text-sm text-yellow-400">
No strategy found version data for this game.
</p>
</div>
</div>
)}
{/* All Strategy Results */}
<h3 className="text-sm font-semibold text-text mt-6 mb-3">
All Strategy Results
</h3>
<div className="space-y-2">
{results.map((r, i) => (
<div
key={i}
className={`p-3 rounded-lg border ${
r.success
? "border-green-500/20 bg-green-500/5"
: "border-red-500/10 bg-red-500/5"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{r.success ? (
<CheckCircleIcon className="h-4 w-4 text-green-400" />
) : (
<XCircleIcon className="h-4 w-4 text-red-400" />
)}
<span className="text-sm font-medium text-text">
{r.strategy}
</span>
</div>
{r.error && (
<span className="text-xs text-red-400">{r.error}</span>
)}
</div>
{r.success && (
<div className="grid grid-cols-2 gap-4 ml-6">
<div>
<span className="text-xs text-text/40">Version: </span>
<span className="text-sm font-mono text-text">
{r.versionString || "—"}
</span>
</div>
<div>
<span className="text-xs text-text/40">Build: </span>
<span className="text-sm font-mono text-text">
{r.buildId || "—"}
</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Quick Test Buttons */}
<div className="mt-8 p-4 rounded-lg border border-border bg-text/2">
<h3 className="text-xs font-semibold text-text/40 mb-3">
Quick Test (known Steam App IDs)
</h3>
<div className="flex flex-wrap gap-2">
{[
{ id: "730", label: "CS2" },
{ id: "440", label: "TF2" },
{ id: "570", label: "Dota 2" },
{ id: "271590", label: "GTA V" },
{ id: "1174180", label: "RDR2" },
{ id: "1086940", label: "BG3" },
{ id: "1245620", label: "Elden Ring" },
{ id: "292030", label: "Witcher 3" },
].map((g) => (
<button
key={g.id}
onClick={() => handleQuickTest(g.id)}
className="px-3 py-1.5 rounded-md border border-border bg-text/5 text-xs text-text/60 hover:text-text hover:border-primary/50 transition-colors"
>
{g.label} ({g.id})
</button>
))}
</div>
</div>
</div>
)
}
+93 -4
View File
@@ -131,12 +131,14 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
// Step 4: Notes
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
// SteamDB version suggestion
// Auto-detected version suggestion (multi-strategy)
const [steamdbVersion, setSteamdbVersion] = useState<{
versionString: string | null
buildId: string | null
source?: string
} | null>(null)
const [steamdbLoading, setSteamdbLoading] = useState(false)
const [steamdbError, setSteamdbError] = useState<string | null>(null)
// Fetch hardware name when slug changes
const handleHardwareChange = useCallback(async (slug: string) => {
@@ -209,23 +211,107 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
const fetchSteamDBVersion = useCallback(async () => {
setSteamdbLoading(true)
setSteamdbError(null)
try {
// Step 1: Try server-side strategies first
const res = await fetch(`/api/games/${gameId}/steamdb-version`)
if (!res.ok) return
const data = await res.json()
// If server found something, use it
if (data.versionString || data.buildId) {
setSteamdbVersion({
versionString: data.versionString,
buildId: data.buildId,
source: data.source,
})
}
// Step 2: If server suggests client-side fetch, run client strategies in browser
if (data.needsClientFetch && data.clientStrategies?.length > 0) {
await runClientStrategies(data.clientStrategies)
}
} catch {
// Silently fail — SteamDB is best-effort
// Silently fail — version detection is best-effort
} finally {
setSteamdbLoading(false)
}
}, [gameId])
// Run client-side strategies (uses browser IP to avoid server rate limits)
const runClientStrategies = useCallback(async (strategyNames: string[]) => {
// Dynamic import of client-side fetchers
const { fetchStorePage } = await import("@/lib/version-fetchers/store-page")
const { fetchCommunityHub } = await import("@/lib/version-fetchers/community-hub")
const { fetchStoreApi } = await import("@/lib/version-fetchers/store-api")
const strategyMap: Record<string, (appId: number) => Promise<{ versionString: string | null; buildId: string | null; source: string; success: boolean }>> = {
"Store Page Scrape": fetchStorePage,
"Community Hub Scrape": fetchCommunityHub,
"Store API Heuristic": fetchStoreApi,
}
// We need the steamAppId — get it from a lightweight endpoint or from props
const gameRes = await fetch(`/api/games/${gameId}`)
if (!gameRes.ok) return
const gameData = await gameRes.json()
const steamAppId = gameData.steamAppId
if (!steamAppId) return
// Run all requested client strategies in parallel
const clientResults = await Promise.all(
strategyNames.map(async (name) => {
const fn = strategyMap[name]
if (!fn) return null
try {
const result = await fn(steamAppId)
return result
} catch {
return null
}
}),
)
const validResults = clientResults.filter(Boolean) as Array<{
versionString: string | null
buildId: string | null
source: string
success: boolean
}>
// Merge with server result — send to server for final merge
if (validResults.length > 0) {
try {
const mergeRes = await fetch(`/api/games/${gameId}/fetch-version-client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientResults: validResults }),
})
if (mergeRes.ok) {
const merged = await mergeRes.json()
if (merged.versionString || merged.buildId) {
setSteamdbVersion({
versionString: merged.versionString,
buildId: merged.buildId,
source: merged.source,
})
}
}
} catch {
// If merge fails, use best client result directly
const bestClient = validResults.find((r) => r.versionString) ??
validResults.find((r) => r.buildId)
if (bestClient && (bestClient.versionString || bestClient.buildId)) {
setSteamdbVersion({
versionString: bestClient.versionString,
buildId: bestClient.buildId,
source: bestClient.source,
})
}
}
}
}, [gameId])
// Initialize existing screenshots when editing
useEffect(() => {
if (editEntry && (editEntry as any).screenshots && Array.isArray((editEntry as any).screenshots)) {
@@ -242,9 +328,11 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
}
}, [editEntry])
// Fetch SteamDB version on mount
// Fetch auto-detected version on mount (disabled by default — set NEXT_PUBLIC_VERSION_AUTO_FETCH=true to enable)
useEffect(() => {
fetchSteamDBVersion()
if (process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true") {
fetchSteamDBVersion()
}
}, [fetchSteamDBVersion])
const canProceed = () => {
@@ -458,6 +546,7 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
platformSupport={platformSupport}
steamdbVersion={steamdbVersion}
steamdbLoading={steamdbLoading}
steamdbError={steamdbError}
onRefreshSteamDB={fetchSteamDBVersion}
/>
)}
+19 -8
View File
@@ -14,6 +14,7 @@ export interface GameVersionInfo {
export interface SteamDBVersion {
versionString: string | null
buildId: string | null
source?: string
}
interface SetupStepProps {
@@ -37,6 +38,7 @@ interface SetupStepProps {
}[]
steamdbVersion: SteamDBVersion | null
steamdbLoading: boolean
steamdbError: string | null
onRefreshSteamDB: () => void
}
@@ -58,10 +60,12 @@ export function SetupStep({
platformSupport,
steamdbVersion,
steamdbLoading,
steamdbError,
onRefreshSteamDB,
}: SetupStepProps) {
const isNewVersion = selectedVersionId === "__new__"
const isSteamDBVersion = selectedVersionId === "__steamdb__"
const autoFetchEnabled = process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true"
return (
<div className="space-y-8">
@@ -92,13 +96,13 @@ export function SetupStep({
onChange={(e) => onVersionChange(e.target.value)}
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
>
{/* SteamDB suggestion — appears at top when available */}
{steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
{/* Auto-detected version suggestion — only when feature is enabled */}
{autoFetchEnabled && steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
<option value="__steamdb__" className="bg-primary/10 text-primary">
Latest from SteamDB: {steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`} recommended
Latest ({steamdbVersion.source ?? "auto-detected"}): {steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`} recommended
</option>
)}
{steamdbLoading && (
{autoFetchEnabled && steamdbLoading && (
<option disabled className="text-text/40">
Fetching latest version from SteamDB...
</option>
@@ -121,7 +125,8 @@ export function SetupStep({
</option>
</select>
{/* Refresh button for SteamDB */}
{/* Refresh button for auto-detection (only shown when feature enabled) */}
{autoFetchEnabled && (
<button
type="button"
onClick={onRefreshSteamDB}
@@ -129,8 +134,12 @@ export function SetupStep({
className="flex items-center gap-1 text-xs text-text/40 hover:text-primary transition-colors cursor-pointer mt-1 disabled:opacity-30"
>
<RefreshCwIcon className={`h-3 w-3 ${steamdbLoading ? "animate-spin" : ""}`} />
Refresh from SteamDB
Refresh auto-detected version
</button>
)}
{steamdbError && (
<p className="text-xs text-red-400 mt-1">{steamdbError}</p>
)}
</div>
{isNewVersion && (
@@ -152,11 +161,13 @@ export function SetupStep({
</div>
)}
{isSteamDBVersion && steamdbVersion && (
{autoFetchEnabled && isSteamDBVersion && steamdbVersion && (
<div className="space-y-3 p-3 rounded-lg border border-primary/30 bg-primary/5">
<div className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-primary" />
<p className="text-xs font-medium text-primary">SteamDB Suggestion</p>
<p className="text-xs font-medium text-primary">
Auto-Detected Version ({steamdbVersion.source ?? "unknown source"})
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
@@ -1,7 +1,7 @@
---
title: "Moderator Role, Performance Tag Fix, and Steam Sale Section"
date: "2026-05-16"
version: "2026.1.0"
version: "2026.0.102"
summary: "New moderator role for community management, handheld-scoped performance tags for more accurate badges, Steam sale discovery on the landing page, and submit wizard fixes."
---
+5 -1
View File
@@ -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)
+144 -22
View File
@@ -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()),
}),
),
}),
},
)
+174
View File
@@ -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(),
}),
},
)
+140
View File
@@ -0,0 +1,140 @@
/**
* Strategy 3: Steam Community Hub Scraping
*
* Fetches steamcommunity.com/app/{appid} and extracts version/build info
* from update announcements, embedded data, and the app sidebar.
*
* Community hub is less aggressively rate-limited than the store.
*/
import type { VersionFetchResult } from "./types"
const COMMUNITY_URL = "https://steamcommunity.com/app"
export async function fetchCommunityHub(
steamAppId: number,
): Promise<VersionFetchResult> {
const source = "Community Hub Scrape"
try {
const res = await fetch(`${COMMUNITY_URL}/${steamAppId}`, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
Accept: "text/html",
},
signal: AbortSignal.timeout(10000),
})
if (!res.ok) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: `HTTP ${res.status}`,
}
}
const html = await res.text()
let versionString: string | null = null
let buildId: string | null = null
// ── Extract from embedded community data ──────────────────
// The community hub embeds JSON in data attributes
const communityDataMatch = html.match(
/data-community="([^"]+)"/i,
)
if (communityDataMatch) {
try {
const decoded = communityDataMatch[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
const data = JSON.parse(decoded)
// Community data usually has CLANSTEAMID, APPID, IS_OGG, etc.
// Not version info, but checked for completeness
if (data.APPID && !buildId) {
// APPID is just the same app ID — not useful
}
} catch {
// Non-fatal
}
}
// ── Extract from "View Stats" link ────────────────────────
// Sometimes game stats pages have more info
// ── Parse update announcements for version numbers ────────
// Look for patterns like "Counter-Strike 2 Update" or "Game Update 1.4.2"
const updateTitlePatterns = [
// "Game Name Update" — generic, skip
// "Update 1.4.2" pattern
/(?:Update|Patch)\s+(\d+\.\d+(?:\.\d+)?(?:[a-z]\d*)?)/gi,
// "Version 1.4.2" pattern
/Version\s+(\d+\.\d+(?:\.\d+)?(?:[a-z]\d*)?)/gi,
// "Build 12345678" pattern
/Build\s+(\d{5,10})/gi,
]
for (const pattern of updateTitlePatterns) {
const match = pattern.exec(html)
if (match) {
const val = match[1]
if (/^\d{5,10}$/.test(val)) {
if (!buildId) buildId = val
} else if (/^\d+\.\d+/.test(val)) {
if (!versionString) versionString = val
}
}
}
// ── Extract build ID from JS globals ──────────────────────
const buildTimestampMatch = html.match(
/"BUILD_TIMESTAMP"[:\s]+(\d{9,10})/,
)
if (buildTimestampMatch && !buildId) {
// BUILD_TIMESTAMP is the page build time, not game build
// But if we have nothing else, it's a signal
// Skipping — too noisy
}
// ── Try apphub sidebar for build/version info ──────────────
const apphubMatch = html.match(
/apphub_AppInfo[^>]*>([\s\S]*?)<\/div>/i,
)
if (apphubMatch) {
const apphubHtml = apphubMatch[1]
// Look for "Build ID" or "Current version" labels
const buildLabelMatch = apphubHtml.match(
/Build\s*ID[:\s]*<\/span>\s*([\d,]+)/i,
)
if (buildLabelMatch && !buildId) {
buildId = buildLabelMatch[1].replace(/,/g, "")
}
const versionLabelMatch = apphubHtml.match(
/(?:Current|Latest)\s*Version[:\s]*<\/span>\s*([\d.]+)/i,
)
if (versionLabelMatch && !versionString) {
versionString = versionLabelMatch[1]
}
}
const success = !!(versionString || buildId)
return {
versionString,
buildId,
source,
success,
error: success ? undefined : "No version data found on community hub",
}
} catch (err) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Version Fetcher Orchestrator
*
* Tries multiple strategies to fetch game version/build info without a Steam API key.
*
* Strategies (tried in order):
* 1. UpToDateCheck API — Fastest, works for Valve dedicated-server games
* 2. Store Page Scrape — Extracts from embedded JS/JSON-LD on store page
* 3. Community Hub Scrape — Extracts from community hub update announcements
* 4. Store API Heuristic — Uses store API + header image timestamps
*
* The first strategy that returns a versionString wins.
* If no strategy returns a versionString, we fall back to the first strategy
* that returns a buildId.
*
* Client-side preferred strategies (2, 3, 4) should be called from the browser
* to avoid server-wide rate limiting. Strategy 1 (UpToDateCheck) is safe
* for server-side use.
*/
import type { VersionFetchResult, VersionFetchStrategy } from "./types"
import { fetchUpToDateCheck } from "./uptodate-api"
import { fetchStorePage } from "./store-page"
import { fetchCommunityHub } from "./community-hub"
import { fetchStoreApi } from "./store-api"
export type { VersionFetchResult, VersionFetchStrategy }
/** All available strategies */
export const ALL_STRATEGIES: Array<{
name: string
fn: VersionFetchStrategy
/** If true, this strategy should be called from the client (browser), not server */
preferClient: boolean
}> = [
{ name: "UpToDateCheck API", fn: fetchUpToDateCheck, preferClient: false },
{ name: "Store Page Scrape", fn: fetchStorePage, preferClient: true },
{ name: "Community Hub Scrape", fn: fetchCommunityHub, preferClient: true },
{ name: "Store API Heuristic", fn: fetchStoreApi, preferClient: true },
]
/** Server-safe strategies (won't trigger rate limits on server IP) */
export const SERVER_STRATEGIES = ALL_STRATEGIES.filter((s) => !s.preferClient)
/** Client-side strategies (use browser IP to avoid server-wide rate limits) */
export const CLIENT_STRATEGIES = ALL_STRATEGIES.filter((s) => s.preferClient)
/**
* Run all strategies and return the best result.
*
* Priority:
* 1. Result with versionString
* 2. Result with buildId
* 3. First failure
*/
export async function fetchAllVersions(
steamAppId: number,
strategies = ALL_STRATEGIES,
): Promise<{
/** Best combined result */
best: VersionFetchResult
/** Individual results from each strategy */
all: VersionFetchResult[]
/** Which strategy produced the best result */
bestSource: string
}> {
const results = await Promise.all(
strategies.map((s) => s.fn(steamAppId)),
)
// Find best: prefer versionString > buildId > nothing
let best: VersionFetchResult = results[0]
for (const result of results) {
if (result.versionString && !best.versionString) {
best = result
}
if (!best.versionString && !best.buildId && result.buildId) {
best = result
}
}
return {
best,
all: results,
bestSource: best.source,
}
}
/**
* Run a single strategy by name.
*/
export async function fetchVersionByStrategy(
steamAppId: number,
strategyName: string,
): Promise<VersionFetchResult> {
const strategy = ALL_STRATEGIES.find(
(s) => s.name === strategyName,
)
if (!strategy) {
return {
versionString: null,
buildId: null,
source: strategyName,
success: false,
error: `Unknown strategy: ${strategyName}`,
}
}
return strategy.fn(steamAppId)
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Strategy 4: Steam Store API (appdetails) + Heuristics
*
* Uses the public store.steampowered.com/api/appdetails endpoint (no API key).
* While it doesn't directly contain version/build IDs, we can extract:
* - The ?t= cache-busting timestamp from image URLs (changes with updates)
* - Release date information
* - Any version hints in the game description
*/
import type { VersionFetchResult } from "./types"
const APPDETAILS_URL = "https://store.steampowered.com/api/appdetails"
interface AppDetailsResponse {
[appId: string]: {
success: boolean
data?: {
name: string
header_image?: string
release_date?: {
coming_soon: boolean
date: string
}
detailed_description?: string
about_the_game?: string
// Steam may include additional fields
[key: string]: unknown
}
}
}
export async function fetchStoreApi(
steamAppId: number,
): Promise<VersionFetchResult> {
const source = "Store API Heuristic"
try {
const res = await fetch(
`${APPDETAILS_URL}?appids=${steamAppId}`,
{
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
Accept: "application/json",
},
signal: AbortSignal.timeout(10000),
},
)
if (!res.ok) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: `HTTP ${res.status}`,
}
}
const data: AppDetailsResponse = await res.json()
const appData = data[String(steamAppId)]
if (!appData?.success || !appData.data) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: "App not found or not available",
}
}
const { data: details } = appData
let versionString: string | null = null
let buildId: string | null = null
// ── Extract timestamp from header_image URL ────────────────
// e.g. header.jpg?t=1749053861 — changes with every game update
if (details.header_image) {
const tsMatch = details.header_image.match(/\?t=(\d{9,10})/)
if (tsMatch) {
buildId = tsMatch[1]
}
}
// ── Search description for version mentions ────────────────
const textToSearch = [
details.about_the_game,
details.detailed_description,
]
.filter(Boolean)
.join(" ")
if (textToSearch) {
// Strip HTML tags
const cleanText = textToSearch.replace(/<[^>]+>/g, " ")
// Look for version patterns in description
const versionPatterns = [
/(?:version|patch|update)\s*[:#]?\s*(\d+\.\d+(?:\.\d+)?(?:[a-z]\d*)?)/i,
/v?(\d+\.\d+\.\d+(?:[a-z]\d*)?)/i,
]
for (const pattern of versionPatterns) {
const match = cleanText.match(pattern)
if (match) {
versionString = match[1]
break
}
}
}
const success = !!(versionString || buildId)
return {
versionString,
buildId,
source,
success,
error: success
? undefined
: "No version hints found in store API data",
}
} catch (err) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}
}
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Strategy 2: Steam Store Page Scraping
*
* Fetches the store.steampowered.com/app/{appid} HTML page and extracts
* version/build info from embedded JavaScript data, JSON-LD structured data,
* and meta tags.
*
* Designed for client-side use (uses the browser's IP to avoid server-wide rate limits).
* Server-side calls may be rate-limited by Steam.
*/
import type { VersionFetchResult } from "./types"
const STORE_URL = "https://store.steampowered.com/app"
export async function fetchStorePage(
steamAppId: number,
): Promise<VersionFetchResult> {
const source = "Store Page Scrape"
try {
const res = await fetch(`${STORE_URL}/${steamAppId}`, {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
Accept: "text/html",
},
signal: AbortSignal.timeout(10000),
})
if (!res.ok) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: `HTTP ${res.status}`,
}
}
const html = await res.text()
let versionString: string | null = null
let buildId: string | null = null
// ── Extract from JSON-LD structured data ──────────────────
const ldJsonMatch = html.match(
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i,
)
if (ldJsonMatch) {
try {
const ld = JSON.parse(ldJsonMatch[1])
// Look for version in SoftwareApplication schema
if (ld.version && typeof ld.version === "string") {
versionString = ld.version
}
// Sometimes build info is in description or other fields
if (ld.description && !versionString) {
const verMatch = ld.description.match(
/(?:version|build)\s*[:#]?\s*([\d.]+[\w.]*)/i,
)
if (verMatch) versionString = verMatch[1]
}
} catch {
// JSON-LD parse failure — non-fatal
}
}
// ── Extract from embedded JS data objects ─────────────────
// Pattern: g_rgAssetData, g_rgAppData, etc.
const jsDataPatterns = [
/(?:g_rgAppData|g_rgAssetData|g_rgDepotData)\s*=\s*(\{[\s\S]*?\});/gi,
/data-ds-appdata\s*=\s*['"]([^'"]+)['"]/gi,
]
for (const pattern of jsDataPatterns) {
const match = pattern.exec(html)
if (match) {
try {
const data = JSON.parse(match[1])
// Search for version/build in nested objects
const found = findVersionInObject(data)
if (found.versionString && !versionString)
versionString = found.versionString
if (found.buildId && !buildId) buildId = found.buildId
} catch {
// Non-fatal
}
}
}
// ── Extract from meta tags ────────────────────────────────
if (!versionString) {
const metaVersion = html.match(
/<meta[^>]+name="[^"]*version[^"]*"[^>]+content="([^"]+)"/i,
)
if (metaVersion) versionString = metaVersion[1]
}
// ── Extract build ID from image URLs ──────────────────────
// The ?t= timestamp in header image URLs changes with each update
if (!buildId) {
const imgMatch = html.match(
/header\.jpg\?t=(\d{9,10})/i,
)
if (imgMatch) {
// This is a timestamp, not a build ID per se, but it changes with updates
// Use it as a build-ish identifier
buildId = imgMatch[1]
}
}
// ── Extract version from changelog/update sections ────────
if (!versionString) {
// Look for "Update X.Y" or "Patch X.Y" in the page text
const updateMatch = html.match(
/(?:Update|Patch|Version)\s+(\d+\.\d+(?:\.\d+)?(?:[a-z]\d*)?)/i,
)
if (updateMatch) versionString = updateMatch[1]
}
const success = !!(versionString || buildId)
return {
versionString,
buildId,
source,
success,
error: success ? undefined : "No version data found on store page",
}
} catch (err) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}
}
}
/** Recursively search an object for version/build strings */
function findVersionInObject(
obj: unknown,
depth = 0,
): { versionString: string | null; buildId: string | null } {
if (depth > 4 || !obj || typeof obj !== "object") {
return { versionString: null, buildId: null }
}
const record = obj as Record<string, unknown>
let versionString: string | null = null
let buildId: string | null = null
const versionKeys = [
"version",
"clientversion",
"gameversion",
"app_version",
"displayVersion",
]
const buildKeys = [
"buildid",
"build_id",
"build",
"app_build",
"publicbuild",
]
for (const key of Object.keys(record)) {
const val = record[key]
if (typeof val === "string") {
const lowerKey = key.toLowerCase()
if (
!versionString &&
versionKeys.some((k) => lowerKey.includes(k)) &&
/^\d+\.\d+/.test(val)
) {
versionString = val
}
if (
!buildId &&
buildKeys.some((k) => lowerKey.includes(k)) &&
/^\d{3,10}$/.test(val)
) {
buildId = val
}
}
if (typeof val === "number" && val > 1000 && val < 99999999) {
const lowerKey = key.toLowerCase()
if (!buildId && buildKeys.some((k) => lowerKey.includes(k))) {
buildId = String(val)
}
}
if (val && typeof val === "object" && !Array.isArray(val)) {
const nested = findVersionInObject(val, depth + 1)
if (!versionString) versionString = nested.versionString
if (!buildId) buildId = nested.buildId
}
}
return { versionString, buildId }
}
+18
View File
@@ -0,0 +1,18 @@
/** Result from a single version-fetch strategy */
export interface VersionFetchResult {
/** Human-readable version string like "1.2.3" or "Patch 4.0" */
versionString: string | null
/** Numeric build ID from Steam */
buildId: string | null
/** Which strategy produced this result */
source: string
/** Whether the strategy succeeded (even if partial — e.g. buildId only) */
success: boolean
/** Error message if strategy failed completely */
error?: string
}
/** A version-fetch strategy function */
export type VersionFetchStrategy = (
steamAppId: number,
) => Promise<VersionFetchResult>
+103
View File
@@ -0,0 +1,103 @@
/**
* Strategy 1: Steam UpToDateCheck API
*
* Uses the public ISteamApps/UpToDateCheck endpoint (no API key required).
* Primarily works for Valve games with dedicated servers (CS2, TF2, Dota 2, etc.).
*
* Returns:
* - required_version: numeric version (used as buildId fallback)
* - message: sometimes contains a named version like "1.41.6.1"
*
* Non-Valve games typically return { success: false }.
*/
import type { VersionFetchResult } from "./types"
const UPTODATE_URL =
"https://api.steampowered.com/ISteamApps/UpToDateCheck/v1"
interface UpToDateResponse {
response: {
success: boolean
up_to_date?: boolean
version_is_listable?: boolean
required_version?: number
message?: string
error?: string
}
}
export async function fetchUpToDateCheck(
steamAppId: number,
): Promise<VersionFetchResult> {
const source = "UpToDateCheck API"
try {
const res = await fetch(
`${UPTODATE_URL}?appid=${steamAppId}&version=0`,
{
headers: {
"User-Agent": "DeckyVault/1.0 (deckyvault.xyz; game version lookup)",
},
signal: AbortSignal.timeout(8000),
},
)
if (!res.ok) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: `HTTP ${res.status}`,
}
}
const data: UpToDateResponse = await res.json()
if (!data.response.success) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: data.response.error ?? "App not supported",
}
}
let versionString: string | null = null
let buildId: string | null = null
// Extract named version from message
// e.g. "Server version required: 1.41.6.1" → "1.41.6.1"
if (data.response.message) {
const namedMatch = data.response.message.match(
/(?:version|required)[:\s]+([\d.]+)/i,
)
if (namedMatch) {
versionString = namedMatch[1]
}
}
// Use required_version as buildId
if (data.response.required_version) {
buildId = String(data.response.required_version)
}
const success = !!(versionString || buildId)
return {
versionString,
buildId,
source,
success,
error: success ? undefined : "No version data in response",
}
} catch (err) {
return {
versionString: null,
buildId: null,
source,
success: false,
error: err instanceof Error ? err.message : "Unknown error",
}
}
}