feat: Add multi-site product search system with SearXNG integration

This commit adds a comprehensive product search feature that allows users
to search for products across multiple configured e-commerce sites.

Backend changes:
- Add SearXNG service to docker-compose for meta-search capability
- Add SearchSite model and Alembic migration with default sites
- Create searxng_service.py for search queries across sites
- Create light_scraper_service.py for fast HTTP-based extraction
- Create search_service.py orchestrator combining SearXNG + HTTP + Browserless
- Add API endpoints for search (/api/search) with SSE streaming
- Add CRUD endpoints for search sites (/api/search-sites)

Frontend changes:
- Add new /search page with real-time results display
- Create SearchResultCard component for product display
- Add Progress, Checkbox, and Badge UI components
- Update Settings page with search sites management section
- Add navigation link to search page
- Create new modern SVG logo with price-flow gradient design
- Update favicon to SVG format

The search system uses a hybrid approach:
1. SearXNG finds product URLs across configured sites
2. Light HTTP scraper attempts fast extraction
3. Falls back to Browserless for JavaScript-heavy sites
4. Results stream progressively via SSE
This commit is contained in:
Claude committed 2025-11-22 11:03:48 +00:00
1 parent 8795c6fea1
commit 1116c3a648
26 files changed
+1831 -10

No files matched your search

+6 -5
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Settings, Moon, Sun, Menu } from 'lucide-react';
import { LayoutDashboard, Search, Settings, Moon, Sun, Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
@@ -13,6 +13,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
const navItems = [
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
{ icon: Settings, label: t('nav.settings'), path: '/settings' },
];
@@ -23,7 +24,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 rounded-lg" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
PriceFlow
</span>
@@ -80,9 +81,9 @@ const Layout = ({ children, theme, toggleTheme }) => {
<SheetContent side="left" className="w-64 p-0">
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8 mr-2" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 mr-2 rounded-lg" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Pricecious
PriceFlow
</span>
</div>
<nav className="flex-1 space-y-1 p-4">
@@ -125,7 +126,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
</SheetContent>
</Sheet>
<Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="PriceFlow Logo" className="h-6 w-6" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-6 w-6 rounded" />
PriceFlow
</Link>
<div className="ml-auto flex items-center gap-2">
@@ -0,0 +1,112 @@
import React from 'react';
import { ExternalLink, Plus, Package, PackageX } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
export function SearchResultCard({ result, onAddToMonitoring }) {
const formatPrice = (price) => {
if (price === null || price === undefined) return 'N/A';
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: result.currency || 'EUR',
}).format(price);
};
const truncateTitle = (title, maxLength = 60) => {
if (!title) return 'Produit sans nom';
if (title.length <= maxLength) return title;
return title.substring(0, maxLength) + '...';
};
return (
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-0">
{/* Image */}
<div className="aspect-square bg-muted flex items-center justify-center">
{result.image_url ? (
<img
src={result.image_url}
alt={result.title}
className="w-full h-full object-contain"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<div
className={`w-full h-full items-center justify-center text-muted-foreground ${result.image_url ? 'hidden' : 'flex'}`}
>
<Package className="h-16 w-16" />
</div>
</div>
{/* Content */}
<div className="p-4 space-y-3">
{/* Title */}
<h3 className="font-medium text-sm leading-tight line-clamp-2" title={result.title}>
{truncateTitle(result.title)}
</h3>
{/* Price & Stock */}
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-primary">
{formatPrice(result.price)}
</span>
{result.in_stock !== null && (
<Badge
variant={result.in_stock ? 'default' : 'destructive'}
className="text-xs"
>
{result.in_stock ? (
<>
<Package className="h-3 w-3 mr-1" />
En stock
</>
) : (
<>
<PackageX className="h-3 w-3 mr-1" />
Rupture
</>
)}
</Badge>
)}
</div>
{/* Site */}
<div className="flex items-center text-xs text-muted-foreground">
<span className="truncate">{result.site_name}</span>
{result.confidence && (
<span className="ml-auto text-xs opacity-50">
{Math.round(result.confidence * 100)}%
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="default"
size="sm"
className="flex-1"
onClick={onAddToMonitoring}
>
<Plus className="h-4 w-4 mr-1" />
Suivre
</Button>
<Button
variant="outline"
size="sm"
asChild
>
<a href={result.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white shadow hover:bg-green-500/80",
warning:
"border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-500/80",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }