From 2a509fab0337aa7ece76641b562d31a3fa36e583 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:32:51 +0000 Subject: [PATCH 1/3] Initial plan From 18b6bc1a147cffa354fde0e38e0ad088c5fb6fd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:16:28 +0000 Subject: [PATCH 2/3] Fix Steam Workshop search scraping flow Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- CHANGELOG.md | 1 + .../controllers/SteamWorkshopController.php | 3 + modules/steam_workshop/lang/en_US.php | 2 +- .../lib/SteamWorkshopService.php | 224 +++++------------- modules/steam_workshop/main.php | 5 +- modules/steam_workshop/steam_workshop.js | 31 ++- modules/steam_workshop/views/edit.php | 1 + .../views/partials/mod_picker.php | 5 +- 8 files changed, 98 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 407d943a..6f592c5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## 2026-01-31 +- Rebuilt the Steam Workshop picker search to rely solely on Steam Community scraping (matching the working curl flow) and updated the request preview to show the actual Steam URL instead of a local panel endpoint. - Added adapter AppID lookup for Workshop search so the picker can query Steam even when the server XML lacks a clear default installer entry. - Switched Workshop picker results to checkbox selection, letting customers toggle multiple mods directly from the search list. - Added a hardened Workshop scraping helper (the same HTML workflow we validated manually) and wired it into the Steam Workshop service as a fallback whenever the official API errors out or returns empty data. diff --git a/modules/steam_workshop/controllers/SteamWorkshopController.php b/modules/steam_workshop/controllers/SteamWorkshopController.php index c4138581..4a6693aa 100644 --- a/modules/steam_workshop/controllers/SteamWorkshopController.php +++ b/modules/steam_workshop/controllers/SteamWorkshopController.php @@ -169,6 +169,8 @@ class SteamWorkshopController private function renderEdit(array $home, array $config, bool $isAdmin, bool $adapterLocked): void { + $gameKey = (string)($home['game_key'] ?? ''); + $appId = $gameKey !== '' ? $this->service->getSteamAppIdForGameKey($gameKey) : null; $this->render('edit', [ 'lang' => $this->lang, 'home' => $home, @@ -176,6 +178,7 @@ class SteamWorkshopController 'isAdmin' => $isAdmin, 'adapterOptions' => $this->service->getAdapterOptions(), 'adapterLocked' => $adapterLocked, + 'appId' => $appId, ]); } diff --git a/modules/steam_workshop/lang/en_US.php b/modules/steam_workshop/lang/en_US.php index 451acaaa..fe2eefae 100644 --- a/modules/steam_workshop/lang/en_US.php +++ b/modules/steam_workshop/lang/en_US.php @@ -85,7 +85,7 @@ return [ 'mod_picker_status_need_query' => 'Enter a Workshop ID or keyword before searching.', 'mod_picker_toggle_label' => 'Sync', 'mod_picker_request_label' => 'Submitting request', - 'mod_picker_request_hint' => 'Exact URL preview. The input shows the text that will be submitted.', + 'mod_picker_request_hint' => 'Exact Steam URL preview. The input shows the text that will be submitted.', 'mod_picker_request_input_label' => 'Workshop query preview', 'error_game_key_required' => 'Select a valid game key before editing the adapter.', 'error_adapter_delete_failed' => 'Adapter could not be deleted.', diff --git a/modules/steam_workshop/lib/SteamWorkshopService.php b/modules/steam_workshop/lib/SteamWorkshopService.php index 6441ff7a..ff3b95a2 100644 --- a/modules/steam_workshop/lib/SteamWorkshopService.php +++ b/modules/steam_workshop/lib/SteamWorkshopService.php @@ -682,125 +682,84 @@ class SteamWorkshopService } if (ctype_digit($query)) { - $item = $this->fetchWorkshopItemById($query); - if ($item !== null) { - $payload['results'][] = $item; + $detail = $this->fetchWorkshopItemByScrape($query); + $payload['request'] = $detail['request']; + if ($detail['error'] !== null) { + $payload['error'] = $detail['error']; + return $payload; + } + if ($detail['item'] !== null) { + $payload['results'][] = $detail['item']; $payload['pagination']['total'] = 1; } return $payload; } - $postFields = [ - 'query_type' => 0, - 'page' => $payload['pagination']['page'], - 'numperpage' => $payload['pagination']['per_page'], - 'appid' => $appId, - 'search_text' => $query, - 'return_details' => true, - 'return_metadata' => false, - ]; - - $response = $this->executeSteamApiRequest('https://api.steampowered.com/IPublishedFileService/QueryFiles/v1/', $postFields); - $requestContext = [ - 'backend' => 'api', - 'url' => $response['url'], - 'params' => $response['fields'], - 'http_code' => $response['http_code'], - 'transport_error' => $response['error'], - ]; - $requestContext['summary'] = $this->formatRequestSummary($requestContext); - $requestAttempts = [$requestContext]; - $payload['request'] = $requestContext; - - $apiFailed = false; - if ($response['error'] !== null || $response['http_code'] < 200 || $response['http_code'] >= 300) { - $apiFailed = true; - $reason = $response['error'] !== null ? $response['error'] : 'HTTP ' . $response['http_code']; - $this->logApiFailure(sprintf('Steam API search failed (app=%s query="%s" page=%d): %s', $appId, $query, $payload['pagination']['page'], $reason)); - $payload['error'] = sprintf('Steam API request failed (%s). URL: %s Params: %s', $reason, $response['url'], http_build_query($response['fields'], '', '&')); + $scrapeResult = $this->scrapeWorkshopItems($appId, $query, $payload['pagination']['per_page'], $payload['pagination']['page']); + $payload['request'] = $scrapeResult['request']; + if (!empty($scrapeResult['attempts'])) { + $payload['request']['attempts'] = $scrapeResult['attempts']; + } + if ($scrapeResult['success']) { + $payload['results'] = $scrapeResult['results']; + $payload['pagination']['total'] = $scrapeResult['total']; + $payload['pagination']['has_more'] = $scrapeResult['has_more']; } else { - $data = json_decode((string)$response['body'], true); - if (!is_array($data) || !isset($data['response'])) { - $apiFailed = true; - $this->logApiFailure(sprintf('Steam API search returned invalid payload (app=%s query="%s")', $appId, $query)); - $payload['error'] = sprintf('Steam API returned invalid data. URL: %s Params: %s', $response['url'], http_build_query($response['fields'], '', '&')); - } else { - $details = $data['response']['publishedfiledetails'] ?? []; - $total = (int)($data['response']['total'] ?? count($details)); - - foreach ($details as $item) { - $id = isset($item['publishedfileid']) ? preg_replace('/[^0-9]/', '', (string)$item['publishedfileid']) : ''; - if ($id === '') { - continue; - } - $title = isset($item['title']) ? trim((string)$item['title']) : ''; - if ($title === '') { - $title = '@' . $id; - } - $payload['results'][] = [ - 'id' => $id, - 'label' => $title, - 'author' => isset($item['creator']) ? (string)$item['creator'] : '', - 'preview_url' => isset($item['preview_url']) ? (string)$item['preview_url'] : '', - 'time_updated' => isset($item['time_updated']) ? (int)$item['time_updated'] : null, - 'subscriptions' => isset($item['subscriptions']) ? (int)$item['subscriptions'] : 0, - 'source' => 'search', - ]; - } - - $payload['pagination']['total'] = $total; - $payload['pagination']['has_more'] = ($payload['pagination']['page'] * $payload['pagination']['per_page']) < $total; - } + $payload['error'] = $scrapeResult['error'] ?? 'Steam Workshop scrape failed.'; + $this->logApiFailure(sprintf('Steam Workshop scrape failed (app=%s query="%s" page=%d): %s', $appId, $query, $payload['pagination']['page'], $payload['error'])); } - if ($this->shouldAttemptScraper($query, $payload)) { - $scrapeResult = $this->scrapeWorkshopItems($appId, $query, $payload['pagination']['per_page'], $payload['pagination']['page']); - $scrapeContext = $scrapeResult['request']; - $attemptContexts = $scrapeResult['attempts'] ?? [$scrapeContext]; - foreach ($attemptContexts as $attemptContext) { - $requestAttempts[] = $attemptContext; - } - if ($scrapeResult['success'] && !empty($scrapeResult['results'])) { - $payload['results'] = $scrapeResult['results']; - $payload['pagination']['total'] = $scrapeResult['total']; - $payload['pagination']['has_more'] = $scrapeResult['has_more']; - $payload['error'] = null; - $payload['request'] = $scrapeContext; - } elseif (!$scrapeResult['success']) { - $fallbackError = $scrapeResult['error'] ?? 'Steam Workshop scrape failed.'; - if ($payload['error'] === null) { - $payload['error'] = $fallbackError; - } else { - $payload['error'] .= ' Scraper fallback failed: ' . $fallbackError; - } - } - } - - $payload['request']['attempts'] = $requestAttempts; - return $payload; } - private function shouldAttemptScraper(string $query, array $payload): bool + private function fetchWorkshopItemByScrape(string $id): array { - if (ctype_digit($query)) { - return false; - } - if (!$this->hasAnyScraperTransport()) { - return false; + $id = preg_replace('/[^0-9]/', '', $id); + $request = [ + 'backend' => 'scraper_http', + 'url' => 'https://steamcommunity.com/sharedfiles/filedetails/', + 'params' => ['id' => $id], + 'http_code' => null, + 'transport_error' => null, + ]; + + if ($id === '') { + $request['summary'] = $this->formatRequestSummary($request); + return ['item' => null, 'request' => $request, 'error' => 'Invalid Workshop ID.']; } - $hasResults = !empty($payload['results']); - return $payload['error'] !== null || $hasResults === false; - } + $response = $this->httpGet($request['url'], $request['params'], $this->getScraperUserAgent()); + $request['url'] = $response['url'] ?? $request['url']; + $request['http_code'] = $response['http_code']; + $request['transport_error'] = $response['error']; + $request['summary'] = $this->formatRequestSummary($request); - private function hasAnyScraperTransport(): bool - { - if ($this->isScraperAvailable()) { - return true; + if ($response['error'] !== null || $response['http_code'] < 200 || $response['http_code'] >= 300 || $response['body'] === null) { + $reason = $response['error'] !== null ? $response['error'] : 'HTTP ' . $response['http_code']; + return [ + 'item' => null, + 'request' => $request, + 'error' => 'Steam Community detail request failed: ' . $reason, + ]; } - return function_exists('curl_init'); + $title = $this->parseWorkshopTitle((string)$response['body']); + if ($title === '') { + $title = '@' . $id; + } + + return [ + 'item' => [ + 'id' => $id, + 'label' => $title, + 'author' => '', + 'preview_url' => '', + 'enabled' => true, + 'source' => 'search', + ], + 'request' => $request, + 'error' => null, + ]; } private function scrapeWorkshopItems(string $appId, string $query, int $perPage, int $page): array @@ -1396,67 +1355,6 @@ class SteamWorkshopService } - private function fetchWorkshopItemById(string $id): ?array - { - $response = $this->executeSteamApiRequest('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', [ - 'itemcount' => 1, - 'publishedfileids[0]' => $id, - ]); - if ($response['error'] !== null || $response['http_code'] < 200 || $response['http_code'] >= 300) { - $reason = $response['error'] ?? ('HTTP ' . $response['http_code']); - $this->logApiFailure(sprintf('Steam API detail lookup failed (id=%s): %s', $id, $reason)); - return null; - } - - $data = json_decode((string)$response['body'], true); - $details = $data['response']['publishedfiledetails'][0] ?? null; - if (!is_array($details) || (int)($details['result'] ?? 0) !== 1) { - return null; - } - - $title = trim((string)($details['title'] ?? '')); - if ($title === '') { - $title = '@' . $id; - } - - return [ - 'id' => $id, - 'label' => $title, - 'author' => (string)($details['creator'] ?? ''), - 'preview_url' => (string)($details['preview_url'] ?? ''), - 'enabled' => true, - 'source' => 'search', - ]; - } - - private function executeSteamApiRequest(string $url, array $fields): array - { - if (!function_exists('curl_init')) { - return ['body' => null, 'http_code' => 0, 'error' => 'PHP cURL extension is required', 'url' => $url, 'fields' => $fields]; - } - - $ch = curl_init($url); - if ($ch === false) { - return ['body' => null, 'http_code' => 0, 'error' => 'Unable to initialize cURL', 'url' => $url, 'fields' => $fields]; - } - - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => http_build_query($fields, '', '&'), - CURLOPT_TIMEOUT => 15, - CURLOPT_USERAGENT => 'GSP-Workshop/1.0 (+https://github.com/GameServerPanel/GSP)', - CURLOPT_HTTPHEADER => ['Accept: application/json', 'Content-Type: application/x-www-form-urlencoded'], - ]); - - $body = curl_exec($ch); - $error = curl_errno($ch) ? curl_error($ch) : null; - $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - return ['body' => $error === null ? $body : null, 'http_code' => $status, 'error' => $error, 'url' => $url, 'fields' => $fields]; - } - private function httpGet(string $url, array $params = [], ?string $userAgent = null): array { if (!function_exists('curl_init')) { diff --git a/modules/steam_workshop/main.php b/modules/steam_workshop/main.php index c26198dd..3a09b471 100644 --- a/modules/steam_workshop/main.php +++ b/modules/steam_workshop/main.php @@ -10,7 +10,10 @@ function exec_ogp_module(): void { global $db; - echo '

