A - glyphe de zone privee Unicode seul en ligne 0 de raw_text decalant tout le
decoupage positionnel : sender, subject ET date faux ensemble. 295/399 notes
avaient un received_date qui n'est pas une date. 11 points de code PUA distincts
recenses sur 40 dossiers, dont 7 hors de la plage Fluent UI habituelle -> retenir
U+E000-U+F8FF. Correctif : nettoyage en amont du decoupage, date par motif,
normalisation ISO 8601 au jour, message_id sur champs normalises + conversation_id
comme discriminant. Corrige aussi checkpoint.last_processed_date (meme source).
B - search_folder() ne transmettait pas limit a /search -> plafond serveur silencieux
a 20 items, dossier marque done comme s'il etait complet.
C - dossiers hors Reception inatteignables : 3 chemins de navigation, 3 defauts deja
resolus ailleurs dans le meme fichier. Diagnostique, non corrige.
D - NON RESOLU, BLOQUANT BACKFILL : /search renvoie le contenu de la Boite de reception
pour certains folder_path, en HTTP 200. Invalide la methode d'audit de completude
et remet en question le classement des 399 notes existantes.
+ 3 regles generales dans PROTOCOL-INFRA.md (glyphe/emoji cassant tout matching de
texte naif sur OWA ; valeur par defaut serveur non transmise = troncature silencieuse ;
verifier la portee d'un resultat scope, pas seulement son code HTTP).
13 KiB
nyora-notes-tt : 4 bugs de la chaîne d'extraction OWA — glyphe de zone privée, plafond /search silencieux, dossiers hors Réception inatteignables, portée /search non respectée
Date : 10/08/2026
Instance : hermes-tt
Statut : A et B CORRIGÉS + déployés · C diagnostiqué (correctif non appliqué) · D NON RÉSOLU — bloquant backfill
Fichiers : /volume1/docker/nyora-notes-tt/hermes_mail_client.py (_parse_item, search_folder) · /volume1/docker/hermes-mail-browser/app/main.py (bug C, non modifié)
Quatre défauts distincts de la même chaîne d'extraction, découverts ensemble. Ils partagent une racine commune côté OWA : du texte rendu pour l'œil humain, utilisé comme structure de données.
Bug A — un glyphe de zone privée Unicode décale tout le découpage positionnel
Symptôme
received_date contenait des objets de mail, des noms d'expéditeur, ou un caractère invisible.
Mesure sur la base au moment du diagnostic :
| Mesure | Résultat |
|---|---|
received_date ne correspondant à aucun format de date |
295 / 399 (74 %) |
| dont contenant un caractère de zone privée | 90 |
Groupes (subject, sender, folder) portant des message_id multiples |
52 |
checkpoint.last_processed_date corrompu |
oui (même source) |
Cause
_parse_item découpait raw_text par position : lines[0]=expéditeur, lines[1]=objet,
lines[2]=date. Or OWA insère un glyphe d'icône seul sur sa propre ligne, en tête de
raw_text, sur une partie seulement des messages (état lu/non-lu, importance, pièce jointe).
Ce caractère n'est pas un caractère d'espacement : .strip() ne le retire pas, et la ligne
survit au filtre if l.strip(). Tout est décalé d'un cran — expéditeur, objet et date
deviennent faux ensemble.
Preuve, même dossier, même expéditeur, deux messages consécutifs :
idx 6 : 'Dr. Nexum Veille\n🔬 Veille IA…\nSam 25/07\n…' → lines[2] = date OK
idx 7 : '\nDr. Nexum Veille\n🔬 Veille IA…\nVen 24/07\n…' → lines[2] = objet FAUX
⚠️ La plage Unicode « Fluent UI » habituelle (U+E700–U+ECFF) est INSUFFISANTE. Recensement sur 40 dossiers réels — 11 points de code distincts, dont 7 hors de cette plage :
| Point de code | Occurrences | Dans U+E700–U+ECFF ? |
|---|---|---|
U+E73E |
1095 | oui |
U+E1B7 |
612 | non |
U+E135 |
61 | non |
U+E0FF |
31 | non |
U+EA4C |
24 | oui |
U+E7B9 |
9 | oui |
U+E350 |
9 | non |
U+E89E |
3 | oui |
U+E340 / U+E0B1 / U+E45A |
2 / 2 / 1 | non |
Coder en dur un ou deux points de code ne tient pas. Retenir la PUA complète : U+E000–U+F8FF.
Cause aggravante : OWA réécrit la date du même message au fil du temps
Trois formats, choisis selon l'ancienneté du message :
| Format | Exemple | Quand |
|---|---|---|
| Heure seule | 09:01 |
message du jour |
| Jour + jj/mm | Sam 25/07 |
récent (année implicite) |
| Date complète | 09/07/2026 |
ancien |
Comme message_id = sha256(subject|date|sender), le même message re-scrapé à deux moments
produisait deux identifiants → doublon garanti dès qu'une synchronisation récurrente repasse
sur un dossier. Le glyphe faisait la même chose, en pire : sa simple apparition changeait l'id.
Correctif
Ordre imposé, les trois étapes dépendent l'une de l'autre :
- Nettoyage en amont du découpage — écarter les lignes entièrement composées de caractères
PUA, puis retirer les glyphes résiduels à l'intérieur des lignes conservées. Corriger
sender/subject/dateséparément en aval ne marche pas : c'est le même décalage d'indice qui les fausse tous les trois. - Date trouvée par reconnaissance de motif, pas par position — balayage des lignes à partir
de l'indice 1, exclusion de la dernière ligne (libellé de dossier). Sentinelle explicite
date_inconnuesi rien ne matche : ne jamais retomber sur une ligne arbitraire, c'est exactement ce qui a rempli 74 % de la base. - Normalisation ISO 8601 à la granularité du JOUR (
normaliser_date_owa). Volontairement pas l'heure : c'est ce qui rend les trois formats convergents. Année absente → occurrence passée la plus récente, jamais dans le futur (un message du 28/12 lu le 3 janvier appartient à l'année précédente). message_id= hash des champs normalisés,conversation_idinclus comme discriminant.
Piège découvert en corrigeant : la granularité au jour fait perdre un discriminant
Ramener la date au jour fait collisionner deux messages distincts de même objet, même expéditeur et même jour (cas réel : newsletter émise plusieurs fois par jour sous un objet identique). Le second aurait été silencieusement sauté comme déjà traité.
Parade : inclure conversation_id (data-convid, ConversationId Exchange natif et stable, cf
nyora-notes-tt-message-id-elem-id-volatile-08-08-2026.md) dans le hash. Mesure sur les 37
lignes de 00 2026|Veille :
| Composition du hash | Identifiants distincts |
|---|---|
| subject + date_iso + sender | 25 / 37 |
| + conversation_id | 34 / 37 ← 34 messages réellement distincts |
Les 3 lignes restantes sont le même message capturé deux fois par la liste virtualisée :
une paire aux raw_text identiques, deux paires ne différant que par le glyphe en tête. Les
fusionner est le comportement correct — et c'est la démonstration directe du fix.
Vérification (avant / après, sur données réelles)
idx3 (sans PUA) AVANT sender='Dr. Nexum Veille' date='Ven 24/07' id=msg_ad9e71e655030f3a
idx7 (avec PUA) AVANT sender='' date='🔬 Veille IA Dr. Nexum…' id=msg_5fefeb296ba7ba15
→ DEUX ids pour UN message
idx3 APRÈS sender='Dr. Nexum Veille' date='2026-07-24' id=msg_5711010f8be85848
idx7 APRÈS sender='Dr. Nexum Veille' date='2026-07-24' id=msg_5711010f8be85848
→ id stable, doublon évité
Sur les 37 lignes : 37/37 dates ISO reconnues, 0 caractère PUA résiduel. Les dates se
recoupent avec le contenu (Saturday 25 july 2026 dans l'objet ↔ received_date=2026-07-25).
checkpoint.last_processed_date est corrigé par la même modification : pipeline_processor.py
consomme item["received_date"] aux trois emplacements d'écriture (l.376-377, 399-401, 417-418)
et ne recompose jamais de date ailleurs — vérifié.
Bug B — /search plafonne silencieusement à 20 items
search_folder() n'a jamais transmis limit à l'appel HTTP : le paramètre n'était appliqué
qu'en tranche cliente ([:limit]) sur une réponse déjà tronquée par le serveur.
hermes-mail-browser plafonne /search à limit=20 par défaut — sans erreur ni
avertissement. Le dossier passe ensuite en statut done comme s'il avait été traité en entier.
Cas témoin 00 2026|Veille : 14 notes + 6 doublons vectoriels = exactement 20 en base, pour
37 messages réels (vérifié avec limit=60).
# AVANT — limit perdu en route
params = {"q": query, "folder_path": folder_path}
...
return processed_items[:limit] # tranche sur une réponse déjà tronquée
# APRÈS
params = {"q": query, "folder_path": folder_path, "limit": limit} # DEFAULT_SEARCH_LIMIT = 200
Leçon : une troncature silencieuse est indétectable en aval. Tout plafond côté serveur doit être transmis explicitement, jamais laissé à sa valeur par défaut.
Bug C — « Éléments envoyés » (et tout dossier hors Réception) inatteignable
Diagnostiqué, non corrigé (hors périmètre de la session). Ni limitation OWA ni problème de
permission : trois chemins de navigation, chacun avec un défaut déjà résolu ailleurs dans le
même fichier main.py.
| Paramètre | Mécanisme | Défaut | Constaté |
|---|---|---|---|
folder_path= |
go_to_folder_path() |
Ancre en dur sur Boîte de réception (cur = inbox_candidates[0], l.325-332) — ne peut jamais atteindre un frère de niveau 2 |
404 « introuvable sous 'boîte de réception' » |
folder= |
resolve_folder_treeitem() (l.207) |
Utilise inner_text(), que le docstring de snapshot_folders (l.222-232) déclare lui-même non fiable — « peut laisser inner_text() vide ou réduit à un glyphe d'icône » |
404 sur envoy, éléments envoyés |
folder_index= |
go_to_folder() (l.416-423) |
Locator.click() natif, exige la visibilité réelle — échoue sur les items masqués par Fluent UI, exactement le problème résolu l.304-311 par page.evaluate("el => el.click()") |
HTTP 500, 11 retries « element is not visible » |
Correctif proposé (une ligne) : dans go_to_folder_path, résoudre le premier segment parmi
les enfants de niveau 2 de la racine du compte, avec repli sur la Boîte de réception pour la
rétrocompatibilité. Toute la machinerie fiable existe déjà (snapshot_folders + clic JS). Coût
réel : rebuild et retest Playwright d'un second container.
Bug D — /search renvoie le contenu de la Boîte de réception pour certains folder_path, en HTTP 200
Découvert en auditant le bug B. NON RÉSOLU — bloquant pour tout backfill.
Symptôme
Deux folder_path différents renvoient ~77 items dont 63 en commun, et chaque ligne porte le
libellé de dossier « Boîte de réception » au lieu du dossier ciblé. Aucune erreur : HTTP 200.
Test discriminant (chemin-spécifique, pas dérive d'état)
Trois appels consécutifs, navigateur libre de toute autre charge :
| Appel | folder_path |
Items | Libellé porté par les lignes |
|---|---|---|---|
| A | 00 2026|⏱️ suivi |
81 | ⏱️ Suivi — correctement scopé |
| B | 00 2026|Relance marchés RLA |
76 | Boîte de réception — NON scopé |
| C | 00 2026|⏱️ suivi (re-test) |
81 | ⏱️ Suivi — reproductible |
C reproduit A à l'identique après B : le défaut est déterministe et lié au chemin, ce n'est pas une dérive d'état de l'UI ni un effet de contention.
Hypothèse écartée
« Chemin stocké sans emoji vs libellé réel avec emoji » : 01 2025|Consommables 2025 a pour
libellé réel consommables 2025, sans emoji, et échoue quand même. Par ailleurs
go_to_folder_path matche en sous-chaîne insensible à la casse — un emoji dans le libellé ne
peut pas faire échouer le match à lui seul.
Piste la plus probable (à confirmer)
Le segment est bien trouvé dans l'arbre, mais le clic ne change pas réellement de dossier ;
la page reste sur la Boîte de réception et perform_search s'exécute donc dans sa portée. Ça
expliquerait le HTTP 200 sans erreur : go_to_folder_path ne lève une 404 que si un segment est
introuvable, jamais si la navigation échoue silencieusement après un clic accepté.
Conséquences (importantes)
- La méthode de l'audit de complétude est invalidée pour les chemins touchés : le « compte réel » mesuré est celui de la Boîte de réception, pas celui du dossier. Tout chiffre de « messages manquants » calculé ainsi est un artefact.
- Intégrité des 399 notes existantes à réexaminer : le pipeline ingère via ce même appel
(
search_folder→/search?q=*). Des messages de la Boîte de réception ont pu être classés sous desfolder_patharbitraires. La dédupmessage_idétant globale (et non par dossier), le premier dossier scanné absorbe les messages et les suivants ressortent quasi vides — ce qui correspond au profil observé (beaucoup de dossiers à 0-3 notes). - Bloquant pour le backfill : ingérer à grande échelle avec une portée fausse polluerait la base massivement.
Contrôle de portée à systématiser
La dernière ligne de raw_text porte le libellé du dossier du message. C'est un contrôle de
portée gratuit : après un /search scopé, si ce libellé ne correspond pas au dossier demandé,
la navigation a échoué. À câbler dans search_folder avant toute ingestion.
La leçon commune aux quatre bugs
Le glyphe qui casse inner_text() sur le volet dossiers (bug C, déjà documenté le 30-31/07/2026
dans mail-o365-folder-path-disambiguation.md) et celui qui décale raw_text sur les lignes de
messages (bug A) sont le même phénomène, jamais généralisé jusqu'ici.
Sur OWA, un glyphe d'icône ou un emoji casse tout matching de texte naïf — qu'il soit positionnel ou par
inner_text(). Lire des attributs structurés (data-folder-name,data-convid,aria-*) plutôt que du texte rendu, et nettoyer la plage PUA U+E000–U+F8FF avant tout découpage.
Ajouté à PROTOCOL-INFRA.md comme leçon générale (trois surfaces touchées : parsing de ligne de
message, clic sur le volet dossiers, résolution de chemin).
Procédure de déploiement (rappel)
Le code est cuit dans l'image (build: .), aucun hot-reload :
cd /volume1/docker/nyora-notes-tt
/usr/local/bin/docker compose build --no-cache
/usr/local/bin/docker compose up -d --force-recreate # jamais `up -d` seul
Contrôle post-déploiement (les deux champs de date) :
SELECT DISTINCT received_date FROM notes; -- que de l'ISO ou la sentinelle
SELECT DISTINCT last_processed_date FROM checkpoint;