mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-07-14 16:32:14 +02:00
feat: improve product image extraction for JS-heavy retailers
- Expand genericImageSelectors with 10+ additional CSS patterns - Fix extractGenericImage to check data-src/data-lazy-src on all img tags - Fix Target scraper: add JSON-LD, og:image, and hero-image fallbacks - Fix Best Buy scraper: add image gallery, JSON-LD, and og:image fallbacks - Add extractImageFromBrowser() helper that evaluates JS in the real browser - Use browser JS extraction as last resort in scrapeProductWithVoting
This commit is contained in:
parent
ea5bf6798a
commit
45efe943bd
1 changed files with 182 additions and 1 deletions
|
|
@ -247,6 +247,118 @@ function extractGenericCssCandidates($: CheerioAPI): PriceCandidate[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser-based scraping for sites that block HTTP requests (e.g., Cloudflare)
|
// Browser-based scraping for sites that block HTTP requests (e.g., Cloudflare)
|
||||||
|
/**
|
||||||
|
* Extract product image URL by evaluating JavaScript in a real browser.
|
||||||
|
* Used as a last resort when cheerio-based extraction fails on JS-heavy sites.
|
||||||
|
*/
|
||||||
|
async function extractImageFromBrowser(url: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const wsEndpoint = await getRemoteBrowserWSEndpoint();
|
||||||
|
if (!wsEndpoint) {
|
||||||
|
console.log(`[BrowserImage] No remote browser available for ${url}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await puppeteer.connect({
|
||||||
|
browserWSEndpoint: wsEndpoint,
|
||||||
|
defaultViewport: { width: 1920, height: 1080 },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
|
||||||
|
// Try multiple strategies in the browser's JS context
|
||||||
|
// Use string-based evaluate to avoid TypeScript DOM type issues in Node context
|
||||||
|
const imageUrl = await page.evaluate(`
|
||||||
|
(function() {
|
||||||
|
// Strategy 1: Open Graph image
|
||||||
|
var ogImg = document.querySelector('meta[property="og:image"]');
|
||||||
|
if (ogImg) {
|
||||||
|
var content = ogImg.getAttribute('content');
|
||||||
|
if (content && !content.startsWith('data:') && !content.startsWith('blob:')) return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: JSON-LD scripts
|
||||||
|
var scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||||
|
for (var i = 0; i < scripts.length; i++) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(scripts[i].textContent || '');
|
||||||
|
var img = data.image || data.thumbnailUrl;
|
||||||
|
if (img && typeof img === 'string' && !img.startsWith('data:') && !img.startsWith('blob:')) return img;
|
||||||
|
if (img && typeof img === 'object' && img.url) return img.url;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: itemprop image
|
||||||
|
var itemProp = document.querySelector('[itemprop="image"]');
|
||||||
|
if (itemProp) {
|
||||||
|
var src = itemProp.getAttribute('src') || itemProp.getAttribute('content') || itemProp.getAttribute('href');
|
||||||
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: Common product image selectors in rendered DOM
|
||||||
|
var selectors = [
|
||||||
|
'[data-test="image-gallery-item-0"] img',
|
||||||
|
'[data-test="product-image"]',
|
||||||
|
'img.primary-image',
|
||||||
|
'[data-testid="hero-image"]',
|
||||||
|
'[data-testid*="image"] img',
|
||||||
|
'[class*="gallery"] img',
|
||||||
|
'[class*="carousel"] img',
|
||||||
|
'.product-image img',
|
||||||
|
'.main-image img',
|
||||||
|
'img[loading="lazy"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (var j = 0; j < selectors.length; j++) {
|
||||||
|
var el = document.querySelector(selectors[j]);
|
||||||
|
if (el) {
|
||||||
|
var src = el.getAttribute('src') || el.getAttribute('data-src') || el.getAttribute('data-lazy-src');
|
||||||
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) return src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 5: Any img with "product" in class or alt
|
||||||
|
var allImgs = document.querySelectorAll('img');
|
||||||
|
for (var k = 0; k < allImgs.length; k++) {
|
||||||
|
var img = allImgs[k];
|
||||||
|
var alt = (img.getAttribute('alt') || '').toLowerCase();
|
||||||
|
var cls = (img.getAttribute('class') || '').toLowerCase();
|
||||||
|
var id = (img.getAttribute('id') || '').toLowerCase();
|
||||||
|
if (alt.indexOf('product') >= 0 || cls.indexOf('product') >= 0 || id.indexOf('product') >= 0) {
|
||||||
|
var src = img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src');
|
||||||
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) return src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 6: First non-placeholder image in main content area
|
||||||
|
var main = document.querySelector('main, [role="main"], #main, .main-content, .product-detail');
|
||||||
|
var container = main || document.body;
|
||||||
|
var imgs = container.querySelectorAll('img:not([src*="placeholder"]):not([src*="pixel"]):not([src*="spacer"])');
|
||||||
|
for (var m = 0; m < imgs.length; m++) {
|
||||||
|
var img = imgs[m];
|
||||||
|
var src = img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy-src');
|
||||||
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) return src;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
`) as string | null;
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
console.log(`[BrowserImage] Found image via browser JS for ${url}: ${(imageUrl as string).substring(0, 80)}`);
|
||||||
|
}
|
||||||
|
return imageUrl as string | null;
|
||||||
|
} finally {
|
||||||
|
await browser.disconnect();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[BrowserImage] Failed to extract image from browser for ${url}: ${(e as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the remote browser WebSocket endpoint from browser-node service
|
* Get the remote browser WebSocket endpoint from browser-node service
|
||||||
*/
|
*/
|
||||||
|
|
@ -752,6 +864,13 @@ const siteScrapers: SiteScraper[] = [
|
||||||
const imageUrl = $('img.primary-image').attr('src') ||
|
const imageUrl = $('img.primary-image').attr('src') ||
|
||||||
$('[data-testid="image-gallery-image"]').attr('src') ||
|
$('[data-testid="image-gallery-image"]').attr('src') ||
|
||||||
$('img[class*="product-image"]').attr('src') ||
|
$('img[class*="product-image"]').attr('src') ||
|
||||||
|
$('[class*="image-gallery"] img').first().attr('src') ||
|
||||||
|
$('.product-image img').first().attr('src') ||
|
||||||
|
$('img[alt*="product" i]').first().attr('src') ||
|
||||||
|
$('script[type="application/ld+json"]').toArray()
|
||||||
|
.map(s => { try { const d = JSON.parse($(s).html() || ''); return d.image || d.thumbnailUrl; } catch { return null; } })
|
||||||
|
.find(Boolean) ||
|
||||||
|
$('meta[property="og:image"]').attr('content') ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
console.log(`[BestBuy] Final result - name: ${!!name}, price: ${price ? (price as ParsedPrice).price : null}, image: ${!!imageUrl}`);
|
console.log(`[BestBuy] Final result - name: ${!!name}, price: ${price ? (price as ParsedPrice).price : null}, image: ${!!imageUrl}`);
|
||||||
|
|
@ -783,6 +902,12 @@ const siteScrapers: SiteScraper[] = [
|
||||||
null;
|
null;
|
||||||
|
|
||||||
const imageUrl = $('[data-test="image-gallery-item-0"] img').attr('src') ||
|
const imageUrl = $('[data-test="image-gallery-item-0"] img').attr('src') ||
|
||||||
|
$('[data-test="product-image"]').attr('src') ||
|
||||||
|
$('[data-test*="hero"] img').first().attr('src') ||
|
||||||
|
$('script[type="application/ld+json"]').toArray()
|
||||||
|
.map(s => { try { const d = JSON.parse($(s).html() || ''); return d.image || d.thumbnailUrl; } catch { return null; } })
|
||||||
|
.find(Boolean) ||
|
||||||
|
$('meta[property="og:image"]').attr('content') ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
return { name, price, imageUrl };
|
return { name, price, imageUrl };
|
||||||
|
|
@ -1174,6 +1299,16 @@ const genericImageSelectors = [
|
||||||
'.main-image img',
|
'.main-image img',
|
||||||
'[data-zoom-image]',
|
'[data-zoom-image]',
|
||||||
'img[class*="product"]',
|
'img[class*="product"]',
|
||||||
|
'img[loading="lazy"]',
|
||||||
|
'[data-test*="image"] img',
|
||||||
|
'[data-testid*="image"] img',
|
||||||
|
'.gallery img',
|
||||||
|
'.carousel img',
|
||||||
|
'.hero-image img',
|
||||||
|
'.pdp-image img',
|
||||||
|
'.product-hero img',
|
||||||
|
'picture source[type="image/webp"]',
|
||||||
|
'img[itemprop="thumbnail"]',
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function scrapeProduct(url: string, userId?: number): Promise<ScrapedProduct> {
|
export async function scrapeProduct(url: string, userId?: number): Promise<ScrapedProduct> {
|
||||||
|
|
@ -1543,6 +1678,20 @@ export async function scrapeProductWithVoting(
|
||||||
result.stockStatus = extractGenericStockStatus($);
|
result.stockStatus = extractGenericStockStatus($);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last resort: if we used the browser and still have no image URL,
|
||||||
|
// try extracting it by evaluating JavaScript in the real browser context
|
||||||
|
if (!result.imageUrl && usedBrowser) {
|
||||||
|
try {
|
||||||
|
const browserImg = await extractImageFromBrowser(url);
|
||||||
|
if (browserImg) {
|
||||||
|
result.imageUrl = browserImg;
|
||||||
|
console.log(`[Voting] Found image via browser JS extraction for ${url}`);
|
||||||
|
}
|
||||||
|
} catch (imgError) {
|
||||||
|
console.error(`[Voting] Browser image extraction failed for ${url}:`, imgError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Store all candidates
|
// Store all candidates
|
||||||
result.priceCandidates = allCandidates;
|
result.priceCandidates = allCandidates;
|
||||||
|
|
||||||
|
|
@ -1997,6 +2146,38 @@ function extractGenericName($: CheerioAPI): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractGenericImage($: CheerioAPI, baseUrl: string): string | null {
|
function extractGenericImage($: CheerioAPI, baseUrl: string): string | null {
|
||||||
|
// First, check ALL img tags for lazy-loaded data attributes
|
||||||
|
// Modern sites often hide the real URL in data attributes instead of src
|
||||||
|
const dataAttrCandidates = [
|
||||||
|
'data-src', 'data-lazy-src', 'data-original',
|
||||||
|
'data-image-src', 'data-image-url', 'data-img-url',
|
||||||
|
'data-zoom-image',
|
||||||
|
];
|
||||||
|
const imgs = $('img').toArray();
|
||||||
|
for (const img of imgs) {
|
||||||
|
const $img = $(img);
|
||||||
|
for (const attr of dataAttrCandidates) {
|
||||||
|
const val = $img.attr(attr);
|
||||||
|
if (val && !val.startsWith('data:') && !val.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
return new URL(val, baseUrl).href;
|
||||||
|
} catch (_e) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check the src attribute directly, but skip base64/blob URLs
|
||||||
|
const src = $img.attr('src');
|
||||||
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
return new URL(src, baseUrl).href;
|
||||||
|
} catch (_e) {
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to CSS selectors for standard patterns
|
||||||
for (const selector of genericImageSelectors) {
|
for (const selector of genericImageSelectors) {
|
||||||
const element = $(selector).first();
|
const element = $(selector).first();
|
||||||
if (element.length) {
|
if (element.length) {
|
||||||
|
|
@ -2005,7 +2186,7 @@ function extractGenericImage($: CheerioAPI, baseUrl: string): string | null {
|
||||||
element.attr('content') ||
|
element.attr('content') ||
|
||||||
element.attr('data-zoom-image') ||
|
element.attr('data-zoom-image') ||
|
||||||
element.attr('data-src');
|
element.attr('data-src');
|
||||||
if (src) {
|
if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
|
||||||
try {
|
try {
|
||||||
return new URL(src, baseUrl).href;
|
return new URL(src, baseUrl).href;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue