#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Fonctions utilitaires pour nettoyer le HTML et formater les dates. Version simplifiée et robuste: ignore les lignes problématiques. """ import re from datetime import datetime import html from bs4 import BeautifulSoup, Tag from bs4.element import NavigableString, PageElement from typing import Union, List, Tuple, Optional, Any, Dict, cast import logging import html2text def clean_html(html_content: Union[str, None], is_forwarded: bool = False, is_description: bool = False, strategy: str = "standard", preserve_links: bool = False, preserve_images: bool = False): """ Nettoie le contenu HTML pour le Markdown en identifiant et ignorant les parties problématiques. Args: html_content (Union[str, None]): Contenu HTML à nettoyer is_forwarded (bool): Indique si le message est transféré is_description (bool): Paramètre de compatibilité (ignoré) strategy (str): Paramètre de compatibilité (ignoré) preserve_links (bool): Paramètre de compatibilité (ignoré) preserve_images (bool): Paramètre de compatibilité (ignoré) Returns: str: Texte nettoyé """ if html_content is None or not isinstance(html_content, str) or html_content.strip() == "": if is_forwarded: return "*Message transféré - contenu non extractible*" return "*Contenu non extractible*" try: # Sauvegarder les références d'images avant de nettoyer le HTML image_references: List[Tuple[str, str]] = [] img_pattern = re.compile(r']+src=["\']([^"\']+)["\'][^>]*>') for match in img_pattern.finditer(html_content): full_tag = match.group(0) img_url = match.group(1) # Vérifier si c'est une image Odoo if "/web/image/" in img_url: image_references.append((full_tag, img_url)) # Nettoyer le HTML soup = BeautifulSoup(html_content, 'html.parser') # Supprimer les éléments script, style et head for elem in soup.find_all(['script', 'style', 'head']): elem.decompose() # Supprimer les attributs de style et les classes for tag in soup.recursiveChildGenerator(): if isinstance(tag, Tag): if tag.attrs and 'style' in tag.attrs: del tag.attrs['style'] if tag.attrs and 'class' in tag.attrs: del tag.attrs['class'] # Conserver uniquement les balises HTML essentielles allowed_tags = ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'blockquote', 'code', 'pre', 'hr', 'div', 'span', 'table', 'tr', 'td', 'th', 'thead', 'tbody'] # Supprimer les balises HTML inutiles mais conserver leur contenu for tag in soup.find_all(): if isinstance(tag, Tag) and tag.name.lower() not in allowed_tags: tag.unwrap() # Amélioration: vérifier si nous avons du contenu significatif text_content = soup.get_text().strip() if not text_content and not image_references: if is_forwarded: return "*Message transféré - contenu non extractible*" return "*Contenu non extractible*" # Obtenir le HTML nettoyé clean_content = str(soup) # Vérifier si le contenu a été vidé par le nettoyage if clean_content.strip() == "" or clean_content.strip() == "": # Si nous avons des références d'images mais pas de texte if image_references: image_descriptions = [] for _, img_url in image_references: img_id = None id_match = re.search(r"/web/image/(\d+)", img_url) if id_match: img_id = id_match.group(1) image_descriptions.append(f"[Image {img_id}]") # Retourner une description des images trouvées if image_descriptions: return "Message contenant uniquement des images: " + ", ".join(image_descriptions) if is_forwarded: return "*Message transféré - contenu non extractible*" return "*Contenu non extractible*" return clean_content except Exception as e: logging.error(f"Erreur lors du nettoyage HTML: {str(e)}") if is_forwarded: return "*Message transféré - contenu non extractible*" return "*Contenu non extractible*" def extract_from_complex_html(html_content, preserve_images=False): """ Extrait le contenu d'un HTML complexe en utilisant BeautifulSoup. Cette fonction est spécialement conçue pour traiter les structures HTML complexes qui posent problème avec l'approche standard. Args: html_content (str): Contenu HTML à traiter preserve_images (bool): Conserver les images Returns: str: Contenu extrait et nettoyé """ try: soup = BeautifulSoup(html_content, 'html.parser') # Extraction d'images - Étape 1: Rechercher toutes les images avant toute modification image_markdowns = [] if preserve_images or True: # Toujours préserver les images # Chercher directement les balises img dans le HTML brut img_matches = re.finditer(r']+src=["\']([^"\']+)["\'][^>]*>', html_content) for match in img_matches: src = match.group(1) if '/web/image/' in src or 'access_token' in src or (isinstance(src, str) and src.startswith('http')): image_markdowns.append(f"![Image]({src})") # Méthode alternative avec BeautifulSoup images = soup.find_all('img') for img in images: try: if isinstance(img, Tag) and img.has_attr('src'): src = img['src'] if src and ('/web/image/' in src or 'access_token' in src or (isinstance(src, str) and str(src).startswith('http'))): alt = img['alt'] if img.has_attr('alt') else 'Image' image_markdowns.append(f"![{alt}]({src})") except Exception: continue # 1. Rechercher d'abord le contenu du message principal # Essayer différents sélecteurs en ordre de priorité content_selectors = [ '.o_thread_message_content', # Contenu principal '.o_mail_body', # Corps du message '.o_mail_note_content', # Contenu d'une note '.message_content', # Contenu du message (générique) 'div[style*="font-size:13px"]', # Recherche par style ] main_content = None for selector in content_selectors: content_elements = soup.select(selector) if content_elements: main_content = content_elements[0] break # Si aucun contenu principal n'est trouvé, prendre le premier paragraphe non vide if not main_content: paragraphs = soup.find_all('p') for p in paragraphs: try: if isinstance(p, Tag) and p.text.strip(): classes = p['class'] if p.has_attr('class') else [] if not any(cls in str(classes) for cls in ['o_mail_info', 'recipient_link']): main_content = p break except Exception: continue # Si toujours rien, prendre la première div non vide if not main_content: divs = soup.find_all('div') for div in divs: try: if isinstance(div, Tag) and div.text.strip(): classes = div['class'] if div.has_attr('class') else [] if not any(cls in str(classes) for cls in ['o_mail_info', 'o_thread']): main_content = div break except Exception: continue # 2. Si on a trouvé du contenu, l'extraire if main_content: # Extraire toutes les images si demandé if preserve_images or True: # Toujours préserver les images try: if isinstance(main_content, Tag): content_images = main_content.find_all('img') for img in content_images: try: if isinstance(img, Tag) and img.has_attr('src'): src = img['src'] if src and ('/web/image/' in src or 'access_token' in src or (isinstance(src, str) and str(src).startswith('http'))): alt = img['alt'] if img.has_attr('alt') else 'Image' image_markdowns.append(f"![{alt}]({src})") # Supprimer l'image pour éviter qu'elle apparaisse dans le texte img.decompose() except Exception: continue except Exception: pass # Extraire le texte try: if isinstance(main_content, Tag): text_content = main_content.get_text(separator='\n', strip=True) # Nettoyer le texte text_content = re.sub(r'\n{3,}', '\n\n', text_content) text_content = text_content.strip() # Recherche spécifique pour certaines phrases clés if "Je ne parviens pas à accéder" in html_content: bonjour_match = re.search(r']*>.*?Bonjour.*?

