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:
@@ -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 > 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -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(/"/g, '"')
|
||||
.replace(/&/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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user