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
|
// Step 4: Notes
|
||||||
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
|
const [userNotes, setUserNotes] = useState(editEntry?.userNotes ?? "")
|
||||||
|
|
||||||
// SteamDB version suggestion
|
// Auto-detected version suggestion (multi-strategy)
|
||||||
const [steamdbVersion, setSteamdbVersion] = useState<{
|
const [steamdbVersion, setSteamdbVersion] = useState<{
|
||||||
versionString: string | null
|
versionString: string | null
|
||||||
buildId: string | null
|
buildId: string | null
|
||||||
|
source?: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [steamdbLoading, setSteamdbLoading] = useState(false)
|
const [steamdbLoading, setSteamdbLoading] = useState(false)
|
||||||
|
const [steamdbError, setSteamdbError] = useState<string | null>(null)
|
||||||
|
|
||||||
// Fetch hardware name when slug changes
|
// Fetch hardware name when slug changes
|
||||||
const handleHardwareChange = useCallback(async (slug: string) => {
|
const handleHardwareChange = useCallback(async (slug: string) => {
|
||||||
@@ -209,23 +211,107 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
|
|||||||
|
|
||||||
const fetchSteamDBVersion = useCallback(async () => {
|
const fetchSteamDBVersion = useCallback(async () => {
|
||||||
setSteamdbLoading(true)
|
setSteamdbLoading(true)
|
||||||
|
setSteamdbError(null)
|
||||||
try {
|
try {
|
||||||
|
// Step 1: Try server-side strategies first
|
||||||
const res = await fetch(`/api/games/${gameId}/steamdb-version`)
|
const res = await fetch(`/api/games/${gameId}/steamdb-version`)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
|
// If server found something, use it
|
||||||
if (data.versionString || data.buildId) {
|
if (data.versionString || data.buildId) {
|
||||||
setSteamdbVersion({
|
setSteamdbVersion({
|
||||||
versionString: data.versionString,
|
versionString: data.versionString,
|
||||||
buildId: data.buildId,
|
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 {
|
} catch {
|
||||||
// Silently fail — SteamDB is best-effort
|
// Silently fail — version detection is best-effort
|
||||||
} finally {
|
} finally {
|
||||||
setSteamdbLoading(false)
|
setSteamdbLoading(false)
|
||||||
}
|
}
|
||||||
}, [gameId])
|
}, [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
|
// Initialize existing screenshots when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editEntry && (editEntry as any).screenshots && Array.isArray((editEntry as any).screenshots)) {
|
if (editEntry && (editEntry as any).screenshots && Array.isArray((editEntry as any).screenshots)) {
|
||||||
@@ -242,9 +328,11 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
|
|||||||
}
|
}
|
||||||
}, [editEntry])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
if (process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true") {
|
||||||
fetchSteamDBVersion()
|
fetchSteamDBVersion()
|
||||||
|
}
|
||||||
}, [fetchSteamDBVersion])
|
}, [fetchSteamDBVersion])
|
||||||
|
|
||||||
const canProceed = () => {
|
const canProceed = () => {
|
||||||
@@ -458,6 +546,7 @@ export function GameEntryWizard({ gameId, gameVersions, defaultVersionId, editEn
|
|||||||
platformSupport={platformSupport}
|
platformSupport={platformSupport}
|
||||||
steamdbVersion={steamdbVersion}
|
steamdbVersion={steamdbVersion}
|
||||||
steamdbLoading={steamdbLoading}
|
steamdbLoading={steamdbLoading}
|
||||||
|
steamdbError={steamdbError}
|
||||||
onRefreshSteamDB={fetchSteamDBVersion}
|
onRefreshSteamDB={fetchSteamDBVersion}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface GameVersionInfo {
|
|||||||
export interface SteamDBVersion {
|
export interface SteamDBVersion {
|
||||||
versionString: string | null
|
versionString: string | null
|
||||||
buildId: string | null
|
buildId: string | null
|
||||||
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetupStepProps {
|
interface SetupStepProps {
|
||||||
@@ -37,6 +38,7 @@ interface SetupStepProps {
|
|||||||
}[]
|
}[]
|
||||||
steamdbVersion: SteamDBVersion | null
|
steamdbVersion: SteamDBVersion | null
|
||||||
steamdbLoading: boolean
|
steamdbLoading: boolean
|
||||||
|
steamdbError: string | null
|
||||||
onRefreshSteamDB: () => void
|
onRefreshSteamDB: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +60,12 @@ export function SetupStep({
|
|||||||
platformSupport,
|
platformSupport,
|
||||||
steamdbVersion,
|
steamdbVersion,
|
||||||
steamdbLoading,
|
steamdbLoading,
|
||||||
|
steamdbError,
|
||||||
onRefreshSteamDB,
|
onRefreshSteamDB,
|
||||||
}: SetupStepProps) {
|
}: SetupStepProps) {
|
||||||
const isNewVersion = selectedVersionId === "__new__"
|
const isNewVersion = selectedVersionId === "__new__"
|
||||||
const isSteamDBVersion = selectedVersionId === "__steamdb__"
|
const isSteamDBVersion = selectedVersionId === "__steamdb__"
|
||||||
|
const autoFetchEnabled = process.env.NEXT_PUBLIC_VERSION_AUTO_FETCH === "true"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -92,13 +96,13 @@ export function SetupStep({
|
|||||||
onChange={(e) => onVersionChange(e.target.value)}
|
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"
|
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 */}
|
{/* Auto-detected version suggestion — only when feature is enabled */}
|
||||||
{steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
|
{autoFetchEnabled && steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
|
||||||
<option value="__steamdb__" className="bg-primary/10 text-primary">
|
<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>
|
</option>
|
||||||
)}
|
)}
|
||||||
{steamdbLoading && (
|
{autoFetchEnabled && steamdbLoading && (
|
||||||
<option disabled className="text-text/40">
|
<option disabled className="text-text/40">
|
||||||
Fetching latest version from SteamDB...
|
Fetching latest version from SteamDB...
|
||||||
</option>
|
</option>
|
||||||
@@ -121,7 +125,8 @@ export function SetupStep({
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Refresh button for SteamDB */}
|
{/* Refresh button for auto-detection (only shown when feature enabled) */}
|
||||||
|
{autoFetchEnabled && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefreshSteamDB}
|
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"
|
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" : ""}`} />
|
<RefreshCwIcon className={`h-3 w-3 ${steamdbLoading ? "animate-spin" : ""}`} />
|
||||||
Refresh from SteamDB
|
Refresh auto-detected version
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{steamdbError && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">{steamdbError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isNewVersion && (
|
{isNewVersion && (
|
||||||
@@ -152,11 +161,13 @@ export function SetupStep({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSteamDBVersion && steamdbVersion && (
|
{autoFetchEnabled && isSteamDBVersion && steamdbVersion && (
|
||||||
<div className="space-y-3 p-3 rounded-lg border border-primary/30 bg-primary/5">
|
<div className="space-y-3 p-3 rounded-lg border border-primary/30 bg-primary/5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DatabaseIcon className="h-4 w-4 text-primary" />
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: "Moderator Role, Performance Tag Fix, and Steam Sale Section"
|
title: "Moderator Role, Performance Tag Fix, and Steam Sale Section"
|
||||||
date: "2026-05-16"
|
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."
|
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 { adminStorageRoutes } from "@/lib/api/admin-storage"
|
||||||
import { adminAnalyticsRoutes } from "@/lib/api/admin-analytics"
|
import { adminAnalyticsRoutes } from "@/lib/api/admin-analytics"
|
||||||
import { steamSearchRoutes } from "@/lib/api/steam-search"
|
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 { searchUnifiedRoutes } from "@/lib/api/search-unified"
|
||||||
import { gameStubRoutes } from "@/lib/api/game-stub"
|
import { gameStubRoutes } from "@/lib/api/game-stub"
|
||||||
import { gameStatsRoutes } from "@/lib/api/game-stats"
|
import { gameStatsRoutes } from "@/lib/api/game-stats"
|
||||||
@@ -182,6 +183,8 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
.use(gameStubRoutes)
|
.use(gameStubRoutes)
|
||||||
.use(steamgridProxyRoutes)
|
.use(steamgridProxyRoutes)
|
||||||
.use(steamdbVersionRoutes)
|
.use(steamdbVersionRoutes)
|
||||||
|
.use(versionTestRoutes)
|
||||||
|
.use(standaloneVersionTestRoutes)
|
||||||
.use(gamesManualRoutes)
|
.use(gamesManualRoutes)
|
||||||
.use(screenshotRoutes)
|
.use(screenshotRoutes)
|
||||||
.use(mobileRoutes)
|
.use(mobileRoutes)
|
||||||
@@ -191,6 +194,7 @@ export const app = new Elysia({ prefix: "/api" })
|
|||||||
app
|
app
|
||||||
.use(rateLimit("write"))
|
.use(rateLimit("write"))
|
||||||
.use(betterAuth)
|
.use(betterAuth)
|
||||||
|
.use(clientVersionRoutes)
|
||||||
.use(performanceVerifyRoutes)
|
.use(performanceVerifyRoutes)
|
||||||
.use(performanceSubmitRoutes)
|
.use(performanceSubmitRoutes)
|
||||||
.use(commentsRoutes)
|
.use(commentsRoutes)
|
||||||
|
|||||||
+137
-15
@@ -2,7 +2,7 @@ import { Elysia, t } from "elysia"
|
|||||||
import { db } from "@/lib/db/index"
|
import { db } from "@/lib/db/index"
|
||||||
import { games } from "@/lib/db/schema"
|
import { games } from "@/lib/db/schema"
|
||||||
import { eq } from "drizzle-orm"
|
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({
|
export const steamdbVersionRoutes = new Elysia({
|
||||||
prefix: "/games/:gameId",
|
prefix: "/games/:gameId",
|
||||||
@@ -10,7 +10,15 @@ export const steamdbVersionRoutes = new Elysia({
|
|||||||
}).get(
|
}).get(
|
||||||
"/steamdb-version",
|
"/steamdb-version",
|
||||||
async ({ params, set }) => {
|
async ({ params, set }) => {
|
||||||
// Look up game's steamAppId
|
// 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) {
|
||||||
|
// Already a Steam App ID — use directly
|
||||||
|
steamAppId = Number(params.gameId)
|
||||||
|
} else {
|
||||||
|
// Look up by DB UUID
|
||||||
const [game] = await db
|
const [game] = await db
|
||||||
.select({ steamAppId: games.steamAppId })
|
.select({ steamAppId: games.steamAppId })
|
||||||
.from(games)
|
.from(games)
|
||||||
@@ -21,30 +29,144 @@ export const steamdbVersionRoutes = new Elysia({
|
|||||||
set.status = 404
|
set.status = 404
|
||||||
return { error: "Game not found" }
|
return { error: "Game not found" }
|
||||||
}
|
}
|
||||||
|
steamAppId = game.steamAppId
|
||||||
|
}
|
||||||
|
|
||||||
if (game.steamAppId === null) {
|
if (steamAppId === null) {
|
||||||
return { unavailable: true, reason: "no_steam_app_id" }
|
return { unavailable: true, reason: "no_steam_app_id" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if scraping is globally disabled
|
// Run ONLY server-safe strategies (no client-preferred ones)
|
||||||
if (process.env.STEAMDB_SCRAPING_ENABLED === "false") {
|
// Client-side strategies should be called from the browser
|
||||||
return { unavailable: true, reason: "scraping_disabled" }
|
const { best, all } = await fetchAllVersions(
|
||||||
|
steamAppId,
|
||||||
|
SERVER_STRATEGIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 (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,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await scrapeSteamDBVersion(game.steamAppId)
|
|
||||||
|
|
||||||
if (result.versionString === null && result.buildId === null) {
|
|
||||||
return { unavailable: true, reason: "not_found" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
versionString: result.versionString,
|
versionString: best.versionString,
|
||||||
buildId: result.buildId,
|
buildId: best.buildId,
|
||||||
steamAppId: game.steamAppId,
|
steamAppId,
|
||||||
source: "steamdb",
|
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() }),
|
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