Files
Priceflow/app/services/parsers/base_parser.py
T

225 lines
6.4 KiB
Python

"""
Base Parser - Abstract interface for site-specific parsers
Defines the common structure all parsers must implement
"""
import logging
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from urllib.parse import urljoin
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
@dataclass
class ProductResult:
"""Unified product result data structure"""
title: str
url: str
source: str
price: float | None = None
original_price: float | None = None
currency: str = "EUR"
in_stock: bool | None = None
image_url: str | None = None
rating: float | None = None
reviews_count: int | None = None
sponsored: bool = False
snippet: str = ""
class BaseParser(ABC):
"""
Abstract base class for site-specific parsers
Each parser must implement:
- parse_search_results: Extract products from search page HTML
- parse_product_details: Extract details from product page HTML (optional)
"""
def __init__(self, site_name: str, base_url: str):
self.site_name = site_name
self.base_url = base_url
self.logger = logging.getLogger(f"{__name__}.{self.__class__.__name__}")
@abstractmethod
def parse_search_results(self, html: str, query: str, search_url: str) -> list[ProductResult]:
"""
Parse search results page HTML to extract products
Args:
html: Raw HTML content of search page
query: Original search query
search_url: URL of the search page
Returns:
List of ProductResult objects
"""
pass
def parse_product_details(self, html: str, product_url: str) -> dict:
"""
Parse product detail page to extract price, stock, etc.
Optional: Override this if you need to scrape individual product pages
Args:
html: Raw HTML of product page
product_url: URL of the product
Returns:
Dict with price, in_stock, etc.
"""
return {}
# ==================== HELPER METHODS ====================
def make_absolute_url(self, url: str, base_url: str | None = None) -> str:
"""Convert relative URL to absolute URL"""
if not url:
return ""
if url.startswith("//"):
return "https:" + url
if url.startswith("http"):
return url
return urljoin(base_url or self.base_url, url)
def parse_price_text(self, price_text: str) -> float | None:
"""
Parse French price format to float
Handles:
- "12,99 €"
- "12,99€"
- "1 234,99 €"
- "12.99 EUR"
"""
if not price_text:
return None
# Remove currency symbols
cleaned = price_text.strip().replace('€', '').replace('EUR', '').strip()
# Remove thousands separators
cleaned = cleaned.replace(' ', '').replace('\xa0', '')
# Replace French decimal separator
cleaned = cleaned.replace(',', '.')
# Extract first number
match = re.search(r'(\d+\.?\d*)', cleaned)
if match:
try:
price = float(match.group(1))
# Validate range
if 0.01 <= price <= 100000:
return price
except ValueError:
pass
return None
def parse_rating_text(self, rating_text: str) -> float | None:
"""Parse rating like '4,5 sur 5 étoiles'"""
if not rating_text:
return None
match = re.search(r'(\d+[,.]\d+)', rating_text)
if match:
try:
return float(match.group(1).replace(',', '.'))
except ValueError:
pass
return None
def parse_reviews_count_text(self, reviews_text: str) -> int | None:
"""Parse review count like '1 234'"""
if not reviews_text:
return None
cleaned = re.sub(r'[^\d\s]', '', reviews_text)
cleaned = cleaned.replace(' ', '').replace('\xa0', '')
try:
return int(cleaned)
except ValueError:
return None
def filter_by_query(self, title: str, query: str, strict: bool = False) -> bool:
"""
Check if title matches query words
Args:
title: Product title
query: Search query
strict: If True, ALL query words must be in title. If False, at least ONE word.
"""
if not query:
return True
title_lower = title.lower()
query_words = [w.lower() for w in query.split() if len(w) > 2]
if not query_words:
return True
if strict:
# All words must be present
return all(word in title_lower for word in query_words)
else:
# At least one word must be present
return any(word in title_lower for word in query_words)
def extract_image_url(self, element, selectors: list[str] = None) -> str | None:
"""
Extract image URL from element with fallback selectors
Args:
element: BeautifulSoup element
selectors: Optional CSS selectors to try
"""
if selectors:
for selector in selectors:
img = element.select_one(selector)
if img:
url = self._get_image_src(img)
if url:
return url
# Fallback: find any img
img = element.find("img")
if img:
return self._get_image_src(img)
# Try picture element
picture = element.find("picture")
if picture:
source = picture.find("source")
if source and source.get("srcset"):
return source.get("srcset").split(",")[0].split()[0]
img = picture.find("img")
if img:
return self._get_image_src(img)
return None
def _get_image_src(self, img_element) -> str | None:
"""Extract image source from img element"""
attrs = ['src', 'data-src', 'data-lazy-src', 'data-original', 'data-lazy']
for attr in attrs:
url = img_element.get(attr)
if url and not url.startswith('data:'):
return url
# Try srcset
srcset = img_element.get('srcset')
if srcset:
return srcset.split(",")[0].split()[0]
return None