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) |