workspace: rapport solaire, prevision de nuit, concierge, courrier, fiche allegee

- Fiche agent maison (AGENTS.md) : prevision de nuit relayee par script, import
  EDF, regle JEEP midi, saisie vehicule/niveau/depart, concierge qui relaie
  « ✅ Noté » ou « Rien n'est noté », groupe A deplace (28 649 -> 25 831 car.).
- Nouvelles references : reference-concierge.md, reference-workspace.md.
- Scripts : rapport-solaire.js, prevision-nuit.js, airtria-ha-vehicule.js,
  concierge-live.js (answer ne sort plus jamais vide, mode CONCIERGE_SIMULATION).
- Courrier Orange / AIRTRIA / Gmail : audits, regles, menage, recents.
- Bancs EVCS tests-pv (G5 accepte « anomal »).
- .gitignore : liste blanche etendue aux nouveaux scripts et bancs.
This commit is contained in:
2026-09-30 11:32:44 +02:00
parent 973a736bc2
commit f121f0a196
37 changed files with 4030 additions and 328 deletions

164
workspace/airtria-menage.py Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
# airtria-menage.py — Vagues de ménage de la boîte pro AIRTRIA (MailPlus sur ARCHIVE2, IMAP),
# règles de airtria-rules.md. Même logique que orange-menage.py.
#
# python3 airtria-menage.py → DRY-RUN de toutes les règles (compte, ne touche rien)
# python3 airtria-menage.py --rule A1.02 → dry-run d'une seule règle
# python3 airtria-menage.py --rule A1.02 --go
# python3 airtria-menage.py --go → exécute TOUTES les règles (après dry-run validé)
# python3 airtria-menage.py --quotidien → tri du soir (cron) : corbeille + veille sur 3 jours
# python3 airtria-menage.py --quotidien --dry-run
#
# Garde-fous : jamais de suppression (UID MOVE vers Trash / Archives.<année> / Veille, aucun
# EXPUNGE) ; whitelist appliquée message par message ; lots de 200, pause 5 s ; journal
# append-only airtria-housekeeping.md ; vague 1 : messages > 90 jours seulement.
import imaplib, ssl, socket, re, sys, time, email, email.utils, email.header
from datetime import datetime, timedelta
from collections import defaultdict
HOST, SNI, USER = "10.10.10.22", "airtria-dsm2.direct.quickconnect.to", "TMZF5455"
WS = "/home/node/.openclaw/workspace"
LOG = f"{WS}/airtria-housekeeping.md"
TRASH, ARCH, VEILLE = "Trash", "Archives", "Veille" # séparateur MailPlus : « . »
BATCH, PAUSE, AGE_DAYS = 200, 5, 90
GO = "--go" in sys.argv
DAILY = "--quotidien" in sys.argv
if DAILY and "--dry-run" not in sys.argv: GO = True
ONLY = sys.argv[sys.argv.index("--rule") + 1] if "--rule" in sys.argv else None
WL_DOMAINS = {"paypal.fr", "cic.fr", "bpgo.fr", "bp.banquepopulaire.fr", "contact.axa.fr", "courriel.axa.fr",
"stoik.io", "bouygues-telecom.fr", "orange.com", "orange.fr",
"laboutique-solaire-pro.com", "bretagne-materiaux.fr", "ilosit.com", "edissio.com", "tourinsoft.com",
"notice.2.ismartlife.me", "airtria.fr", "airtria.net", "airtria.com", "airtria.pro"}
WL_ADDR = {"confirmation-commande@amazon.fr", "order-update@amazon.fr", "shipment-tracking@amazon.fr",
"marketplace-messages@amazon.fr", "account-update@amazon.fr", "donotreply@amazon.fr", "no-reply@amazon.fr",
"nepasrepondre@interencheres.com", "donotreply@audible.fr", "copieur@apriwey.fr", "developer-notify@tuya.com",
"thierry.tallagrand@wanadoo.fr", "tallagrand.thierry@gmail.com"}
def whitelisted(addr):
a = addr.lower(); return a in WL_ADDR or a.split("@")[-1] in WL_DOMAINS
# kind : trash | trash_unsub (corbeille seulement si en-tête List-Unsubscribe) | veille | year
RULES = [
("A1.01", "IDFP / Top Infos Pros", "trash", 'OR FROM "infodespros.fr" FROM "top-infos-pros.fr"', r"@(infodespros|top-infos-pros)\.fr$"),
("A1.02", "TonerPartenaire", "trash", 'FROM "tonerpartenaire.fr"', r"@tonerpartenaire\.fr$"),
("A1.03", "Amazon promos", "trash", 'OR OR OR FROM "annonce@amazon.fr" FROM "store-news@amazon.fr" FROM "announce@amazon.fr" FROM "amazonmusic.com"', r"^(annonce|store-news|announce)@amazon\.fr$|@amazonmusic\.com$"),
("A1.04", "Prime Video", "trash", 'FROM "primevideo.com"', r"@primevideo\.com$"),
("A1.05", "Geocoyote", "trash", 'FROM "geo-coyote.fr"', r"@geo-coyote\.fr$"),
("A1.06", "Reolink", "trash", 'FROM "reolinksupport.com"', r"@news\.reolinksupport\.com$"),
("A1.07", "Interencheres newsletters", "trash", 'FROM "interencheres.com"', r"^(newsletter@interencheres\.com|bonjour@newsletter\.interencheres\.com)$"),
("A1.08", "Airton", "trash", 'FROM "airton.shop"', r"@airton\.shop$"),
("A1.09", "Sage", "trash", 'FROM "go.sage.com"', r"@go\.sage\.com$"),
("A1.10", "Génération Numérique", "trash", 'FROM "genum.fr"', r"@genum\.fr$"),
("A1.11", "EaseUS", "trash", 'FROM "easeus.com"', r"@easeus\.com$"),
("A1.12", "Audible promos", "trash", 'FROM "audible.fr"', r"^noreply@(mail\.)?audible\.fr$"),
("A1.13", "Espacebusiness", "trash", 'FROM "espacebusiness"', r"@(espacebusiness\.com|mb[0-9]\.espacebusiness\.pro)$"),
("A1.14", "Corus", "trash", 'FROM "corus.pro"', r"@newsletter\.corus\.pro$"),
("A1.15", "Auctibid", "trash", 'FROM "auctibid.com"', r"@mkt\.auctibid\.com$"),
("A1.16", "Point.P", "trash", 'FROM "news.pointp.fr"', r"@news\.pointp\.fr$"),
("A1.17", "Vertical-Mail", "trash", 'FROM "lescomptes.fr"', r"@lescomptes\.fr$"),
("A1.18", "Eventbrite", "trash", 'FROM "eventbrite.com"', r"@campaign\.eventbrite\.com$"),
("A1.19", "Pour-les-pro", "trash", 'FROM "pour-les-pro.fr"', r"@pour-les-pro\.fr$"),
("A1.20", "bwonline", "trash", 'FROM "bwonline.in"', r"@bwonline\.in$"),
("A1.21", "i-Run", "trash", 'FROM "i-run.me"', r"@i-run\.me$"),
("A1.22", "TP-Link newsletter", "trash", 'FROM "newsletter.tp-link.com"', r"@newsletter\.tp-link\.com$"),
("A1.23", "Overland-Tandberg", "trash", 'FROM "overlandtandberg.com"', r"@overlandtandberg\.com$"),
("A1.24", "Bpifrance media", "trash", 'FROM "media.bpifrance.fr"', r"@media\.bpifrance\.fr$"),
("A1.25", "Aprudit", "trash", 'FROM "aprudit.com"', r"@aprudit\.com$"),
("A1.26", "Culligan", "trash", 'FROM "vmle-poiz.com"', r"^culligan@vmle-poiz\.com$"),
("A1.27", "NVIDIA GeForce", "trash", 'FROM "nvgaming.nvidia.com"', r"@nvgaming\.nvidia\.com$"),
("A1.28", "Amazon Business (désabo)", "trash_unsub", 'FROM "business.amazon.fr"', r"^no-reply@business\.amazon\.fr$"),
("A1.29", "aprem-hi (prospection)", "trash", 'FROM "aprem-hi.com"', r"@aprem-hi\.com$"),
("A3.01", "NVIDIA / Victron → Veille", "veille", 'OR FROM "news@nvidia.com" FROM "victronenergy.com"', r"^(news@nvidia\.com|(news|noreply)@victronenergy\.com)$"),
("A3.02", "Forum Victron → Veille", "veille", 'FROM "victron.discoursemail.com"', r"@victron\.discoursemail\.com$"),
("A2.01", "Archive 2023 (tout)", "year", 2023, None),
("A2.02", "Archive 2024 (tout)", "year", 2024, None),
("A2.03", "Archive 2025 (tout)", "year", 2025, None),
]
env = dict(l.strip().split("=", 1) for l in open("/home/node/.openclaw/.env") if "=" in l and not l.startswith("#"))
PWD = env["AIRTRIA_IMAP_PASSWORD"].strip().strip('"').strip("'")
class _IMAPSurSocket(imaplib.IMAP4):
def __init__(self, sock): self._s = sock; imaplib.IMAP4.__init__(self, "", 993)
def open(self, host, port, timeout=None): self.host, self.port, self.sock = host, port, self._s; self.file = self.sock.makefile("rb")
def connect():
ctx = ssl.create_default_context() # certificat vérifié sur le nom SNI QuickConnect
m = _IMAPSurSocket(ctx.wrap_socket(socket.create_connection((HOST, 993), 10), server_hostname=SNI))
m.login(USER, PWD); m.select("INBOX", readonly=not GO); return m
def imap_date(d): return d.strftime("%d-%b-%Y")
RX_META = re.compile(rb'UID (\d+).*?INTERNALDATE "([^"]+)"', re.S)
def fetch_meta(m, uids):
"""{uid: (adresse, année, a_desabo)} via BODY.PEEK."""
out = {}
for i in range(0, len(uids), 500):
chunk = uids[i:i + 500]
typ, resp = m.uid("fetch", ",".join(u.decode() for u in chunk), "(UID INTERNALDATE BODY.PEEK[HEADER.FIELDS (FROM LIST-UNSUBSCRIBE)])")
for part in resp:
if not isinstance(part, tuple): continue
mm = RX_META.search(part[0])
if not mm: continue
msg = email.message_from_bytes(part[1])
try: raw = str(email.header.make_header(email.header.decode_header(msg.get("From", "")))) if msg.get("From") else ""
except Exception: raw = msg.get("From", "")
addr = email.utils.parseaddr(raw)[1].lower()
try: year = email.utils.parsedate_to_datetime(mm.group(2).decode()).year
except Exception: year = None
out[mm.group(1)] = (addr, year, bool(msg.get("List-Unsubscribe")))
return out
def ensure_folder(m, name):
typ, data = m.list('""', f'"{name}"')
if typ == "OK" and data and data[0]: return
if GO: m.create(name); m.subscribe(name); print(f" dossier créé : {name}")
else: print(f" (dry-run) dossier à créer : {name}")
def move(m, uids, dest):
n = 0
for i in range(0, len(uids), BATCH):
chunk = uids[i:i + BATCH]
typ, _ = m.uid("move", ",".join(u.decode() for u in chunk), dest)
if typ != "OK": raise RuntimeError(f"MOVE {dest} : {typ}")
n += len(chunk)
if i + BATCH < len(uids): time.sleep(PAUSE)
return n
def log(line):
with open(LOG, "a") as f: f.write(line + "\n")
m = connect()
cutoff = imap_date(datetime.now() - timedelta(days=AGE_DAYS))
since_daily = imap_date(datetime.now() - timedelta(days=3))
mode = ("QUOTIDIEN " if DAILY else "") + ("GO" if GO else "DRY-RUN")
print(f"=== airtria-menage {mode} — {datetime.now():%Y-%m-%d %H:%M} — vague 1 : avant le {cutoff} ===")
if GO: log(f"\n## {datetime.now():%Y-%m-%d %H:%M:%S} — airtria-menage {'--quotidien' if DAILY else '--go'}{' --rule ' + ONLY if ONLY else ''}\n\n| Règle | Description | Déplacés | Destination | Écartés (whitelist) |\n|---|---|---:|---|---:|")
total = 0
for rid, desc, kind, crit, rx in RULES:
if ONLY and rid != ONLY: continue
if DAILY and kind == "year": continue
if kind in ("trash", "trash_unsub", "veille"):
if kind == "veille": q = f'({crit} SINCE {since_daily})' if DAILY else f'({crit})'
else: q = f'({crit} SINCE {since_daily})' if DAILY else f'({crit} BEFORE {cutoff})'
typ, data = m.uid("search", None, q)
cand = data[0].split(); info = fetch_meta(m, cand)
keep = [u for u in cand if u in info and re.search(rx, info[u][0]) and not whitelisted(info[u][0])
and (kind != "trash_unsub" or info[u][2])]
wl = sum(1 for u in cand if u in info and whitelisted(info[u][0]))
dest = VEILLE if kind == "veille" else TRASH
if kind == "veille": ensure_folder(m, VEILLE)
if not DAILY or keep: print(f"{rid} {desc:30s} candidats {len(cand):5d} → à déplacer {len(keep):5d} vers {dest} (whitelist écartés {wl}, hors motif {len(cand) - len(keep) - wl})")
if GO and keep:
n = move(m, keep, dest); total += n; log(f"| {rid} | {desc} | {n} | {dest} | {wl} |")
elif kind == "year":
y = crit
typ, data = m.uid("search", None, f'(SINCE 01-Jan-{y} BEFORE 01-Jan-{y + 1})')
cand = data[0].split(); dest = f"{ARCH}.{y}"
print(f"{rid} {desc:30s} à déplacer {len(cand):5d} vers {dest}")
ensure_folder(m, dest)
if GO and cand:
n = move(m, cand, dest); total += n; log(f"| {rid} | {desc} | {n} | {dest} | 0 |")
m.logout()
print(f"=== fin {mode} : {total} messages déplacés ===")
if GO: log(f"\nTotal déplacés : {total}")