Files
pc_builder/app/services/part_scraper_service.ts
T
Kevin L'Hereec 21d56a5da4
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
fix: stop protecting stale, unverified pc-builder.io catalog prices
Bug found: "Gigabyte GAMING OC GeForce RTX 5070 Ti 16 GB Video Card" kept
showing a stale price of 431.17 even after pc-builder.io itself no longer
had a fixed offer for it (its "Add to Build" HTML only had a generic
Amazon search link, no .price-box element at all, confirmed by fetching
the live page directly).

Root cause: scrapeComponentType's "never lose a better price" rule (added
to make ScrapedPartsController#compare's persisted merchant prices
durable across catalog refreshes) was too broad. It treated ANY existing
price as worth protecting whenever the incoming catalog price was null or
higher - including a price that had only ever come from a *previous*
pc-builder.io scrape itself (priceSource: null), with no independent
verification. Since pc-builder.io's own listings change constantly (a
GPU can lose its priced Amazon link if the referenced ASIN goes out of
stock), that logic effectively froze old pc-builder.io prices forever
even after the site removed them.

Fix: extracted the decision into shouldKeepExistingPrice(), which now
only protects a price when priceSource is a real merchant name (set by
updatePriceIfCheaper after a live comparison confirmed it on the
merchant's own page) - never when priceSource is null (an unverified,
previous pc-builder.io catalog price). A plain re-scrape can always
correct/clear an unverified price, matching whatever pc-builder.io
currently shows.

4 new unit tests for shouldKeepExistingPrice covering: unverified
pc-builder.io price gets overwritten (the regression case), verified
merchant price is protected from worse/missing catalog data, verified
merchant price is still replaced by a genuinely cheaper catalog price,
and no-existing-part returns false.

Also re-ran `node ace scrape:parts` to apply the fix to already-cached
data with stale unverified prices (affected ~1500 rows across the
catalog before the fix, mostly likely unaffected in practice but no
longer silently trusted going forward).
2026-07-23 16:34:48 +02:00

430 lines
16 KiB
TypeScript

