Spaces:
Sleeping
Sleeping
""" | |
Module de scraping pour extraire le contenu des pages web. | |
""" | |
import os | |
import logging | |
from typing import Dict, Optional, Union, List | |
import requests | |
from bs4 import BeautifulSoup | |
from readability import Document | |
from dotenv import load_dotenv | |
import re | |
# Configuration du logging | |
logging.basicConfig(level=logging.INFO, | |
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') | |
logger = logging.getLogger(__name__) | |
# Chargement des variables d'environnement | |
load_dotenv() | |
# Configuration par défaut | |
DEFAULT_USER_AGENT = os.getenv( | |
'USER_AGENT', | |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' | |
) | |
DEFAULT_TIMEOUT = int(os.getenv('REQUEST_TIMEOUT', 30)) | |
DEFAULT_MAX_RETRIES = int(os.getenv('MAX_RETRIES', 3)) | |
class WebScraper: | |
"""Classe pour scraper des pages web et nettoyer leur contenu.""" | |
def __init__(self, user_agent: str = DEFAULT_USER_AGENT, | |
timeout: int = DEFAULT_TIMEOUT, | |
max_retries: int = DEFAULT_MAX_RETRIES): | |
""" | |
Initialise le scraper. | |
Args: | |
user_agent: User-Agent à utiliser pour les requêtes HTTP | |
timeout: Délai d'attente en secondes pour les requêtes | |
max_retries: Nombre maximal de tentatives en cas d'échec | |
""" | |
self.user_agent = user_agent | |
self.timeout = timeout | |
self.max_retries = max_retries | |
self.session = requests.Session() | |
self.session.headers.update({"User-Agent": self.user_agent}) | |
def fetch_url(self, url: str) -> Optional[str]: | |
""" | |
Récupère le contenu HTML d'une URL. | |
Args: | |
url: L'URL à scraper | |
Returns: | |
Le contenu HTML ou None en cas d'échec | |
""" | |
for attempt in range(self.max_retries): | |
try: | |
logger.info(f"Tentative {attempt + 1}/{self.max_retries} de récupération de {url}") | |
response = self.session.get(url, timeout=self.timeout) | |
response.raise_for_status() | |
# Détection de l'encodage | |
encoding = response.encoding | |
# Si le site ne spécifie pas d'encodage ou qu'il est incorrect, essayer de le détecter | |
if encoding == 'ISO-8859-1' or not encoding: | |
detected_encoding = response.apparent_encoding | |
if detected_encoding: | |
response.encoding = detected_encoding | |
return response.text | |
except requests.RequestException as e: | |
logger.error(f"Erreur lors de la récupération de {url}: {str(e)}") | |
if attempt == self.max_retries - 1: | |
logger.error(f"Échec après {self.max_retries} tentatives.") | |
return None | |
return None | |
def extract_additional_content(self, soup: BeautifulSoup) -> str: | |
""" | |
Extrait du contenu supplémentaire qui pourrait être ignoré par Readability. | |
Args: | |
soup: Objet BeautifulSoup contenant la page HTML | |
Returns: | |
Contenu HTML supplémentaire | |
""" | |
additional_html = "" | |
# Rechercher des sections de contenu courantes qui pourraient être manquées | |
content_selectors = [ | |
'article', '.article', '.post', '.content', '.main-content', | |
'main', '#main', '#content', '.body', '.entry-content', | |
'.page-content', '[role="main"]', '[itemprop="articleBody"]', | |
'.blog-post', '.text', '.publication-content', '.story' | |
] | |
for selector in content_selectors: | |
elements = soup.select(selector) | |
if elements: | |
for element in elements: | |
additional_html += str(element) | |
# Si aucun contenu n'a été trouvé avec les sélecteurs, essayer d'autres méthodes | |
if not additional_html: | |
# Obtenir tous les paragraphes qui ont un contenu substantiel | |
paragraphs = [] | |
for p in soup.find_all('p'): | |
text = p.get_text().strip() | |
# Considérer uniquement les paragraphes avec un contenu significatif | |
if len(text) > 50: # Paragraphes d'au moins 50 caractères | |
paragraphs.append(str(p)) | |
if paragraphs: | |
additional_html = "\n".join(paragraphs) | |
return additional_html | |
def remove_headers_footers(self, soup: BeautifulSoup) -> BeautifulSoup: | |
""" | |
Supprime les headers, footers, scripts, styles et autres éléments non désirés des pages web, | |
avec une approche plus modérée pour préserver davantage de contenu. | |
Args: | |
soup: L'objet BeautifulSoup contenant le HTML | |
Returns: | |
L'objet BeautifulSoup nettoyé | |
""" | |
# Liste des sélecteurs pour les headers et footers courants - version allégée | |
header_selectors = [ | |
'header', '#header', '.header', '.site-header', | |
'.masthead', '[role="banner"]' | |
] | |
footer_selectors = [ | |
'footer', '#footer', '.footer', '.site-footer', | |
'.copyright', '[role="contentinfo"]' | |
] | |
# Sélecteurs essentiels pour les navbars | |
navbar_selectors = [ | |
'nav', '.navbar', '.main-nav', | |
'#navbar', '#navigation', '#menu', | |
'[role="navigation"]' | |
] | |
# Sélecteurs essentiels pour les sidebars | |
sidebar_selectors = [ | |
'aside', '.sidebar', '#sidebar', | |
'[role="complementary"]' | |
] | |
# Éléments non désirés les plus courants et intrusifs | |
unwanted_selectors = [ | |
'.ads', '.advertisement', '.banner', '.cookie-notice', | |
'.popup', '.modal', '.newsletter-signup', | |
'.cookie-banner', '.adsbygoogle', '.ad-container', | |
'.gdpr' | |
] | |
# Combiner tous les sélecteurs | |
all_selectors = header_selectors + footer_selectors + navbar_selectors + sidebar_selectors + unwanted_selectors | |
# Supprimer tous ces éléments | |
for selector in all_selectors: | |
for element in soup.select(selector): | |
# Vérifier si l'élément contient du contenu significatif | |
text_content = element.get_text(strip=True) | |
# Ignorer les éléments avec beaucoup de contenu textuel | |
# (probablement du contenu principal mal classé) | |
if len(text_content) > 1000 and selector not in ['.ads', '.advertisement', '.cookie-notice', '.popup', '.modal']: | |
# Ne pas supprimer - contient trop de contenu pour être juste un élément de navigation | |
continue | |
element.decompose() | |
# Supprimer tous les scripts | |
for script in soup.find_all('script'): | |
script.decompose() | |
# Supprimer tous les styles CSS | |
for style in soup.find_all('style'): | |
style.decompose() | |
# Supprimer tous les noscript | |
for noscript in soup.find_all('noscript'): | |
noscript.decompose() | |
# Supprimer tous les iframes | |
for iframe in soup.find_all('iframe'): | |
iframe.decompose() | |
# Supprimer les attributs de style, onclick, onload, etc. | |
for tag in soup.find_all(True): | |
# Créer une liste des attributs à supprimer | |
attrs_to_remove = [] | |
for attr in tag.attrs: | |
# Supprimer les attributs de style | |
if attr == 'style': | |
attrs_to_remove.append(attr) | |
# Supprimer les gestionnaires d'événements JavaScript (onclick, onload, etc.) | |
elif attr.startswith('on'): | |
attrs_to_remove.append(attr) | |
# Supprimer les classes qui pourraient indiquer des scripts/publicités | |
elif attr == 'class': | |
classes = tag.get('class', []) | |
if any(cls in ' '.join(classes) for cls in ['js-', 'ad-', 'ads-', 'script-', 'tracking']): | |
attrs_to_remove.append(attr) | |
# Supprimer les attributs identifiés | |
for attr in attrs_to_remove: | |
del tag[attr] | |
return soup | |
def detect_nav_by_content(self, soup: BeautifulSoup) -> None: | |
""" | |
Détecte et supprime les éléments de navigation et barres latérales | |
en analysant leur contenu et leur position, de manière moins agressive. | |
Args: | |
soup: L'objet BeautifulSoup à nettoyer | |
""" | |
# 1. Détecter les éléments qui contiennent de nombreux liens | |
all_divs = soup.find_all(['div', 'section', 'ul', 'ol']) | |
for element in all_divs: | |
links = element.find_all('a') | |
# Si un élément contient beaucoup de liens, c'est probablement un menu ou une barre latérale | |
# Augmenté le seuil de 5 à 8 liens pour être moins agressif | |
if len(links) > 8: | |
# Vérifier si les liens sont courts (typique des menus) | |
short_links = [link for link in links if len(link.get_text(strip=True)) < 20] | |
# Augmenté le seuil de 70% à 85% pour être sûr que c'est vraiment un menu | |
if len(short_links) > len(links) * 0.85: | |
# Vérifier s'il contient du texte informatif substantiel | |
text_content = element.get_text(strip=True) | |
# Si le contenu textuel est substantiel par rapport au nombre de liens, ne pas supprimer | |
if len(text_content) > len(links) * 50: # En moyenne 50 caractères de contenu par lien | |
continue | |
element.decompose() | |
continue | |
# Vérifier si c'est une liste de catégories, tags, etc. | |
# Liste plus restreinte de termes pour être moins agressif | |
list_terms = ['menu', 'navigation', 'liens', 'links'] | |
# Vérifier le texte de l'élément pour des indices, plus strict | |
element_text = element.get_text().lower() | |
if any(term in element_text for term in list_terms) and len(links) > 4: | |
# Vérifier la proportion de texte vs liens | |
if len(element_text) < 200: # Seulement supprimer les petits éléments de navigation | |
element.decompose() | |
continue | |
# 2. Détecter les éléments par leur position (uniquement la première div) | |
main_content = soup.find('body') | |
if main_content: | |
# Examiner seulement le premier enfant direct du body (souvent la navigation) | |
# Réduit de 3 à 1 pour être moins agressif | |
children = list(main_content.children) | |
if children and len(children) > 0: | |
child = children[0] | |
if child.name in ['div', 'nav'] and not child.find(['h1', 'h2', 'article', 'p']): | |
# Vérifier si c'est probablement une navigation sans contenu substantiel | |
if child.find_all('a', limit=5) and len(child.get_text(strip=True)) < 200: | |
child.decompose() | |
# Examiner uniquement le dernier enfant direct du body (souvent le footer) | |
# Réduit à seulement le dernier enfant | |
if len(children) > 0: | |
child = children[-1] | |
if child.name in ['div', 'footer'] and not child.find(['h1', 'h2', 'article']): | |
if 'copyright' in child.get_text().lower() or ( | |
child.find_all('a', limit=3) and len(child.get_text(strip=True)) < 150): | |
child.decompose() | |
# 3. Supprimer les éléments qui ont une largeur très réduite (sidebars) | |
# Réduit de 40% à 25% pour être moins agressif | |
for element in soup.find_all(True): | |
if 'style' in element.attrs: | |
style = element['style'].lower() | |
if 'width' in style: | |
# Seulement si la largeur est très petite (moins de 25%) | |
width_match = re.search(r'width\s*:\s*(\d+)%', style) | |
if width_match and int(width_match.group(1)) < 25: | |
# Vérifier qu'il s'agit bien d'un élément de navigation | |
if element.find_all('a', limit=4) and not element.find(['p', 'article']) and len(element.get_text(strip=True)) < 300: | |
element.decompose() | |
def clean_html(self, html_content: str) -> str: | |
""" | |
Nettoie le HTML en utilisant readability-lxml pour extraire le contenu principal. | |
Version moins agressive pour préserver plus de contenu original. | |
Args: | |
html_content: Le contenu HTML brut | |
Returns: | |
Le HTML nettoyé avec le contenu principal | |
""" | |
try: | |
# Parser le HTML | |
soup = BeautifulSoup(html_content, 'html.parser') | |
# Récupérer la longueur du contenu original pour analyse | |
original_content_length = len(soup.get_text(strip=True)) | |
# Supprimer les headers, footers et autres éléments non désirés | |
soup = self.remove_headers_footers(soup) | |
# Récupérer la longueur du contenu après première passe de nettoyage | |
post_header_footer_length = len(soup.get_text(strip=True)) | |
# Si on a déjà perdu plus de 30% du contenu, on ne fait pas de détection avancée | |
# qui risquerait de trop supprimer de contenu | |
if post_header_footer_length > original_content_length * 0.7: | |
# Détection avancée des éléments de navigation par leur contenu | |
self.detect_nav_by_content(soup) | |
# Extraire le titre | |
title = soup.title.string if soup.title else "Sans titre" | |
# Utiliser Readability pour extraire le contenu principal | |
doc = Document(html_content) | |
clean_html = doc.summary() | |
readability_title = doc.title() | |
# Si le titre de Readability est plus informatif, l'utiliser | |
if readability_title and len(readability_title) > len(title): | |
title = readability_title | |
# Parser le HTML nettoyé par Readability | |
clean_soup = BeautifulSoup(clean_html, 'html.parser') | |
# Récupérer la longueur du contenu extrait par Readability | |
readability_content_length = len(clean_soup.get_text(strip=True)) | |
# Nettoyer aussi les headers et footers du contenu extrait par Readability | |
clean_soup = self.remove_headers_footers(clean_soup) | |
# Appliquer la détection avancée uniquement si le contenu est conséquent | |
# et on ne veut pas trop perdre de contenu | |
if readability_content_length > 1000: | |
self.detect_nav_by_content(clean_soup) | |
# Vérifier si le contenu extrait est suffisant | |
clean_text = clean_soup.get_text() | |
if len(clean_text) < 500: # Si moins de 500 caractères, c'est probablement incomplet | |
# Extraire du contenu supplémentaire | |
additional_content = self.extract_additional_content(soup) | |
if additional_content: | |
# Ajouter ce contenu au HTML nettoyé | |
additional_soup = BeautifulSoup(additional_content, 'html.parser') | |
# Nettoyer également ce contenu supplémentaire | |
additional_soup = self.remove_headers_footers(additional_soup) | |
self.detect_nav_by_content(additional_soup) | |
# Créer un nouvel élément div pour contenir le contenu supplémentaire | |
div = BeautifulSoup("<div class='additional-content'></div>", 'html.parser') | |
div_tag = div.div | |
# Ajouter chaque élément de contenu supplémentaire | |
for element in additional_soup.children: | |
if element.name: # Ignorer les nœuds de texte | |
div_tag.append(element) | |
clean_soup.body.append(div_tag) | |
clean_html = str(clean_soup) | |
# Construire un HTML propre avec le titre et le contenu | |
full_html = f"<html><head><title>{title}</title></head><body><h1>{title}</h1>{clean_html}</body></html>" | |
return full_html | |
except Exception as e: | |
logger.error(f"Erreur lors du nettoyage du HTML: {str(e)}") | |
# En cas d'erreur, retourner le HTML original | |
return html_content | |
def get_text_content(self, html_content: str) -> str: | |
""" | |
Extrait le texte brut à partir du HTML. | |
Args: | |
html_content: Le contenu HTML | |
Returns: | |
Le texte extrait sans balises HTML | |
""" | |
soup = BeautifulSoup(html_content, 'html.parser') | |
# Supprimer les scripts et styles qui ne contiennent pas de contenu utile | |
for script_or_style in soup(['script', 'style', 'meta', 'noscript']): | |
script_or_style.decompose() | |
# Obtenir le texte avec des sauts de ligne entre les éléments | |
text = soup.get_text(separator='\n', strip=True) | |
# Nettoyer les sauts de ligne multiples | |
text = re.sub(r'\n{3,}', '\n\n', text) | |
return text | |
def scrape(self, url: str, clean: bool = True, extract_text: bool = False) -> Dict[str, Union[str, None]]: | |
""" | |
Scrape une URL et retourne différentes versions du contenu. | |
Args: | |
url: L'URL à scraper | |
clean: Si True, nettoie le HTML | |
extract_text: Si True, extrait également le texte brut | |
Returns: | |
Dictionnaire contenant les différentes formes du contenu | |
""" | |
result = { | |
"url": url, | |
"raw_html": None, | |
"clean_html": None, | |
"text_content": None, | |
"title": None, | |
} | |
# Récupération du HTML | |
html_content = self.fetch_url(url) | |
if not html_content: | |
return result | |
result["raw_html"] = html_content | |
# Extraction du titre | |
try: | |
soup = BeautifulSoup(html_content, 'html.parser') | |
result["title"] = soup.title.string.strip() if soup.title else None | |
except Exception as e: | |
logger.error(f"Erreur lors de l'extraction du titre: {str(e)}") | |
pass | |
# Nettoyage du HTML si demandé | |
if clean: | |
result["clean_html"] = self.clean_html(html_content) | |
# Extraction du texte si demandé | |
if extract_text: | |
if result["clean_html"]: | |
result["text_content"] = self.get_text_content(result["clean_html"]) | |
else: | |
result["text_content"] = self.get_text_content(html_content) | |
return result | |
# Fonction pratique pour une utilisation rapide | |
def scrape_url(url: str, clean: bool = True, extract_text: bool = False) -> Dict[str, Union[str, None]]: | |
""" | |
Fonction utilitaire pour scraper rapidement une URL. | |
Args: | |
url: L'URL à scraper | |
clean: Si True, nettoie le HTML | |
extract_text: Si True, extrait également le texte brut | |
Returns: | |
Dictionnaire contenant les différentes formes du contenu | |
""" | |
scraper = WebScraper() | |
return scraper.scrape(url, clean, extract_text) |