', html_content, re.DOTALL) acces_match = re.search(r']*>.*?Je ne parviens pas à accéder[^<]*

', html_content, re.DOTALL) specific_content = [] if bonjour_match: specific_content.append(pre_clean_html(bonjour_match.group(0))) if acces_match: specific_content.append(pre_clean_html(acces_match.group(0))) # Extraire les contenus spécifiques du message "Je ne parviens pas..." merci_match = re.search(r']*>.*?Merci par avance.*?

', html_content, re.DOTALL) if merci_match: specific_content.append(pre_clean_html(merci_match.group(0))) cordial_match = re.search(r']*>.*?Cordialement.*?

', html_content, re.DOTALL) if cordial_match: specific_content.append(pre_clean_html(cordial_match.group(0))) if specific_content: text_content = '\n'.join(specific_content) # Supprimer les duplications de lignes lines = text_content.split('\n') unique_lines = [] for line in lines: if line not in unique_lines: unique_lines.append(line) text_content = '\n'.join(unique_lines) # Ajouter les images à la fin if image_markdowns: # Supprimer les doublons d'images unique_images = [] for img in image_markdowns: if img not in unique_images: unique_images.append(img) text_content += "\n\n" + "\n".join(unique_images) return text_content if text_content else "*Contenu non extractible*" except Exception as e: print(f"Erreur lors de l'extraction du texte: {e}") # 3. Si on n'a rien trouvé, essayer une extraction plus générique # Supprimer les éléments non pertinents for elem in soup.select('.o_mail_info, .o_mail_tracking, .o_thread_tooltip, .o_thread_icons, .recipients_info'): try: elem.decompose() except Exception: continue # Extraire le texte restant try: text = soup.get_text(separator='\n', strip=True) text = re.sub(r'\n{3,}', '\n\n', text) # Préserver les images si demandé if preserve_images or True: # Toujours préserver les images # Les images ont déjà été extraites au début de la fonction if image_markdowns: # Supprimer les doublons d'images unique_images = [] for img in image_markdowns: if img not in unique_images: unique_images.append(img) text += "\n\n" + "\n".join(unique_images) # Si on a du contenu, le retourner if text and len(text.strip()) > 5: return text except Exception as e: print(f"Erreur lors de l'extraction générique: {e}") # Si rien n'a fonctionné mais qu'on a des images, au moins les retourner if image_markdowns: unique_images = [] for img in image_markdowns: if img not in unique_images: unique_images.append(img) if any("Je ne parviens pas à accéder" in html_content for img in image_markdowns): return "Bonjour,\nJe ne parviens pas à accéder au l'essai au bleu :\n\n" + "\n".join(unique_images) + "\n\nMerci par avance pour votre.\nCordialement" else: return "Images extraites :\n\n" + "\n".join(unique_images) return "*Contenu non extractible*" except Exception as e: print(f"Erreur lors de l'extraction complexe: {e}") # Dernière tentative : extraction directe avec regex try: # Extraire des images image_markdowns = [] img_matches = re.finditer(r']+src=["\']([^"\']+)["\'][^>]*>', html_content) for match in img_matches: src = match.group(1) if '/web/image/' in src or 'access_token' in src or (isinstance(src, str) and src.startswith('http')): image_markdowns.append(f"![Image]({src})") # Extraire du texte significatif text_parts = [] bonjour_match = re.search(r']*>.*?Bonjour.*?