import * as cheerio from 'cheerio'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import ComponentType from '#models/component_type'
import ScrapedPart from '#models/scraped_part'
const SOURCE = 'pc-builder.io'
const BASE_URL = 'https://fr.pc-builder.io'
const USER_AGENT = 'Mozilla/5.0 (compatible; PcBuilderPartFinder/1.0)'
const PAGE_SIZE = 100
/**
* Default freshness window for the scraped_parts cache. A category is
* re-scraped automatically the first time it is searched after this delay
* has elapsed since its last successful scrape.
*/
const CACHE_TTL_HOURS = 24
/**
* How many products to fetch when a search triggers an automatic refresh
* (see ensureFreshCategory). Kept small since this runs synchronously
* inside a user-facing HTTP request — a full deep scrape (thousands of
* products for some categories) belongs in the `scrape:parts` command
* instead, not in the request/response cycle of a search.
*/
const QUICK_REFRESH_MAX_PRODUCTS = 200
/**
* Maps our own ComponentType names to pc-builder.io's category slug,
* numeric category_id (required by the paginated /products/data endpoint,
* found in each category page's inline DataTables config), and how many
* products `node ace scrape:parts` should fetch for that category.
*
* maxProducts is chosen per category based on how much price variance
* matters there and how large the catalog is: GPUs get close-to-full
* coverage since price differences between models are large and users
* specifically look for cheaper alternatives (see the RTX 5070 case that
* prompted this), while smaller/less price-sensitive categories use a
* lighter cap to keep the full scrape run in a reasonable time.
*
* Note: pc-builder.io only has a single "storage" category covering both
* SSDs and HDDs (no separate slugs exist), so it is only mapped to our
* "SSD" type to avoid two types racing to own the same scraped rows
* (source_ref is unique per source, independent of type_id).
*/
const CATEGORY_CONFIG: Record<string, { slug: string; categoryId: string; maxProducts: number }> = {
'Processor': { slug: 'cpu', categoryId: '1', maxProducts: 500 },
'Motherboard': { slug: 'motherboard', categoryId: '3', maxProducts: 500 },
'Graphic Card': { slug: 'gpu', categoryId: '6', maxProducts: 6000 },
'Memory': { slug: 'ram', categoryId: '4', maxProducts: 800 },
'SSD': { slug: 'storage', categoryId: '5', maxProducts: 800 },
'Cooler': { slug: 'cpu-cooler', categoryId: '2', maxProducts: 400 },
'Power Supply': { slug: 'power-supply', categoryId: '8', maxProducts: 400 },
'Case': { slug: 'case', categoryId: '7', maxProducts: 400 },
'Fan': { slug: 'case-fan', categoryId: '20', maxProducts: 300 },
'WiFi-Card': { slug: 'wireless-network-adapter', categoryId: '13', maxProducts: 400 },
}
/** Backwards-compatible slug lookup, kept for anything relying on it. */
const CATEGORY_SLUGS: Record<string, string> = Object.fromEntries(
Object.entries(CATEGORY_CONFIG).map(([name, config]) => [name, config.slug])
)
interface ScrapedRow {
name: string
/**
* Null when pc-builder.io has no fixed offer for this part (common for
* very recent products — it only links to a generic Amazon search,
* e.g. "https://amazon.fr/s?k=..." instead of a priced "/dp/{ASIN}"
* page). Such parts are still kept (not skipped) so they remain
* searchable and importable; ScrapedPartsController#compare lets the
* user resolve — and persist — a real price for them on demand from
* the other supported merchants.
*/
price: number | null
imageUrl: string | null
/**
* 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) — either a direct priced product page
* or a generic search results page when no price is known. 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
}
/**
* Loads a category page once to grab the CSRF token and session cookie
* needed to call the paginated /products/data endpoint, which is what
* powers the client-side DataTables widget on every category page.
*/
async function getSessionAndToken(slug: string): Promise<{ cookies: string; token: string }> {
const response = await fetch(`${BASE_URL}/${slug}`, {
headers: { 'User-Agent': USER_AGENT },
})
if (!response.ok) {
throw new Error(`Failed to load ${slug} category page: HTTP ${response.status}`)
}
const html = await response.text()
const setCookieHeaders = response.headers.getSetCookie?.() ?? []
const cookies = setCookieHeaders.map((cookie) => cookie.split(';')[0]).join('; ')
const tokenMatch = html.match(/"_token":\s*"([^"]+)"/)
if (!tokenMatch) {
throw new Error(`Could not find CSRF token on ${slug} category page`)
}
return { cookies, token: tokenMatch[1] }
}
/**
* Parses the "Name" / "Add to Build" cell HTML fragments returned by
* /products/data into a single row, using the exact same markup structure
* as the plain category page (both come from the same Blade partials), so
* the same selectors apply.
*/
function parseRow(nameHtml: string, addToBuildHtml: string): ScrapedRow | null {
const $name = cheerio.load(nameHtml)
const nameLink = $name('a').first()
const name = nameLink.text().trim()
const productPageUrl = nameLink.attr('href')
const img = $name('img.product-image')
const imageUrl = img.attr('data-src') || img.attr('src') || null
const $btn = cheerio.load(addToBuildHtml)
const priceText = $btn('.price-box p').first().text().trim()
const sourceRef = $btn('a.add-to-build').attr('data-id')
const amazonUrl = $btn('a.btn-default').attr('href')
if (!name || !productPageUrl || !sourceRef) {
// Only truly unusable rows (missing name/link/id) are skipped. A
// missing price is not disqualifying — see the ScrapedRow.price doc.
return null
}
let price: number | null = null
if (priceText) {
const parsed = Number.parseFloat(priceText.replace(/[^\d.,]/g, '').replace(',', '.'))
price = Number.isNaN(parsed) ? null : parsed
}
return { name, price, imageUrl, purchaseUrl: amazonUrl || productPageUrl, sourceRef }
}
/**
* Fetches one page of `length` products starting at `start` from
* pc-builder.io's DataTables-backed /products/data endpoint. This is a
* plain HTTP POST (no headless browser needed) once the CSRF
* token/session cookie from getSessionAndToken() are supplied — see
* app/services/part_scraper_service.ts git history / PR description for
* the reverse-engineered request shape (field names matter exactly:
* columns[].data must be "Name"/"Add to Build", not numeric indices).
*/
async function fetchProductsPage(
categoryId: string,
cookies: string,
token: string,
start: number,
length: number
): Promise<{ rows: ScrapedRow[]; recordsTotal: number }> {
const body = new URLSearchParams({
'_token': token,
'draw': '1',
'start': String(start),
'length': String(length),
'compatibility_filter': 'false',
'filter_status': '',
'category_id': categoryId,
'compatible_with': '',
'checkbox_filters': '[]',
'range_filters': '[]',
'search_query': '',
'columns[0][data]': 'Name',
'columns[0][name]': 'Name',
'columns[0][searchable]': 'true',
'columns[0][orderable]': 'false',
'columns[0][search][value]': '',
'columns[0][search][regex]': 'false',
'columns[1][data]': 'Add to Build',
'columns[1][name]': 'Add to Build',
'columns[1][searchable]': 'true',
'columns[1][orderable]': 'false',
'columns[1][search][value]': '',
'columns[1][search][regex]': 'false',
'search[value]': '',
'search[regex]': 'false',
})
const response = await fetch(`${BASE_URL}/products/data`, {
method: 'POST',
headers: {
'User-Agent': USER_AGENT,
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': cookies,
},
body,
})
if (!response.ok) {
throw new Error(`products/data request failed: HTTP ${response.status}`)
}
const json = (await response.json()) as {
recordsTotal: number
data: { 'Name': string; 'Add to Build': string }[]
}
const rows = json.data
.map((entry) => parseRow(entry.Name, entry['Add to Build']))
.filter((row): row is ScrapedRow => row !== null)
return { rows, recordsTotal: json.recordsTotal }
}
/**
* Fetches up to `maxProducts` products for a category by paginating
* through pc-builder.io's /products/data endpoint PAGE_SIZE at a time,
* stopping early once the category's real total is exhausted. A short
* delay between pages avoids hammering the site with a burst of requests
* back-to-back for the larger categories (e.g. GPU's ~60 pages).
*/
async function fetchCategoryProducts(
slug: string,
categoryId: string,
maxProducts: number
): Promise<ScrapedRow[]> {
const { cookies, token } = await getSessionAndToken(slug)
const rows: ScrapedRow[] = []
let start = 0
let recordsTotal = maxProducts
while (start < maxProducts && start < recordsTotal) {
const length = Math.min(PAGE_SIZE, maxProducts - start)
const page = await fetchProductsPage(categoryId, cookies, token, start, length)
rows.push(...page.rows)
recordsTotal = page.recordsTotal
start += length
if (start < maxProducts && start < recordsTotal) {
await new Promise((resolve) => setTimeout(resolve, 150))
}
}
return rows
}
/**
* Decides whether a catalog refresh row should overwrite an existing
* ScrapedPart's price fields, or leave them untouched.
*
* Only a price that was actually *verified* via a merchant comparison
* (priceSource set to a merchant name by updatePriceIfCheaper, e.g.
* "LDLC") is protected from being overwritten by a plain catalog refresh
* — that price was confirmed live on the merchant's own page.
*
* A price with priceSource === null just came from a *previous*
* pc-builder.io catalog scrape, with no independent verification.
* pc-builder.io's own listings change constantly — a GPU can have a fixed
* Amazon price today and only a generic "search on Amazon" link (no
* price) tomorrow, if the ASIN it referenced runs out of stock. Treating
* that old, unverified pc-builder.io price as untouchable would freeze it
* forever even after the site itself removed it, which is exactly the
* bug this function fixes (a €431.17 price kept showing for a GPU
* pc-builder.io no longer prices at all).
*/
function shouldKeepExistingPrice(
existing: { price: number | null; priceSource: string | null } | null,
incomingPrice: number | null
): boolean {
if (!existing || existing.price === null || existing.priceSource === null) return false
return incomingPrice === null || existing.price <= incomingPrice
}
/**
* Scrapes a single component type's mapped category and upserts the
* resulting rows into the scraped_parts cache table.
*
* @param maxProductsOverride Caps how many products are fetched for this
* run, regardless of the category's configured maxProducts. Used by
* ensureFreshCategory to keep an on-demand refresh fast (a handful of
* pages) while `node ace scrape:parts` uses the full configured depth.
*/
async function scrapeComponentType(
componentType: ComponentType,
maxProductsOverride?: number
): Promise<number> {
const config = CATEGORY_CONFIG[componentType.name]
if (!config) {
logger.warn({ type: componentType.name }, 'No pc-builder.io category mapped, skipping')
return 0
}
const maxProducts = Math.min(maxProductsOverride ?? config.maxProducts, config.maxProducts)
const rows = await fetchCategoryProducts(config.slug, config.categoryId, maxProducts)
const scrapedAt = DateTime.now()
for (const row of rows) {
const existing = await ScrapedPart.query()
.where('source', SOURCE)
.andWhere('sourceRef', row.sourceRef)
.first()
const keepExistingPrice = shouldKeepExistingPrice(existing, row.price)
await ScrapedPart.updateOrCreate(
{ source: SOURCE, sourceRef: row.sourceRef },
{
typeId: componentType.id,
name: row.name,
imageUrl: row.imageUrl,
scrapedAt,
...(keepExistingPrice
? {}
: {
price: row.price,
sourceUrl: row.purchaseUrl,
priceSource: row.price !== null ? SOURCE : null,
priceCheckedAt: row.price !== null ? scrapedAt : null,
}),
}
)
}
return rows.length
}
/**
* Scrapes every mapped ComponentType category from pc-builder.io and
* refreshes the scraped_parts cache, using each category's full
* configured depth (see CATEGORY_CONFIG). Returns a per-type summary.
* This can take a few minutes overall (GPU alone fetches up to 6000
* products across ~60 requests) — intended to be run manually or via a
* scheduled job, not inside a request.
*/
async function scrapeAllCategories(): Promise<Record<string, number>> {
const componentTypes = await ComponentType.all()
const summary: Record<string, number> = {}
for (const componentType of componentTypes) {
if (!CATEGORY_CONFIG[componentType.name]) continue
try {
summary[componentType.name] = await scrapeComponentType(componentType)
} catch (error) {
logger.error({ type: componentType.name, err: error }, 'Failed to scrape category')
summary[componentType.name] = 0
}
}
return summary
}
/**
* Returns true if the given ComponentType's category has never been
* scraped, or was last scraped more than CACHE_TTL_HOURS ago.
*/
async function isCategoryStale(componentType: ComponentType): Promise<boolean> {
const mostRecent = await ScrapedPart.query()
.where('source', SOURCE)
.andWhere('typeId', componentType.id)
.orderBy('scrapedAt', 'desc')
.first()
if (!mostRecent) return true
return mostRecent.scrapedAt < DateTime.now().minus({ hours: CACHE_TTL_HOURS })
}
/**
* Ensures the cache for a given ComponentType's mapped category is fresh
* (scraped within the last CACHE_TTL_HOURS), re-scraping it synchronously
* if it's missing or stale. No-op for unmapped types. Scraping errors are
* logged but not thrown, so a search still falls back to whatever is
* already cached.
*
* Only fetches QUICK_REFRESH_MAX_PRODUCTS products (not the category's
* full configured depth), since this runs inline in a user's search
* request — run `node ace scrape:parts` for full-depth coverage.
*/
async function ensureFreshCategory(componentType: ComponentType): Promise<void> {
if (!CATEGORY_CONFIG[componentType.name]) return
if (!(await isCategoryStale(componentType))) return
try {
await scrapeComponentType(componentType, QUICK_REFRESH_MAX_PRODUCTS)
} catch (error) {
logger.error({ type: componentType.name, err: error }, 'Failed to refresh stale category cache')
}
}
/**
* Persists `offer` as a part's new price/source if it's cheaper than
* whatever is currently stored (or nothing was stored yet), so the "best
* known price" keeps improving as users compare prices (see
* ScrapedPartsController#compare) instead of resetting to a possibly
* stale/missing pc-builder.io price on the next catalog refresh —
* scrapeComponentType already refuses to overwrite a price with a worse
* one using this same "keep whichever is lower" rule.
*/
async function updatePriceIfCheaper(
part: ScrapedPart,
offer: { merchant: string; price: number; url: string }
): Promise<void> {
if (part.price !== null && part.price <= offer.price) return
part.price = offer.price
part.sourceUrl = offer.url
part.priceSource = offer.merchant
part.priceCheckedAt = DateTime.now()
await part.save()
}
export {
scrapeAllCategories,
scrapeComponentType,
ensureFreshCategory,
isCategoryStale,
updatePriceIfCheaper,
shouldKeepExistingPrice,
CATEGORY_CONFIG,
CATEGORY_SLUGS,
CACHE_TTL_HOURS,
QUICK_REFRESH_MAX_PRODUCTS,
}