File size: 16,984 Bytes
698ce3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Module de conversion du HTML en Markdown.
"""
import os
import logging
import re
from typing import Optional, Dict, Any
from html2markdown import convert
from bs4 import BeautifulSoup
import markdown
from urllib.parse import urlparse, urljoin

# Configuration du logging
logging.basicConfig(level=logging.INFO, 
                    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

class MarkdownConverter:
    """Classe pour convertir le HTML en Markdown avec options de nettoyage avancées."""
    
    def __init__(self, base_url: Optional[str] = None):
        """
        Initialise le convertisseur.
        
        Args:
            base_url: URL de base pour résoudre les liens relatifs
        """
        self.base_url = base_url
    
    def fix_relative_urls(self, html_content: str, base_url: Optional[str] = None) -> str:
        """
        Remplace les URLs relatives par des URLs absolues.
        
        Args:
            html_content: Le contenu HTML
            base_url: L'URL de base pour résoudre les liens relatifs
            
        Returns:
            HTML avec liens absolus
        """
        if not base_url and not self.base_url:
            return html_content
            
        url_to_use = base_url if base_url else self.base_url
        
        soup = BeautifulSoup(html_content, 'html.parser')
        
        # Corriger les liens
        for a_tag in soup.find_all('a', href=True):
            if not a_tag['href'].startswith(('http://', 'https://', 'mailto:', 'tel:', '#')):
                a_tag['href'] = urljoin(url_to_use, a_tag['href'])
        
        # Corriger les images
        for img_tag in soup.find_all('img', src=True):
            if not img_tag['src'].startswith(('http://', 'https://', 'data:')):
                img_tag['src'] = urljoin(url_to_use, img_tag['src'])
        
        return str(soup)
    
    def pre_process_html(self, html_content: str) -> str:
        """
        Pré-traitement du HTML pour améliorer la conversion en Markdown.
        
        Args:
            html_content: Le contenu HTML
            
        Returns:
            HTML pré-traité
        """
        soup = BeautifulSoup(html_content, 'html.parser')
        
        # Supprimer tous les scripts et styles - Première passe critique
        for element in soup.find_all(['script', 'style', 'noscript', 'iframe']):
            element.decompose()
        
        # Supprimer les attributs JavaScript inline et styles
        for tag in soup.find_all(True):
            # Liste pour stocker les attributs à supprimer
            attrs_to_remove = []
            
            for attr in tag.attrs:
                # Supprimer style et attributs JavaScript
                if attr == 'style' or attr.startswith('on'):
                    attrs_to_remove.append(attr)
            
            # Supprimer les attributs identifiés
            for attr in attrs_to_remove:
                del tag[attr]
        
        # Convertir les divs qui se comportent comme des paragraphes en paragraphes réels
        for div in soup.find_all('div'):
            if not div.find(['div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'ul', 'ol']):
                div.name = 'p'
        
        # S'assurer que les listes sont correctement formatées
        for ul in soup.find_all(['ul', 'ol']):
            for child in ul.children:
                if child.name != 'li' and child.name is not None:
                    # Convertir ou envelopper dans un li
                    if child.string and child.string.strip():
                        new_li = soup.new_tag('li')
                        child.wrap(new_li)
        
        # Traiter les tableaux pour une meilleure conversion
        for table in soup.find_all('table'):
            # S'assurer que chaque tableau a un thead et tbody
            if not table.find('thead'):
                thead = soup.new_tag('thead')
                first_tr = table.find('tr')
                if first_tr:
                    first_tr.wrap(thead)
            
            # S'assurer que tbody existe
            if not table.find('tbody'):
                tbody = soup.new_tag('tbody')
                for tr in table.find_all('tr')[1:]:
                    tr.wrap(tbody)
        
        # Nettoyer les balises span inutiles
        for span in soup.find_all('span'):
            if not span.attrs:  # Si span n'a pas d'attributs
                span.unwrap()
        
        # Supprimer les objets JavaScript/Flash/etc.
        for obj in soup.find_all(['object', 'embed']):
            obj.decompose()
        
        # Supprimer les formulaires (souvent inutiles pour l'extraction de contenu)
        for form in soup.find_all('form'):
            form.decompose()
        
        # Retourner le HTML pré-traité
        return str(soup)
    
    def clean_markdown(self, markdown_content: str) -> str:
        """
        Nettoie le markdown généré.
        
        Args:
            markdown_content: Le contenu Markdown
            
        Returns:
            Markdown nettoyé
        """
        # Supprimer les lignes vides consécutives
        markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
        
        # Nettoyer les liens qui ont pu être mal convertis
        markdown_content = re.sub(r'\[(.+?)\]\s*\[\]', r'\1', markdown_content)
        
        # Supprimer les blocs de scripts JavaScript
        markdown_content = re.sub(r'<script[^>]*>[\s\S]*?</script>', '', markdown_content)
        
        # Supprimer les blocs de style CSS
        markdown_content = re.sub(r'<style[^>]*>[\s\S]*?</style>', '', markdown_content)
        
        # Supprimer les blocs CDATA qui pourraient contenir du JavaScript ou CSS
        markdown_content = re.sub(r'<!\[CDATA\[[\s\S]*?\]\]>', '', markdown_content)
        
        # Nettoyer TOUTES les balises HTML, pas seulement certaines
        markdown_content = re.sub(r'</?[a-zA-Z][^>]*>', '', markdown_content)
        
        # Nettoyer les balises <br> et les remplacer par des sauts de ligne
        markdown_content = re.sub(r'<br\s*/?>',  '\n', markdown_content)
        
        # Nettoyer les espaces excessifs
        markdown_content = re.sub(r' {2,}', ' ', markdown_content)
        
        # Nettoyer les attributs HTML restants et toutes les balises avec leurs attributs
        markdown_content = re.sub(r'<([a-z0-9]+)(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|\'[^\']*\'))?)*\s*>', '', markdown_content)
        markdown_content = re.sub(r'</[a-z0-9]+>', '', markdown_content)
        
        # Supprimer les commentaires HTML
        markdown_content = re.sub(r'<!--[\s\S]*?-->', '', markdown_content)
        
        # Supprimer tous les caractères d'échappement HTML comme &nbsp;
        markdown_content = re.sub(r'&[a-zA-Z]+;', ' ', markdown_content)
        
        # Supprimer les styles et scripts qui pourraient être intégrés dans des blocs de code
        markdown_content = re.sub(r'```(?:javascript|js|css|style)[\s\S]*?```', '', markdown_content)
        
        # Supprimer les lignes qui ressemblent à du CSS (propriété: valeur;)
        markdown_content = re.sub(r'^[a-z-]+:\s*[^;]+;\s*$', '', markdown_content, flags=re.MULTILINE)
        
        # Supprimer les lignes qui ressemblent à des déclarations JavaScript
        markdown_content = re.sub(r'^var\s+[a-zA-Z0-9_$]+\s*=', '', markdown_content, flags=re.MULTILINE)
        markdown_content = re.sub(r'^function\s+[a-zA-Z0-9_$]+\s*\(', '', markdown_content, flags=re.MULTILINE)
        markdown_content = re.sub(r'^const\s+[a-zA-Z0-9_$]+\s*=', '', markdown_content, flags=re.MULTILINE)
        markdown_content = re.sub(r'^let\s+[a-zA-Z0-9_$]+\s*=', '', markdown_content, flags=re.MULTILINE)
        
        # Supprimer les accolades isolées qui pourraient provenir de code
        markdown_content = re.sub(r'^\s*[{}]\s*$', '', markdown_content, flags=re.MULTILINE)
        
        # Supprimer les doubles espaces après avoir enlevé les balises
        markdown_content = re.sub(r' {2,}', ' ', markdown_content)
        
        # Nettoyer les lignes vides multiples qui peuvent être créées après suppression des balises
        markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
        
        # Supprimer les lignes qui ne contiennent que des caractères non significatifs
        markdown_content = re.sub(r'^\s*[;:.,_\-*+#]+\s*$', '', markdown_content, flags=re.MULTILINE)
        
        return markdown_content.strip()
    
    def html_to_markdown(self, html_content: str, url: Optional[str] = None) -> str:
        """
        Convertit le HTML en Markdown.
        
        Args:
            html_content: Le contenu HTML
            url: L'URL source pour résoudre les liens relatifs
            
        Returns:
            Contenu au format Markdown
        """
        try:
            # Pré-traiter le HTML
            html_content = self.pre_process_html(html_content)
            
            # Fixer les URLs relatives si une URL est fournie
            base_url = url or self.base_url
            if base_url:
                html_content = self.fix_relative_urls(html_content, base_url)
            
            # Approche 1: Utiliser html2markdown (la bibliothèque standard)
            markdown_content_1 = convert(html_content)
            markdown_content_1 = self.clean_markdown(markdown_content_1)
            
            # Si le résultat semble bon, on le retourne
            if not ('<' in markdown_content_1 and '>' in markdown_content_1):
                return markdown_content_1
            
            # Approche 2: Extraction directe avec BeautifulSoup
            soup = BeautifulSoup(html_content, 'html.parser')
            content_parts = []
            
            # Ajouter le titre
            if soup.title:
                content_parts.append(f"# {soup.title.string.strip()}\n\n")
            
            # Ajouter les titres et sous-titres
            for i in range(1, 7):
                for header in soup.find_all(f'h{i}'):
                    content_parts.append(f"{'#' * i} {header.get_text().strip()}\n\n")
            
            # Ajouter les paragraphes
            for p in soup.find_all('p'):
                text = p.get_text().strip()
                if text:
                    content_parts.append(f"{text}\n\n")
            
            # Ajouter les listes non ordonnées
            for ul in soup.find_all('ul'):
                for li in ul.find_all('li'):
                    content_parts.append(f"* {li.get_text().strip()}\n")
                content_parts.append("\n")
            
            # Ajouter les listes ordonnées
            for ol in soup.find_all('ol'):
                for i, li in enumerate(ol.find_all('li')):
                    content_parts.append(f"{i+1}. {li.get_text().strip()}\n")
                content_parts.append("\n")
            
            # Ajouter les tableaux (version simple)
            for table in soup.find_all('table'):
                for tr in table.find_all('tr'):
                    row = []
                    for cell in tr.find_all(['td', 'th']):
                        row.append(cell.get_text().strip())
                    if row:
                        content_parts.append("| " + " | ".join(row) + " |\n")
                content_parts.append("\n")
            
            # Ajouter les citations
            for blockquote in soup.find_all('blockquote'):
                lines = blockquote.get_text().strip().split('\n')
                for line in lines:
                    if line.strip():
                        content_parts.append(f"> {line.strip()}\n")
                content_parts.append("\n")
            
            # Ajouter les blocs de code
            for pre in soup.find_all('pre'):
                content_parts.append("```\n")
                content_parts.append(pre.get_text().strip() + "\n")
                content_parts.append("```\n\n")
            
            # Ajouter les images
            for img in soup.find_all('img'):
                alt = img.get('alt', '')
                src = img.get('src', '')
                if src:
                    content_parts.append(f"![{alt}]({src})\n\n")
            
            # Ajouter les liens
            for a in soup.find_all('a'):
                text = a.get_text().strip()
                href = a.get('href', '')
                if href and text:
                    content_parts.append(f"[{text}]({href})\n\n")
            
            # Autres blocs de texte significatifs
            for div in soup.find_all(['div', 'article', 'section', 'main']):
                # Éviter les div qui contiennent déjà des éléments traités
                if not div.find(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'table']):
                    text = div.get_text().strip()
                    if len(text) > 100:  # Contenu significatif
                        content_parts.append(f"{text}\n\n")
            
            markdown_content_2 = ''.join(content_parts)
            
            # Approche 3: Extraction de texte brut en dernier recours
            if not markdown_content_2 or len(markdown_content_2) < 200:
                markdown_content_3 = soup.get_text(separator='\n\n', strip=True)
                # Nettoyer et structurer le texte brut
                paragraphs = [p.strip() for p in markdown_content_3.split('\n\n') if p.strip()]
                markdown_content_3 = '\n\n'.join(paragraphs)
                
                # Si cette approche donne un meilleur résultat, l'utiliser
                if len(markdown_content_3) > len(markdown_content_2):
                    markdown_content_2 = markdown_content_3
            
            # Nettoyer le résultat final
            markdown_content_2 = self.clean_markdown(markdown_content_2)
            
            # Sélectionner la meilleure approche
            if len(markdown_content_1) > len(markdown_content_2) and '<' not in markdown_content_1:
                return markdown_content_1
            else:
                return markdown_content_2
                
        except Exception as e:
            logger.error(f"Erreur lors de la conversion en Markdown: {str(e)}")
            # Fallback: extraction simple du texte
            soup = BeautifulSoup(html_content, 'html.parser')
            text = soup.get_text(separator='\n\n', strip=True)
            return self.clean_markdown(text)
    
    def save_markdown(self, markdown_content: str, filepath: str) -> bool:
        """
        Enregistre le contenu Markdown dans un fichier.
        
        Args:
            markdown_content: Le contenu Markdown
            filepath: Chemin où sauvegarder le fichier
            
        Returns:
            True si la sauvegarde a réussi, False sinon
        """
        try:
            # S'assurer que le répertoire existe
            os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
            
            with open(filepath, 'w', encoding='utf-8') as f:
                f.write(markdown_content)
            
            logger.info(f"Contenu Markdown sauvegardé avec succès dans {filepath}")
            return True
        except Exception as e:
            logger.error(f"Erreur lors de la sauvegarde du fichier Markdown: {str(e)}")
            return False
    
    def markdown_to_html(self, markdown_content: str) -> str:
        """
        Convertit le Markdown en HTML (utile pour la prévisualisation).
        
        Args:
            markdown_content: Le contenu Markdown
            
        Returns:
            Contenu au format HTML
        """
        try:
            return markdown.markdown(markdown_content, extensions=['tables', 'fenced_code'])
        except Exception as e:
            logger.error(f"Erreur lors de la conversion du Markdown en HTML: {str(e)}")
            return f"<pre>{markdown_content}</pre>"


# Fonctions utilitaires pour une utilisation rapide
def html_to_markdown(html_content: str, url: Optional[str] = None) -> str:
    """
    Fonction utilitaire pour convertir HTML en Markdown.
    
    Args:
        html_content: Le contenu HTML
        url: L'URL source pour résoudre les liens relatifs
        
    Returns:
        Contenu au format Markdown
    """
    converter = MarkdownConverter(base_url=url)
    return converter.html_to_markdown(html_content, url)

def save_markdown(markdown_content: str, filepath: str) -> bool:
    """
    Fonction utilitaire pour sauvegarder du Markdown dans un fichier.
    
    Args:
        markdown_content: Le contenu Markdown
        filepath: Chemin où sauvegarder le fichier
        
    Returns:
        True si la sauvegarde a réussi, False sinon
    """
    converter = MarkdownConverter()
    return converter.save_markdown(markdown_content, filepath)