Open source candidates with configured browser

This commit is contained in:
2026-08-05 21:20:06 +02:00
parent d90e6c6dd8
commit fe329cd19b
5 changed files with 105 additions and 20 deletions
+1
View File
@@ -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.
+34 -10
View File
@@ -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;
+32 -10
View File
@@ -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() {
+13
View File
@@ -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,
+25
View File
@@ -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);