Several memory sticks (e.g. Crucial CT8G48C40U5, Patriot Signature Line,
Kingston FURY Beast) kept displaying their pc-builder.io catalog price
("€35.99", "€39.99"...) even after clicking "Compare prices" and getting
"No live price found, using catalog price" - an unconfirmed number was
being shown as if it were still valid, which is misleading given
pc-builder.io's catalog is known to go stale (see the earlier RTX 5070
€128 vs €66.89 case).
New clearPriceIfNoOffers(): when ScrapedPartsController#compare finds
zero offers across all 7 supported merchants, the part's price/priceSource
are reset to null, regardless of whether the price previously came from
the pc-builder.io catalog or an earlier verified merchant match - per
explicit product decision, an empty comparison always wins over keeping a
possibly-stale number around.
- ScrapedPartsController#compare now calls clearPriceIfNoOffers() in the
empty-offers branch, and returns the resulting price in the JSON
response so the frontend can mirror it exactly
- NewPartSearch.vue: comparePrices() now sets part.price from the
response's `price` field (mirroring exactly what the backend just
persisted) instead of only updating it on a cheaper find; removed the
now-inaccurate "using catalog price €X" message since that price no
longer exists once compared with no result
- 3 new tests for clearPriceIfNoOffers: clears an unverified
pc-builder.io price, also clears a previously-verified merchant price
when no longer confirmed, and is a no-op when there was no price to
begin with
Verified end-to-end against the reported Crucial CT8G48C40U5 example:
price went from 35.99 (pc-builder.io) to null after a real comparison
found zero matching offers on any merchant.
110 lines
3.8 KiB
TypeScript
110 lines
3.8 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,
|
|
clearPriceIfNoOffers,
|
|
} from '#services/part_scraper_service'
|
|
import { findCheapestOffers } from '#services/merchant_price_service'
|
|
|
|
const SEARCH_RESULTS_LIMIT = 150
|
|
|
|
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(SEARCH_RESULTS_LIMIT)
|
|
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.
|
|
*
|
|
* If NO merchant confirms a price at all, the part's price is cleared
|
|
* to null (see clearPriceIfNoOffers) rather than silently keeping a
|
|
* pc-builder.io catalog price the user just explicitly asked to verify
|
|
* and that no longer checks out anywhere.
|
|
*/
|
|
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])
|
|
} else {
|
|
await clearPriceIfNoOffers(part)
|
|
}
|
|
|
|
return response.json({ offers, price: part.price })
|
|
}
|
|
}
|