Open source candidates with configured browser
This commit is contained in:
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user