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:
175
workspace/orange-menage.py
Normal file
175
workspace/orange-menage.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
# orange-menage.py — Vagues de ménage Orange (IMAP), règles de orange-rules.md.
|
||||
#
|
||||
# python3 orange-menage.py → DRY-RUN de toutes les règles (compte, ne touche rien)
|
||||
# python3 orange-menage.py --rule O1.05 → dry-run d'une seule règle
|
||||
# python3 orange-menage.py --rule O1.05 --go
|
||||
# python3 orange-menage.py --go → exécute TOUTES les règles (après dry-run validé)
|
||||
# python3 orange-menage.py --quotidien → tri du soir (cron) : règles corbeille sur les 3 derniers jours
|
||||
# python3 orange-menage.py --quotidien --dry-run → même chose sans rien déplacer
|
||||
#
|
||||
# Garde-fous (mêmes que Gmail §4.6/4.7) :
|
||||
# - jamais de suppression : UID MOVE vers INBOX/TRASH (corbeille Orange, récupérable) ou
|
||||
# vers INBOX/Archives/<année> ; aucun EXPUNGE, aucun STORE \Deleted ;
|
||||
# - whitelist relue et appliquée message par message, sur l'adresse réelle du From ;
|
||||
# - lots de 200, pause 5 s, journal append-only orange-housekeeping.md ;
|
||||
# - vague 1 : seulement les messages plus vieux que 90 jours.
|
||||
import imaplib, re, sys, time, email, email.utils, email.header
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
|
||||
USER = "thierry.tallagrand@wanadoo.fr"
|
||||
HOST, PORT = "imap.orange.fr", 993
|
||||
WS = "/home/node/.openclaw/workspace"
|
||||
LOG = f"{WS}/orange-housekeeping.md"
|
||||
TRASH = "INBOX/TRASH"
|
||||
ARCH = "INBOX/Archives"
|
||||
BATCH, PAUSE, AGE_DAYS = 200, 5, 90
|
||||
|
||||
GO = "--go" in sys.argv
|
||||
DAILY = "--quotidien" in sys.argv # tri du soir : règles corbeille seules, arrivées récentes, sans délai 90 j
|
||||
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
|
||||
|
||||
# ---- whitelist (orange-rules.md §🔴) : domaine EXACT ou adresse exacte ----
|
||||
WL_DOMAINS = {"cic.fr", "e-cic.fr", "bpgo.fr", "cmb.fr", "ca-cotesdarmor.fr",
|
||||
"impots.gouv.fr", "dgfip.finances.gouv.fr", "digiposte.fr", "ameli.fr", "fft.fr",
|
||||
"orange.com", "orange.fr", "nokia.com", "neuf.fr",
|
||||
"vwfs.com", "volkswagen.fr", "jeep.com", "ze-watt.fr", "ze-watt.com", "zeromotorcycles.com",
|
||||
"gissacg.fr", "listes.gissacg.fr", "airtria.net", "airtria.fr", "laboutique-solaire-pro.com",
|
||||
"email.apple.com"}
|
||||
WL_ADDR = {"president.gissacg@free.fr", "thierry.tallagrand@wanadoo.fr", "tallagrand.thierry@gmail.com",
|
||||
"thierry.tallagrand@me.com", "gtb.alisea@gmail.com", "sebastien.buis@gmail.com", "quibeldaniel@yahoo.com",
|
||||
"anthony.masse@gmail.com", "claudine.jacques-sermet@orange.fr", "thierry.ouvrard@neuf.fr",
|
||||
"jean-christophe.pettier@laposte.net", "valeriepriser@orange.fr", "halper.jal@orange.fr"}
|
||||
|
||||
def whitelisted(addr):
|
||||
a = addr.lower(); dom = a.split("@")[-1]
|
||||
return a in WL_ADDR or dom in WL_DOMAINS
|
||||
|
||||
# ---- règles ----
|
||||
# kind=trash : imap = critères SEARCH (FROM …), match = regex sur l'adresse réelle, cible TRASH, âge > 90 j
|
||||
# kind=year : tous les messages de l'année → Archives/<année> (whitelist NON appliquée : simple déplacement)
|
||||
# kind=year_from : messages d'un expéditeur, années < 2026 → Archives/<année>
|
||||
RULES = [
|
||||
("O1.01", "Fnac (en entier)", "trash", 'FROM "info@fnac.com"', r"^info@fnac\.com$"),
|
||||
("O1.02", "Fnac Photo", "trash", 'FROM "fnac.com"', r"@(email-photo|photos)\.fnac\.com$"),
|
||||
("O1.03", "Nature & Découvertes", "trash", 'FROM "natureetdecouvertes.com"', r"@elettre\.natureetdecouvertes\.com$"),
|
||||
("O1.04", "Darty news", "trash", 'FROM "news.darty.com"', r"@news\.darty\.com$"),
|
||||
("O1.05", "Edenred Meyclub/Kadéos", "trash", 'FROM "kadeos.com"', r"@user-edenred-meyclub\.kadeos\.com$"),
|
||||
("O1.06", "Vistaprint", "trash", 'FROM "vistaprint.fr"', r"@(email|mail)\.vistaprint\.fr$"),
|
||||
("O1.07", "Canal+", "trash", 'FROM "emailing.canalplus.fr"', r"@emailing\.canalplus\.fr$"),
|
||||
("O1.08", "Rue du Commerce promos", "trash", 'FROM "rueducommerce"', r"^(.+@relation\.rueducommerce\.fr|news@rueducommerce\.com)$"),
|
||||
("O1.09", "Netflix", "trash", 'FROM "netflix.com"', r"@(mailer|members)\.netflix\.com$"),
|
||||
("O1.10", "Galeries Lafayette", "trash", 'FROM "indo-wifi.com"', r"^contact@indo-wifi\.com$"),
|
||||
("O1.11", "ProTennis", "trash", 'FROM "protennis.fr"', r"^contact@protennis\.fr$"),
|
||||
("O1.12", "HP news", "trash", 'FROM "hpnews.hp.com"', r"@hpnews\.hp\.com$"),
|
||||
("O1.13", "IKEA", "trash", 'FROM "news.email.ikea.fr"', r"@news\.email\.ikea\.fr$"),
|
||||
("O1.14", "famille critelab", "trash", 'OR OR OR OR FROM "rndamailing.com" FROM "mahlesoldes.com" FROM "mayasoldes.com" FROM "verysent.com" FROM "jrcc-splash.com"',
|
||||
r"@(rndamailing\.com|mahlesoldes\.com|server2\.mayasoldes\.com|verysent\.com|jrcc-splash\.com)$"),
|
||||
("O1.15", "studioSPORT", "trash", 'FROM "studiosport.fr"', r"@studiosport\.fr$"),
|
||||
("O1.16", "Dive Trip Adékua", "trash", 'FROM "voyages-adekua.fr"', r"@email\.voyages-adekua\.fr$"),
|
||||
("O1.17", "Bruneau", "trash", 'FROM "newsletter.bruneau.fr"', r"@newsletter\.bruneau\.fr$"),
|
||||
# O1.18 Simply Piano RETIRÉE le 27/09/2026 (à conserver)
|
||||
("O1.19", "Disney+", "trash", 'FROM "disneyplus.com"', r"@mail\.disneyplus\.com$"),
|
||||
("O1.20", "Accor", "trash", 'OR FROM "mail.all.com" FROM "accor-mail.com"', r"@(mail\.all\.com|accor-mail\.com)$"),
|
||||
("O1.21", "Sage marketing", "trash", 'FROM "go.sage.com"', r"@go\.sage\.com$"),
|
||||
("O1.22", "Quora (relais Apple)", "trash", 'FROM "quora" FROM "privaterelay.appleid.com"', r"quora.*@privaterelay\.appleid\.com$"),
|
||||
("O1.23", "Strava (relais Apple)", "trash", 'FROM "strava" FROM "privaterelay.appleid.com"', r"strava.*@privaterelay\.appleid\.com$"),
|
||||
("O1.24", "Apple newsletters", "trash", 'OR FROM "insideapple.apple.com" FROM "applemusic.com"', r"@(insideapple\.apple\.com|applemusic\.com)$"),
|
||||
("O1.25", "News Orange promo", "trash", 'FROM "noreply-orange@infos.orange.fr"', r"^noreply-orange@infos\.orange\.fr$"),
|
||||
("O1.26", "Decathlon", "trash", 'FROM "email.decathlon.fr"', r"@email\.decathlon\.fr$"),
|
||||
("O2.01", "Archive 2017 et avant", "year", 2017, None),
|
||||
("O2.02", "Archive 2018", "year", 2018, None),
|
||||
("O2.03", "Archive 2019", "year", 2019, None),
|
||||
("O2.10", "Listes club < 2026 → Archives/<année>", "year_from", 'FROM "listes.gissacg.fr"', r"@listes\.gissacg\.fr$"),
|
||||
]
|
||||
|
||||
env = dict(l.strip().split("=", 1) for l in open("/home/node/.openclaw/.env") if "=" in l and not l.startswith("#"))
|
||||
PWD = env["ORANGE_IMAP_PASSWORD"].strip().strip('"').strip("'")
|
||||
|
||||
def connect():
|
||||
m = imaplib.IMAP4_SSL(HOST, PORT); 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_from(m, uids):
|
||||
"""{uid: (adresse, année)} via BODY.PEEK (ne marque rien comme lu)."""
|
||||
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)])")
|
||||
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])
|
||||
raw = str(email.header.make_header(email.header.decode_header(msg.get("From", "")))) if msg.get("From") else ""
|
||||
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)
|
||||
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"=== orange-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} — orange-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 != "trash": continue
|
||||
if kind == "trash":
|
||||
typ, data = m.uid("search", None, f'({crit} SINCE {since_daily})' if DAILY else f'({crit} BEFORE {cutoff})')
|
||||
cand = data[0].split()
|
||||
info = fetch_from(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])]
|
||||
wl = sum(1 for u in cand if u in info and whitelisted(info[u][0]))
|
||||
if not DAILY or keep: print(f"{rid} {desc:36s} candidats {len(cand):5d} → à déplacer {len(keep):5d} vers {TRASH} (whitelist écartés {wl}, hors motif {len(cand) - len(keep) - wl})")
|
||||
if GO and keep:
|
||||
n = move(m, keep, TRASH); total += n; log(f"| {rid} | {desc} | {n} | {TRASH} | {wl} |")
|
||||
elif kind == "year":
|
||||
y = crit
|
||||
q = f'BEFORE 01-Jan-{y + 1}' if y == 2017 else f'(SINCE 01-Jan-{y} BEFORE 01-Jan-{y + 1})'
|
||||
typ, data = m.uid("search", None, q)
|
||||
cand = data[0].split(); dest = f"{ARCH}/{y}"
|
||||
print(f"{rid} {desc:36s} à 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 |")
|
||||
elif kind == "year_from":
|
||||
typ, data = m.uid("search", None, f'({crit} BEFORE 01-Jan-2026)')
|
||||
cand = data[0].split(); info = fetch_from(m, cand)
|
||||
by_year = defaultdict(list)
|
||||
for u in cand:
|
||||
if u in info and re.search(rx, info[u][0]) and info[u][1]: by_year[info[u][1]].append(u)
|
||||
print(f"{rid} {desc:36s} " + ", ".join(f"{y}: {len(v)}" for y, v in sorted(by_year.items())))
|
||||
for y, uids in sorted(by_year.items()):
|
||||
dest = f"{ARCH}/{y}"; ensure_folder(m, dest)
|
||||
if GO:
|
||||
n = move(m, uids, dest); total += n; log(f"| {rid} | {desc} ({y}) | {n} | {dest} | 0 |")
|
||||
m.logout()
|
||||
print(f"=== fin {mode} : {total} messages déplacés ===")
|
||||
if GO: log(f"\nTotal déplacés : {total}")
|
||||
Reference in New Issue
Block a user