' . get_lang('steam_workshop') . '

'; + $action = $_GET['action'] ?? ''; + if ($action !== 'search') { + echo '

' . get_lang('steam_workshop') . '

'; + } $controller = new SteamWorkshopController($db); $controller->handle(); diff --git a/modules/steam_workshop/steam_workshop.js b/modules/steam_workshop/steam_workshop.js index 29d8673d..554187ce 100644 --- a/modules/steam_workshop/steam_workshop.js +++ b/modules/steam_workshop/steam_workshop.js @@ -241,16 +241,31 @@ } }; Picker.prototype.updateRequestPreview = function () { - if (this.requestInput && this.searchInput) { - this.requestInput.value = this.searchInput.value; + if (!this.searchInput) { + return; } - if (this.requestSummary) { - var encoded = ''; - if (this.searchInput && this.searchInput.value.trim() !== '') { - encoded = encodeURIComponent(this.searchInput.value.trim()); - } - this.requestSummary.textContent = (this.requestSummaryBase || '') + encoded; + var term = this.searchInput.value.trim(); + if (this.requestInput) { + this.requestInput.value = term; } + if (!this.requestSummary) { + return; + } + var base = this.requestSummaryBase || ''; + if (!base) { + this.requestSummary.textContent = ''; + return; + } + if (!term) { + this.requestSummary.textContent = base; + return; + } + var isId = /^\d+$/.test(term); + if (isId) { + this.requestSummary.textContent = 'https://steamcommunity.com/sharedfiles/filedetails/?id=' + encodeURIComponent(term); + return; + } + this.requestSummary.textContent = base + encodeURIComponent(term); }; Picker.prototype.isSelected = function (id) { return this.state.selected.some(function (item) { return item.id === id; }); diff --git a/modules/steam_workshop/views/edit.php b/modules/steam_workshop/views/edit.php index d414427e..eda48de6 100644 --- a/modules/steam_workshop/views/edit.php +++ b/modules/steam_workshop/views/edit.php @@ -4,6 +4,7 @@ declare(strict_types=1); /** @var array $config */ /** @var array $lang */ /** @var array $adapterOptions */ +/** @var string|null $appId */ $homeName = htmlspecialchars($home['home_name'] ?? ('#' . $home['home_id'])); $homeId = (int)$home['home_id']; ?> diff --git a/modules/steam_workshop/views/partials/mod_picker.php b/modules/steam_workshop/views/partials/mod_picker.php index 648264ac..58448921 100644 --- a/modules/steam_workshop/views/partials/mod_picker.php +++ b/modules/steam_workshop/views/partials/mod_picker.php @@ -4,6 +4,7 @@ declare(strict_types=1); /** @var array $config */ /** @var array $home */ /** @var int $homeId */ +/** @var string|null $appId */ $homeId = (int)($home['home_id'] ?? 0); $scriptPath = (string)($_SERVER['PHP_SELF'] ?? '/index.php'); if ($scriptPath === '') { @@ -13,6 +14,8 @@ if ($scriptPath[0] !== '/') { $scriptPath = '/' . ltrim($scriptPath, '/'); } $endpoint = sprintf('%s?m=steam_workshop&p=main&action=search&home_id=%d', $scriptPath, $homeId); +$steamBase = 'https://steamcommunity.com/workshop/browse/?appid='; +$steamAppId = isset($appId) && $appId !== '' ? $appId : '0'; $initialItems = []; foreach ($config['workshop_items'] ?? [] as $item) { if (!is_array($item)) { @@ -62,7 +65,7 @@ $langAttrs = [
- +
From 5af47cdcd1ee91388185602f9172093c054042ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Jan 2026 20:23:40 +0000 Subject: [PATCH 3/3] Address Steam Workshop search review notes Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com> --- .../steam_workshop/lib/SteamWorkshopService.php | 15 ++++++++------- modules/steam_workshop/steam_workshop.js | 8 +++++--- .../steam_workshop/views/partials/mod_picker.php | 6 +++--- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/modules/steam_workshop/lib/SteamWorkshopService.php b/modules/steam_workshop/lib/SteamWorkshopService.php index ff3b95a2..23b9adb6 100644 --- a/modules/steam_workshop/lib/SteamWorkshopService.php +++ b/modules/steam_workshop/lib/SteamWorkshopService.php @@ -5,6 +5,7 @@ class SteamWorkshopService { private const MIN_INTERVAL = 15; private const MAX_INTERVAL = 360; + private const STEAM_WORKSHOP_DETAIL_URL = 'https://steamcommunity.com/sharedfiles/filedetails/'; private OGPDatabase $db; private string $configDir; @@ -714,16 +715,16 @@ class SteamWorkshopService private function fetchWorkshopItemByScrape(string $id): array { - $id = preg_replace('/[^0-9]/', '', $id); + $sanitizedId = preg_replace('/[^0-9]/', '', $id); $request = [ 'backend' => 'scraper_http', - 'url' => 'https://steamcommunity.com/sharedfiles/filedetails/', - 'params' => ['id' => $id], + 'url' => self::STEAM_WORKSHOP_DETAIL_URL, + 'params' => ['id' => $sanitizedId], 'http_code' => null, 'transport_error' => null, ]; - if ($id === '') { + if ($sanitizedId === '') { $request['summary'] = $this->formatRequestSummary($request); return ['item' => null, 'request' => $request, 'error' => 'Invalid Workshop ID.']; } @@ -745,12 +746,12 @@ class SteamWorkshopService $title = $this->parseWorkshopTitle((string)$response['body']); if ($title === '') { - $title = '@' . $id; + $title = '@' . $sanitizedId; } return [ 'item' => [ - 'id' => $id, + 'id' => $sanitizedId, 'label' => $title, 'author' => '', 'preview_url' => '', @@ -960,7 +961,7 @@ class SteamWorkshopService $results = []; foreach ($sliceIds as $id) { - $detailResponse = $this->httpGet('https://steamcommunity.com/sharedfiles/filedetails/', ['id' => $id], $this->getScraperUserAgent()); + $detailResponse = $this->httpGet(self::STEAM_WORKSHOP_DETAIL_URL, ['id' => $id], $this->getScraperUserAgent()); $title = ''; if ($detailResponse['error'] === null && $detailResponse['http_code'] >= 200 && $detailResponse['http_code'] < 300 && $detailResponse['body'] !== null) { $title = $this->parseWorkshopTitle((string)$detailResponse['body']); diff --git a/modules/steam_workshop/steam_workshop.js b/modules/steam_workshop/steam_workshop.js index 554187ce..ef2b3059 100644 --- a/modules/steam_workshop/steam_workshop.js +++ b/modules/steam_workshop/steam_workshop.js @@ -5,6 +5,7 @@ function Picker(root) { this.root = root; this.endpoint = root.getAttribute('data-endpoint') || ''; + this.detailBase = root.getAttribute('data-detail-base') || 'https://steamcommunity.com/sharedfiles/filedetails/?id='; this.lang = { add: root.getAttribute('data-lang-add') || 'Add', remove: root.getAttribute('data-lang-remove') || 'Remove', @@ -260,9 +261,10 @@ this.requestSummary.textContent = base; return; } - var isId = /^\d+$/.test(term); - if (isId) { - this.requestSummary.textContent = 'https://steamcommunity.com/sharedfiles/filedetails/?id=' + encodeURIComponent(term); + // Numeric-only terms are treated as Workshop item IDs and link to detail pages instead of search. + var isWorkshopId = /^\d+$/.test(term); + if (isWorkshopId) { + this.requestSummary.textContent = this.detailBase + encodeURIComponent(term); return; } this.requestSummary.textContent = base + encodeURIComponent(term); diff --git a/modules/steam_workshop/views/partials/mod_picker.php b/modules/steam_workshop/views/partials/mod_picker.php index 58448921..82efd95c 100644 --- a/modules/steam_workshop/views/partials/mod_picker.php +++ b/modules/steam_workshop/views/partials/mod_picker.php @@ -15,7 +15,7 @@ if ($scriptPath[0] !== '/') { } $endpoint = sprintf('%s?m=steam_workshop&p=main&action=search&home_id=%d', $scriptPath, $homeId); $steamBase = 'https://steamcommunity.com/workshop/browse/?appid='; -$steamAppId = isset($appId) && $appId !== '' ? $appId : '0'; +$steamAppIdParam = $appId ?? ''; $initialItems = []; foreach ($config['workshop_items'] ?? [] as $item) { if (!is_array($item)) { @@ -46,7 +46,7 @@ $langAttrs = [ 'sync' => $lang['mod_picker_toggle_label'] ?? 'Sync', ]; ?> -
$value): ?>data-lang-="" >

@@ -65,7 +65,7 @@ $langAttrs = [
- +