File size: 20,498 Bytes
6a48f7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
"""
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)