CoolFace
Apppublic

IsaacDbc/WaParser_manon

sourceHugging Faceupdated 1y agoView on Hugging Face
0likes
app.py484 linesDownload Raw Back to root
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