diff --git a/.adonisjs/client/registry/index.ts b/.adonisjs/client/registry/index.ts index b46a549..f582d44 100644 --- a/.adonisjs/client/registry/index.ts +++ b/.adonisjs/client/registry/index.ts @@ -150,6 +150,12 @@ const routes = { tokens: [{"old":"/scraped-parts","type":0,"val":"scraped-parts","end":""}], types: placeholder as Registry['scraped_parts.index']['types'], }, + 'scraped_parts.compare': { + methods: ["GET","HEAD"], + pattern: '/scraped-parts/compare', + tokens: [{"old":"/scraped-parts/compare","type":0,"val":"scraped-parts","end":""},{"old":"/scraped-parts/compare","type":0,"val":"compare","end":""}], + types: placeholder as Registry['scraped_parts.compare']['types'], + }, } as const satisfies Record export { routes } diff --git a/.adonisjs/client/registry/schema.d.ts b/.adonisjs/client/registry/schema.d.ts index f08684d..a97e206 100644 --- a/.adonisjs/client/registry/schema.d.ts +++ b/.adonisjs/client/registry/schema.d.ts @@ -295,4 +295,16 @@ export interface Registry { errorResponse: ExtractErrorResponse>> } } + 'scraped_parts.compare': { + methods: ["GET","HEAD"] + pattern: '/scraped-parts/compare' + types: { + body: {} + paramsTuple: [] + params: {} + query: {} + response: ExtractResponse>> + errorResponse: ExtractErrorResponse>> + } + } } diff --git a/.adonisjs/client/registry/tree.d.ts b/.adonisjs/client/registry/tree.d.ts index d071ee3..ce87143 100644 --- a/.adonisjs/client/registry/tree.d.ts +++ b/.adonisjs/client/registry/tree.d.ts @@ -39,5 +39,6 @@ export interface ApiDefinition { } scrapedParts: { index: typeof routes['scraped_parts.index'] + compare: typeof routes['scraped_parts.compare'] } } diff --git a/.adonisjs/server/routes.d.ts b/.adonisjs/server/routes.d.ts index 8f4fb9d..e014c48 100644 --- a/.adonisjs/server/routes.d.ts +++ b/.adonisjs/server/routes.d.ts @@ -28,6 +28,7 @@ export type ScannedRoutes = { 'components.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'components.destroy': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'scraped_parts.index': { paramsTuple?: []; params?: {} } + 'scraped_parts.compare': { paramsTuple?: []; params?: {} } } GET: { 'home': { paramsTuple?: []; params?: {} } @@ -43,6 +44,7 @@ export type ScannedRoutes = { 'components.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'components.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'scraped_parts.index': { paramsTuple?: []; params?: {} } + 'scraped_parts.compare': { paramsTuple?: []; params?: {} } } HEAD: { 'home': { paramsTuple?: []; params?: {} } @@ -58,6 +60,7 @@ export type ScannedRoutes = { 'components.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'components.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} } 'scraped_parts.index': { paramsTuple?: []; params?: {} } + 'scraped_parts.compare': { paramsTuple?: []; params?: {} } } POST: { 'new_account.store': { paramsTuple?: []; params?: {} } diff --git a/app/controllers/scraped_parts_controller.ts b/app/controllers/scraped_parts_controller.ts index e5f1b17..b4bca15 100644 --- a/app/controllers/scraped_parts_controller.ts +++ b/app/controllers/scraped_parts_controller.ts @@ -3,6 +3,7 @@ import vine from '@vinejs/vine' import ScrapedPart from '#models/scraped_part' import ComponentType from '#models/component_type' import { ensureFreshCategory } from '#services/part_scraper_service' +import { findCheapestOffers } from '#services/merchant_price_service' const searchValidator = vine.compile( vine.object({ @@ -11,6 +12,12 @@ const searchValidator = vine.compile( }) ) +const compareValidator = vine.compile( + vine.object({ + id: vine.number().min(1), + }) +) + export default class ScrapedPartsController { /** * Search the new-parts cache (scraped from pc-builder.io) by name and/or @@ -48,4 +55,26 @@ export default class ScrapedPartsController { const parts = await query.limit(30) return response.json(parts.map((part) => part.serialize())) } + + /** + * Compares live prices for a given scraped part across Amazon, LDLC, + * Grosbill and TopAchat, and returns them sorted cheapest first. This is + * triggered on demand (one part at a time) rather than for every search + * result, since it makes several outbound HTTP requests and can take a + * few seconds. + */ + async compare({ request, auth, response }: HttpContext) { + await auth.authenticate() + const { id } = await request.validateUsing(compareValidator) + + const part = await ScrapedPart.find(id) + if (!part) { + return response.notFound({ offers: [] }) + } + + const amazonUrl = part.sourceUrl.includes('amazon.') ? part.sourceUrl : null + const offers = await findCheapestOffers(part.name, amazonUrl) + + return response.json({ offers }) + } } diff --git a/app/services/merchant_price_service.ts b/app/services/merchant_price_service.ts new file mode 100644 index 0000000..fe20261 --- /dev/null +++ b/app/services/merchant_price_service.ts @@ -0,0 +1,291 @@ +import * as cheerio from 'cheerio' +import logger from '@adonisjs/core/services/logger' + +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' + price: number + url: string +} + +async function fetchHtml(url: string): Promise { + 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) render prices split across sibling/child + * elements instead of plain text, e.g. `
64€95
` + * renders as "64€95" once flattened to text, which parsePriceText would + * misread as 6495. This reads the integer part and the `` cents part + * separately and recombines them properly. + * + * LDLC additionally nests a duplicate `.price` wrapper + * (`
64€95
`), + * so this always resolves to the innermost element actually containing a + * ``, rather than assuming $el itself is the leaf node. + */ +function parseFragmentedPrice($el: cheerio.Cheerio): 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 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 { + 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 { + 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 | 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).find('.pdt-desc a').first().attr('href') + const priceEl = (best as cheerio.Cheerio).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 { + 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 | 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).find('.price-main').first() + const price = parseFragmentedPrice(priceEl) + if (price === null) return null + + const link = (best as cheerio.Cheerio).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 { + 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 | 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).find('.product__price').first().text().trim() + const price = parsePriceText(priceText) + if (price === null) return null + + const link = (best as cheerio.Cheerio).closest('a[href]').attr('href') + return { merchant: 'TopAchat', price, url: link ? new URL(link, url).toString() : url } +} + +/** + * 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 { + const lookups: Promise[] = [ + amazonUrl ? fetchAmazonPrice(amazonUrl) : Promise.resolve(null), + fetchLdlcPrice(productName), + fetchGrosbillPrice(productName), + fetchTopAchatPrice(productName), + ] + + const results = await Promise.allSettled(lookups) + const offers = results + .filter( + (result): result is PromiseFulfilledResult => + 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, +} diff --git a/app/services/part_scraper_service.ts b/app/services/part_scraper_service.ts index 9f1ace0..d4765a9 100644 --- a/app/services/part_scraper_service.ts +++ b/app/services/part_scraper_service.ts @@ -40,7 +40,15 @@ interface ScrapedRow { name: string price: number imageUrl: string | null - sourceUrl: string + /** + * The actual merchant page where this part can be bought (currently + * always an Amazon.fr link, since that's the only "buy" link pc-builder.io + * exposes on its category pages). This is what gets stored as + * ScrapedPart.sourceUrl and later copied into Component.purchaseUrl — it + * intentionally is NOT the pc-builder.io product page, which isn't a + * place a user can actually purchase anything from. + */ + purchaseUrl: string sourceRef: string } @@ -70,14 +78,20 @@ async function fetchCategoryPage(slug: string): Promise { const row = $(el) const nameLink = row.find('td.product a').first() const name = nameLink.text().trim() - const sourceUrl = nameLink.attr('href') + const productPageUrl = nameLink.attr('href') const img = row.find('td.product img.product-image') const imageUrl = img.attr('data-src') || img.attr('src') || null const priceText = row.find('td.add-btn .price-box p').first().text().trim() const addButton = row.find('a.add-to-build') const sourceRef = addButton.attr('data-id') + // Real merchant link ("View on Amazon"), either a direct product page + // (https://www.amazon.fr/dp/{ASIN}?tag=...) when pc-builder.io knows the + // ASIN, or a generic search results page (https://www.amazon.fr/s?k=...) + // otherwise. Either way it's a genuine purchase link, unlike the + // pc-builder.io product page. + const amazonUrl = row.find('a.btn-default').attr('href') - if (!name || !sourceUrl || !sourceRef || !priceText) { + if (!name || !productPageUrl || !sourceRef || !priceText) { // Skip rows without a resolved price (e.g. "search on Amazon" links // with no fixed offer) or missing identifiers. return @@ -88,7 +102,7 @@ async function fetchCategoryPage(slug: string): Promise { return } - rows.push({ name, price, imageUrl, sourceUrl, sourceRef }) + rows.push({ name, price, imageUrl, purchaseUrl: amazonUrl || productPageUrl, sourceRef }) }) return rows @@ -116,7 +130,7 @@ async function scrapeComponentType(componentType: ComponentType): Promise }>() const form = reactive({ @@ -16,12 +16,15 @@ const form = reactive({ quantity: 1, }) -function importPart(part: ScrapedPart) { +function importPart(part: ScrapedPart, cheapestOffer: MerchantOffer | null) { form.name = part.name - form.price = part.price + // Prefer the live price/link found by comparing merchants (Amazon, LDLC, + // Grosbill, TopAchat) when available, since pc-builder.io's catalog price + // can be stale; fall back to the catalog price/link otherwise. + form.price = cheapestOffer?.price ?? part.price form.typeId = part.type.id form.purchaseDate = new Date().toISOString().split('T')[0] - form.purchaseUrl = part.sourceUrl + form.purchaseUrl = cheapestOffer?.url ?? part.sourceUrl } function submitForm() { diff --git a/inertia/pages/types/UITypes.ts b/inertia/pages/types/UITypes.ts index 9931553..c0fd205 100644 --- a/inertia/pages/types/UITypes.ts +++ b/inertia/pages/types/UITypes.ts @@ -61,6 +61,12 @@ export type ScrapedPart = { type: ComponentType } +export type MerchantOffer = { + merchant: 'Amazon' | 'LDLC' | 'Grosbill' | 'TopAchat' + price: number + url: string +} + export interface ServerImage { id: number link: string diff --git a/inertia/pages/widgets/NewPartSearch.vue b/inertia/pages/widgets/NewPartSearch.vue index 4a6173d..7872dd9 100644 --- a/inertia/pages/widgets/NewPartSearch.vue +++ b/inertia/pages/widgets/NewPartSearch.vue @@ -1,17 +1,26 @@