Prefer large Idealo gallery pictures

This commit is contained in:
2026-08-06 00:33:44 +02:00
parent b5a69050e0
commit d3d1147a3a
3 changed files with 143 additions and 1 deletions
+1
View File
@@ -193,6 +193,7 @@ There are three selectable data sources:
- Review UI: show picture size, source, target assignment, duplicate state, per-row save and batch save actions. - Review UI: show picture size, source, target assignment, duplicate state, per-row save and batch save actions.
- Hudy picture parsing uses the correct product color variant, checks EANs and extracts large gallery images. - Hudy picture parsing uses the correct product color variant, checks EANs and extracts large gallery images.
- Idealo uses the browser adapter and is intended mainly as a picture source. - Idealo uses the browser adapter and is intended mainly as a picture source.
- Idealo picture parsing must read product gallery images from Splide gallery markup (`splide__track`, `splide__list`, `splide__slide`) and choose the largest available URL from `srcset`, `data-srcset`, `data-large`, `data-original`, `data-src` or `src`. Do not use search-result thumbnails or small preview URLs as production picture candidates when gallery images exist.
- Image preview is square. - Image preview is square.
- Settings is at the bottom of the left panel; database status is directly above it. - Settings is at the bottom of the left panel; database status is directly above it.
+109 -1
View File
@@ -310,7 +310,11 @@ async function findSourceCandidates(source, product, { includePictures = false }
attempt, attempt,
product, product,
); );
if (includePictures) candidate.pictures = extractPictureUrls(result.pageSource, result.url || attempt.url); if (includePictures) {
candidate.pictures = source.key === "idealo"
? extractIdealoGalleryPictureUrls(result.pageSource, result.url || attempt.url)
: extractPictureUrls(result.pageSource, result.url || attempt.url);
}
return { return {
source, source,
query: attempt.query, query: attempt.query,
@@ -381,6 +385,8 @@ async function findSourceCandidates(source, product, { includePictures = false }
for (const candidate of candidates) { for (const candidate of candidates) {
if (source.key === "hudy" && candidate.url) { if (source.key === "hudy" && candidate.url) {
candidate.pictures = await extractPicturesFromPage(candidate.url, source.browserEngine); candidate.pictures = await extractPicturesFromPage(candidate.url, source.browserEngine);
} else if (source.key === "idealo") {
candidate.pictures = extractIdealoGalleryPictureUrls(html, response.url || attempt.url);
} else { } else {
candidate.pictures = extractPictureUrls(html, response.url || attempt.url); candidate.pictures = extractPictureUrls(html, response.url || attempt.url);
} }
@@ -742,6 +748,108 @@ function extractPictureUrls(html, pageUrl) {
return urls.slice(0, 12); return urls.slice(0, 12);
} }
export function extractIdealoGalleryPictureUrls(html, pageUrl) {
const source = String(html ?? "");
const galleryMatches = [
...source.matchAll(/<li\b[^>]*class=["'][^"']*splide__slide[^"']*["'][^>]*>[\s\S]*?<\/li>/gi),
];
const orderedGalleryUrls = [];
for (const match of galleryMatches) {
const best = pickBestImageUrlFromMarkup(match[0], pageUrl);
if (best && !orderedGalleryUrls.includes(best)) orderedGalleryUrls.push(best);
}
if (orderedGalleryUrls.length) return orderedGalleryUrls.slice(0, 12);
const trackMatch = source.match(/<div\b[^>]*class=["'][^"']*splide__track[^"']*["'][^>]*>[\s\S]*?<\/div>/i);
if (trackMatch) {
const best = pickBestImageUrlFromMarkup(trackMatch[0], pageUrl);
if (best) return [best];
}
return extractPictureUrls(source, pageUrl);
}
function pickBestImageUrlFromMarkup(markup, pageUrl) {
const candidates = [];
const add = (value, descriptorScore = 0) => {
const normalized = normalizeImageCandidateUrl(value, pageUrl);
if (!normalized) return;
candidates.push({
url: normalized,
score: scoreImageCandidate(normalized, descriptorScore),
});
};
for (const attribute of ["srcset", "data-srcset"]) {
for (const match of markup.matchAll(new RegExp(`${attribute}=["']([^"']+)`, "gi"))) {
for (const candidate of parseSrcsetCandidates(match[1], pageUrl)) {
add(candidate.url, candidate.score);
}
}
}
for (const attribute of ["data-large", "data-original", "data-full", "data-zoom-image", "data-src", "src"]) {
for (const match of markup.matchAll(new RegExp(`${attribute}=["']([^"']+)`, "gi"))) {
add(match[1]);
}
}
candidates.sort((a, b) => b.score - a.score);
return candidates[0]?.url || "";
}
function parseSrcsetCandidates(value, pageUrl) {
return String(value ?? "")
.split(",")
.map((part) => {
const [urlPart, descriptor = ""] = part.trim().split(/\s+/);
const descriptorScore = /(\d+)w/i.test(descriptor)
? Number(descriptor.match(/(\d+)w/i)?.[1] || 0)
: /(\d+(?:\.\d+)?)x/i.test(descriptor)
? Number(descriptor.match(/(\d+(?:\.\d+)?)x/i)?.[1] || 0) * 400
: 0;
return {
url: normalizeImageCandidateUrl(urlPart, pageUrl),
score: descriptorScore,
};
})
.filter((candidate) => candidate.url);
}
function normalizeImageCandidateUrl(value, pageUrl) {
const decoded = decodeHtmlEntities(String(value ?? "").trim());
if (!decoded || decoded.startsWith("data:") || /\.svg(?:[?#]|$)/i.test(decoded)) return "";
try {
const url = new URL(decoded.startsWith("//") ? `https:${decoded}` : decoded, pageUrl).href;
if (!/^https?:/i.test(url)) return "";
if (/(?:logo|icon|sprite|favicon|placeholder)/i.test(url)) return "";
return url;
} catch {
return "";
}
}
function scoreImageCandidate(url, descriptorScore = 0) {
const dimensionScore = Math.max(
...[...String(url).matchAll(/(?:^|[^0-9])(\d{2,5})[x_-](\d{2,5})(?:[^0-9]|$)/g)].map(
(match) => Number(match[1]) * Number(match[2]),
),
...[...String(url).matchAll(/w(\d{2,5})h(\d{2,5})/gi)].map(
(match) => Number(match[1]) * Number(match[2]),
),
0,
);
const largePathBonus = /(?:original|large|zoom|full|1000|1200|1500|2000)/i.test(url) ? 100000 : 0;
const thumbnailPenalty = /(?:thumb|thumbnail|small|preview|\/s\d+_|_s\d+|150x150|80x80)/i.test(url)
? -100000
: 0;
return dimensionScore + descriptorScore + largePathBonus + thumbnailPenalty;
}
async function extractPicturesFromPage(url, browserEngine = "chromium") { async function extractPicturesFromPage(url, browserEngine = "chromium") {
try { try {
const response = await scrapePage(url, { engine: browserEngine, waitAfterLoadMs: 750 }); const response = await scrapePage(url, { engine: browserEngine, waitAfterLoadMs: 750 });
+33
View File
@@ -0,0 +1,33 @@
// @ts-nocheck
import test from "node:test";
import assert from "node:assert/strict";
import { extractIdealoGalleryPictureUrls } from "../src/services/catalog-products.ts";
test("idealo gallery parser prefers large splide images over thumbnails", () => {
const html = `
<div class="splide__track container">
<ul class="splide__list">
<li class="splide__slide">
<img
src="//img.idealo.com/folder/Product/200123/4/200123456/s1_product_thumb.jpg"
srcset="//img.idealo.com/folder/Product/200123/4/200123456/s1_product_150x150.jpg 150w,
//img.idealo.com/folder/Product/200123/4/200123456/s1_product_1000x1000.jpg 1000w">
</li>
<li class="splide__slide">
<picture>
<source srcset="//img.idealo.com/folder/Product/200123/4/200123456/s2_product_400x400.jpg 400w,
//img.idealo.com/folder/Product/200123/4/200123456/s2_product_1200x1200.jpg 1200w">
<img src="//img.idealo.com/folder/Product/200123/4/200123456/s2_product_80x80.jpg">
</picture>
</li>
</ul>
</div>`;
const urls = extractIdealoGalleryPictureUrls(html, "https://www.idealo.de/product.html");
assert.deepEqual(urls, [
"https://img.idealo.com/folder/Product/200123/4/200123456/s1_product_1000x1000.jpg",
"https://img.idealo.com/folder/Product/200123/4/200123456/s2_product_1200x1200.jpg",
]);
});