Premier commit
Build and Push Docker Image / build-and-push (push) Failing after 38s

This commit is contained in:
pyriec
2026-09-09 18:51:08 +02:00
commit 3d06c9a7ee
8 changed files with 733 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.git/
.gitignore
.env
config.ini
screenshots/
data/
*.json
+23
View File
@@ -0,0 +1,23 @@
USE_ENV=true
# Intervalle en secondes entre chaque boucle (3600 = 1 heure)
CHECK_INTERVAL=3600
# Configuration Home Assistant
HA_URL=http://homeassistant.local:8123
HA_TOKEN=votre_jeton_ha_longue_duree
HA_WEBHOOK_ID=votre_webhook_id
HA_CALENDAR_ID=calendar.votre_calendrier
# Configuration CCI
CCI_BASE_URL=https://votre-portail-cci.fr
CCI_USERNAME=votre_identifiant_cci
CCI_PASSWORD=votre_mot_de_passe_cci
CCI_CODE_RESSOURCE=12345
CCI_TYPE_RESSOURCE=1
# Fichiers de stockage dans le conteneur
STATE_FILE=data/planning_state.json
SCREENSHOT_DIR=screenshots
TZ=Europe/Paris
+58
View File
@@ -0,0 +1,58 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
tags:
- 'v*.*.*'
jobs:
build-and-push:
runs-on: [serveur-dell-ubuntu-latest] # Ou le label de votre runner (ex: ubuntu-latest, act-runner, etc.)
steps:
- name: Checkout du code
uses: actions/checkout@v4
- name: Extraction du nom de domaine du registre et du nom du dépôt
id: prep
run: |
# Extrait le nom de domaine de Gitea (supprime http:// ou https://)
REGISTRY_HOST=$(echo "${{ gitea.server_url }}" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||')
echo "registry=${REGISTRY_HOST}" >> $GITHUB_OUTPUT
# Convertit le nom du dépôt en minuscules (exigence de Docker pour les tags)
REPO_LOWER=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
echo "repo_lower=${REPO_LOWER}" >> $GITHUB_OUTPUT
- name: Connexion au Registre de Conteneurs Gitea
uses: docker/login-action@v3
with:
registry: ${{ steps.prep.outputs.registry }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # Token généré automatiquement par Gitea
- name: Configuration de Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Génération automatique des tags et métadonnées
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.prep.outputs.registry }}/${{ steps.prep.outputs.repo_lower }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,prefix=git-
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' || gitea.ref == 'refs/heads/master' }}
- name: Construction et publication de l'image Docker
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.git/
.env
config.ini
screenshots/
data/
*.json
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV USE_ENV=true
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install --with-deps chromium
COPY . .
# Configuration de la vérification de santé Docker
HEALTHCHECK --interval=1m --timeout=10s --start-period=30s --retries=3 \
CMD python healthcheck.py
CMD ["python", "main.py"]
+36
View File
@@ -0,0 +1,36 @@
import os
import sys
import json
import time
HEALTH_FILE = "/tmp/health_status.json"
if not os.path.exists(HEALTH_FILE):
print("Healthcheck ÉCHEC : Fichier d'état introuvable.")
sys.exit(1)
try:
with open(HEALTH_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
interval = int(os.getenv("CHECK_INTERVAL", "3600"))
# Marge de sécurité (ex: 2x l'intervalle + 10 min) avant de déclarer le conteneur unhealthy
max_allowed_age = interval * 2 + 600
last_heartbeat = data.get("timestamp", 0)
status = data.get("status", "unknown")
age = time.time() - last_heartbeat
if age > max_allowed_age:
print(f"Healthcheck ÉCHEC : Fichier trop ancien ({int(age)}s > {max_allowed_age}s)")
sys.exit(1)
if status in ("ok", "running"):
sys.exit(0)
else:
print(f"Healthcheck ÉCHEC : Statut du script est '{status}'")
sys.exit(1)
except Exception as e:
print(f"Healthcheck ÉCHEC : Erreur de lecture : {e}")
sys.exit(1)
+566
View File
@@ -0,0 +1,566 @@
import json
import re
import os
import time
import base64
import configparser
import requests
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from playwright.sync_api import sync_playwright
from PIL import Image, ImageDraw, ImageFont
import websocket # Nécessite: pip install websocket-client
# === Configuration (config.ini en dev, variables d'environnement en conteneur) ===
CONFIG_FILE = "config.ini"
HEALTH_FILE = "/tmp/health_status.json"
USE_ENV = os.getenv("USE_ENV", "false").lower() in ("true", "1", "yes") or not os.path.exists(CONFIG_FILE)
if not USE_ENV:
print(f"🔧 Mode DEV local : Chargement de la configuration depuis '{CONFIG_FILE}'")
config = configparser.ConfigParser()
config.read(CONFIG_FILE, encoding="utf-8")
HA_URL = config.get("HOME_ASSISTANT", "url").rstrip('/')
HA_TOKEN = config.get("HOME_ASSISTANT", "token")
HA_WEBHOOK_ID = config.get("HOME_ASSISTANT", "webhook_id")
HA_CALENDAR_ID = config.get("HOME_ASSISTANT", "calendar_id")
BASE_URL = config.get("CCI", "base_url").rstrip('/')
CCI_USERNAME = config.get("CCI", "username")
CCI_PASSWORD = config.get("CCI", "password")
CODE_RESSOURCE = config.getint("CCI", "code_ressource")
TYPE_RESSOURCE = config.getint("CCI", "type_ressource")
STATE_FILE = config.get("SCRIPT", "state_file", fallback="planning_state.json")
SCREENSHOT_DIR = config.get("SCRIPT", "screenshot_dir", fallback="screenshots")
CHECK_INTERVAL = config.getint("SCRIPT", "check_interval", fallback=3600)
# Gestion propre du fuseau horaire
TZ = ZoneInfo(config.get("SCRIPT", "tz", fallback="Europe/Paris"))
else:
print("🐳 Mode CONTENEUR (Docker) : Chargement de la configuration depuis les variables d'environnement")
HA_URL = os.getenv("HA_URL", "").rstrip('/')
HA_TOKEN = os.getenv("HA_TOKEN", "")
HA_WEBHOOK_ID = os.getenv("HA_WEBHOOK_ID", "")
HA_CALENDAR_ID = os.getenv("HA_CALENDAR_ID", "")
BASE_URL = os.getenv("CCI_BASE_URL", "").rstrip('/')
CCI_USERNAME = os.getenv("CCI_USERNAME", "")
CCI_PASSWORD = os.getenv("CCI_PASSWORD", "")
CODE_RESSOURCE = int(os.getenv("CCI_CODE_RESSOURCE", "0"))
TYPE_RESSOURCE = int(os.getenv("CCI_TYPE_RESSOURCE", "0"))
STATE_FILE = os.getenv("STATE_FILE", "planning_state.json")
SCREENSHOT_DIR = os.getenv("SCREENSHOT_DIR", "screenshots")
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "3600"))
# Gestion propre du fuseau horaire
TZ = ZoneInfo(os.getenv("TZ", "Europe/Paris"))
required_vars = [
("HA_URL", HA_URL),
("HA_TOKEN", HA_TOKEN),
("HA_WEBHOOK_ID", HA_WEBHOOK_ID),
("HA_CALENDAR_ID", HA_CALENDAR_ID),
("CCI_BASE_URL", BASE_URL),
("CCI_USERNAME", CCI_USERNAME),
("CCI_PASSWORD", CCI_PASSWORD),
]
missing = [name for name, val in required_vars if not val]
if missing:
raise ValueError(f"❌ Configuration incomplète. Paramètres/Variables manquants : {', '.join(missing)}")
if not os.path.exists(SCREENSHOT_DIR):
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
def update_health_status(status="ok", error=None):
"""Met à jour le fichier d'état de santé pour le healthcheck Docker."""
data = {
"status": status,
"timestamp": time.time(),
"last_check": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"error": error
}
try:
with open(HEALTH_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"⚠️ Erreur mise à jour fichier health: {e}")
def get_weeks_2026_2027():
"""Génère la liste des semaines au format YYYYSS de sept 2026 à août 2027."""
weeks = []
current_date = datetime(2026, 9, 1)
end_date = datetime(2027, 8, 31)
while current_date <= end_date:
year, week_num, _ = current_date.isocalendar()
if current_date.month == 12 and week_num == 1:
year += 1
elif current_date.month == 1 and week_num > 50:
year -= 1
week_str = f"{year}{week_num:02d}"
if week_str not in weeks:
weeks.append(week_str)
current_date += timedelta(days=7)
return weeks
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def save_state(state):
state_dir = os.path.dirname(STATE_FILE)
if state_dir:
os.makedirs(state_dir, exist_ok=True)
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
def timestamp_image(image_path):
"""Ajoute la date et l'heure en filigrane sur la capture d'écran."""
img = Image.open(image_path)
draw = ImageDraw.Draw(img)
try:
font = ImageFont.load_default(size=28)
except:
font = ImageFont.load_default()
timestamp = datetime.now().strftime("Capture du : %d/%m/%Y à %H:%M:%S")
draw.rectangle([(10, 10), (460, 50)], fill="black")
draw.text((20, 15), timestamp, fill="white", font=font)
img.save(image_path)
def handle_week_screenshot(page, week, is_first_time, week_changed):
"""Gère la capture d'écran par semaine."""
week_dir = os.path.join(SCREENSHOT_DIR, f"semaine_{week}")
os.makedirs(week_dir, exist_ok=True)
latest_filename = f"latest.png"
latest_path = os.path.join(week_dir, latest_filename)
temp_path = os.path.join(week_dir, f"temp_{week}.png")
page.screenshot(path=temp_path, full_page=True)
timestamp_image(temp_path)
if week_changed and not is_first_time and os.path.exists(latest_path):
mtime = os.path.getmtime(latest_path)
dt_mtime = datetime.fromtimestamp(mtime)
timestamp_str = dt_mtime.strftime("%Y-%m-%d_%H-%M-%S")
archive_filename = f"{timestamp_str}.png"
archive_path = os.path.join(week_dir, archive_filename)
os.rename(latest_path, archive_path)
print(f" 📸 Modification détectée : Ancien screenshot archivé -> {archive_filename}")
if os.path.exists(latest_path):
os.remove(latest_path)
os.rename(temp_path, latest_path)
print(f" 📸 Capture 'latest' mise à jour : {latest_path}")
def notify_home_assistant(date_str, before, after):
"""Envoie un Webhook à Home Assistant avec les données formatées."""
try:
dt_obj = datetime.strptime(date_str, "%d/%m/%Y")
date_iso = dt_obj.strftime("%Y-%m-%d")
except ValueError:
date_iso = date_str
url = f"{HA_URL}/api/webhook/{HA_WEBHOOK_ID}"
payload = {
"action": "planning_updated",
"date_iso": date_iso, # ex: "2026-09-14"
"date_humain": date_str, # ex: "14/09/2026"
"before": before, # Séances avant modif (sans numJour, numSemaine, minuteDebut)
"after": after # Séances après modif (sans numJour, numSemaine, minuteDebut)
}
try:
resp = requests.post(url, json=payload, timeout=10)
if resp.status_code == 200:
print(f" 📡 Webhook HA envoyé pour le {date_str} ({date_iso})")
else:
print(f" ⚠️ Webhook HA a renvoyé le statut {resp.status_code}")
except Exception as e:
print(f" ❌ Erreur Webhook HA: {e}")
def get_ha_events_for_day(date_str):
"""Récupère les événements existants dans HA pour une date (DD/MM/YYYY)."""
try:
dt_start = datetime.strptime(date_str, "%d/%m/%Y")
dt_end = dt_start + timedelta(days=1)
start_iso = dt_start.strftime("%Y-%m-%dT00:00:00Z")
end_iso = dt_end.strftime("%Y-%m-%dT00:00:00Z")
url = f"{HA_URL}/api/calendars/{HA_CALENDAR_ID}?start={start_iso}&end={end_iso}"
headers = {"Authorization": f"Bearer {HA_TOKEN}"}
resp = requests.get(url, headers=headers, timeout=10)
if resp.status_code == 200:
return resp.json()
except Exception as e:
print(f" ⚠️ Impossible de lire le calendrier HA pour le {date_str} : {e}")
return []
def delete_ha_calendar_event(ha_url, ha_token, calendar_entity_id, uid):
"""Supprime un événement dans Home Assistant via WebSocket."""
if not uid:
print(" ❌ Aucun UID fourni pour la suppression.")
return False
ws_url = ha_url.replace("https://", "wss://").replace("http://", "ws://").rstrip('/') + "/api/websocket"
try:
ws = websocket.create_connection(ws_url, timeout=10)
init_response = json.loads(ws.recv())
if init_response.get("type") != "auth_required":
ws.close()
return False
ws.send(json.dumps({
"type": "auth",
"access_token": ha_token
}))
auth_response = json.loads(ws.recv())
if auth_response.get("type") != "auth_ok":
ws.close()
return False
delete_command = {
"id": 1,
"type": "calendar/event/delete",
"entity_id": calendar_entity_id,
"uid": uid
}
ws.send(json.dumps(delete_command))
result = json.loads(ws.recv())
ws.close()
return result.get("success", False)
except Exception as e:
print(f" ❌ Erreur de connexion WebSocket : {e}")
return False
def create_ha_calendar_event(seance):
"""Crée un événement dans le calendrier Home Assistant en utilisant les ISO formatés."""
headers = {
"Authorization": f"Bearer {HA_TOKEN}",
"Content-Type": "application/json"
}
details = "\n".join(seance.get("detail", [])) if isinstance(seance.get("detail"), list) else str(seance.get("detail", ""))
payload = {
"entity_id": HA_CALENDAR_ID,
"summary": seance["libelle"],
"start_date_time": seance["debut_iso"],
"end_date_time": seance["fin_iso"],
"description": details
}
try:
url = f"{HA_URL}/api/services/calendar/create_event"
resp = requests.post(url, json=payload, headers=headers, timeout=10)
if resp.status_code == 200:
print(f" ✅ Cours créé : {seance['libelle']} ({seance['heure_debut']} - {seance['heure_fin']})")
return True
else:
print(f" ⚠️ Erreur création HA ({resp.status_code}) : {resp.text}")
return False
except Exception as e:
print(f" ❌ Erreur réseau lors de la création d'événement HA : {e}")
return False
def reconcile_day_ha_calendar(date_str, expected_seances):
"""Réconcilie les événements HA avec le planning CCI."""
print(f" 🔍 Audit et réconciliation du calendrier HA pour le {date_str}...")
existing_events = get_ha_events_for_day(date_str)
expected_matches = []
for seance in expected_seances:
expected_matches.append({
"seance": seance,
"summary": seance["libelle"],
"heure_debut": seance["heure_debut"],
"found": False
})
events_to_delete = []
for ev in existing_events:
ev_summary = ev.get("summary", "")
ev_start_raw = ev.get("start", {})
ev_start_str = ev_start_raw.get("dateTime", "") if isinstance(ev_start_raw, dict) else str(ev_start_raw)
ev_uid = ev.get("uid") or ev.get("id")
matched = False
for exp in expected_matches:
if exp["found"]:
continue
if ev_summary == exp["summary"] and exp["heure_debut"] in ev_start_str:
exp["found"] = True
matched = True
break
if not matched:
events_to_delete.append({
"summary": ev_summary,
"uid": ev_uid,
"start": ev_start_str
})
if events_to_delete:
for ev_del in events_to_delete:
print(f" 🗑️ [Correction HA] Événement en trop détecté : {ev_del['summary']} ({ev_del['start']})")
if ev_del["uid"]:
if delete_ha_calendar_event(HA_URL, HA_TOKEN, HA_CALENDAR_ID, ev_del["uid"]):
print(f" ✅ Événement supprimé du calendrier HA.")
else:
print(f" ⚠️ Échec de suppression de l'événement (UID: {ev_del['uid']}).")
else:
print(f" ⚠️ Impossible de supprimer '{ev_del['summary']}' : aucun UID renvoyé par l'API HA.")
else:
print(f" ✅ Aucun événement obsolète/en trop dans HA.")
missing_count = 0
for exp in expected_matches:
if not exp["found"]:
missing_count += 1
print(f" ✨ [Correction HA] Événement manquant dans HA : {exp['summary']} ({exp['heure_debut']})")
create_ha_calendar_event(exp["seance"])
if missing_count == 0:
print(f" ✅ Tous les cours attendus sont bien présents dans HA.")
def format_seance(seance, dt_debut_semaine):
"""
Transforme une séance brute en supprimant numJour/numSemaine/minuteDebut
et en ajoutant date_iso, date_humain, debut_iso, fin_iso, heure_debut, heure_fin.
"""
num_jour = seance.get("numJour", 1)
jour_delta = num_jour - 1
dt_jour = dt_debut_semaine + timedelta(days=jour_delta)
minute_debut = seance.get("minuteDebut", 0)
duree = seance.get("duree", 0)
minute_fin = minute_debut + duree
heure_debut_h = minute_debut // 60
heure_debut_m = minute_debut % 60
heure_fin_h = minute_fin // 60
heure_fin_m = minute_fin % 60
dt_start_naive = datetime(dt_jour.year, dt_jour.month, dt_jour.day, heure_debut_h, heure_debut_m)
dt_end_naive = datetime(dt_jour.year, dt_jour.month, dt_jour.day, heure_fin_h, heure_fin_m)
dt_start = dt_start_naive.replace(tzinfo=TZ)
dt_end = dt_end_naive.replace(tzinfo=TZ)
# Dégager numJour, numSemaine et minuteDebut
cleaned = {
k: v for k, v in seance.items()
if k not in ("numJour", "numSemaine", "minuteDebut")
}
date_iso = dt_jour.strftime("%Y-%m-%d")
date_humain = dt_jour.strftime("%d/%m/%Y")
cleaned.update({
"date_iso": date_iso,
"date_humain": date_humain,
"debut_iso": dt_start.isoformat(),
"fin_iso": dt_end.isoformat(),
"heure_debut": f"{heure_debut_h:02d}:{heure_debut_m:02d}",
"heure_fin": f"{heure_fin_h:02d}:{heure_fin_m:02d}",
"duree_minutes": duree
})
return cleaned, date_humain
def parse_planning_to_days(planning_data):
"""Convertit le JSON brut en dictionnaire classé par date avec données formatées."""
days_data = {}
seen_seances = set()
for semaine in planning_data.get("semaines", []):
date_debut_str = semaine.get("dateDebut")
if not date_debut_str:
continue
try:
dt_debut = datetime.strptime(date_debut_str, "%d/%m/%Y")
except ValueError:
continue
for ressource in semaine.get("ressources", []):
for seance in ressource.get("seances", []):
seance_key = (
seance.get("id"),
seance.get("numJour"),
seance.get("minuteDebut"),
seance.get("libelle")
)
if seance_key in seen_seances:
continue
seen_seances.add(seance_key)
formatted_seance, date_str = format_seance(seance, dt_debut)
if date_str not in days_data:
days_data[date_str] = []
days_data[date_str].append(formatted_seance)
for date_str in days_data:
days_data[date_str] = sorted(days_data[date_str], key=lambda x: x["debut_iso"])
return days_data
def run():
weeks_to_check = get_weeks_2026_2027()
state = load_state()
ressources_json = json.dumps([{"code": CODE_RESSOURCE, "type": TYPE_RESSOURCE}])
ressources_b64 = base64.b64encode(ressources_json.encode()).decode()
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1920, "height": 1080})
page = context.new_page()
print("🔑 Connexion au portail CCI...")
page.goto(f"{BASE_URL}/IntNum/index.php")
username_field = page.locator('input[type="text"], input[name="username"], input[name="login"]')
username_field.click()
username_field.press_sequentially(CCI_USERNAME, delay=20)
password_field = page.locator('input[type="password"], input[name="password"]')
password_field.click()
password_field.press_sequentially(CCI_PASSWORD, delay=20)
page.locator('body').click()
page.wait_for_load_state("networkidle")
print("✅ Connexion réussie !\n")
for week in weeks_to_check:
print("=" * 60)
print(f"🔍 Traitement de la semaine {week}...")
params = f"orientationPaysage=0&afficheContraintes=1&zoom=2&modeAffichage=0&semaineDebut={week}&semaineFin={week}&ressources={ressources_b64}"
url = f"{BASE_URL}/IntNum/index.php/apprenant/planning/courant/?{params}"
page.goto(url)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1000)
content = page.content()
match = re.search(r'var planningJSON\s*=\s*(\{.*?\});\s*\n', content, re.DOTALL)
if not match:
print(f"⚠️ Impossible d'extraire planningJSON pour la semaine {week}")
continue
try:
planning_data = json.loads(match.group(1))
except Exception as e:
print(f"⚠️ Erreur de lecture du JSON ({e})")
continue
new_days_data = parse_planning_to_days(planning_data)
old_days_data = state.get(week, {})
is_first_time = (week not in state)
week_changed = (new_days_data != old_days_data)
handle_week_screenshot(page, week, is_first_time, week_changed)
if not new_days_data:
print(f"ℹ️ Aucun cours prévu cette semaine.")
if is_first_time or week_changed:
state[week] = {}
save_state(state)
print(f"✅ Semaine {week} vérifiée.")
continue
if is_first_time:
print(f"🆕 Premier traitement pour cette semaine.")
elif week_changed:
print(f"🔄 Modifications détectées dans le planning CCI !")
else:
print(f"ℹ️ Planning CCI inchangé. Réconciliation du calendrier HA...")
all_dates = sorted(list(set(new_days_data.keys()).union(set(old_days_data.keys()))))
for date_str in all_dates:
old_day = old_days_data.get(date_str, [])
new_day = new_days_data.get(date_str, [])
if old_day != new_day and not is_first_time:
print(f" ⚠️ Changement détecté sur le planning pour la journée du {date_str}")
notify_home_assistant(date_str, old_day, new_day)
reconcile_day_ha_calendar(date_str, new_day)
state[week] = new_days_data
save_state(state)
print(f"✅ Semaine {week} traitée et réconciliée avec succès.")
browser.close()
print("\n" + "=" * 60)
print("🎉 Traitement et audit complets terminés avec succès !")
if __name__ == "__main__":
print(f"🔄 Service démarré. Intervalle d'analyse : {CHECK_INTERVAL} secondes ({CHECK_INTERVAL // 60} minutes)")
while True:
start_time = datetime.now()
print("\n" + "=" * 60)
print(f"🚀 Début du cycle d'analyse : {start_time.strftime('%d/%m/%Y %H:%M:%S')}")
update_health_status(status="running")
try:
run()
update_health_status(status="ok")
except Exception as e:
print(f"❌ Erreur lors du cycle d'exécution : {e}")
update_health_status(status="error", error=str(e))
next_run = datetime.now() + timedelta(seconds=CHECK_INTERVAL)
print(f"💤 Prochaine analyse prévue à {next_run.strftime('%H:%M:%S')} (dans {CHECK_INTERVAL}s)")
print("=" * 60)
time.sleep(CHECK_INTERVAL)
+5
View File
@@ -0,0 +1,5 @@
playwright>=1.40.0
requests>=2.31.0
Pillow>=10.0.0
websocket-client>=1.6.0
tzdata