The RTX 5070 case that prompted this: only 100 GPUs out of pc-builder.io's
actual 6222 were being scraped (its category pages only render the first
page server-side), so cheaper RTX 5070 variants existed on the site but
were invisible to our search. Investigating further also surfaced that
even with full pagination, most recent GPU listings (RTX 5070 included)
have no fixed Amazon price on pc-builder.io at all - just a generic
"search on Amazon" link - so pagination alone doesn't fully solve price
coverage either.
Pagination (part_scraper_service.ts):
- Reverse-engineered pc-builder.io's /products/data endpoint, which powers
the client-side DataTables widget behind every category page. Requires
a session cookie + CSRF token scraped from a fresh category page load,
and an exact field shape (columns[].data must be the literal strings
"Name"/"Add to Build", not numeric indices - a naive DataTables-shaped
request 500s otherwise).
- CATEGORY_CONFIG now tracks each category's real pc-builder.io
category_id and a per-category maxProducts depth, chosen by actual
catalog size and price-sensitivity: GPU near-full coverage (6000),
smaller/less price-volatile categories capped lower (300-800) to keep a
full `scrape:parts` run (all categories) in a reasonable time.
- ensureFreshCategory (triggered inline by a user's search when the cache
is stale) uses a much shallower QUICK_REFRESH_MAX_PRODUCTS=200 cap
instead of the category's full depth, since it runs inside the
request/response cycle of a search - full-depth coverage is only done
by the standalone `scrape:parts` command.
Always keep the best known price:
- scraped_parts.price is now nullable (migration): parts with no fixed
pc-builder.io offer are kept and searchable instead of being silently
skipped, so at least the model is discoverable even before a price is
resolved.
- New price_source / price_checked_at columns track where the currently
stored price came from ("pc-builder.io" or a merchant name from
merchant_price_service.ts).
- ScrapedPartsController#compare now persists the cheapest offer found
(via the new updatePriceIfCheaper()) when it beats the currently stored
price - so comparing prices benefits every future search/import for
that part, not just the user who triggered it. Never regresses the
price: a comparison finding a higher price than what's stored is
ignored.
- scrapeComponentType's catalog refresh applies the same "keep whichever
price is lower" rule per row, so a routine pc-builder.io re-scrape can
never overwrite a better price a comparison had already found and
persisted with a worse (or missing) one.
- Search results (GET /scraped-parts) are now ordered priced-cheapest-first,
then parts with no known price last (`ORDER BY price IS NULL, price ASC`),
so unpriced parts stay visible without pushing priced results down.
- NewPartSearch.vue displays the catalog price inline (or "No price yet"),
and the existing "Compare prices" flow now works for those unpriced
parts the same way it does for priced ones.
7 new/updated functional tests: nullable price is searchable and sorted
last, and 3 tests for updatePriceIfCheaper (cheaper offer replaces price,
pricier offer is ignored, first-ever price is accepted for a previously
unpriced part).
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
import type { HttpContext } from '@adonisjs/core/http'
|
|
import vine from '@vinejs/vine'
|
|
import ScrapedPart from '#models/scraped_part'
|
|
import ComponentType from '#models/component_type'
|
|
import { ensureFreshCategory, updatePriceIfCheaper } from '#services/part_scraper_service'
|
|
import { findCheapestOffers } from '#services/merchant_price_service'
|
|
|
|
const searchValidator = vine.compile(
|
|
vine.object({
|
|
q: vine.string().trim().minLength(1).optional(),
|
|
typeId: vine.number().min(1).optional(),
|
|
})
|
|
)
|
|
|
|
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
|
|
* component type. Read-only, authenticated (the search UI lives inside
|
|
* the authenticated "create component" flow).
|
|
*
|
|
* When a typeId is given, the corresponding category cache is refreshed
|
|
* synchronously first if it's missing or older than 24h (see
|
|
* part_scraper_service.CACHE_TTL_HOURS), so results stay reasonably
|
|
* fresh without needing an external cron job. Free-text searches across
|
|
* all types skip this eager refresh to avoid re-scraping every mapped
|
|
* category on every keystroke; run `node ace scrape:parts` to refresh
|
|
* everything at once.
|
|
*
|
|
* Results are ordered with a resolved price first (cheapest first), then
|
|
* parts with no known price yet (see ScrapedPart.price doc) last — so a
|
|
* part is never hidden just because pc-builder.io hasn't found a fixed
|
|
* Amazon offer for it, but priced parts still surface first.
|
|
*/
|
|
async index({ request, auth, response }: HttpContext) {
|
|
await auth.authenticate()
|
|
const { q, typeId } = await request.validateUsing(searchValidator)
|
|
|
|
if (typeId) {
|
|
const componentType = await ComponentType.find(typeId)
|
|
if (componentType) {
|
|
await ensureFreshCategory(componentType)
|
|
}
|
|
}
|
|
|
|
const query = ScrapedPart.query()
|
|
.preload('type')
|
|
.orderByRaw('price IS NULL, price ASC, name ASC')
|
|
|
|
if (q) {
|
|
query.whereRaw('LOWER(name) LIKE ?', [`%${q.toLowerCase()}%`])
|
|
}
|
|
if (typeId) {
|
|
query.where('typeId', typeId)
|
|
}
|
|
|
|
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, TopAchat, Materiel.net, Cdiscount and PCComponentes, and
|
|
* returns them sorted cheapest first. Triggered on demand (one part at a
|
|
* time) rather than for every search result, since it makes several
|
|
* outbound requests and can take a few seconds.
|
|
*
|
|
* If the cheapest offer found beats the part's currently stored price
|
|
* (or no price was known yet), it's persisted as the new default price
|
|
* (see updatePriceIfCheaper) — so this comparison benefits every future
|
|
* search/import for this part, not just the current user.
|
|
*/
|
|
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)
|
|
|
|
if (offers.length > 0) {
|
|
await updatePriceIfCheaper(part, offers[0])
|
|
}
|
|
|
|
return response.json({ offers })
|
|
}
|
|
}
|