diff --git a/app/test-version-fetchers/page.tsx b/app/test-version-fetchers/page.tsx new file mode 100644 index 0000000..ef29ed3 --- /dev/null +++ b/app/test-version-fetchers/page.tsx @@ -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(null) + const [best, setBest] = useState(null) + const [error, setError] = useState(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 ( +
+

Version Fetcher — Strategy Comparison

+

+ Enter a Steam App ID to test all version-fetching strategies. + The best result (priority: named version > build ID) will be highlighted. +

+ + {/* Input */} +
+ 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()} + /> + +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Results */} + {results && ( +
+ {/* Best Result */} + {best ? ( +
+
+ +

+ Best Result: {best.strategy} +

+
+
+
+

Version String

+

+ {best.versionString || "—"} +

+
+
+

Build ID

+

+ {best.buildId || "—"} +

+
+
+
+ ) : ( +
+
+ +

+ No strategy found version data for this game. +

+
+
+ )} + + {/* All Strategy Results */} +

+ All Strategy Results +

+
+ {results.map((r, i) => ( +
+
+
+ {r.success ? ( + + ) : ( + + )} + + {r.strategy} + +
+ {r.error && ( + {r.error} + )} +
+ {r.success && ( +
+
+ Version: + + {r.versionString || "—"} + +
+
+ Build: + + {r.buildId || "—"} + +
+
+ )} +
+ ))} +
+
+ )} + + {/* Quick Test Buttons */} +
+

+ Quick Test (known Steam App IDs) +

+
+ {[ + { 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) => ( + + ))} +
+
+
+ ) +} diff --git a/components/wizard/game-entry-wizard.tsx b/components/wizard/game-entry-wizard.tsx index c93e708..9f19266 100644 --- a/components/wizard/game-entry-wizard.tsx +++ b/components/wizard/game-entry-wizard.tsx @@ -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(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 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} /> )} diff --git a/components/wizard/steps/setup-step.tsx b/components/wizard/steps/setup-step.tsx index a038f76..b8f796b 100644 --- a/components/wizard/steps/setup-step.tsx +++ b/components/wizard/steps/setup-step.tsx @@ -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 (
@@ -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) && ( )} - {steamdbLoading && ( + {autoFetchEnabled && steamdbLoading && ( @@ -121,7 +125,8 @@ export function SetupStep({ - {/* Refresh button for SteamDB */} + {/* Refresh button for auto-detection (only shown when feature enabled) */} + {autoFetchEnabled && ( + )} + {steamdbError && ( +

{steamdbError}

+ )}
{isNewVersion && ( @@ -152,11 +161,13 @@ export function SetupStep({ )} - {isSteamDBVersion && steamdbVersion && ( + {autoFetchEnabled && isSteamDBVersion && steamdbVersion && (
-

SteamDB Suggestion

+

+ Auto-Detected Version ({steamdbVersion.source ?? "unknown source"}) +

diff --git a/content/updates/2026-05-16-v2026.1.0.md b/content/updates/2026-05-16-v2026.0.102.md similarity index 98% rename from content/updates/2026-05-16-v2026.1.0.md rename to content/updates/2026-05-16-v2026.0.102.md index 9250c17..dc36bd0 100644 --- a/content/updates/2026-05-16-v2026.1.0.md +++ b/content/updates/2026-05-16-v2026.0.102.md @@ -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." --- diff --git a/lib/api/app.ts b/lib/api/app.ts index 0d20a7e..1c1580b 100644 --- a/lib/api/app.ts +++ b/lib/api/app.ts @@ -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) diff --git a/lib/api/steamdb-version.ts b/lib/api/steamdb-version.ts index 8213f65..0f70c34 100644 --- a/lib/api/steamdb-version.ts +++ b/lib/api/steamdb-version.ts @@ -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() }), }, -) \ No newline at end of file +) + +/** + * 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()), + }), + ), + }), + }, +) diff --git a/lib/api/version-test.ts b/lib/api/version-test.ts new file mode 100644 index 0000000..79ab7e3 --- /dev/null +++ b/lib/api/version-test.ts @@ -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(), + }), + }, +) diff --git a/lib/version-fetchers/community-hub.ts b/lib/version-fetchers/community-hub.ts new file mode 100644 index 0000000..c1b4ace --- /dev/null +++ b/lib/version-fetchers/community-hub.ts @@ -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 { + 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", + } + } +} diff --git a/lib/version-fetchers/index.ts b/lib/version-fetchers/index.ts new file mode 100644 index 0000000..fca0f0e --- /dev/null +++ b/lib/version-fetchers/index.ts @@ -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 { + 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) +} diff --git a/lib/version-fetchers/store-api.ts b/lib/version-fetchers/store-api.ts new file mode 100644 index 0000000..0952d40 --- /dev/null +++ b/lib/version-fetchers/store-api.ts @@ -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 { + 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", + } + } +} diff --git a/lib/version-fetchers/store-page.ts b/lib/version-fetchers/store-page.ts new file mode 100644 index 0000000..db2d377 --- /dev/null +++ b/lib/version-fetchers/store-page.ts @@ -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 { + 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( + /]*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( + /]+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 + 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 } +} diff --git a/lib/version-fetchers/types.ts b/lib/version-fetchers/types.ts new file mode 100644 index 0000000..e5a3e88 --- /dev/null +++ b/lib/version-fetchers/types.ts @@ -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 diff --git a/lib/version-fetchers/uptodate-api.ts b/lib/version-fetchers/uptodate-api.ts new file mode 100644 index 0000000..ad724dd --- /dev/null +++ b/lib/version-fetchers/uptodate-api.ts @@ -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 { + 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", + } + } +}