mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-23 15:18:12 +02:00
feat: implement direct e-commerce search service with Playwright/Browserless and add supporting admin page and debug routes.
This commit is contained in:
1 parent
df052ae4f7
commit
e45290350c
4 files changed
+176
-5
No files matched your search
+2
-2
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Reference in new issue
Block a user