Files
pc_builder/app/controllers/scraped_parts_controller.ts
T
Kevin L'Hereec a1f6ff419a
Build & Deploy / build (push) Failing after 3s
Build & Deploy / docker (push) Skipped
feat: raise search results limit from 30 to 150
30 results was too low once pc-builder.io's full catalog is scraped
(e.g. 47 RTX 5070/5070 Ti variants alone), silently truncating results
a user would otherwise see. Also added a result count hint in
NewPartSearch.vue since the list is scrollable (max-h-96) rather than
paginated, so it wasn't obvious more results existed below the fold.
2026-07-23 16:26:30 +02:00

99 lines
3.5 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 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.
*/
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 })
}
}