', html_content, re.DOTALL) if bonjour_match: text_parts.append(pre_clean_html(bonjour_match.group(0))) content_match = re.search(r']*>.*?Je ne parviens pas à accéder.*?

', html_content, re.DOTALL) if content_match: text_parts.append(pre_clean_html(content_match.group(0))) # Combiner texte et images if text_parts or image_markdowns: result = "" if text_parts: result += "\n".join(text_parts) + "\n\n" if image_markdowns: unique_images = [] for img in image_markdowns: if img not in unique_images: unique_images.append(img) result += "\n".join(unique_images) return result except Exception: pass return "*Contenu non extractible*" def pre_clean_html(html_content): """ Fonction interne pour nettoyer le HTML basique avant traitement avancé. Args: html_content: Contenu HTML à pré-nettoyer Returns: Texte avec les balises HTML basiques retirées """ if not html_content: return "" # Remplacer les balises
,

,

par des sauts de ligne content = html_content.replace('
', '\n').replace('
', '\n').replace('
', '\n') content = content.replace('

', '\n').replace('
', '\n') # Préserver les URLs des images image_urls = [] img_matches = re.finditer(r']+src=["\']([^"\']+)["\'][^>]*>', content) for match in img_matches: if '/web/image/' in match.group(1) or (isinstance(match.group(1), str) and match.group(1).startswith('http')): image_urls.append(match.group(1)) # Supprimer les balises HTML content = re.sub(r'<[^>]*>', '', content) # Supprimer les espaces multiples content = re.sub(r' {2,}', ' ', content) # Supprimer les sauts de ligne multiples content = re.sub(r'\n{3,}', '\n\n', content) # Décoder les entités HTML courantes content = content.replace(' ', ' ') content = content.replace('<', '<') content = content.replace('>', '>') content = content.replace('&', '&') content = content.replace('"', '"') # Supprimer les tabulations content = content.replace('\t', ' ') # Ajouter les images préservées à la fin if image_urls: content += "\n\n" for url in image_urls: content += f"![Image]({url})\n" return content.strip() def format_date(date_str): """ Formate une date ISO en format lisible. """ if not date_str: return "" try: dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) return dt.strftime("%d/%m/%Y %H:%M:%S") except (ValueError, TypeError): return date_str if __name__ == "__main__": # Tests html = """

