feat: implement direct e-commerce search service with Playwright/Browserless and add supporting admin page and debug routes.

This commit is contained in:
Michael committed 2025-11-24 07:18:12 +01:00
1 parent df052ae4f7
commit e45290350c
4 files changed
+176 -5

No files matched your search

+2 -2
View File
@@ -13,7 +13,7 @@ from sqlalchemy import text
from app.database import SessionLocal, engine from app.database import SessionLocal, engine
from app.limiter import limiter from app.limiter import limiter
from app.routers import auth, items, jobs, notifications, openrouter, search, search_sites, settings from app.routers import auth, items, jobs, notifications, openrouter, search, search_sites, settings, debug
from app.services.scheduler_service import scheduled_refresh, scheduler from app.services.scheduler_service import scheduled_refresh, scheduler
from app.services import auth_service, search_service from app.services import auth_service, search_service
@@ -163,7 +163,7 @@ os.makedirs("screenshots", exist_ok=True)
app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots") app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots")
# Routers # Routers
for router in [notifications.router, items.router, settings.router, jobs.router, openrouter.router]: for router in [notifications.router, items.router, settings.router, jobs.router, openrouter.router, debug.router]:
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
# Search routers (already have /api prefix) # Search routers (already have /api prefix)
+91
View File
@@ -0,0 +1,91 @@
import os
import logging
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.services import auth_service
router = APIRouter(
prefix="/debug",
tags=["debug"],
dependencies=[Depends(auth_service.get_current_active_admin)],
)
logger = logging.getLogger(__name__)
DEBUG_DIR = "debug_dumps"
class DebugFile(BaseModel):
filename: str
size: int
created_at: float
created_at_formatted: str
@router.get("/dumps", response_model=List[DebugFile])
def list_debug_dumps():
"""List all debug dump files"""
if not os.path.exists(DEBUG_DIR):
return []
files = []
try:
for filename in os.listdir(DEBUG_DIR):
filepath = os.path.join(DEBUG_DIR, filename)
if os.path.isfile(filepath):
stat = os.stat(filepath)
files.append(DebugFile(
filename=filename,
size=stat.st_size,
created_at=stat.st_ctime,
created_at_formatted=datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M:%S")
))
# Sort by creation time (newest first)
files.sort(key=lambda x: x.created_at, reverse=True)
return files
except Exception as e:
logger.error(f"Error listing debug dumps: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dumps/{filename}")
def download_debug_dump(filename: str):
"""Download a specific debug dump file"""
if not os.path.exists(DEBUG_DIR):
raise HTTPException(status_code=404, detail="Debug directory not found")
filepath = os.path.join(DEBUG_DIR, filename)
# Security check: prevent directory traversal
if not os.path.abspath(filepath).startswith(os.path.abspath(DEBUG_DIR)):
raise HTTPException(status_code=403, detail="Access denied")
if not os.path.isfile(filepath):
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(filepath, filename=filename)
@router.delete("/dumps/{filename}")
def delete_debug_dump(filename: str):
"""Delete a specific debug dump file"""
if not os.path.exists(DEBUG_DIR):
raise HTTPException(status_code=404, detail="Debug directory not found")
filepath = os.path.join(DEBUG_DIR, filename)
# Security check: prevent directory traversal
if not os.path.abspath(filepath).startswith(os.path.abspath(DEBUG_DIR)):
raise HTTPException(status_code=403, detail="Access denied")
if not os.path.isfile(filepath):
raise HTTPException(status_code=404, detail="File not found")
try:
os.remove(filepath)
return {"message": "File deleted"}
except Exception as e:
logger.error(f"Error deleting debug dump {filename}: {e}")
raise HTTPException(status_code=500, detail=str(e))
+31 -1
View File
@@ -21,10 +21,25 @@ MIN_TITLE_LENGTH = 3
MIN_LINK_TEXT_LENGTH = 5 MIN_LINK_TEXT_LENGTH = 5
USER_AGENT = ( USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "(KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
) )
def _dump_debug_html(html: str, domain: str, query: str):
"""Sauvegarde le HTML pour débogage"""
try:
debug_dir = "debug_dumps"
if not os.path.exists(debug_dir):
os.makedirs(debug_dir)
filename = f"{debug_dir}/{domain}_{query}_{int(asyncio.get_event_loop().time())}.html"
with open(filename, "w", encoding="utf-8") as f:
f.write(html)
logger.info(f"HTML dump saved to {filename}")
except Exception as e:
logger.error(f"Failed to dump HTML: {e}")
class SearchResult: class SearchResult:
"""Résultat de recherche""" """Résultat de recherche"""
@@ -280,6 +295,7 @@ DEFAULT_SITE_CONFIGS = {
}, },
} }
# Sélecteurs communs pour les bannières de cookies # Sélecteurs communs pour les bannières de cookies
# Ordonnés du plus spécifique au plus générique pour une meilleure détection # Ordonnés du plus spécifique au plus générique pour une meilleure détection
COOKIE_ACCEPT_SELECTORS = [ COOKIE_ACCEPT_SELECTORS = [
@@ -571,6 +587,12 @@ async def _search_site_browserless( # noqa: PLR0912, PLR0915
# Extraire le HTML # Extraire le HTML
html_content = await page.content() html_content = await page.content()
# Check for CAPTCHA
if "Enter the characters you see below" in html_content or "Saisissez les caractères" in html_content:
logger.warning(f"CAPTCHA detected on {domain}!")
_dump_debug_html(html_content, domain, query)
return []
# Parser le HTML avec BeautifulSoup # Parser le HTML avec BeautifulSoup
soup = BeautifulSoup(html_content, "lxml") soup = BeautifulSoup(html_content, "lxml")
results = [] results = []
@@ -617,15 +639,18 @@ async def _search_site_browserless( # noqa: PLR0912, PLR0915
# Vérifier que c'est bien un lien vers ce domaine # Vérifier que c'est bien un lien vers ce domaine
url_domain = _extract_domain(href) url_domain = _extract_domain(href)
if domain not in url_domain and url_domain not in domain: if domain not in url_domain and url_domain not in domain:
# logger.debug(f"Ignored external link: {href}")
continue continue
# Filtrer les liens non-produits # Filtrer les liens non-produits
if _is_non_product_url(href): if _is_non_product_url(href):
# logger.debug(f"Ignored non-product link: {href}")
continue continue
# Extraire le titre # Extraire le titre
title = _extract_title(link) title = _extract_title(link)
if not title or len(title) < MIN_TITLE_LENGTH: if not title or len(title) < MIN_TITLE_LENGTH:
# logger.debug(f"Ignored link with no title: {href}")
continue continue
results.append(SearchResult( results.append(SearchResult(
@@ -641,6 +666,11 @@ async def _search_site_browserless( # noqa: PLR0912, PLR0915
break break
logger.info(f"{domain}: {len(results)} résultats extraits") logger.info(f"{domain}: {len(results)} résultats extraits")
if len(results) == 0:
logger.warning(f"{domain}: 0 results found. Dumping HTML for debugging.")
_dump_debug_html(html_content, domain, query)
return results return results
except Exception as e: except Exception as e:
+52 -2
View File
@@ -7,7 +7,7 @@ import {
Settings as SettingsIcon, Cpu, Clock, Bell, Globe, Settings as SettingsIcon, Cpu, Clock, Bell, Globe,
ChevronDown, ChevronUp, RefreshCw, RotateCcw, Edit2, ChevronDown, ChevronUp, RefreshCw, RotateCcw, Edit2,
Sparkles, Eye, Code, Brain, DollarSign, Filter, Sparkles, Eye, Code, Brain, DollarSign, Filter,
TrendingDown, CheckCircle2, Package TrendingDown, CheckCircle2, Package, Bug, Download
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -87,7 +87,56 @@ export default function Admin() {
{ id: 'code', name: 'Code', description: 'Programmation' }, { id: 'code', name: 'Code', description: 'Programmation' },
{ id: 'reasoning', name: 'Raisonnement', description: 'Raisonnement avancé' }, { id: 'reasoning', name: 'Raisonnement', description: 'Raisonnement avancé' },
{ id: 'free', name: 'Gratuit', description: 'Modèles gratuits' } { id: 'free', name: 'Gratuit', description: 'Modèles gratuits' }
]); const [debugFiles, setDebugFiles] = useState([]);
const [loadingDebugFiles, setLoadingDebugFiles] = useState(false);
useEffect(() => {
if (activeTab === 'debug') {
fetchDebugFiles();
}
}, [activeTab]);
const fetchDebugFiles = async () => {
setLoadingDebugFiles(true);
try {
const response = await axios.get(`${API_URL}/debug/dumps`, { headers: getAuthHeaders() });
setDebugFiles(response.data);
} catch (error) {
console.error('Error fetching debug files:', error);
toast.error('Erreur lors du chargement des fichiers');
} finally {
setLoadingDebugFiles(false);
}
};
const downloadDebugFile = async (filename) => {
try {
const response = await axios.get(`${API_URL}/debug/dumps/${filename}`, {
headers: getAuthHeaders(),
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
} catch (error) {
toast.error('Erreur lors du téléchargement');
}
};
const deleteDebugFile = async (filename) => {
if (!confirm(`Supprimer ${filename} ?`)) return;
try {
await axios.delete(`${API_URL}/debug/dumps/${filename}`, { headers: getAuthHeaders() });
toast.success('Fichier supprimé');
fetchDebugFiles();
} catch (error) {
toast.error('Erreur lors de la suppression');
}
};
useEffect(() => { useEffect(() => {
if (!isAdmin) { if (!isAdmin) {
@@ -415,6 +464,7 @@ export default function Admin() {
{ id: 'sites', label: 'Sites', icon: Globe }, { id: 'sites', label: 'Sites', icon: Globe },
{ id: 'jobs', label: 'Jobs', icon: Clock }, { id: 'jobs', label: 'Jobs', icon: Clock },
{ id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'debug', label: 'Debug', icon: Bug },
]; ];
return ( return (