CSP fix:
- The previous shield.ts config used '@viteDevUrl'/'@viteHmrUrl' CSP
keywords that don't exist in this version of @adonisjs/shield (only
'@viteUrl' is registered by @adonisjs/vite, and it resolves to an empty
string here since Vite assets are proxied through AdonisJS on the same
origin). Both placeholders were sent to the browser literally, breaking
Google Fonts, the Vite HMR websocket, and pc-builder.io/favicon images.
- Replaced with concrete directives: 'self' covers same-origin Vite
assets, ws://localhost:{VITE_HMR_PORT|24678} is added only in dev for
the HMR socket, and explicit allowlists for Google Fonts
(fonts.googleapis.com/fonts.gstatic.com), pc-builder.io product images,
and Google's favicon service (www.google.com) used by the merchant
comparison UI
Real purchase links + live price comparison:
- part_scraper_service.ts: ScrapedPart.sourceUrl now stores the actual
Amazon.fr purchase link scraped from pc-builder.io's category pages
(https://www.amazon.fr/dp/{ASIN}?tag=... or a search fallback), instead
of the pc-builder.io product page, which isn't a place anyone can buy
anything from
- New merchant_price_service.ts: on-demand live price lookup across
Amazon, LDLC, Grosbill and TopAchat. Of 6 requested merchants,
Materiel.net (search requires JS/Turbo, no results via plain GET) and
Cdiscount/PCComponentes (empty body / 403) were dropped as not scrapable
without a headless browser or paid API
- Matching strategy: pc-builder.io's full product names (e.g. "AMD Ryzen 3
3200G 3.6 GHz Quad-Core Processor") return zero results on every
merchant's search engine, so we search on a short brand+series query
and then pick the specific result whose label contains the extracted
model token (e.g. "3200G"). No token match means no offer returned,
rather than risk quoting the price of a different, unrelated product
- Fixed price parsing for LDLC/Grosbill, which render prices as nested
price/sup elements for whole euros vs cents (and LDLC additionally
wraps that in a duplicate outer .price div) - naive text concatenation
was misreading "64,95" as "6495"
- New GET /scraped-parts/compare endpoint (authenticated), called on
demand per search result rather than for every row, since it makes
several outbound requests and can take a few seconds
- NewPartSearch.vue: "Compare prices" button per result shows the
cheapest merchant with its favicon and links to the actual offer;
importing a part now prefers the live cheapest price/link over
pc-builder.io's potentially stale catalog price (confirmed stale in
practice: pc-builder.io listed a Ryzen 3 3200G at 128 euros while
Amazon sells it for 66.89 euros)
- 3 new functional tests for the compare endpoint (auth required, 404 on
unknown part, 422 on invalid id)
173 lines
5.2 KiB
Vue
173 lines
5.2 KiB
Vue
<script setup lang="ts">
|
|
import { reactive, ref, watch } from 'vue'
|
|
import type { MerchantOffer, ScrapedPart } from '~/pages/types/UITypes'
|
|
|
|
const emit = defineEmits<{
|
|
import: [part: ScrapedPart, offer: MerchantOffer | null]
|
|
}>()
|
|
|
|
function faviconUrl(source: string): string {
|
|
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(source)}&sz=32`
|
|
}
|
|
|
|
const query = ref('')
|
|
const results = ref<ScrapedPart[]>([])
|
|
const isLoading = ref(false)
|
|
const hasSearched = ref(false)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
// Per-part price comparison state, keyed by scraped part id.
|
|
const comparing = reactive<Record<number, boolean>>({})
|
|
const offers = reactive<Record<number, MerchantOffer[]>>({})
|
|
const compareErrors = reactive<Record<number, boolean>>({})
|
|
|
|
async function search() {
|
|
if (!query.value.trim()) {
|
|
results.value = []
|
|
hasSearched.value = false
|
|
return
|
|
}
|
|
|
|
isLoading.value = true
|
|
try {
|
|
const response = await fetch(`/scraped-parts?q=${encodeURIComponent(query.value.trim())}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
if (response.ok) {
|
|
results.value = await response.json()
|
|
} else {
|
|
results.value = []
|
|
}
|
|
} finally {
|
|
isLoading.value = false
|
|
hasSearched.value = true
|
|
}
|
|
}
|
|
|
|
watch(query, () => {
|
|
if (debounceTimer) clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(search, 400)
|
|
})
|
|
|
|
async function comparePrices(part: ScrapedPart) {
|
|
comparing[part.id] = true
|
|
compareErrors[part.id] = false
|
|
try {
|
|
const response = await fetch(`/scraped-parts/compare?id=${part.id}`, {
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
if (!response.ok) {
|
|
compareErrors[part.id] = true
|
|
return
|
|
}
|
|
const data = await response.json()
|
|
offers[part.id] = data.offers ?? []
|
|
if (offers[part.id].length === 0) {
|
|
compareErrors[part.id] = true
|
|
}
|
|
} catch {
|
|
compareErrors[part.id] = true
|
|
} finally {
|
|
comparing[part.id] = false
|
|
}
|
|
}
|
|
|
|
function cheapestOffer(part: ScrapedPart): MerchantOffer | null {
|
|
return offers[part.id]?.[0] ?? null
|
|
}
|
|
|
|
function merchantDomain(merchant: MerchantOffer['merchant']): string {
|
|
const domains: Record<MerchantOffer['merchant'], string> = {
|
|
Amazon: 'amazon.fr',
|
|
LDLC: 'ldlc.com',
|
|
Grosbill: 'grosbill.com',
|
|
TopAchat: 'topachat.com',
|
|
}
|
|
return domains[merchant]
|
|
}
|
|
|
|
function importPart(part: ScrapedPart) {
|
|
emit('import', part, cheapestOffer(part))
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
|
<label class="block text-gray-700 text-sm font-bold mb-2">
|
|
Search a new part (pc-builder.io)
|
|
</label>
|
|
<input
|
|
v-model="query"
|
|
type="text"
|
|
placeholder="e.g. Ryzen 5 7600X, RTX 4070..."
|
|
class="w-full border-gray-200 focus:border-indigo-600 focus:ring focus:ring-indigo-500/40 px-3 py-2 border rounded text-gray-700"
|
|
/>
|
|
|
|
<p v-if="isLoading" class="text-sm text-gray-500 mt-2">Searching…</p>
|
|
<p v-else-if="hasSearched && results.length === 0" class="text-sm text-gray-500 mt-2">
|
|
No matching part found.
|
|
</p>
|
|
|
|
<ul v-if="results.length > 0" class="mt-3 space-y-2 max-h-96 overflow-y-auto">
|
|
<li
|
|
v-for="part in results"
|
|
:key="part.id"
|
|
class="bg-white border border-gray-200 rounded p-2"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
v-if="part.imageUrl"
|
|
:src="part.imageUrl"
|
|
alt=""
|
|
class="w-10 h-10 object-contain shrink-0"
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm text-gray-800 truncate" :title="part.name">{{ part.name }}</p>
|
|
<p class="text-xs text-gray-500">{{ part.type.name }}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="px-3 py-1 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 transition shrink-0"
|
|
@click="importPart(part)"
|
|
>
|
|
Import
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-2 pl-13 flex items-center gap-2 flex-wrap">
|
|
<button
|
|
v-if="!offers[part.id]"
|
|
type="button"
|
|
:disabled="comparing[part.id]"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded text-gray-600 hover:bg-gray-100 transition disabled:opacity-50"
|
|
@click="comparePrices(part)"
|
|
>
|
|
{{ comparing[part.id] ? 'Checking prices…' : 'Compare prices' }}
|
|
</button>
|
|
|
|
<template v-else-if="cheapestOffer(part)">
|
|
<img
|
|
:src="faviconUrl(merchantDomain(cheapestOffer(part)!.merchant))"
|
|
alt=""
|
|
class="w-4 h-4 shrink-0"
|
|
/>
|
|
<a
|
|
:href="cheapestOffer(part)!.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-xs text-indigo-600 hover:underline"
|
|
>
|
|
{{ cheapestOffer(part)!.merchant }}: €{{ cheapestOffer(part)!.price.toFixed(2) }}
|
|
(cheapest)
|
|
</a>
|
|
</template>
|
|
|
|
<span v-if="compareErrors[part.id]" class="text-xs text-gray-400">
|
|
No live price found, using catalog price €{{ part.price.toFixed(2) }}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|