Files
pc_builder/app/services/merchant_price_service.ts
T
Kevin L'Hereec a7788e593e
Build & Deploy / build (push) Failing after 4s
Build & Deploy / docker (push) Skipped
feat: add Materiel.net, Cdiscount and PCComponentes via headless browser
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).
2026-07-23 15:25:12 +02:00

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,
}