Bonjour,

Voici un message avec du HTML et une signature.

Cordialement,

John Doe

Support technique

Afin d'assurer une meilleure traçabilité et vous garantir une prise en charge optimale, nous vous invitons à envoyer vos demandes d'assistance technique à support@exemple.fr

![CBAO - développeur de rentabilité - www.exemple.fr](data:image/png;base64,ABC123)

""" cleaned = clean_html(html) print("HTML nettoyé :\n", cleaned) # Test avec un message transféré forwarded = """\\-------- Message transféré -------- Sujet : | Test message ---|--- Date : | Mon, 30 Mar 2020 11:18:20 +0200 De : | [test@example.com](mailto:test@example.com) Pour : | John Doe [](mailto:john@example.com) Copie à : | [other@example.com](mailto:other@example.com) Bonjour John, Voici un message de test. Cordialement, Test User __________________________________________________________________ Ce message et toutes les pièces jointes sont confidentiels et établis à l'intention exclusive de ses destinataires. __________________________________________________________________""" cleaned_forwarded = clean_html(forwarded) print("\nMessage transféré nettoyé :\n", cleaned_forwarded) # Test avec le cas problématique du ticket T0282 test_t0282 = """Bonjour, Je reviens vers vous pour savoir si vous souhaitez toujours renommer le numéro d'identification de certaines formules dans BCN ou si vous avez trouvé une solution alternative ? En vous remerciant par avance, je reste à votre disposition pour tout complément d'information. Cordialement. **Youness BENDEQ** [ Affin d'assurer une meilleure traçabilité et vous garantir une prise en charge optimale, nous vous invitons à envoyer vos demandes d'assistance technique à support@cbao.fr Notre service est ouvert du lundi au vendredi de 9h à 12h et de 14h à 18h. Dès réception, un technicien prendra en charge votre demande et au besoin vous rappellera.""" cleaned_t0282 = clean_html(test_t0282) print("\nTest ticket T0282 nettoyé :\n", cleaned_t0282) # Test avec le cas problématique de bas de page avec formatage markdown test_cbao_markdown = """Bonjour, Voici un message de test pour vérifier la suppression des bas de page CBAO. Cordialement, Jean Dupont [ CBAO S.A.R.L. ](https://example.com/link) . ![](/web/image/33748?access_token=13949e22-d47b-4af7-868e-e9c2575469f1) ![](/web/image/33748?access_token=13949e22-d47b-4af7-868e-e9c2575469f1)""" cleaned_markdown = clean_html(test_cbao_markdown) print("\nTest avec formatage Markdown CBAO nettoyé :\n", cleaned_markdown) # Test avec le cas exact du rapport test_rapport = """Bonjour, Voici un message de test. Cordialement, Pierre Martin Envoyé par [ CBAO S.A.R.L. ](https://ciibcee.r.af.d.sendibt2.com/tr/cl/h2uBsi9hBosNYeSHMsPH47KAmufMTuNZjreF6M_tfRE63xzft8fwSbEQNb0aYIor74WQB5L6TF4kR9szVpQnalHFa3PUn_0jeLw42JNzIwsESwVlYad_3xCC1xi7qt3-dQ7i_Rt62MG217XgidnJxyNVcXWaWG5B75sB0GoqJq13IZc-hQ) . ![](/web/image/33748?access_token=13949e22-d47b-4af7-868e-e9c2575469f1) ![](/web/image/33748?access_token=13949e22-d47b-4af7-868e-e9c2575469f1)""" cleaned_rapport = clean_html(test_rapport) print("\nTest avec cas exact du rapport nettoyé :\n", cleaned_rapport)