RTX 5060 investigation: confirmed via pc-builder.io's own server-side
search (search_query=RTX 5060 against the full 6222-product GPU catalog,
0 results) that it simply isn't in pc-builder.io's catalog yet - not a
scraping bug, the source data doesn't have it. However, while checking
whether the other merchants had it in stock, found a real matching bug:
labelMatchesModelToken bug: extractModelToken("...RTX 5060 Ti...") only
returned "5060", identical to extractModelToken("...RTX 5060...") without
Ti. Every merchant lookup for either card matched on plain "5060" and
could silently return the *other* card's price (confirmed live: LDLC and
Grosbill returned the exact same product/price for both "RTX 5060" and
"RTX 5060 Ti" queries before this fix).
Fixed by:
- extractModelToken now appends a trailing suffix token (Ti, Super, XT,
XTX) when it immediately follows the model number, so "5060 Ti" and
"5060" are extracted as distinct tokens
- New labelMatchesModelToken() replaces the naive
label.includes(modelToken) check everywhere: a bare token ("5060") no
longer matches a label that actually has a suffix right after it
("5060 Ti"), while a suffixed token ("5060 Ti") only matches labels
carrying that exact suffix
- 9 new unit tests covering extraction and matching, including the
regression case (RTX 5060 must not match "RTX 5060 Ti" labels)
Verified fix live: LDLC/Grosbill now return correctly distinct prices for
"RTX 5060" (369.95/364.99) vs "RTX 5060 Ti" (489.95/329.99).
New manual_part_seeder_service.ts: since pc-builder.io's catalog lags
behind real product releases, this seeds a small manually-tracked list of
recent parts it doesn't carry yet (currently RTX 5060 8GB and 5060 Ti
16GB), resolving their price live from the already-supported merchants
rather than hardcoding a number that would go stale. `scrape:parts` now
runs this after the regular catalog scrape. Skips an entry rather than
inserting a guessed price if no merchant has it in stock.
Also fixes a real bug hit while testing the seeder: scraped_parts and
components.purchase_url were varchar(255), which truncates/rejects
Grosbill's long tracking-redirect URLs (500+ characters with encoded
analytics params) - both widened to TEXT via a new migration.
526 lines
19 KiB
TypeScript
526 lines
19 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(' ')
|
|
}
|
|
|
|
/**
|
|
* Suffix tokens that modify a base GPU/CPU model number into a distinct
|
|
* SKU with a different price (e.g. "RTX 5060" vs "RTX 5060 Ti", "RTX 4070"
|
|
* vs "RTX 4070 Super"). When immediately following the model token, these
|
|
* must be treated as part of it — otherwise "5060 Ti" only matches on
|
|
* "5060" and can't be told apart from plain "5060".
|
|
*/
|
|
const MODEL_SUFFIXES = ['Ti', 'Super', 'XT', 'XTX']
|
|
|
|
/**
|
|
* 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 ...", "970" out of "Samsung 970 Evo Plus ...", or
|
|
* "5060 Ti" out of "NVIDIA GeForce RTX 5060 Ti 16 GB ..." — as opposed to
|
|
* plain "5060", which is a different, cheaper card). 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 matchWithSuffix = (index: number, base: string): string => {
|
|
const next = tokens[index + 1]
|
|
return next && MODEL_SUFFIXES.includes(next) ? `${base} ${next}` : base
|
|
}
|
|
|
|
const mixedIndex = tokens.findIndex(
|
|
(t) => /[a-zA-Z]/.test(t) && /\d/.test(t) && !/^\d+(\.\d+)?ghz$/i.test(t)
|
|
)
|
|
if (mixedIndex !== -1) return matchWithSuffix(mixedIndex, tokens[mixedIndex])
|
|
|
|
const bareIndex = tokens.findIndex((t) => /^\d{3,4}$/.test(t))
|
|
if (bareIndex !== -1) return matchWithSuffix(bareIndex, tokens[bareIndex])
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* True if `label` matches `modelToken` as the specific SKU it represents,
|
|
* not just as a loose substring. Plain string.includes() would let "5060"
|
|
* match "RTX 5060 Ti" (wrong, more expensive/different card) and vice
|
|
* versa "5060 Ti" would still substring-match "5060" only by accident of
|
|
* word order — this instead requires the model token to appear as its own
|
|
* word boundary, and additionally rejects a bare model number (no suffix)
|
|
* when the label actually has one of MODEL_SUFFIXES right after it.
|
|
*/
|
|
function labelMatchesModelToken(label: string, modelToken: string): boolean {
|
|
const normalizedLabel = label.toLowerCase()
|
|
const normalizedToken = modelToken.toLowerCase()
|
|
|
|
if (!normalizedLabel.includes(normalizedToken)) return false
|
|
|
|
const hasSuffix = normalizedToken.includes(' ')
|
|
if (hasSuffix) return true
|
|
|
|
// Bare token (e.g. "5060"): reject if the label has it immediately
|
|
// followed by a known suffix (e.g. "5060 Ti"), since that's a distinct,
|
|
// different-priced SKU we should not conflate with the plain model.
|
|
const suffixPattern = MODEL_SUFFIXES.join('|')
|
|
const followedBySuffix = new RegExp(`\\b${normalizedToken}\\s+(${suffixPattern})\\b`, 'i')
|
|
return !followedBySuffix.test(label)
|
|
}
|
|
|
|
/**
|
|
* 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 (labelMatchesModelToken(label, modelToken)) {
|
|
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 (labelMatchesModelToken(label, modelToken)) {
|
|
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 || labelMatchesModelToken(label, modelToken)) {
|
|
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 || labelMatchesModelToken(label, modelToken)) {
|
|
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 || labelMatchesModelToken(title, modelToken)) {
|
|
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 && !labelMatchesModelToken(label, modelToken)) 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 && !labelMatchesModelToken(label, modelToken)) 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 && !labelMatchesModelToken(name, modelToken)) 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,
|
|
extractModelToken,
|
|
labelMatchesModelToken,
|
|
}
|