Google/Bing/DuckDuckGo/Qwant were tested as a way to find prices on the
3 remaining requested merchants and all four block scraping immediately
(visible captcha or JS-only anti-bot challenge on the very first
automated request) - not usable as an intermediary. Instead, a headless
browser was used to scrape these 3 merchants directly, since their
blocking (JS-only search, empty body, 403) turned out to be bypassable
with real browser rendering rather than needing to get past a captcha:
- Materiel.net: search is entirely client-side (Stimulus/Turbo), a plain
GET on /recherche/?query=... always returns the empty "no results"
template. Fixed by actually filling the search input and clicking
submit in a real page.
- Cdiscount: returns a fully empty response body to plain HTTP fetches
(no error, just nothing) but renders fine in a real browser.
- PCComponentes: blocks plain HTTP requests with a 403, but a headless
browser with a normal user agent goes through.
Implementation:
- New playwright-core dependency, pointed at a system-installed
Chromium binary via CHROMIUM_EXECUTABLE_PATH (set to /usr/bin/chromium
here) instead of downloading Playwright's own ~300MB browser
- New headless_browser_service.ts: lazily launches a single shared
Chromium instance reused across lookups (avoids paying browser launch
cost per request), with a withPage() helper that always closes its
browser context afterwards. A new start/browser.ts preload registers
an app.terminating() hook so the browser process is closed on shutdown
instead of leaking a child process.
- fetchMaterielNetPrice/fetchCdiscountPrice/fetchPcComponentesPrice
follow the same model-token matching strategy as the existing
cheerio-based merchants (LDLC/Grosbill/TopAchat): search on a short
brand+series query, then pick the result whose label/attribute
contains the extracted model number, to avoid misreporting an
unrelated product's price
- PCComponentes exposes clean data-product-price/data-product-category
attributes directly on result links, so full pre-built "Sobremesa" PCs
that happen to mention the searched CPU are excluded rather than
reported as its price
- Fixed two bugs found while testing with a Ryzen 5 9600X (a case with
multiple search results, unlike the Ryzen 3 3200G single-redirect case
used earlier):
- Materiel.net: a search results list page also has .o-product__price
elements (one per card), so checking for their mere presence isn't
enough to detect the "redirected to a single product" case; now
disambiguated by URL pattern (/produit/ vs /recherche/)
- New parseFlattenedFragmentedPriceText(): Playwright's innerText()
flattens "<whole>€<sup>cents</sup>" prices to plain strings like
"284€95" or "2 049€95", which the existing parser misread as
28495/204995. Cheerio-based scrapers (LDLC/Grosbill) already handled
this correctly via DOM structure (parseFragmentedPrice); this adds
the equivalent for already-flattened text.
- findCheapestOffers() now queries all 7 merchants in parallel
- Extended the MerchantOffer type/favicon domain map on the frontend for
the 3 new merchants
All 30 existing tests still pass unchanged (they don't exercise live
network scraping, only the surrounding auth/validation/caching logic).
477 lines
17 KiB
TypeScript
477 lines
17 KiB
TypeScript
import * as cheerio from 'cheerio'
|
|
import logger from '@adonisjs/core/services/logger'
|
|
import { withPage } from '#services/headless_browser_service'
|
|
|
|
const USER_AGENT =
|
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
|
|
export interface MerchantOffer {
|
|
merchant:
|
|
| 'Amazon'
|
|
| 'LDLC'
|
|
| 'Grosbill'
|
|
| 'TopAchat'
|
|
| 'Materiel.net'
|
|
| 'Cdiscount'
|
|
| 'PCComponentes'
|
|
price: number
|
|
url: string
|
|
}
|
|
|
|
async function fetchHtml(url: string): Promise<string | null> {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'User-Agent': USER_AGENT,
|
|
'Accept-Language': 'fr-FR,fr;q=0.9',
|
|
},
|
|
})
|
|
if (!response.ok) return null
|
|
return await response.text()
|
|
} catch (error) {
|
|
logger.warn({ url, err: error }, 'Failed to fetch merchant page')
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses a price from plain text like "64,95 €" or "413.59 €*".
|
|
*/
|
|
function parsePriceText(text: string): number | null {
|
|
const normalized = text
|
|
.replace(/\u00a0/g, ' ')
|
|
.replace(/[^\d,.\s]/g, '')
|
|
.trim()
|
|
.replace(/\s/g, '')
|
|
.replace(',', '.')
|
|
const value = Number.parseFloat(normalized)
|
|
return Number.isNaN(value) ? null : value
|
|
}
|
|
|
|
/**
|
|
* Some merchants (LDLC, Grosbill, Materiel.net) render prices split across
|
|
* sibling/child elements instead of plain text, e.g.
|
|
* `<div class="price">64€<sup>95</sup></div>` renders as "64€95" once
|
|
* flattened to text, which parsePriceText would misread as 6495 (or, worse,
|
|
* "2 049€95" as 204995). This detects that "<whole>€<cents>" shape in
|
|
* flattened text and recombines it as <whole>.<cents> instead.
|
|
*/
|
|
function parseFlattenedFragmentedPriceText(text: string): number | null {
|
|
const match = text
|
|
.replace(/\u00a0/g, ' ')
|
|
.trim()
|
|
.match(/^([\d\s]+)\s*€\s*(\d{1,2})$/)
|
|
if (!match) return null
|
|
const whole = match[1].replace(/\s/g, '')
|
|
const cents = match[2].padEnd(2, '0').slice(0, 2)
|
|
const value = Number.parseFloat(`${whole}.${cents}`)
|
|
return Number.isNaN(value) ? null : value
|
|
}
|
|
|
|
/**
|
|
* Same fragmented-price shape as parseFlattenedFragmentedPriceText, but
|
|
* operating on a Cheerio element with a real `<sup>` child instead of
|
|
* already-flattened text — used when scraping static HTML with cheerio
|
|
* (LDLC, Grosbill) rather than reading `.innerText()` from a live browser
|
|
* page (Materiel.net).
|
|
*
|
|
* LDLC additionally nests a duplicate `.price` wrapper
|
|
* (`<div class="price"><div class="price">64€<sup>95</sup></div></div>`),
|
|
* so this always resolves to the innermost element actually containing a
|
|
* `<sup>`, rather than assuming $el itself is the leaf node.
|
|
*/
|
|
function parseFragmentedPrice($el: cheerio.Cheerio<any>): number | null {
|
|
const sup = $el.find('sup').first()
|
|
if (sup.length > 0) {
|
|
// The element that directly holds both the whole-part text node and
|
|
// the <sup> cents — not necessarily $el itself if it's a wrapper.
|
|
const leaf = sup.parent()
|
|
const cents = sup.text().replace(/\D/g, '')
|
|
const wholePart = leaf.clone().children().remove().end().text().replace(/\D/g, '')
|
|
if (wholePart) {
|
|
const value = Number.parseFloat(`${wholePart}.${cents.padEnd(2, '0').slice(0, 2)}`)
|
|
return Number.isNaN(value) ? null : value
|
|
}
|
|
}
|
|
return parsePriceText($el.text())
|
|
}
|
|
|
|
/**
|
|
* Builds a short, merchant-search-friendly query from a full pc-builder.io
|
|
* product name. These sites' search engines are strict/exact-match and
|
|
* choke on the long, spec-heavy names pc-builder.io uses (e.g. "AMD Ryzen 3
|
|
* 3200G 3.6 GHz Quad-Core Processor" returns zero results everywhere, but
|
|
* "AMD Ryzen 3" does). We search on the first few tokens (brand + series),
|
|
* then use extractModelToken() to pick out the right result from the list.
|
|
*/
|
|
function shortSearchQuery(name: string): string {
|
|
const tokens = name
|
|
.replace(/\([^)]*\)/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
return tokens.slice(0, 3).join(' ')
|
|
}
|
|
|
|
/**
|
|
* Extracts the most specific "model number" token from a product name, to
|
|
* disambiguate between multiple search results (e.g. "3200G" out of "AMD
|
|
* Ryzen 3 3200G ...", or "970" out of "Samsung 970 Evo Plus ..."). Prefers
|
|
* alphanumeric tokens that mix letters and digits (typical of CPU/GPU model
|
|
* numbers), falling back to a bare 3-4 digit number.
|
|
*/
|
|
function extractModelToken(name: string): string | null {
|
|
const tokens = name
|
|
.replace(/\([^)]*\)/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
const mixed = tokens.find(
|
|
(t) => /[a-zA-Z]/.test(t) && /\d/.test(t) && !/^\d+(\.\d+)?ghz$/i.test(t)
|
|
)
|
|
if (mixed) return mixed
|
|
return tokens.find((t) => /^\d{3,4}$/.test(t)) ?? null
|
|
}
|
|
|
|
/**
|
|
* Reads the price directly from a known Amazon.fr product page
|
|
* (https://www.amazon.fr/dp/{ASIN}?...). Only works for direct product
|
|
* links; Amazon search-result links (https://www.amazon.fr/s?k=...) can't
|
|
* be resolved to a single price this way.
|
|
*/
|
|
async function fetchAmazonPrice(amazonUrl: string): Promise<MerchantOffer | null> {
|
|
if (!amazonUrl.includes('/dp/')) return null
|
|
|
|
const html = await fetchHtml(amazonUrl)
|
|
if (!html) return null
|
|
|
|
const $ = cheerio.load(html)
|
|
const priceText = $('.a-price .a-offscreen').first().text().trim()
|
|
const price = parsePriceText(priceText)
|
|
if (price === null) return null
|
|
|
|
return { merchant: 'Amazon', price, url: amazonUrl }
|
|
}
|
|
|
|
/**
|
|
* Searches LDLC by product name and reads the price of the result whose
|
|
* label contains the extracted model token (e.g. "3200G"). Only falls back
|
|
* to the first result when there is no model token to disambiguate with
|
|
* (rare); otherwise a search that returns unrelated products (LDLC simply
|
|
* doesn't carry this exact model) correctly yields no offer instead of a
|
|
* misleading price for the wrong product.
|
|
*/
|
|
async function fetchLdlcPrice(productName: string): Promise<MerchantOffer | null> {
|
|
const query = shortSearchQuery(productName)
|
|
const modelToken = extractModelToken(productName)
|
|
const url = `https://www.ldlc.com/recherche/${encodeURIComponent(query)}/`
|
|
const html = await fetchHtml(url)
|
|
if (!html) return null
|
|
|
|
const $ = cheerio.load(html)
|
|
const items = $('.listing-product li.pdt-item')
|
|
if (!items.length) return null
|
|
|
|
let best: cheerio.Cheerio<any> | null = modelToken ? null : items.first()
|
|
if (modelToken) {
|
|
items.each((_, el) => {
|
|
const label = $(el).find('.pdt-desc a').first().text()
|
|
if (label.toLowerCase().includes(modelToken.toLowerCase())) {
|
|
best = $(el)
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
if (!best) return null
|
|
|
|
const link = (best as cheerio.Cheerio<any>).find('.pdt-desc a').first().attr('href')
|
|
const priceEl = (best as cheerio.Cheerio<any>).find('.price').first()
|
|
const price = parseFragmentedPrice(priceEl)
|
|
if (price === null || !link) return null
|
|
|
|
return { merchant: 'LDLC', price, url: new URL(link, 'https://www.ldlc.com').toString() }
|
|
}
|
|
|
|
/**
|
|
* Searches Grosbill by product name and reads the price of the result
|
|
* whose label contains the extracted model token. Same rationale as
|
|
* fetchLdlcPrice: without a model token match, we return null rather than
|
|
* risk quoting the price of an unrelated product.
|
|
*/
|
|
async function fetchGrosbillPrice(productName: string): Promise<MerchantOffer | null> {
|
|
const query = shortSearchQuery(productName)
|
|
const modelToken = extractModelToken(productName)
|
|
const url = `https://www.grosbill.com/produit.aspx?q=${encodeURIComponent(query)}/`
|
|
const html = await fetchHtml(url)
|
|
if (!html) return null
|
|
|
|
const $ = cheerio.load(html)
|
|
const items = $('[class*="la_liste_des_produits"]')
|
|
if (!items.length) return null
|
|
|
|
let best: cheerio.Cheerio<any> | null = modelToken ? null : items.first()
|
|
if (modelToken) {
|
|
items.each((_, el) => {
|
|
const label = $(el)
|
|
.find('.grb__liste-produit__liste__produit__information__libelle')
|
|
.text()
|
|
.replace(/\s+/g, ' ')
|
|
if (label.toLowerCase().includes(modelToken.toLowerCase())) {
|
|
best = $(el)
|
|
return false
|
|
}
|
|
})
|
|
}
|
|
if (!best) return null
|
|
|
|
const priceEl = (best as cheerio.Cheerio<any>).find('.price-main').first()
|
|
const price = parseFragmentedPrice(priceEl)
|
|
if (price === null) return null
|
|
|
|
const link = (best as cheerio.Cheerio<any>).find('a[href]').first().attr('href')
|
|
return { merchant: 'Grosbill', price, url: link ? new URL(link, url).toString() : url }
|
|
}
|
|
|
|
/**
|
|
* Searches TopAchat by product name. When the search resolves to a single
|
|
* matching product, TopAchat redirects straight to the product page
|
|
* (label-product / offer-price__price). List-page matching by model token
|
|
* is attempted first for queries with multiple hits.
|
|
*/
|
|
async function fetchTopAchatPrice(productName: string): Promise<MerchantOffer | null> {
|
|
const query = shortSearchQuery(productName)
|
|
const modelToken = extractModelToken(productName)
|
|
const url = `https://www.topachat.com/search/${encodeURIComponent(query)}`
|
|
const html = await fetchHtml(url)
|
|
if (!html) return null
|
|
|
|
const $ = cheerio.load(html)
|
|
|
|
// Case 1: redirected straight to a single product page.
|
|
const singlePrice = $('.offer-price__price').first()
|
|
if (singlePrice.length) {
|
|
const label = $('.label-product').first().text()
|
|
if (!modelToken || label.toLowerCase().includes(modelToken.toLowerCase())) {
|
|
const priceText = singlePrice.clone().children().remove().end().text().trim()
|
|
const price = parsePriceText(priceText)
|
|
if (price !== null) {
|
|
const canonical = $('link[rel="canonical"]').attr('href')
|
|
return { merchant: 'TopAchat', price, url: canonical || url }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Case 2: a list of results (product__price / product__label cards).
|
|
const cards = $('article.product, [class*="product__"]').filter(
|
|
(_, el) => $(el).find('.product__price').length > 0
|
|
)
|
|
let best: cheerio.Cheerio<any> | null = null
|
|
cards.each((_, el) => {
|
|
const label = $(el).find('.product__label').text()
|
|
if (!modelToken || label.toLowerCase().includes(modelToken.toLowerCase())) {
|
|
best = $(el)
|
|
return false
|
|
}
|
|
})
|
|
if (!best) return null
|
|
|
|
const priceText = (best as cheerio.Cheerio<any>).find('.product__price').first().text().trim()
|
|
const price = parsePriceText(priceText)
|
|
if (price === null) return null
|
|
|
|
const link = (best as cheerio.Cheerio<any>).closest('a[href]').attr('href')
|
|
return { merchant: 'TopAchat', price, url: link ? new URL(link, url).toString() : url }
|
|
}
|
|
|
|
/**
|
|
* Searches Materiel.net by simulating the real search form submission in a
|
|
* headless browser. A plain GET on /recherche/?query=... always returns an
|
|
* empty "no results" template — the search only actually runs client-side
|
|
* (Stimulus/Turbo), so this is one of three merchants where a real browser
|
|
* is required rather than a plain HTTP fetch.
|
|
*
|
|
* When there's a single match, Materiel.net redirects straight to the
|
|
* product page. Otherwise it lists cards matched by model token.
|
|
*/
|
|
async function fetchMaterielNetPrice(productName: string): Promise<MerchantOffer | null> {
|
|
const query = shortSearchQuery(productName)
|
|
const modelToken = extractModelToken(productName)
|
|
|
|
return withPage(async (page) => {
|
|
await page.goto('https://www.materiel.net/', { waitUntil: 'networkidle', timeout: 20000 })
|
|
await page.fill('#search_search_text', query)
|
|
await page.click('button[type="submit"]')
|
|
await page.waitForLoadState('networkidle', { timeout: 20000 })
|
|
|
|
// Case 1: redirected straight to a single product page (URL pattern
|
|
// distinguishes this from Case 2 — a list page also has
|
|
// .o-product__price elements, once per card, so checking for its mere
|
|
// presence isn't enough to tell the two cases apart).
|
|
if (/\/produit\//.test(page.url())) {
|
|
const title = await page.title()
|
|
if (!modelToken || title.toLowerCase().includes(modelToken.toLowerCase())) {
|
|
const text = await page
|
|
.locator('.o-product__price')
|
|
.first()
|
|
.innerText()
|
|
.catch(() => '')
|
|
const price = parseFlattenedFragmentedPriceText(text) ?? parsePriceText(text)
|
|
if (price !== null) {
|
|
return { merchant: 'Materiel.net' as const, price, url: page.url() }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Case 2: a list of results.
|
|
const cards = page.locator('li.c-products-list__item[data-id]')
|
|
const count = await cards.count()
|
|
for (let i = 0; i < count; i++) {
|
|
const card = cards.nth(i)
|
|
const label = await card
|
|
.locator('.c-product__meta h2.c-product__title')
|
|
.innerText()
|
|
.catch(() => '')
|
|
if (modelToken && !label.toLowerCase().includes(modelToken.toLowerCase())) continue
|
|
|
|
const priceText = await card
|
|
.locator('.o-product__price')
|
|
.first()
|
|
.innerText()
|
|
.catch(() => '')
|
|
const price = parseFlattenedFragmentedPriceText(priceText) ?? parsePriceText(priceText)
|
|
if (price === null) continue
|
|
|
|
const link = await card
|
|
.locator('.c-product__meta a.c-product__link')
|
|
.first()
|
|
.getAttribute('href')
|
|
return { merchant: 'Materiel.net' as const, price, url: link ?? page.url() }
|
|
}
|
|
|
|
return null
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Searches Cdiscount by product name via a headless browser. Cdiscount's
|
|
* search-results page returns an empty body to plain HTTP fetches (no
|
|
* error, just nothing), which is consistent with the listing being
|
|
* rendered client-side and/or gated behind bot-detection that only lets
|
|
* real browsers through.
|
|
*/
|
|
async function fetchCdiscountPrice(productName: string): Promise<MerchantOffer | null> {
|
|
const query = shortSearchQuery(productName)
|
|
const modelToken = extractModelToken(productName)
|
|
const url = `https://www.cdiscount.com/search/10/${encodeURIComponent(query)}.html`
|
|
|
|
return withPage(async (page) => {
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 })
|
|
|
|
const cards = page.locator('div[data-e2e="offer-wrapper"]')
|
|
const count = await cards.count()
|
|
for (let i = 0; i < count; i++) {
|
|
const card = cards.nth(i)
|
|
const label = await card
|
|
.locator('[data-e2e="lplr-title"]')
|
|
.innerText()
|
|
.catch(() => '')
|
|
if (modelToken && !label.toLowerCase().includes(modelToken.toLowerCase())) continue
|
|
|
|
const priceText = await card
|
|
.locator('[data-e2e="lplr-price"]')
|
|
.first()
|
|
.innerText()
|
|
.catch(() => '')
|
|
const price = parsePriceText(priceText)
|
|
if (price === null) continue
|
|
|
|
const link = await card.locator('a').first().getAttribute('href')
|
|
const offerUrl = link ? new URL(link, 'https://www.cdiscount.com').toString() : url
|
|
return { merchant: 'Cdiscount' as const, price, url: offerUrl }
|
|
}
|
|
|
|
return null
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Searches PCComponentes.com by product name via a headless browser
|
|
* (blocked with a plain HTTP 403 otherwise). Product cards expose clean,
|
|
* pre-parsed data on the <a> element itself (data-product-price as a plain
|
|
* number, data-product-category to distinguish standalone components from
|
|
* full "Sobremesa" pre-built PCs), so full-PC listings that happen to
|
|
* mention the same CPU are excluded rather than misreported as its price.
|
|
*/
|
|
async function fetchPcComponentesPrice(productName: string): Promise<MerchantOffer | null> {
|
|
const query = shortSearchQuery(productName)
|
|
const modelToken = extractModelToken(productName)
|
|
const url = `https://www.pccomponentes.com/buscar/?query=${encodeURIComponent(query)}`
|
|
|
|
return withPage(async (page) => {
|
|
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 })
|
|
|
|
const cards = page.locator('#search-results-product-grid > a[data-product-id]')
|
|
const count = await cards.count()
|
|
for (let i = 0; i < count; i++) {
|
|
const card = cards.nth(i)
|
|
const category = await card.getAttribute('data-product-category')
|
|
if (category === 'Sobremesa') continue // full pre-built PC, not the component itself
|
|
|
|
const name = (await card.getAttribute('data-product-name')) ?? ''
|
|
if (modelToken && !name.toLowerCase().includes(modelToken.toLowerCase())) continue
|
|
|
|
const priceAttr = await card.getAttribute('data-product-price')
|
|
const price = priceAttr ? Number.parseFloat(priceAttr) : null
|
|
if (price === null || Number.isNaN(price)) continue
|
|
|
|
const href = await card.getAttribute('href')
|
|
const offerUrl = href ? new URL(href, 'https://www.pccomponentes.com').toString() : url
|
|
return { merchant: 'PCComponentes' as const, price, url: offerUrl }
|
|
}
|
|
|
|
return null
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Fetches offers from every supported merchant in parallel and returns
|
|
* them sorted by ascending price. Merchants that fail (blocked, no match,
|
|
* network error) are silently skipped rather than failing the whole
|
|
* comparison.
|
|
*/
|
|
async function findCheapestOffers(
|
|
productName: string,
|
|
amazonUrl: string | null
|
|
): Promise<MerchantOffer[]> {
|
|
const lookups: Promise<MerchantOffer | null>[] = [
|
|
amazonUrl ? fetchAmazonPrice(amazonUrl) : Promise.resolve(null),
|
|
fetchLdlcPrice(productName),
|
|
fetchGrosbillPrice(productName),
|
|
fetchTopAchatPrice(productName),
|
|
fetchMaterielNetPrice(productName),
|
|
fetchCdiscountPrice(productName),
|
|
fetchPcComponentesPrice(productName),
|
|
]
|
|
|
|
const results = await Promise.allSettled(lookups)
|
|
const offers = results
|
|
.filter(
|
|
(result): result is PromiseFulfilledResult<MerchantOffer | null> =>
|
|
result.status === 'fulfilled'
|
|
)
|
|
.map((result) => result.value)
|
|
.filter((offer): offer is MerchantOffer => offer !== null)
|
|
|
|
return offers.sort((a, b) => a.price - b.price)
|
|
}
|
|
|
|
export {
|
|
findCheapestOffers,
|
|
fetchAmazonPrice,
|
|
fetchLdlcPrice,
|
|
fetchGrosbillPrice,
|
|
fetchTopAchatPrice,
|
|
fetchMaterielNetPrice,
|
|
fetchCdiscountPrice,
|
|
fetchPcComponentesPrice,
|
|
}
|