From fe329cd19b0294a0fd8b72a45506fce36c5103ce Mon Sep 17 00:00:00 2001 From: rajch_ales Date: Wed, 5 Aug 2026 21:20:06 +0200 Subject: [PATCH] Open source candidates with configured browser --- agent.md | 1 + public/app.js | 44 ++++++++++++++++++++++++-------- public/app.ts | 42 ++++++++++++++++++++++-------- src/server.ts | 13 ++++++++++ src/services/catalog-products.ts | 25 ++++++++++++++++++ 5 files changed, 105 insertions(+), 20 deletions(-) diff --git a/agent.md b/agent.md index d08f101..2f8eb36 100644 --- a/agent.md +++ b/agent.md @@ -71,6 +71,7 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N - Scrapers must use controlled browser navigation, selectors, explicit waits and bounded request counts. - The configured browser engine for a source is authoritative. If a source says `firefox`, launch Firefox or fail loudly; do not silently fall back to Chromium, Edge or another browser. - Visible source windows, URL health checks, source lookup and picture parsing must all use the same configured browser engine from that source's database setting. +- Source candidate `Open` actions in the UI must call the backend source opener with `manufacturerId`, `sourceKey` and URL. Do not use plain `target="_blank"` source links for mapped supplier/manufacturer sources because that ignores the configured browser engine. - Keep scraping slow and observable, preserve source URL/status information, and handle blocked pages or missing selectors as explicit errors. - Supplier data remains a local draft until the user explicitly confirms an Apply / Save action. diff --git a/public/app.js b/public/app.js index 3d1a76f..765779a 100644 --- a/public/app.js +++ b/public/app.js @@ -791,8 +791,8 @@ function renderSourceCandidates(items) { actions.append(status, pictures); header.append(name, actions); const rows = item.candidates.length - ? item.candidates.map(renderSourceCandidateRow) - : [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; + ? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source)) + : [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)]; section.append(header, ...rows); return section; })); @@ -867,27 +867,51 @@ function updateFindSourcesButtonState() { setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources"); findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; } -function renderSourceCandidateRow(candidate) { - const row = document.createElement("a"); +function renderSourceCandidateRow(candidate, source) { + const row = document.createElement("div"); row.className = "source-row"; - row.href = candidate.url; - row.target = "_blank"; - row.rel = "noreferrer"; const title = document.createElement("span"); title.className = "source-row-title"; title.textContent = formatValue(candidate.title || candidate.url); title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); - const action = document.createElement("span"); + const action = document.createElement("button"); + action.type = "button"; action.className = "source-row-action"; action.textContent = "Open"; + action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action)); row.append(title, action); return row; } -function renderManualSearchRow(url, label) { +async function openSourceCandidate(url, source, button) { + if (!url || !source?.key || !manufacturerSelect.value) + return; + const previousText = button.textContent; + button.disabled = true; + button.textContent = "Opening..."; + try { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + sourceKey: source.key, + url, + }); + const response = await fetch(`/api/catalog-maker/open-source?${params.toString()}`); + const data = await response.json(); + if (!response.ok || data.error) + throw new Error(data.error || "Source browser could not be opened."); + } + catch (error) { + alert(error.message || "Source browser could not be opened."); + } + finally { + button.disabled = false; + button.textContent = previousText; + } +} +function renderManualSearchRow(url, label, source) { return renderSourceCandidateRow({ title: label, url, - }); + }, source); } function closeSourceCandidates() { sourceModal.hidden = true; diff --git a/public/app.ts b/public/app.ts index bc3a8a0..4638cbb 100644 --- a/public/app.ts +++ b/public/app.ts @@ -864,8 +864,8 @@ function renderSourceCandidates(items) { header.append(name, actions); const rows = item.candidates.length - ? item.candidates.map(renderSourceCandidateRow) - : [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; + ? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source)) + : [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)]; section.append(header, ...rows); return section; @@ -950,31 +950,53 @@ function updateFindSourcesButtonState() { findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; } -function renderSourceCandidateRow(candidate) { - const row = document.createElement("a"); +function renderSourceCandidateRow(candidate, source) { + const row = document.createElement("div"); row.className = "source-row"; - row.href = candidate.url; - row.target = "_blank"; - row.rel = "noreferrer"; const title = document.createElement("span"); title.className = "source-row-title"; title.textContent = formatValue(candidate.title || candidate.url); title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); - const action = document.createElement("span"); + const action = document.createElement("button"); + action.type = "button"; action.className = "source-row-action"; action.textContent = "Open"; + action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action)); row.append(title, action); return row; } -function renderManualSearchRow(url, label) { +async function openSourceCandidate(url, source, button) { + if (!url || !source?.key || !manufacturerSelect.value) return; + const previousText = button.textContent; + button.disabled = true; + button.textContent = "Opening..."; + + try { + const params = new URLSearchParams({ + manufacturerId: manufacturerSelect.value, + sourceKey: source.key, + url, + }); + const response = await fetch(`/api/catalog-maker/open-source?${params.toString()}`); + const data = await response.json(); + if (!response.ok || data.error) throw new Error(data.error || "Source browser could not be opened."); + } catch (error) { + alert(error.message || "Source browser could not be opened."); + } finally { + button.disabled = false; + button.textContent = previousText; + } +} + +function renderManualSearchRow(url, label, source) { return renderSourceCandidateRow({ title: label, url, - }); + }, source); } function closeSourceCandidates() { diff --git a/src/server.ts b/src/server.ts index 9d9467c..e45ed86 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ import { loadProductByEan, loadProductInfo, getProductPictures, + openProductSource, } from "./services/catalog-products.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -122,6 +123,18 @@ const server = http.createServer(async (request, response) => { return; } + if (url.pathname === "/api/catalog-maker/open-source") { + await sendJson( + response, + await openProductSource(requestConfig, { + manufacturerId: url.searchParams.get("manufacturerId"), + sourceKey: url.searchParams.get("sourceKey"), + url: url.searchParams.get("url"), + }), + ); + return; + } + if (url.pathname === "/api/catalog-maker/product-info") { await sendJson( response, diff --git a/src/services/catalog-products.ts b/src/services/catalog-products.ts index 1584db8..128d244 100644 --- a/src/services/catalog-products.ts +++ b/src/services/catalog-products.ts @@ -240,6 +240,31 @@ export async function getProductPictures( }; } +export async function openProductSource(config, { manufacturerId, sourceKey, url }) { + const sourceUrl = normalizeSourceUrl(url); + const mapping = await listMappingSources(config, { manufacturerId }); + const source = mapping.items.find( + (item) => item.enabled && String(item.key ?? "").toLowerCase() === String(sourceKey ?? "").toLowerCase(), + ); + + if (!source) { + throw new Error("Source mapping was not found for selected manufacturer."); + } + + await openInBrowser(sourceUrl, { engine: source.browserEngine }); + + return { + configured: mapping.configured, + opened: true, + source: { + key: source.key, + name: source.name, + browserEngine: source.browserEngine, + }, + url: sourceUrl, + }; +} + async function findSourceCandidates(source, product, { includePictures = false } = {}) { const searchAttempts = buildAdapterSearchUrls(source, product); let lastSearchUrl = searchAttempts[0]?.url || normalizeSourceUrl(source.url);