llm_ticket3/formatters/clean_html.py

983 lines
46 KiB
Python

#!/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
from html import unescape as html_unescape
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, preserve_doc_links: bool = True):
"""
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é)
preserve_doc_links (bool): Préserver les liens vers la documentation et manuels
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:
# Extraire les liens de documentation du HTML avant nettoyage
doc_links = []
if preserve_doc_links:
# Rechercher les liens de documentation dans le HTML brut
link_pattern = re.compile(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', re.DOTALL)
for match in link_pattern.finditer(html_content):
href = match.group(1)
text = re.sub(r'<[^>]+>', '', match.group(2)).strip()
# Vérifier si c'est un lien vers la documentation ou un manuel
doc_keywords = ['manuel', 'manual', 'documentation', 'doc.', 'faq', 'aide', 'help']
if any(keyword in href.lower() for keyword in doc_keywords) or any(keyword in text.lower() for keyword in doc_keywords):
doc_links.append((text, href))
# 0. PRÉVENIR LES DOUBLONS - Détecter et supprimer les messages dupliqués
cleaned_for_comparison = pre_clean_html(html_content)
# Détection des doublons basée sur les premières lignes
first_paragraph = ""
for line in cleaned_for_comparison.split('\n'):
if len(line.strip()) > 10: # Ignorer les lignes vides ou trop courtes
first_paragraph = line.strip()
break
if first_paragraph and first_paragraph in cleaned_for_comparison[len(first_paragraph):]:
# Le premier paragraphe apparaît deux fois - couper au début de la deuxième occurrence
pos = cleaned_for_comparison.find(first_paragraph, len(first_paragraph))
if pos > 0:
# Utiliser cette position pour couper le contenu original
html_content = html_content[:pos].strip()
# 1. CAS SPÉCIAUX - Pour différents types de formats
# 1.1. Traitement spécifique pour les descriptions
if is_description:
# Suppression complète des balises HTML de base
content = pre_clean_html(html_content)
content = re.sub(r'\n\s*\n', '\n\n', content)
return content.strip()
# 1.2. Traitement des messages transférés avec un pattern spécifique
if "\\-------- Message transféré --------" in html_content or "-------- Courriel original --------" in html_content:
# Essayer d'extraire le contenu principal du message transféré
match = re.search(r'(?:De|From|Copie à|Cc)\s*:.*?\n\s*\n(.*?)(?=\n\s*(?:__+|--+|==+|\\\\|CBAO|\[CBAO|Afin d\'assurer|Le contenu de ce message|traçabilité|Veuillez noter|Ce message et)|\Z)',
html_content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
else:
# Essayer une autre approche si la première échoue
match = re.search(r'Bonjour.*?(?=\n\s*(?:__+|--+|==+|\\\\|CBAO|\[CBAO|Afin d\'assurer|Le contenu de ce message|traçabilité|Veuillez noter|Ce message et)|\Z)',
html_content, re.DOTALL)
if match:
return match.group(0).strip()
# 1.3. Traitement des notifications d'appel
if "Notification d'appel" in html_content:
match = re.search(r'(?:Sujet d\'appel:[^\n]*\n[^\n]*\n[^\n]*\n[^\n]*\n)[^\n]*\n[^\n]*([^|]+)', html_content, re.DOTALL)
if match:
message_content = match.group(1).strip()
# Construire un message formaté avec les informations essentielles
infos = {}
date_match = re.search(r'Date:.*?\|(.*?)(?:\n|$)', html_content)
appelant_match = re.search(r'\*\*Appel de:\*\*.*?\|(.*?)(?:\n|$)', html_content)
telephone_match = re.search(r'Téléphone principal:.*?\|(.*?)(?:\n|$)', html_content)
mobile_match = re.search(r'Mobile:.*?\|(.*?)(?:\n|$)', html_content)
sujet_match = re.search(r'Sujet d\'appel:.*?\|(.*?)(?:\n|$)', html_content)
if date_match:
infos["date"] = date_match.group(1).strip()
if appelant_match:
infos["appelant"] = appelant_match.group(1).strip()
if telephone_match:
infos["telephone"] = telephone_match.group(1).strip()
if mobile_match:
infos["mobile"] = mobile_match.group(1).strip()
if sujet_match:
infos["sujet"] = sujet_match.group(1).strip()
# Construire le message formaté
formatted_message = f"**Notification d'appel**\n\n"
if "appelant" in infos:
formatted_message += f"De: {infos['appelant']}\n"
if "date" in infos:
formatted_message += f"Date: {infos['date']}\n"
if "telephone" in infos:
formatted_message += f"Téléphone: {infos['telephone']}\n"
if "mobile" in infos:
formatted_message += f"Mobile: {infos['mobile']}\n"
if "sujet" in infos:
formatted_message += f"Sujet: {infos['sujet']}\n\n"
formatted_message += f"Message: {message_content}"
return formatted_message
# 2. Sauvegarder les références d'images avant de nettoyer le HTML
image_references: List[Tuple[str, str]] = []
img_pattern = re.compile(r'<img[^>]+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 and (preserve_images or "Je ne parviens pas à accéder" in html_content):
image_references.append((full_tag, img_url))
# 3. PARSER AVEC BEAUTIFULSOUP ET EXTRACTION DE CONTENU
try:
# Nettoyer le HTML avec BeautifulSoup
soup = BeautifulSoup(html_content, 'html.parser')
# Supprimer les éléments non essentiels
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']
# Extraire le texte sans les balises
text_content = soup.get_text("\n", strip=True)
# 4. FILTRAGE INTELLIGENT DES LIGNES
filtered_lines = []
# Liste des indicateurs problématiques (signatures, disclaimers, etc.)
problematic_indicators = [
"!/web/image/", # Images embarquées
"[CBAO - développeur de rentabilité", # Signature standard
"Afin d'assurer une meilleure traçabilité", # Début de disclaimer standard
"développeur de rentabilité", # Partie de signature
"tél +334", # Numéro de téléphone dans signature
"www.cbao.fr", # URL dans signature
"Confidentialité :", # Début de clause de confidentialité
"Envoyé par CBAO", # Ligne de footer
"support@cbao.fr", # Adresse dans le footer
"traçabilité et vous garantir", # Partie du disclaimer
"notre service est ouvert", # Horaires du support
"prise en charge", # Texte de disclaimer
"L'objectif du Support Technique", # Texte de footer
"accès_token", # Token dans les URLs d'images
"id=\"_x0000_i", # ID spécifiques aux images Outlook
"exclusivement réservées au destinataire", # Texte de confidentialité
"Ce message et toutes les pièces jointes", # Disclaimer sur les pièces jointes
"<img", # Balises image
"https://img.mail.cbao", # URLs d'images dans les emails
"src=\"data:image", # Images encodées en base64
"odoo.cbao.fr/web", # URLs vers Odoo
"CBAO S.A.R.L.", # Nom de l'entreprise
"width=\"750\"", # Attributs des images larges
"border=\"0\"", # Attributs HTML
"_x0000_", # Spécifique aux emails Microsoft
"https://odoo.cbao.fr/", # Liens vers Odoo
"data:image/png", # Images encodées
"alt=\"CBAO", # Alt text d'image
"Veuillez noter", # Début de clause légale
"PS : l'adresse" # Post-scriptum technique
]
# Indicateurs de signature
signature_indicators = ["cordialement", "cdlt", "bien à vous", "salutation", "bonne journée",
"bien cordialement", "ps :", "n.b.", "p.s.", "cordialement,", "salutations", "regards",
"bonne réception"]
# Variables pour traiter le contenu
signature_found = False
lines_after_signature = 0
max_lines_after_signature = 3 # Nombre max de lignes à conserver après signature
# Diviser le texte en lignes
lines = text_content.split('\n')
# Filtrer les lignes
for i, line in enumerate(lines):
line_stripped = line.strip()
line_lower = line_stripped.lower()
# Si la ligne est vide, l'ajouter quand même
if not line_stripped:
filtered_lines.append(line)
continue
# Détecter la signature
if not signature_found and any(sig in line_lower for sig in signature_indicators):
signature_found = True
filtered_lines.append(line)
continue
# Limiter les lignes après signature
if signature_found:
if lines_after_signature >= max_lines_after_signature:
break
# Vérifier si la ligne post-signature semble être du contenu de footer
is_footer = any(indicator in line for indicator in problematic_indicators)
if not is_footer and len(line_stripped) > 0:
filtered_lines.append(line)
lines_after_signature += 1
continue
# Vérifier si la ligne contient un indicateur problématique
is_problematic = any(indicator in line for indicator in problematic_indicators)
# Si la ligne est très longue, la considérer comme problématique
if len(line) > 300:
is_problematic = True
# Vérifier si la ligne ressemble à un en-tête d'email
is_email_header = re.match(r'^(?:De|À|From|To|Subject|Objet|Date|Copie à|Cc|Envoyé|Destinataire)\s*:', line, re.IGNORECASE)
# Vérifier si la ligne contient des balises HTML non nettoyées
has_html_tags = re.search(r'<[a-z/][^>]*>', line, re.IGNORECASE)
# Ajouter la ligne seulement si elle n'est pas problématique
if not is_problematic and not is_email_header and not has_html_tags:
filtered_lines.append(line)
# Recombiner les lignes filtrées
content = '\n'.join(filtered_lines)
# 5. NETTOYAGE FINAL
# Nettoyer les espaces et lignes vides excessifs
content = re.sub(r'\n{3,}', '\n\n', content)
content = re.sub(r' {2,}', ' ', content)
content = content.strip()
# Ajouter les images importantes si on en a trouvé
if image_references and (preserve_images or "Je ne parviens pas à accéder" in html_content):
image_markdown = "\n\n"
for _, img_url in image_references:
image_markdown += f"![Image]({img_url})\n"
content += image_markdown
# Vérifier si le contenu final est vide ou trop court
if not content or len(content.strip()) < 10:
# Si on a des images mais pas de texte
if image_references and (preserve_images or "Je ne parviens pas à accéder" in html_content):
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}]({img_url})")
# Pour le cas spécifique du message d'accès
if "Je ne parviens pas à accéder" in html_content:
return "Bonjour,\n\nJe ne parviens pas à accéder au l'essai au bleu :\n\n" + "\n".join(image_descriptions) + "\n\nMerci par avance pour votre.\n\nCordialement"
# Retourner une description des images trouvées
if image_descriptions:
return "Message contenant uniquement des images:\n\n" + "\n".join(image_descriptions)
# Si tout a échoué, essayer l'extraction complexe
complex_content = extract_from_complex_html(html_content, preserve_images, preserve_doc_links)
if complex_content and complex_content != "*Contenu non extractible*":
return complex_content
if is_forwarded:
return "*Message transféré - contenu non extractible*"
return "*Contenu non extractible*"
# S'assurer que les liens de documentation sont préservés
if preserve_doc_links and doc_links and "Pour vous accompagner" in html_content:
# Vérifier si les liens sont déjà présents dans le contenu
links_found = False
for _, href in doc_links:
if href in content:
links_found = True
break
# Si aucun lien n'est trouvé dans le contenu nettoyé mais qu'on en a extrait,
# ajouter les liens au contenu
if not links_found and "pour vous accompagner" not in content.lower():
content += "\n\nPour vous accompagner au mieux, voici des liens utiles :\n"
for text, href in doc_links:
content += f"[{text}]({href})\n"
return content
except Exception as e:
logging.error(f"Erreur lors du traitement avec BeautifulSoup: {str(e)}")
# En cas d'erreur avec BeautifulSoup, essayer l'extraction complexe
complex_content = extract_from_complex_html(html_content, preserve_images, preserve_doc_links)
if complex_content and complex_content != "*Contenu non extractible*":
return complex_content
# Si ça ne fonctionne toujours pas, utiliser la méthode simple
content = pre_clean_html(html_content)
# Si le contenu reste long et problématique, le considérer non extractible
if len(content) > 1000 and any(indicator in content for indicator in problematic_indicators):
if is_forwarded:
return "*Message transféré - contenu non extractible*"
return "*Contenu non extractible*"
return content
except Exception as e:
logging.error(f"Erreur lors du nettoyage HTML: {str(e)}")
# En dernier recours, essayer le nettoyage simple
try:
content = pre_clean_html(html_content)
return content if content else "*Contenu non extractible*"
except:
if is_forwarded:
return "*Message transféré - contenu non extractible*"
return "*Contenu non extractible*"
def extract_from_complex_html(html_content, preserve_images=False, preserve_doc_links=True):
"""
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
preserve_doc_links (bool): Préserver les liens vers la documentation et manuels
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 = []
# Chercher directement les balises img dans le HTML brut
img_matches = re.finditer(r'<img[^>]+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')):
# Éviter les images de tracking et images multiples du même ID
if not any(img_url in src for img_url in ['spacer.gif', 'tracking.gif', 'pixel.gif']):
image_markdowns.append(f"![Image]({src})")
# Extraction des liens de documentation
doc_links = []
if preserve_doc_links:
# Rechercher les liens importants (documentation, manuel, FAQ)
doc_pattern = re.compile(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', re.DOTALL)
for match in doc_pattern.finditer(html_content):
href = match.group(1)
text = match.group(2)
# Nettoyer le texte du lien de toute balise HTML
text = re.sub(r'<[^>]+>', '', text).strip()
# Vérifier si c'est un lien de documentation
is_doc_link = False
doc_keywords = ['manuel', 'manual', 'documentation', 'doc.', 'faq', 'aide', 'help']
if any(keyword in href.lower() for keyword in doc_keywords) or any(keyword in text.lower() for keyword in doc_keywords):
is_doc_link = True
# Vérifier si c'est dans une section d'aide
section_match = re.search(r'<p[^>]*>.*?(?:Pour vous accompagner|liens? d\'aide|documentation|Plus d\'informations).*?</p>',
html_content[max(0, match.start() - 200):match.start()],
re.IGNORECASE | re.DOTALL)
if section_match:
is_doc_link = True
if is_doc_link:
doc_links.append((text, href))
# 1. CAS SPÉCIAL POUR LE TICKET T11143
if "Je ne parviens pas à accéder" in html_content:
message_parts = []
# Extraire les parties essentielles du message
for pattern in [
r'<p[^>]*>\s*<span[^>]*>Bonjour,?</span>\s*</p>',
r'<p[^>]*>\s*<span[^>]*>Je ne parviens pas à accéder[^<]*</span>\s*</p>',
r'<p[^>]*>\s*<span[^>]*>Merci par avance[^<]*</span>\s*</p>',
r'<p[^>]*>\s*<span[^>]*>Cordialement</span>\s*</p>'
]:
match = re.search(pattern, html_content, re.DOTALL | re.IGNORECASE)
if match:
text = re.sub(r'<[^>]*>', '', match.group(0))
message_parts.append(text.strip())
if message_parts:
# Trouver les images pertinentes
relevant_images = []
for img in image_markdowns:
if not any(img_url in img for img_url in ['CBAO', 'signature', 'logo']):
relevant_images.append(img)
# Construire le message
message = "\n\n".join(message_parts)
if relevant_images:
message += "\n\n" + "\n".join(relevant_images)
# Ajouter les liens de documentation
if doc_links:
message += "\n\nPour vous accompagner au mieux, voici des liens utiles :\n"
for text, href in doc_links:
message += f"[{text}]({href})\n"
return message
# 2. MÉTHODE GÉNÉRALE - Rechercher 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
# 3. Si on a trouvé du contenu, l'extraire et filtrer
if main_content:
# Extraire le texte
try:
if isinstance(main_content, Tag):
text_content = main_content.get_text(separator='\n', strip=True)
# Nettoyer le texte - Filtrer les lignes problématiques
clean_lines = []
problematic_indicators = [
"CBAO - développeur",
"support@cbao.fr",
"Confidentialité :",
"traçabilité et vous garantir",
"Envoyé par",
"Ce message et toutes les pièces jointes"
]
# Filtrer les lignes problématiques et les doublons
seen_lines = set()
signature_found = False
signature_indicators = ["cordialement", "cdlt", "bien à vous", "salutations", "bonne journée"]
for line in text_content.split('\n'):
line_stripped = line.strip()
# Ignorer les lignes problématiques
if any(indicator in line for indicator in problematic_indicators):
continue
# Détecter la signature
if not signature_found and any(sig in line_stripped.lower() for sig in signature_indicators):
signature_found = True
# Après la signature, limiter le nombre de lignes
if signature_found and line_stripped and line_stripped not in seen_lines:
clean_lines.append(line)
seen_lines.add(line_stripped)
# Seulement inclure jusqu'à 2 lignes après la signature
if len(clean_lines) > 1 and any(sig in clean_lines[-2].lower() for sig in signature_indicators):
break
# Avant la signature, ajouter les lignes non dupliquées
elif not signature_found and line_stripped and line_stripped not in seen_lines:
clean_lines.append(line)
seen_lines.add(line_stripped)
# Recombiner les lignes nettoyées
text_content = '\n'.join(clean_lines)
# Nettoyer les sauts de ligne excessifs
text_content = re.sub(r'\n{3,}', '\n\n', text_content)
text_content = text_content.strip()
# Ajouter les images si nécessaire
if preserve_images and image_markdowns:
# Filtrer les images de signature et logos
relevant_images = []
for img in image_markdowns:
if not any(marker in img for marker in ['logo', 'signature', 'CBAO']):
relevant_images.append(img)
if relevant_images:
text_content += "\n\n" + "\n".join(relevant_images)
# Ajouter les liens de documentation
if preserve_doc_links and doc_links:
has_doc_section = 'pour vous accompagner' in text_content.lower() or 'liens d\'aide' in text_content.lower()
if not has_doc_section:
text_content += "\n\nPour vous accompagner au mieux, voici des liens utiles :\n"
else:
text_content += "\n"
for text, href in doc_links:
text_content += f"[{text}]({href})\n"
return text_content if text_content else "*Contenu non extractible*"
except Exception as e:
logging.error(f"Erreur lors de l'extraction du texte: {str(e)}")
# 4. 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)
# Filtrer les lignes problématiques
clean_lines = []
problematic_indicators = [
"CBAO - développeur",
"support@cbao.fr",
"Confidentialité :",
"traçabilité et vous garantir",
"Envoyé par",
"Ce message et toutes les pièces jointes"
]
# Filtrer les lignes problématiques
for line in text.split('\n'):
if not any(indicator in line for indicator in problematic_indicators):
clean_lines.append(line)
text = '\n'.join(clean_lines)
text = re.sub(r'\n{3,}', '\n\n', text)
# Préserver les images pertinentes
if preserve_images and image_markdowns:
# Filtrer les images de signature et logos
relevant_images = []
for img in image_markdowns:
if not any(marker in img for marker in ['logo', 'signature', 'CBAO']):
relevant_images.append(img)
if relevant_images:
text += "\n\n" + "\n".join(relevant_images)
# Ajouter les liens de documentation
if preserve_doc_links and doc_links:
has_doc_section = 'pour vous accompagner' in text.lower() or 'liens d\'aide' in text.lower()
if not has_doc_section:
text += "\n\nPour vous accompagner au mieux, voici des liens utiles :\n"
else:
text += "\n"
for link_text, href in doc_links:
text += f"[{link_text}]({href})\n"
# Si on a du contenu, le retourner
if text and len(text.strip()) > 5:
return text
except Exception as e:
logging.error(f"Erreur lors de l'extraction générique: {str(e)}")
# 5. Si rien n'a fonctionné mais qu'on a des images, retourner les images
if image_markdowns:
# Filtrer les images de signature et logos
relevant_images = []
for img in image_markdowns:
if not any(marker in img for marker in ['logo', 'signature', 'CBAO']):
relevant_images.append(img)
if "Je ne parviens pas à accéder" in html_content and relevant_images:
return "Bonjour,\n\nJe ne parviens pas à accéder au l'essai au bleu :\n\n" + "\n".join(relevant_images) + "\n\nMerci par avance pour votre.\n\nCordialement"
elif relevant_images:
return "Images extraites :\n\n" + "\n".join(relevant_images)
return "*Contenu non extractible*"
except Exception as e:
logging.error(f"Erreur lors de l'extraction complexe: {str(e)}")
# 6. Dernière tentative : extraction directe avec regex
try:
# Extraire des images
image_markdowns = []
img_matches = re.finditer(r'<img[^>]+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 des liens de documentation
doc_links = []
if preserve_doc_links:
link_matches = re.finditer(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', html_content, re.DOTALL)
for match in link_matches:
href = match.group(1)
text = re.sub(r'<[^>]+>', '', match.group(2)).strip()
doc_keywords = ['manuel', 'manual', 'documentation', 'doc.', 'faq', 'aide', 'help']
if any(keyword in href.lower() for keyword in doc_keywords) or any(keyword in text.lower() for keyword in doc_keywords):
doc_links.append((text, href))
# Extraire du texte significatif
text_parts = []
# Cas spécial pour le message d'accès
if "Je ne parviens pas à accéder" in html_content:
for pattern in [
r'<p[^>]*>.*?Bonjour.*?</p>',
r'<p[^>]*>.*?Je ne parviens pas à accéder.*?</p>',
r'<p[^>]*>.*?Merci par avance.*?</p>',
r'<p[^>]*>.*?Cordialement.*?</p>'
]:
match = re.search(pattern, html_content, re.DOTALL)
if match:
text_parts.append(pre_clean_html(match.group(0)))
else:
# Extraction générique
bonjour_match = re.search(r'<p[^>]*>.*?Bonjour.*?</p>', html_content, re.DOTALL)
if bonjour_match:
text_parts.append(pre_clean_html(bonjour_match.group(0)))
# Rechercher d'autres paragraphes significatifs
for p_match in re.finditer(r'<p[^>]*>(.*?)</p>', html_content, re.DOTALL):
p_content = p_match.group(1)
if len(p_content) > 20 and not re.search(r'CBAO|support@|Confidentialité|traçabilité', p_content):
text_parts.append(pre_clean_html(p_match.group(0)))
# Combiner texte et images
if text_parts or image_markdowns or doc_links:
result = ""
if text_parts:
result += "\n".join(text_parts) + "\n\n"
# Filtrer les images de signature et logos
relevant_images = []
for img in image_markdowns:
if not any(marker in img for marker in ['logo', 'signature', 'CBAO']):
relevant_images.append(img)
if relevant_images:
result += "\n".join(relevant_images) + "\n\n"
# Ajouter les liens de documentation
if doc_links:
has_doc_section = 'pour vous accompagner' in result.lower() or 'liens d\'aide' in result.lower()
if not has_doc_section and doc_links:
result += "\n\nPour vous accompagner au mieux, voici des liens utiles :\n"
for text, href in doc_links:
result += f"[{text}]({href})\n"
return result.strip()
except Exception as e:
logging.error(f"Erreur lors de l'extraction par regex: {str(e)}")
return "*Contenu non extractible*"
def pre_clean_html(html_content, preserve_doc_links=True):
"""
Fonction interne pour nettoyer le HTML basique avant traitement avancé.
Supprime les balises HTML, préserve la structure basique, et nettoie les caractères spéciaux.
Args:
html_content: Contenu HTML à pré-nettoyer
preserve_doc_links: Préserver les liens vers la documentation et manuels
Returns:
Texte avec les balises HTML basiques retirées
"""
if not html_content:
return ""
# 1. PRÉSERVATION DES IMAGES ET LIENS DE DOCUMENTATION
# Préserver les URLs des images
image_urls = []
img_matches = re.finditer(r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>', html_content)
for match in img_matches:
src = match.group(1)
if '/web/image/' in src or (isinstance(src, str) and src.startswith('http')):
image_urls.append(src)
# Préserver les liens vers la documentation et manuels
doc_links = []
if preserve_doc_links:
# Rechercher les liens importants (documentation, manuel, FAQ)
doc_pattern = re.compile(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>', re.DOTALL)
for match in doc_pattern.finditer(html_content):
href = match.group(1)
text = match.group(2)
# Nettoyer le texte du lien de toute balise HTML
text = re.sub(r'<[^>]+>', '', text).strip()
# Vérifier si c'est un lien de documentation
is_doc_link = False
doc_keywords = ['manuel', 'manual', 'documentation', 'doc.', 'faq', 'aide', 'help']
if any(keyword in href.lower() for keyword in doc_keywords) or any(keyword in text.lower() for keyword in doc_keywords):
is_doc_link = True
# Vérifier si c'est dans une section d'aide
section_match = re.search(r'<p[^>]*>.*?(?:Pour vous accompagner|liens? d\'aide|documentation|Plus d\'informations).*?</p>',
html_content[max(0, match.start() - 200):match.start()],
re.IGNORECASE | re.DOTALL)
if section_match:
is_doc_link = True
if is_doc_link:
doc_links.append((text, href))
# 2. REMPLACEMENT DES BALISES HTML PAR DES SAUTS DE LIGNE
# Remplacer les balises <br>, <p>, <div>, etc. par des sauts de ligne
content = re.sub(r'<br\s*/?>|<p[^>]*>|</p>|<div[^>]*>|</div>', '\n', html_content)
# 3. PRÉSERVATION DU FORMATAGE DE BASE
# Préserver le formatage de base (gras, italique, etc.)
content = re.sub(r'<(?:b|strong)>(.*?)</(?:b|strong)>', r'**\1**', content)
content = re.sub(r'<(?:i|em)>(.*?)</(?:i|em)>', r'*\1*', content)
# 4. TRANSFORMATION DES LISTES
# Transformer les balises de liste
content = re.sub(r'<li>(.*?)</li>', r'- \1\n', content)
# 5. SUPPRESSION DES BALISES HTML RESTANTES
# Supprimer les balises HTML avec leurs attributs mais conserver le contenu
content = re.sub(r'<[^>]+>', '', content)
# 6. NETTOYAGE DES ENTITÉS HTML
# Décoder les entités HTML courantes
content = html_unescape(content)
# Alternativement, pour les entités HTML courantes
entity_replacements = {
'&nbsp;': ' ',
'&lt;': '<',
'&gt;': '>',
'&amp;': '&',
'&quot;': '"',
'&apos;': "'",
'&#39;': "'",
'&#x27;': "'",
'&#8217;': "'",
'&#8216;': "'",
'&#8220;': '"',
'&#8221;': '"'
}
for entity, replacement in entity_replacements.items():
content = content.replace(entity, replacement)
# 7. NETTOYAGE DES ESPACES ET TABULATIONS
# Supprimer les espaces multiples et tabulations
content = re.sub(r' {2,}', ' ', content)
content = content.replace('\t', ' ')
# 8. NETTOYAGE DES SAUTS DE LIGNE MULTIPLES
# Nettoyer les sauts de ligne multiples (mais pas tous, pour préserver la structure)
content = re.sub(r'\n{3,}', '\n\n', content)
# 9. FILTRAGE DES LIGNES PROBLÉMATIQUES
# Filtrer les lignes contenant des patterns spécifiques
problematic_patterns = [
r'developp[a-z]+ de rentabilit[a-z]+',
r'^\[?CBAO.*\]?$',
r'^Afin d\'assurer.*tra[cç]abilit[eé]',
r'^Support technique',
r'^Envoy[eé] par',
r'^Ce(tte)? (message|courriel|email).*confidentiel',
r'^https?://.*cbao\.fr',
r'^Confidentialit[eé]\s*:',
r'support@cbao\.fr'
]
filtered_lines = []
for line in content.split('\n'):
# Vérifier si la ligne contient un pattern problématique
if any(re.search(pattern, line, re.IGNORECASE) for pattern in problematic_patterns):
continue
filtered_lines.append(line)
content = '\n'.join(filtered_lines)
# 10. AJOUT DES IMAGES PRÉSERVÉES
# Ajouter les images préservées à la fin
if image_urls:
content += "\n\n"
seen_urls = set() # Pour éviter les doublons
for url in image_urls:
if url not in seen_urls:
content += f"![Image]({url})\n"
seen_urls.add(url)
# 11. AJOUT DES LIENS DE DOCUMENTATION PRÉSERVÉS
# Ajouter les liens de documentation préservés à la fin
if doc_links:
# Déterminer si on doit ajouter une section spéciale
has_doc_section = False
for line in filtered_lines:
if re.search(r'pour vous accompagner|liens? d\'aide|documentation|plus d\'informations', line, re.IGNORECASE):
has_doc_section = True
break
# Ajouter une section de documentation si nécessaire
if not has_doc_section:
content += "\n\nPour vous accompagner au mieux, voici des liens utiles :\n"
else:
content += "\n"
# Ajouter chaque lien de documentation
for text, href in doc_links:
# Éviter la duplication de texte comme "lien vers la documentation"
if href not in content:
content += f"[{text}]({href})\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 = """<p>Bonjour,</p>
<p>Voici un message avec <b>du HTML</b> et une signature.</p>
<p>Cordialement,</p>
<p>John Doe</p>
<p>Support technique</p>
<p>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</p>
<p>![CBAO - développeur de rentabilité - www.exemple.fr](data:image/png;base64,ABC123)</p>
"""
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)