IsaacDbc/WaParser_manon
0
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3"""4Streamlit WhatsApp Viewer — Pour Manon 💚5 6- Drag & drop de plusieurs exports WhatsApp .zip7- Parsing robuste (crochets, 12/24h, —/–/-, espaces avant ":", NBSP, etc.)8- UI façon WhatsApp + badge “Pour Manon”9- Export PDF (WeasyPrint si dispo, sinon ReportLab)10 11Dépendances minimales :12 pip install streamlit jinja2 reportlab13Optionnel (PDF plus joli) :14 pip install weasyprint15"""16import base6417import datetime as dt18import io19import re20import zipfile21from pathlib import Path22from typing import List, Dict, Optional, Tuple23 24import streamlit as st25 26# --- Page setup27st.set_page_config(page_title="WhatsApp Viewer — Pour Manon", layout="wide", page_icon="💚")28 29# --- Styles30BASE_CSS = """31<style>32* { box-sizing: border-box; }33 34/* Force le texte en noir, même en thème sombre */35:root, .stApp, body { color:#111 !important; }36 37/* Fond + en-tête */38body { background: linear-gradient(180deg,#e5ddd5 0%, #efeae2 100%); }39.header { background:#075e54; color:#fff !important; padding:14px 18px; font-weight:700; border-radius:12px; margin-bottom:8px; }40.badge { display:inline-block; padding:6px 10px; background:#25d366; color:#033 !important; border-radius:999px; font-weight:700; font-size:12px; margin-left:8px; }41 42/* Conteneur & bulles */43.container { background:#efeae2; border-radius:12px; padding:8px 8px 80px; min-height:60vh; border:1px solid #ded6cf; color:#111 !important; }44.bubbles { display:flex; flex-direction:column; gap:10px; }45.msg { max-width:72%; padding:8px 10px; border-radius:12px; position:relative; box-shadow: 0 1px 0 rgba(0,0,0,0.06); color:#111 !important; }46.msg div, .doc, .author, .meta { color:#111 !important; }47 48/* Couleurs des bulles */49.left { background:#ffffff; align-self:flex-start; border-top-left-radius:0; }50.right { background:#d9fdd3; align-self:flex-end; border-top-right-radius:0; }51 52.meta { font-size:11px; color:#555 !important; margin-top:4px; text-align:right; }53.author { font-size:12px; font-weight:600; margin-bottom:4px; color:#075e54 !important; }54 55/* Médias */56img.media, video.media { max-width:100%; border-radius:10px; margin-top:6px; display:block; }57.audio { margin-top:6px; width:100%; }58.doc { margin-top:6px; font-size:13px; }59 60/* Divers */61hr.sep { border:0; height:1px; background:#ddd; margin:8px 0; }62.sidebar-note { font-size:12px; color:#444 !important; }63a { color:#0b57d0 !important; }64</style>65"""66 67st.markdown(BASE_CSS, unsafe_allow_html=True)68 69# --- Helpers & parsing70MEDIA_EXTS = {71 "image": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"},72 "video": {".mp4", ".3gp", ".mov", ".avi", ".mkv", ".m4v"},73 "audio": {".opus", ".ogg", ".mp3", ".wav", ".m4a"},74 "doc": {".pdf", ".txt", ".vcf", ".csv", ".doc", ".docx", ".xls", ".xlsx", ".zip"}75}76ALL_MEDIA_EXTS = set().union(*MEDIA_EXTS.values())77 78# Formats de ligne reconnus (crochets / sans virgule / AM-PM / secondes optionnelles / espaces avant ':')79DATE_TIME_PATTERNS = [80 # [DD/MM/YYYY HH:MM(:SS)?] Name : msg81 (re.compile(r"^\[(\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{2,4})\s+(\d{1,2}:\d{2}(?::\d{2})?)\]\s+([^:]+?)\s*:\s(.*)$"), "%d/%m/%Y %H:%M:%S"),82 # [DD/MM/YYYY HH:MM(:SS)? AM/PM] Name : msg83 (re.compile(r"^\[(\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{2,4})\s+(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM|am|pm))\]\s+([^:]+?)\s*:\s(.*)$"), "%d/%m/%Y %I:%M:%S %p"),84 # DD/MM/YYYY, HH:MM(:SS)? { -,–,— } Name : msg85 (re.compile(r"^(\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{2,4}),\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[–—-]\s*([^:]+?)\s*:\s(.*)$"), "%d/%m/%Y %H:%M:%S"),86 # DD/MM/YY, HH:MM(:SS)? { -,–,— } Name : msg87 (re.compile(r"^(\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{2}),\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*[–—-]\s*([^:]+?)\s*:\s(.*)$"), "%d/%m/%y %H:%M:%S"),88 # iOS style sans virgule (sans crochets), "date time Name : msg"89 (re.compile(r"^(\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{2,4})\s+(\d{1,2}:\d{2}:\d{2})\s+([^:]+?)\s*:\s(.*)$"), "%d/%m/%Y %H:%M:%S"),90]91 92MEDIA_OMITTED_TOKENS = {"<Media omitted>", "<Média omis>", "<Média omise>", "image omitted", "video omitted", "image omise", "video omise"}93 94def classify_ext(path: Path) -> str:95 ext = path.suffix.lower()96 for kind, exts in MEDIA_EXTS.items():97 if ext in exts:98 return kind99 return "doc"100 101def parse_datetime(date_str: str, time_str: str) -> Optional[dt.datetime]:102 # Normalise séparateurs et espaces (NBSP, NNBSP)103 d_norm = re.sub(r"[.\-]", "/", date_str).replace("\u00a0", " ").replace("\u202f", " ").strip()104 t_norm = time_str.replace("\u00a0", " ").replace("\u202f", " ").strip().upper()105 106 fmts = [107 # 24h108 "%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M",109 "%d/%m/%y %H:%M:%S", "%d/%m/%y %H:%M",110 "%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M",111 "%m/%d/%y %H:%M:%S", "%m/%d/%y %H:%M",112 # 12h AM/PM113 "%d/%m/%Y %I:%M:%S %p", "%d/%m/%Y %I:%M %p",114 "%d/%m/%y %I:%M:%S %p", "%d/%m/%y %I:%M %p",115 "%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %I:%M %p",116 "%m/%d/%y %I:%M:%S %p", "%m/%d/%y %I:%M %p",117 ]118 for f in fmts:119 try:120 return dt.datetime.strptime(f"{d_norm} {t_norm}", f)121 except Exception:122 continue123 return None124 125def detect_txt_file(extract_dir: Path) -> Optional[Path]:126 txts = list(extract_dir.glob("*.txt")) + list(extract_dir.glob("**/*.txt"))127 if not txts:128 return None129 txts.sort(key=lambda p: p.stat().st_size if p.exists() else 0, reverse=True)130 return txts[0]131 132def detect_title_from_txtname(txt_path: Path) -> str:133 name = txt_path.stem134 name = re.sub(r"^WhatsApp Chat with\s+", "", name, flags=re.IGNORECASE)135 name = re.sub(r"^Discussion WhatsApp avec\s+", "", name, flags=re.IGNORECASE)136 name = name.replace("_", " ")137 return name or "WhatsApp Chat"138 139class Attachment:140 def __init__(self, relpath: str, kind: str, filename: str):141 self.relpath = relpath142 self.kind = kind143 self.filename = filename144 145class Message:146 def __init__(self, ts: dt.datetime, author: str, text: str):147 self.timestamp = ts148 self.author = author149 self.text = text150 self.attachments: List[Attachment] = []151 152class Conversation:153 def __init__(self, chat_id: str, title: str, messages: List[Message], base_dir: Path):154 self.chat_id = chat_id155 self.title = title156 self.messages = messages157 self.base_dir = base_dir158 159def parse_chat_text(txt_path: Path) -> Tuple[str, List[Message]]:160 data = None161 for enc in ("utf-8-sig", "utf-16", "utf-8"):162 try:163 data = txt_path.read_text(encoding=enc)164 break165 except Exception:166 continue167 if data is None:168 raise RuntimeError(f"Impossible de lire {txt_path}")169 lines = data.splitlines()170 messages: List[Message] = []171 current: Optional[Message] = None172 title = detect_title_from_txtname(txt_path)173 174 for raw in lines:175 # Normalise LRM + NBSP + NNBSP176 line = raw.replace("\u200e", "").replace("\u00a0", " ").replace("\u202f", " ").strip()177 matched = False178 for pat, _fmt in DATE_TIME_PATTERNS:179 m = pat.match(line)180 if m:181 matched = True182 date_part, time_part, author, text = m.group(1), m.group(2), m.group(3).strip(), m.group(4)183 ts = parse_datetime(date_part, time_part) or dt.datetime.now()184 if current:185 messages.append(current)186 current = Message(ts, author, text.strip())187 break188 if not matched:189 if current:190 current.text += "\n" + line191 else:192 continue193 if current:194 messages.append(current)195 messages.sort(key=lambda m: m.timestamp)196 return title, messages197 198def link_attachments(conv: Conversation) -> None:199 """Associer heuristiquement les fichiers médias aux messages."""200 media_files: Dict[str, Path] = {}201 for p in conv.base_dir.rglob("*"):202 if p.is_file() and p.suffix.lower() in ALL_MEDIA_EXTS:203 media_files[p.name] = p204 assigned = {k: False for k in media_files.keys()}205 206 # Regex sûres (pas de 'bad character range')207 filename_pat = re.compile(r"([A-Za-z0-9_-]+-\d{8}-WA\d+\.[A-Za-z0-9]{1,5})")208 generic_file_pat = re.compile(209 r"([\w.\-]+\.(?:jpg|jpeg|png|gif|mp4|3gp|mov|avi|mkv|m4v|opus|ogg|mp3|wav|m4a|pdf|webp|heic|docx?|xlsx?|zip))",210 re.IGNORECASE211 )212 date_from_name = re.compile(r".*-(\d{8})-WA\d+\.[A-Za-z0-9]{1,5}$")213 214 for msg in conv.messages:215 files_in_text = set()216 for rx in (filename_pat, generic_file_pat):217 for m in rx.finditer(msg.text):218 files_in_text.add(m.group(1))219 for fname in files_in_text:220 p = media_files.get(fname)221 if p and not assigned[fname]:222 msg.attachments.append(Attachment(str(p.relative_to(conv.base_dir)), classify_ext(p), fname))223 assigned[fname] = True224 225 # Heuristique par date si le nom ressemble à ...-YYYYMMDD-...226 for msg in conv.messages:227 if msg.attachments:228 continue229 text_l = msg.text.strip().lower()230 if not text_l or any(tok.lower() in text_l for tok in MEDIA_OMITTED_TOKENS):231 for fname, p in list(media_files.items()):232 if assigned.get(fname):233 continue234 m = date_from_name.match(fname)235 if not m:236 continue237 try:238 d = dt.datetime.strptime(m.group(1), "%Y%m%d").date()239 except Exception:240 continue241 if d == msg.timestamp.date():242 msg.attachments.append(Attachment(str(p.relative_to(conv.base_dir)), classify_ext(p), fname))243 assigned[fname] = True244 break245 246def ensure_dir(p: Path):247 p.mkdir(parents=True, exist_ok=True)248 249def safe_slug(s: str) -> str:250 import re as _re251 slug = _re.sub(r"[^a-zA-Z0-9_-]+", "_", s.strip())252 return slug[:80] if slug else "chat"253 254def b64_image(path: Path) -> Optional[str]:255 try:256 mime = {257 ".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".webp":"image/webp",".heic":"image/heic"258 }.get(path.suffix.lower(), "application/octet-stream")259 data = path.read_bytes()260 return f"data:{mime};base64," + base64.b64encode(data).decode("ascii")261 except Exception:262 return None263 264def render_chat_html(conv: Conversation, me_names: List[str], show_author: bool) -> str:265 html_parts = []266 html_parts.append(f'<div class="header">{conv.title}<span class="badge">Pour Manon</span></div>')267 html_parts.append('<div class="container"><div class="bubbles">')268 for m in conv.messages:269 side = "right" if m.author in me_names else "left"270 bubble = [f'<div class="msg {side}">']271 if show_author:272 bubble.append(f'<div class="author">{m.author}</div>')273 for line in m.text.split("\\n"):274 bubble.append(f"<div>{line}</div>")275 for a in m.attachments:276 p = conv.base_dir / a.relpath277 if a.kind == "image":278 src = b64_image(p)279 if src:280 bubble.append(f'<img class="media" src="{src}" alt="{a.filename}">')281 else:282 bubble.append(f'<div class="doc">🖼 {a.filename}</div>')283 elif a.kind == "video":284 bubble.append(f'<div class="doc">🎞 {a.filename}</div>')285 elif a.kind == "audio":286 bubble.append(f'<div class="doc">🔊 {a.filename}</div>')287 else:288 bubble.append(f'<div class="doc">📎 {a.filename}</div>')289 bubble.append(f'<div class="meta">{m.timestamp.strftime("%d/%m/%Y %H:%M")}</div>')290 bubble.append("</div>")291 html_parts.append("".join(bubble))292 html_parts.append("</div></div>")293 return "".join(html_parts)294 295# --- Sidebar (upload + options)296st.sidebar.title("📦 Import")297uploaded = st.sidebar.file_uploader("Glisse-dépose un ou plusieurs exports WhatsApp (.zip)", type=["zip"], accept_multiple_files=True)298me_name = st.sidebar.text_input('Ton nom (pour aligner tes messages à droite)', value="")299st.sidebar.markdown('<span class="sidebar-note">Astuce: exporte la discussion avec les médias sur iPhone/Android.</span>', unsafe_allow_html=True)300 301# --- Work directory302root = Path(st.session_state.get("wa_root", str(Path.home() / ".wa_streamlit")))303ensure_dir(root)304 305def load_zip(upload_file) -> Optional["Conversation"]:306 stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f")307 extract_dir = root / f"upload_{stamp}"308 ensure_dir(extract_dir)309 data = upload_file.read()310 try:311 with zipfile.ZipFile(io.BytesIO(data), "r") as z:312 z.extractall(extract_dir)313 except zipfile.BadZipFile:314 st.warning(f"ZIP invalide: {upload_file.name}")315 return None316 txt_path = detect_txt_file(extract_dir)317 if not txt_path:318 st.warning(f"Aucun .txt trouvé dans {upload_file.name}")319 return None320 title, messages = parse_chat_text(txt_path)321 conv = Conversation(chat_id=safe_slug(title), title=title, messages=messages, base_dir=extract_dir)322 link_attachments(conv)323 return conv324 325# --- Header326st.markdown(f'<div class="header">Conversations WhatsApp <span class="badge">Pour Manon</span></div>', unsafe_allow_html=True)327 328# --- Load conversations329convs: Dict[str, Conversation] = {}330if uploaded:331 for uf in uploaded:332 conv = load_zip(uf)333 if conv:334 if conv.chat_id in convs:335 convs[conv.chat_id].messages.extend(conv.messages)336 convs[conv.chat_id].messages.sort(key=lambda m: m.timestamp)337 else:338 convs[conv.chat_id] = conv339 340if not convs:341 st.info("Dépose tes .zip ici pour commencer. Le viewer reconstruira la conversation avec un look WhatsApp ✨.")342 st.stop()343 344# --- Sidebar: list of convos345items = []346for cid, c in convs.items():347 if c.messages:348 first = c.messages[0].timestamp.strftime("%d/%m/%Y")349 last = c.messages[-1].timestamp.strftime("%d/%m/%Y")350 items.append((cid, f"{c.title} — {len(c.messages)} msgs — {first} → {last}"))351 352items.sort(key=lambda t: convs[t[0]].messages[-1].timestamp if convs[t[0]].messages else dt.datetime.min, reverse=True)353 354if not items:355 st.warning("Aucune discussion avec des messages exploitables n'a été trouvée dans tes .zip. Vérifie l'export (inclure les médias) et réessaie.")356 if convs:357 st.caption("Conversations détectées : " + ", ".join(sorted([c.title for c in convs.values()])))358 st.stop()359 360labels = [lbl for _, lbl in items]361choice = st.sidebar.selectbox("Choisis une discussion", options=list(range(len(items))), index=0,362 format_func=lambda i: labels[i] if 0 <= i < len(labels) else "")363sel_cid = items[int(choice)][0]364conv = convs[sel_cid]365 366# --- Controls367c1, c2, c3 = st.columns([1,1,2])368with c1:369 show_author_default = (len({m.author for m in conv.messages}) > 2)370 show_author = st.toggle("Afficher l'auteur", value=show_author_default)371with c2:372 export_pdf_click = st.button("📄 Exporter en PDF")373 374# --- Render375me_names = [me_name.strip()] if me_name.strip() else []376me_names += ["You", "Vous", "Moi"]377html_chat = render_chat_html(conv, me_names=me_names, show_author=show_author)378st.markdown(html_chat, unsafe_allow_html=True)379 380# --- PDF export381def export_pdf(conv: Conversation, me_names: List[str]) -> Optional[Path]:382 try:383 from jinja2 import Template384 from weasyprint import HTML385 pdf_tpl = """386 <!doctype html><html><head><meta charset="utf-8">387 <style>{{ css }} body{background:white}.container{background:white}.msg{box-shadow:none}</style>388 </head><body>389 <div class="header">{{ conv.title }} <span class="badge">Pour Manon</span></div>390 <div class="container"><div class="bubbles">391 {% for m in conv.messages %}392 <div class="msg {{ 'right' if m.author in me_names else 'left' }}">393 {% if show_author %}<div class="author">{{ m.author }}</div>{% endif %}394 {% for line in m.text.split('\\n') %}<div>{{ line }}</div>{% endfor %}395 {% for a in m.attachments %}396 {% if a.kind == 'image' %}397 <img class="media" src="{{ base }}/{{ a.relpath }}" />398 {% elif a.kind == 'video' %}399 <div class="doc">🎞 {{ a.filename }}</div>400 {% elif a.kind == 'audio' %}401 <div class="doc">🔊 {{ a.filename }}</div>402 {% else %}403 <div class="doc">📎 {{ a.filename }}</div>404 {% endif %}405 {% endfor %}406 <div class="meta">{{ m.timestamp.strftime("%d/%m/%Y %H:%M") }}</div>407 </div>408 {% endfor %}409 </div></div></body></html>410 """411 tpl = Template(pdf_tpl)412 html_str = tpl.render(conv=conv, css=BASE_CSS, me_names=me_names,413 show_author=(len({m.author for m in conv.messages})>2), base=str(conv.base_dir))414 out_dir = Path(root) / "pdf_exports"; ensure_dir(out_dir)415 out_pdf = out_dir / f"{safe_slug(conv.title)}.pdf"416 HTML(string=html_str, base_url=str(conv.base_dir)).write_pdf(str(out_pdf))417 return out_pdf418 except Exception:419 pass420 try:421 from reportlab.lib.pagesizes import A4422 from reportlab.lib.units import mm423 from reportlab.pdfgen import canvas as rl_canvas424 from reportlab.lib.utils import ImageReader425 width, height = A4426 out_dir = Path(root) / "pdf_exports"; ensure_dir(out_dir)427 out_pdf = out_dir / f"{safe_slug(conv.title)}.pdf"428 c = rl_canvas.Canvas(str(out_pdf), pagesize=A4)429 margin = 15 * mm430 max_w = width - 2*margin431 y = height - margin432 me_set = set(me_names)433 def draw_text(text, right=False):434 nonlocal y435 from reportlab.lib.styles import getSampleStyleSheet436 from reportlab.platypus import Paragraph, Frame437 from reportlab.lib.enums import TA_LEFT, TA_RIGHT438 from reportlab.lib.styles import ParagraphStyle439 style = ParagraphStyle('bubble', parent=getSampleStyleSheet()['Normal'],440 alignment=TA_RIGHT if right else TA_LEFT, fontSize=9, leading=11)441 p = Paragraph(text.replace("\\n","<br/>"), style)442 w, h = p.wrap(max_w, 10000)443 if y - h < margin: c.showPage(); y = height - margin444 x = width - margin - w if right else margin445 f = Frame(x, y - h, w, h, showBoundary=0)446 f.addFromList([p], c)447 y -= h + 6448 def draw_img(img_path, right=False):449 nonlocal y450 try:451 img = ImageReader(str(img_path))452 iw, ih = img.getSize()453 scale = min(1.0, max_w/iw)454 w, h = iw*scale, ih*scale455 if y - h < margin: c.showPage(); y = height - margin456 x = width - margin - w if right else margin457 c.drawImage(img, x, y - h, width=w, height=h, preserveAspectRatio=True, mask='auto')458 y -= h + 6459 except Exception:460 pass461 for m in conv.messages:462 right = (m.author in me_set)463 draw_text(f"{m.author} — {m.timestamp.strftime('%d/%m/%Y %H:%M')}", right)464 if m.text.strip():465 draw_text(m.text.strip(), right)466 for a in m.attachments:467 p = conv.base_dir / a.relpath468 if p.suffix.lower() in {'.jpg','.jpeg','.png','.gif'}:469 draw_img(p, right)470 else:471 draw_text(f"[{a.kind.upper()}] {a.filename}", right)472 c.save()473 return out_pdf474 except Exception:475 return None476 477if export_pdf_click:478 out = export_pdf(conv, me_names)479 if out and out.exists():480 st.success(f"PDF prêt : {out.name}")481 st.markdown(f"[Télécharger le PDF]({out.as_posix()})")482 else:483 st.error("Impossible de générer le PDF. Installe WeasyPrint ou ReportLab (voir la doc).")484 