Initial commit: 25 scripts Python sauvegardés
Date: 2026-08-28 11:43:34 EDT
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List today's events from ALL Google Calendars (not just primary).
|
||||
|
||||
Usage:
|
||||
python3 agenda_all_calendars.py [YYYYMMDD] # default: today
|
||||
|
||||
Outputs JSON array of events with added 'calendar' field.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Paths
|
||||
# ----------------------------------------------------------------
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
TOKEN_PATH = HERMES_HOME / "google_token.json"
|
||||
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents",
|
||||
]
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Load and refresh credentials."""
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
if not TOKEN_PATH.exists():
|
||||
print("[]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
# Save refreshed token
|
||||
TOKEN_PATH.write_text(
|
||||
json.dumps(
|
||||
{**json.loads(creds.to_json()), "type": "authorized_user"},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return creds
|
||||
|
||||
|
||||
def get_all_calendar_events(date_id_str=None):
|
||||
"""
|
||||
Discover all calendars and get today's events from each.
|
||||
Returns list of events with 'calendar' field added.
|
||||
"""
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
if not date_id_str:
|
||||
date_id_str = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
dt = datetime.strptime(date_id_str, "%Y%m%d")
|
||||
# Use Toronto timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
tz = ZoneInfo("America/Toronto")
|
||||
|
||||
# Start of day in Toronto time
|
||||
start_dt = datetime(dt.year, dt.month, dt.day, tzinfo=tz)
|
||||
end_dt = start_dt + timedelta(days=1)
|
||||
|
||||
time_min = start_dt.isoformat()
|
||||
time_max = end_dt.isoformat()
|
||||
|
||||
creds = get_credentials()
|
||||
service = build("calendar", "v3", credentials=creds)
|
||||
|
||||
# 1. Discover all calendars
|
||||
cal_list = service.calendarList().list().execute()
|
||||
calendars = cal_list.get("items", [])
|
||||
|
||||
all_events = []
|
||||
for cal in calendars:
|
||||
cal_id = cal["id"]
|
||||
cal_name = cal.get("summary", cal_id)
|
||||
cal_selected = cal.get("selected", True) # user has this calendar visible
|
||||
|
||||
if not cal_selected:
|
||||
continue # skip hidden calendars
|
||||
|
||||
try:
|
||||
result = service.events().list(
|
||||
calendarId=cal_id,
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
maxResults=50,
|
||||
).execute()
|
||||
except Exception:
|
||||
continue # skip inaccessible calendars
|
||||
|
||||
for ev in result.get("items", []):
|
||||
ev["calendar"] = cal_name # add calendar name
|
||||
all_events.append({
|
||||
"id": ev["id"],
|
||||
"summary": ev.get("summary", "(no title)"),
|
||||
"start": ev.get("start", {}).get("dateTime", ev.get("start", {}).get("date", "")),
|
||||
"end": ev.get("end", {}).get("dateTime", ev.get("end", {}).get("date", "")),
|
||||
"location": ev.get("location", ""),
|
||||
"description": ev.get("description", ""),
|
||||
"calendar": cal_name,
|
||||
})
|
||||
|
||||
return all_events
|
||||
|
||||
|
||||
def main():
|
||||
date_id_str = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
events = get_all_calendar_events(date_id_str)
|
||||
print(json.dumps(events, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,972 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a daily briefing markdown file and print the ObsiGate link.
|
||||
|
||||
Usage:
|
||||
python3 generate_briefing.py # today's briefing
|
||||
python3 generate_briefing.py 20260520 # specific date (YYYYMMDD)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Config
|
||||
# ----------------------------------------------------------------
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
GAPI = HERMES_HOME / "skills/productivity/google-workspace/scripts/google_api.py"
|
||||
VAULT_BASE = Path("/NFS/OBSIDIAN_DOC/Obsidian_IT")
|
||||
BRIEFING_DIR = VAULT_BASE / "05_Journal_Quotidien/5.1_Quotidien/HermesBerriPi/briefing-matin"
|
||||
OBSIGATE_BASE = "https://og.dracodev.net/popout/IT"
|
||||
|
||||
# Beloeil, QC
|
||||
LAT, LON = "45.5667", "-73.2"
|
||||
|
||||
# Calendar emoji map (by summary keyword)
|
||||
CAL_EMOJI = {
|
||||
"famille": "👪",
|
||||
"travail": "💼",
|
||||
"desjardins": "💼",
|
||||
"finances": "💰",
|
||||
"paie": "💰",
|
||||
}
|
||||
|
||||
# Email action prefixes (from GitHub notification subjects)
|
||||
ACTION_PREFIXES = {
|
||||
"PR #": "🔀",
|
||||
"Issue #": "💬",
|
||||
"Merged #": "✅",
|
||||
"pushé": "📌",
|
||||
"commit": "📌",
|
||||
}
|
||||
|
||||
# Weather WMO codes
|
||||
WMO_EMOJI = {
|
||||
0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️",
|
||||
45: "🌫️", 48: "🌫️",
|
||||
51: "🌧️", 53: "🌧️", 55: "🌧️",
|
||||
61: "🌧️", 63: "🌧️", 65: "🌧️",
|
||||
71: "❄️", 73: "❄️", 75: "❄️",
|
||||
80: "🌧️", 81: "🌧️", 82: "🌧️",
|
||||
85: "❄️", 86: "❄️",
|
||||
95: "⛈️", 96: "⛈️", 99: "⛈️",
|
||||
}
|
||||
WMO_LABELS = {
|
||||
0: "Ciel clair", 1: "Peu nuageux", 2: "Partiellement nuageux",
|
||||
3: "Nuageux", 45: "Brouillard", 48: "Brouillard givrant",
|
||||
51: "Bruine légère", 53: "Bruine modérée", 55: "Bruine dense",
|
||||
61: "Pluie légère", 63: "Pluie modérée", 65: "Pluie forte",
|
||||
71: "Neige légère", 73: "Neige modérée", 75: "Neige forte",
|
||||
80: "Averses légères", 81: "Averses modérées", 82: "Averses violentes",
|
||||
85: "Neige légère", 86: "Neige forte",
|
||||
95: "Orage", 96: "Orage + grêle légère", 99: "Orage + grêle forte",
|
||||
}
|
||||
|
||||
|
||||
def shell(cmd, **kwargs):
|
||||
"""Run a shell command and return stdout, exit_code."""
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True,
|
||||
timeout=kwargs.get("timeout", 30)
|
||||
)
|
||||
return result.stdout.strip(), result.returncode
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Section generators
|
||||
# ================================================================
|
||||
|
||||
def get_date(date_id=None):
|
||||
"""Return (date_id_yyyymmdd, day_fr, date_fr, jour_complet)."""
|
||||
if date_id:
|
||||
dt = datetime.strptime(date_id, "%Y%m%d")
|
||||
else:
|
||||
dt = datetime.now()
|
||||
|
||||
jours_fr = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
|
||||
mois_fr = ["janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
|
||||
|
||||
date_id_str = dt.strftime("%Y%m%d")
|
||||
jour = jours_fr[dt.weekday()]
|
||||
date_str = f"{jour} {dt.day} {mois_fr[dt.month-1]} {dt.year}"
|
||||
return date_id_str, jour.capitalize(), date_str, f"{jour.capitalize()} {dt.day} {mois_fr[dt.month-1]} {dt.year}"
|
||||
|
||||
|
||||
def get_citation():
|
||||
"""Fetch random quote from zenquotes.io."""
|
||||
try:
|
||||
out, _ = shell("curl -s --max-time 5 'https://zenquotes.io/api/random'")
|
||||
data = json.loads(out)
|
||||
if data and isinstance(data, list):
|
||||
q = data[0]["q"]
|
||||
a = data[0]["a"]
|
||||
return q, a
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
return (
|
||||
"La meilleure façon de prédire l'avenir est de le créer.",
|
||||
"Peter Drucker"
|
||||
)
|
||||
|
||||
|
||||
def get_emails(max_count=5):
|
||||
"""Fetch unread emails and format as markdown table rows."""
|
||||
try:
|
||||
out, _ = shell(
|
||||
f"cd {GAPI.parent} && python3 {GAPI.name} gmail search 'is:unread' --max {max_count} 2>&1"
|
||||
)
|
||||
data = json.loads(out)
|
||||
if not data:
|
||||
return None, 0
|
||||
|
||||
rows = []
|
||||
for i, email in enumerate(data, 1):
|
||||
sender = email.get("from", "Inconnu")
|
||||
# Clean sender: remove email address, keep name
|
||||
if "<" in sender:
|
||||
sender = sender.split("<")[0].strip().strip('"')
|
||||
|
||||
subject = email.get("subject", "(Sans objet)")
|
||||
snippet = email.get("snippet", "")[:100]
|
||||
|
||||
# Action prefix
|
||||
prefix = ""
|
||||
subj_lower = subject.lower()
|
||||
snippet_lower = snippet.lower()
|
||||
if "pr #" in subj_lower:
|
||||
if "merged" in snippet_lower:
|
||||
prefix = "✅ "
|
||||
else:
|
||||
prefix = "🔀 "
|
||||
elif "issue #" in subj_lower:
|
||||
prefix = "💬 "
|
||||
elif "push" in snippet_lower or "commit" in snippet_lower:
|
||||
prefix = "📌 "
|
||||
|
||||
rows.append(f"| {i} | **{sender[:30]}** | `{subject[:50]}` | {prefix}{snippet[:80]} |")
|
||||
|
||||
return rows, len(data)
|
||||
except Exception as e:
|
||||
return None, 0
|
||||
|
||||
|
||||
def get_agenda(date_id_str):
|
||||
"""Fetch today's calendar events from ALL calendars."""
|
||||
try:
|
||||
# Use the dedicated script
|
||||
script = HERMES_HOME / "skills/productivity/briefing-quotidien/scripts/agenda_all_calendars.py"
|
||||
if script.exists():
|
||||
out, _ = shell(f"python3 {script} {date_id_str} 2>&1")
|
||||
else:
|
||||
# Fallback: use google_api.py with primary + common calendars
|
||||
out, _ = shell(
|
||||
f"cd {GAPI.parent} && python3 {GAPI.name} calendar list --max 20 2>&1"
|
||||
)
|
||||
|
||||
data = json.loads(out)
|
||||
if not data:
|
||||
return None, 0
|
||||
|
||||
# Filter today's events (date_id_str = YYYYMMDD)
|
||||
today_events = []
|
||||
for ev in data:
|
||||
start = ev.get("start", "")
|
||||
if date_id_str in start.replace("-", ""):
|
||||
today_events.append(ev)
|
||||
|
||||
if not today_events:
|
||||
return None, 0
|
||||
|
||||
rows = []
|
||||
for ev in today_events:
|
||||
start = ev.get("start", "")
|
||||
summary = ev.get("summary", "(Sans titre)")
|
||||
|
||||
# Format time
|
||||
if "T" in start:
|
||||
time_part = start.split("T")[1][:5]
|
||||
time_str = f"🕐 **{time_part}**"
|
||||
else:
|
||||
time_str = "📆 Journée"
|
||||
|
||||
# Determine category emoji
|
||||
summary_lower = summary.lower()
|
||||
cat_emoji = "📅" # default
|
||||
for keyword, emoji in CAL_EMOJI.items():
|
||||
if keyword in summary_lower:
|
||||
cat_emoji = emoji
|
||||
break
|
||||
|
||||
# Also check calendar name if available
|
||||
cal_name = ev.get("calendar", "").lower()
|
||||
for keyword, emoji in CAL_EMOJI.items():
|
||||
if keyword in cal_name:
|
||||
cat_emoji = emoji
|
||||
break
|
||||
|
||||
rows.append(f"| {time_str} | {summary} | {cat_emoji} |")
|
||||
|
||||
return rows, len(today_events)
|
||||
except Exception as e:
|
||||
# If agenda_all_calendars.py doesn't exist, try direct approach
|
||||
return None, 0
|
||||
|
||||
|
||||
def get_meteo():
|
||||
"""Fetch weather for Beloeil from Open-Meteo."""
|
||||
try:
|
||||
out, _ = shell(
|
||||
f"curl -s --max-time 5 "
|
||||
f"'https://api.open-meteo.com/v1/forecast?"
|
||||
f"latitude={LAT}&longitude={LON}"
|
||||
f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m,weather_code"
|
||||
f"&daily=temperature_2m_max,temperature_2m_min"
|
||||
f"&timezone=America%2FToronto&forecast_days=3'"
|
||||
)
|
||||
data = json.loads(out)
|
||||
|
||||
current = data.get("current", {})
|
||||
daily = data.get("daily", {})
|
||||
|
||||
wmo = current.get("weather_code", 0)
|
||||
emoji = WMO_EMOJI.get(wmo, "❓")
|
||||
label = WMO_LABELS.get(wmo, "Inconnu")
|
||||
temp = current.get("temperature_2m", "?")
|
||||
feels = current.get("apparent_temperature", "?")
|
||||
wind = current.get("wind_speed_10m", "?")
|
||||
hum = current.get("relative_humidity_2m", "?")
|
||||
|
||||
# Current table
|
||||
current_row = f"| **Aujourd'hui** | {emoji} {label} | **{temp}°C** | {feels}°C | {wind} km/h | {hum}% |"
|
||||
|
||||
# Forecast table
|
||||
jours = ["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."]
|
||||
forecast_rows = []
|
||||
for i in range(1, min(3, len(daily.get("time", [])))):
|
||||
date_str = daily["time"][i]
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
||||
jour = jours[dt.weekday()]
|
||||
tmax = daily["temperature_2m_max"][i]
|
||||
tmin = daily["temperature_2m_min"][i]
|
||||
label_jour = "Demain" if i == 1 else "Après-demain"
|
||||
forecast_rows.append(f"| 📅 **{label_jour}** ({jour}) | {tmin}°C | {tmax}°C |")
|
||||
|
||||
return current_row, forecast_rows
|
||||
except Exception as e:
|
||||
return None, None
|
||||
|
||||
|
||||
def get_system():
|
||||
"""Check Hermes services and system resources."""
|
||||
# Services
|
||||
services = ["hermes-gateway", "hermes-dashboard", "hermes-workspace"]
|
||||
svc_rows = []
|
||||
svc_ok = 0
|
||||
for svc in services:
|
||||
out, rc = shell(f"systemctl --user is-active {svc} 2>/dev/null")
|
||||
if out == "active":
|
||||
svc_rows.append(f"| {svc.replace('hermes-', '').capitalize()} | 🟢 |")
|
||||
svc_ok += 1
|
||||
else:
|
||||
svc_rows.append(f"| {svc.replace('hermes-', '').capitalize()} | 🔴 |")
|
||||
|
||||
svc_status = f"🟢 {svc_ok}/3 en ligne" if svc_ok == 3 else f"⚠️ {svc_ok}/3 en ligne"
|
||||
|
||||
# Resources
|
||||
try:
|
||||
# CPU
|
||||
out, _ = shell("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'")
|
||||
cpu = out.replace("%", "").strip() if out else "?"
|
||||
cpu = f"{cpu}% `us`" if cpu and cpu != "?" else "?"
|
||||
|
||||
# RAM
|
||||
out, _ = shell("free -m | awk 'NR==2{printf \"%.0f\", $3*100/$2}'")
|
||||
ram = f"{out}%" if out else "?"
|
||||
|
||||
# Disk
|
||||
out, _ = shell("df -h / | awk 'NR==2{print $5}' | sed 's/%//'")
|
||||
disk = f"{out}%" if out else "?"
|
||||
|
||||
# Uptime
|
||||
out, _ = shell("uptime -p | sed 's/up //'")
|
||||
uptime = out if out else "?"
|
||||
except Exception:
|
||||
cpu = ram = disk = uptime = "?"
|
||||
|
||||
return svc_status, svc_rows, cpu, ram, disk, uptime
|
||||
|
||||
|
||||
def get_docker_services():
|
||||
"""List running Docker containers with status and uptime."""
|
||||
try:
|
||||
out, rc = shell("docker ps --format '{{.Names}}\t{{.Status}}\t{{.Image}}' 2>/dev/null")
|
||||
if rc != 0 or not out:
|
||||
return None, 0
|
||||
|
||||
containers = []
|
||||
for line in out.split("\n"):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
name = parts[0]
|
||||
status = parts[1]
|
||||
# Parse status: "Up 2 hours" or "Up 3 days (healthy)"
|
||||
is_healthy = "(healthy)" in status or "(unhealthy)" not in status
|
||||
is_up = status.startswith("Up")
|
||||
emoji = "🟢" if (is_up and is_healthy) else ("🟡" if is_up else "🔴")
|
||||
containers.append((name, emoji, status))
|
||||
|
||||
return containers, len(containers)
|
||||
except Exception:
|
||||
return None, 0
|
||||
|
||||
|
||||
def get_docker_stats():
|
||||
"""Get resource usage stats for Docker containers."""
|
||||
try:
|
||||
out, rc = shell(
|
||||
"docker stats --no-stream --format "
|
||||
"'{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.MemUsage}}\t{{.NetIO}}' 2>/dev/null"
|
||||
)
|
||||
if rc != 0 or not out:
|
||||
return None
|
||||
|
||||
stats = []
|
||||
for line in out.split("\n"):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 4:
|
||||
# Clean up: remove spaces in percentages, trim
|
||||
name = parts[0]
|
||||
cpu = parts[1].strip()
|
||||
mem_pct = parts[2].strip()
|
||||
# NetIO: "1.2MB / 3.4MB" → take IN only
|
||||
net = parts[4].strip() if len(parts) >= 5 else "—"
|
||||
net_in = net.split(" / ")[0] if " / " in net else net
|
||||
stats.append((name, cpu, mem_pct, net_in))
|
||||
|
||||
return stats
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_honcho_stats():
|
||||
"""Query Honcho API for workspace statistics."""
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
BASE = "http://localhost:8000/v3"
|
||||
WS = "hermes"
|
||||
|
||||
def post_json(path, data=None):
|
||||
body = json.dumps(data or {}).encode()
|
||||
req = urllib.request.Request(f"{BASE}{path}", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
return json.loads(resp.read())
|
||||
|
||||
# Health check
|
||||
try:
|
||||
req = urllib.request.urlopen("http://localhost:8000/health", timeout=3)
|
||||
health = json.loads(req.read())
|
||||
api_ok = health.get("status") == "ok"
|
||||
except Exception:
|
||||
api_ok = False
|
||||
|
||||
# Peers
|
||||
try:
|
||||
data = post_json(f"/workspaces/{WS}/peers/list")
|
||||
items = data.get("items", data)
|
||||
nb_peers = len(items) if isinstance(items, list) else "?"
|
||||
except Exception:
|
||||
nb_peers = "?"
|
||||
|
||||
# Sessions
|
||||
try:
|
||||
data = post_json(f"/workspaces/{WS}/sessions/list")
|
||||
items = data.get("items", data)
|
||||
nb_sessions = len(items) if isinstance(items, list) else "?"
|
||||
except Exception:
|
||||
nb_sessions = "?"
|
||||
|
||||
# Conclusions (observations)
|
||||
try:
|
||||
data = post_json(f"/workspaces/{WS}/conclusions/list")
|
||||
items = data.get("items", data)
|
||||
nb_conclusions = len(items) if isinstance(items, list) else "?"
|
||||
except Exception:
|
||||
nb_conclusions = "?"
|
||||
|
||||
return {
|
||||
"api": api_ok,
|
||||
"peers": nb_peers,
|
||||
"sessions": nb_sessions,
|
||||
"conclusions": nb_conclusions,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_priorities():
|
||||
"""Read priorities file if it exists."""
|
||||
priorities_file = Path.home() / ".hermes" / "priorities.txt"
|
||||
if priorities_file.exists():
|
||||
return priorities_file.read_text().strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_news_freshrss():
|
||||
"""Collect news from FreshRSS via Fever API, grouped by FreshRSS categories.
|
||||
Returns dict: {category_name: [(title, url, source, cat_emoji), ...]}
|
||||
Falls back to None if FreshRSS is unavailable."""
|
||||
try:
|
||||
FRESHRSS_API = "http://localhost:8088/api/fever.php"
|
||||
API_KEY = "c2cc2566601a7b5c6f28fcc8268786d4" # md5("admin:freshrss2026")
|
||||
|
||||
# 1. Get all unread item IDs
|
||||
data = f"api&email=admin&api_key={API_KEY}&unread_item_ids".encode()
|
||||
req = Request(FRESHRSS_API, data=data)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
unread_ids = result.get("unread_item_ids", "")
|
||||
if not unread_ids:
|
||||
return None
|
||||
all_ids = unread_ids.split(",")
|
||||
|
||||
# Sample evenly across the full ID range to hit all feeds/categories.
|
||||
# FreshRSS returns IDs sorted by date (newest first), so taking only
|
||||
# the first N would capture only the most active feed (usually International).
|
||||
# By sampling every Nth ID we span the full time range and get all categories.
|
||||
MAX_SAMPLE = 200
|
||||
step = max(1, len(all_ids) // MAX_SAMPLE)
|
||||
sampled_ids = all_ids[::step][:MAX_SAMPLE]
|
||||
|
||||
# 2. Get feeds and groups first (to know which feeds exist)
|
||||
data = f"api&email=admin&api_key={API_KEY}&feeds&groups".encode()
|
||||
req = Request(FRESHRSS_API, data=data)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
|
||||
feeds = {f["id"]: f for f in result.get("feeds", [])}
|
||||
groups = {g["id"]: g["title"] for g in result.get("groups", [])}
|
||||
feeds_groups = {}
|
||||
for fg in result.get("feeds_groups", []):
|
||||
for fid in fg["feed_ids"].split(","):
|
||||
fid_int = int(fid) if fid.isdigit() else fid
|
||||
feeds_groups[fid_int] = fg["group_id"]
|
||||
|
||||
# 3. Get items for sampled IDs
|
||||
data = f"api&email=admin&api_key={API_KEY}&items&with_ids={','.join(sampled_ids)}".encode()
|
||||
req = Request(FRESHRSS_API, data=data)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read())
|
||||
items = result.get("items", [])
|
||||
|
||||
# Category emoji mapping
|
||||
CAT_EMOJI = {
|
||||
"International": "🌍",
|
||||
"Regional": "🇨🇦",
|
||||
"Sciences": "🔬",
|
||||
"Technologies": "💻",
|
||||
"Développement": "⚙️",
|
||||
"Intelligence Artificielle": "🤖",
|
||||
}
|
||||
MAX_PER_CATEGORY = 5 # Keep briefing concise but diverse
|
||||
|
||||
# Group items by category
|
||||
cat_items = {}
|
||||
for item in items:
|
||||
fid = item.get("feed_id")
|
||||
if fid not in feeds:
|
||||
continue
|
||||
feed = feeds[fid]
|
||||
gid = feeds_groups.get(fid, 1)
|
||||
cat_name = groups.get(gid, "Sans catégorie")
|
||||
if cat_name == "Sans catégorie":
|
||||
continue # Skip uncategorized items
|
||||
emoji = CAT_EMOJI.get(cat_name, "📰")
|
||||
|
||||
if cat_name not in cat_items:
|
||||
cat_items[cat_name] = []
|
||||
if len(cat_items[cat_name]) >= MAX_PER_CATEGORY:
|
||||
continue # Limit per category
|
||||
|
||||
cat_items[cat_name].append((
|
||||
item.get("title", "")[:120],
|
||||
item.get("html_url", item.get("url", "")),
|
||||
feed.get("title", ""),
|
||||
emoji,
|
||||
))
|
||||
|
||||
return cat_items if cat_items else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_news_rss():
|
||||
"""Collect tech/AI news from RSS feeds (fallback when FreshRSS is unavailable).
|
||||
Returns list of (title, url, source, category)."""
|
||||
FEEDS = [
|
||||
("https://feeds.arstechnica.com/arstechnica/index", "Ars Technica", "🔬"),
|
||||
("https://www.theverge.com/rss/index.xml", "The Verge", "💻"),
|
||||
("https://news.mit.edu/topic/mitartificial-intelligence2-rss.xml", "MIT News", "🔬"),
|
||||
("https://techcrunch.com/feed/", "TechCrunch", "🏛️"),
|
||||
("https://www.wired.com/feed/category/security/latest/rss", "Wired Security", "🛡️"),
|
||||
]
|
||||
|
||||
items = []
|
||||
for url, source, default_cat in FEEDS:
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "HermesBerryPi/1.0"})
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
tree = ET.fromstring(resp.read())
|
||||
|
||||
# RSS 2.0
|
||||
for item_elem in tree.iter("item"):
|
||||
title = item_elem.find("title")
|
||||
link = item_elem.find("link")
|
||||
title_text = title.text.strip() if title is not None and title.text else ""
|
||||
link_text = link.text.strip() if link is not None and link.text else ""
|
||||
if title_text and link_text:
|
||||
# Catégorie basée sur mots-clés dans le titre
|
||||
title_lower = title_text.lower()
|
||||
if any(w in title_lower for w in ["ai ", "gpt", "llm", "model", "machine learning", "deep learning", "neural"]):
|
||||
cat = "🔬"
|
||||
elif any(w in title_lower for w in ["security", "hack", "vuln", "cyber", "breach", "ransom"]):
|
||||
cat = "🛡️"
|
||||
elif any(w in title_lower for w in ["lawsuit", "regulation", "eu ", "ftc", "privacy", "copyright"]):
|
||||
cat = "🏛️"
|
||||
else:
|
||||
cat = default_cat
|
||||
items.append((title_text[:120], link_text, source, cat))
|
||||
|
||||
# Atom feed
|
||||
if not items or all(i[2] != source for i in items[-5:]):
|
||||
for entry in tree.iter("{http://www.w3.org/2005/Atom}entry"):
|
||||
title = entry.find("{http://www.w3.org/2005/Atom}title")
|
||||
link = entry.find("{http://www.w3.org/2005/Atom}link")
|
||||
title_text = title.text.strip() if title is not None and title.text else ""
|
||||
link_text = link.get("href", "").strip() if link is not None else ""
|
||||
if title_text and link_text:
|
||||
title_lower = title_text.lower()
|
||||
if any(w in title_lower for w in ["ai ", "gpt", "llm", "model"]):
|
||||
cat = "🔬"
|
||||
elif any(w in title_lower for w in ["security", "hack", "vuln"]):
|
||||
cat = "🛡️"
|
||||
else:
|
||||
cat = default_cat
|
||||
items.append((title_text[:120], link_text, source, cat))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Dédupliquer par titre (similarité simple)
|
||||
seen = set()
|
||||
unique = []
|
||||
for title, url, source, cat in items:
|
||||
key = title[:50].lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append((title, url, source, cat))
|
||||
|
||||
return unique[:10] # Max 10 articles
|
||||
|
||||
|
||||
def get_honcho_advanced():
|
||||
"""Query Honcho API for detailed workspace statistics."""
|
||||
try:
|
||||
import urllib.request as ur
|
||||
BASE = "http://localhost:8000/v3"
|
||||
WS = "hermes"
|
||||
|
||||
def post_json(path, data=None):
|
||||
body = json.dumps(data or {}).encode()
|
||||
req = ur.Request(f"{BASE}{path}", data=body, method="POST",
|
||||
headers={"Content-Type": "application/json"})
|
||||
resp = ur.urlopen(req, timeout=5)
|
||||
return json.loads(resp.read())
|
||||
|
||||
stats = {}
|
||||
|
||||
# Health
|
||||
try:
|
||||
req = ur.urlopen("http://localhost:8000/health", timeout=3)
|
||||
health = json.loads(req.read())
|
||||
stats["api"] = health.get("status") == "ok"
|
||||
ver = health.get("version", "")
|
||||
if not ver:
|
||||
# Try /v3/workspaces/{WS} for version info
|
||||
try:
|
||||
ws_data = post_json(f"/workspaces/{WS}")
|
||||
ver = ws_data.get("version", "")
|
||||
except Exception:
|
||||
pass
|
||||
stats["version"] = ver if ver else "?"
|
||||
except Exception:
|
||||
stats["api"] = False
|
||||
stats["version"] = "?"
|
||||
|
||||
# Peers
|
||||
try:
|
||||
data = post_json(f"/workspaces/{WS}/peers/list")
|
||||
items = data.get("items", data)
|
||||
stats["peers"] = len(items) if isinstance(items, list) else "?"
|
||||
except Exception:
|
||||
stats["peers"] = "?"
|
||||
|
||||
# Sessions
|
||||
try:
|
||||
data = post_json(f"/workspaces/{WS}/sessions/list")
|
||||
items = data.get("items", data)
|
||||
stats["sessions"] = len(items) if isinstance(items, list) else "?"
|
||||
|
||||
# Sessions actives (7 derniers jours)
|
||||
cutoff = (datetime.now() - timedelta(days=7)).isoformat()
|
||||
recent = 0
|
||||
if isinstance(items, list):
|
||||
for s in items:
|
||||
created = s.get("created_at", s.get("created", ""))
|
||||
if created and created > cutoff:
|
||||
recent += 1
|
||||
stats["sessions_recent"] = recent
|
||||
except Exception:
|
||||
stats["sessions"] = "?"
|
||||
stats["sessions_recent"] = "?"
|
||||
|
||||
# Conclusions
|
||||
try:
|
||||
data = post_json(f"/workspaces/{WS}/conclusions/list")
|
||||
items = data.get("items", data)
|
||||
stats["conclusions"] = len(items) if isinstance(items, list) else "?"
|
||||
except Exception:
|
||||
stats["conclusions"] = "?"
|
||||
|
||||
# DB size
|
||||
try:
|
||||
out, _ = shell("du -sh /home/bruno/workspace/honcho/ 2>/dev/null | cut -f1")
|
||||
stats["db_size"] = out if out else "?"
|
||||
except Exception:
|
||||
stats["db_size"] = "?"
|
||||
|
||||
return stats
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_system_advanced():
|
||||
"""Get advanced system metrics (temperature, load, I/O)."""
|
||||
metrics = {}
|
||||
|
||||
# Température CPU (Raspberry Pi)
|
||||
try:
|
||||
out, rc = shell("vcgencmd measure_temp 2>/dev/null")
|
||||
if rc == 0 and out:
|
||||
metrics["cpu_temp"] = out.replace("temp=", "").replace("'C", "°C")
|
||||
else:
|
||||
metrics["cpu_temp"] = "?"
|
||||
except Exception:
|
||||
metrics["cpu_temp"] = "?"
|
||||
|
||||
# Load average
|
||||
try:
|
||||
out, rc = shell("cat /proc/loadavg 2>/dev/null | awk '{print $1, $2, $3}'")
|
||||
metrics["load"] = out if rc == 0 and out else "?"
|
||||
except Exception:
|
||||
metrics["load"] = "?"
|
||||
|
||||
# Disk I/O
|
||||
try:
|
||||
out, rc = shell("iostat -d sda 2>/dev/null | awk 'NR==4{print $3, $4}' || iostat -d mmcblk0 2>/dev/null | awk 'NR==4{print $3, $4}'")
|
||||
if rc == 0 and out:
|
||||
metrics["disk_io"] = out
|
||||
else:
|
||||
# Fallback: /proc/diskstats
|
||||
out2, _ = shell("awk '{if($3==\"mmcblk0\"||$3==\"sda\") printf \"%.1f MB/s\", $7*512/1024/1024}' /proc/diskstats 2>/dev/null")
|
||||
metrics["disk_io"] = out2 if out2 else "?"
|
||||
except Exception:
|
||||
metrics["disk_io"] = "?"
|
||||
|
||||
# Swap
|
||||
try:
|
||||
out, rc = shell("free -m | awk 'NR==3{printf \"%.0f/%.0fMB\", $3, $2}'")
|
||||
metrics["swap"] = out if rc == 0 and out else "?"
|
||||
except Exception:
|
||||
metrics["swap"] = "?"
|
||||
|
||||
# Uptime
|
||||
try:
|
||||
out, rc = shell("uptime -p | sed 's/up //'")
|
||||
metrics["uptime"] = out if rc == 0 and out else "?"
|
||||
except Exception:
|
||||
metrics["uptime"] = "?"
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def get_docker_advanced():
|
||||
"""Get detailed Docker container info with ports and health."""
|
||||
try:
|
||||
out, rc = shell("docker ps --format '{{.Names}}\\t{{.Status}}\\t{{.Image}}\\t{{.Ports}}' 2>/dev/null")
|
||||
if rc != 0 or not out:
|
||||
return None, 0
|
||||
|
||||
containers = []
|
||||
for line in out.split("\n"):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
name = parts[0]
|
||||
status = parts[1]
|
||||
image = parts[2] if len(parts) > 2 else "?"
|
||||
ports = parts[3] if len(parts) > 3 else "—"
|
||||
|
||||
# Health status
|
||||
is_healthy = "(healthy)" in status
|
||||
is_unhealthy = "(unhealthy)" in status
|
||||
is_up = status.startswith("Up")
|
||||
|
||||
if is_up and is_healthy:
|
||||
emoji = "🟢"
|
||||
elif is_up and is_unhealthy:
|
||||
emoji = "🟡"
|
||||
elif is_up:
|
||||
emoji = "🟢" # No healthcheck = assume OK
|
||||
else:
|
||||
emoji = "🔴"
|
||||
|
||||
# Clean ports display
|
||||
if ports and ports != "—" and len(ports) > 20:
|
||||
# Take first port mapping only
|
||||
ports = ports.split(",")[0].strip()
|
||||
|
||||
containers.append((name, emoji, status, image, ports))
|
||||
|
||||
return containers, len(containers)
|
||||
except Exception:
|
||||
return None, 0
|
||||
|
||||
|
||||
def build_markdown(date_id_str, jour, date_complete):
|
||||
"""Build the complete briefing markdown."""
|
||||
|
||||
parts = []
|
||||
|
||||
# ---- Header ----
|
||||
parts.append(f"# 📅 Briefing Matinal\n")
|
||||
parts.append(f"> **{date_complete}** · 🕖 07:00 · `America/Toronto`\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Citation ----
|
||||
quote, author = get_citation()
|
||||
parts.append(f"# 💡 Motivation du jour\n")
|
||||
parts.append(f"> *\"{quote}\"*\n")
|
||||
parts.append(f"> — **{author}**\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Emails ----
|
||||
parts.append(f"# 📧 Emails récents\n")
|
||||
emails, nb_emails = get_emails()
|
||||
if emails:
|
||||
parts.append(f"| # | Expéditeur | Sujet | Résumé |")
|
||||
parts.append(f"|:-:|:-----------|:------|:-------|")
|
||||
parts.extend(emails)
|
||||
parts.append("")
|
||||
else:
|
||||
parts.append("📭 Aucun email non lu.\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Agenda ----
|
||||
parts.append(f"# 📅 Agenda du jour\n")
|
||||
agenda, nb_events = get_agenda(date_id_str)
|
||||
if agenda:
|
||||
parts.append(f"| Heure | Événement | Catégorie |")
|
||||
parts.append(f"|:-----:|:----------|:---------:|")
|
||||
parts.extend(agenda)
|
||||
parts.append("")
|
||||
else:
|
||||
parts.append("📅 Aucun événement aujourd'hui.\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Météo ----
|
||||
parts.append(f"# 🌤️ Météo — Beloeil\n")
|
||||
current_row, forecast_rows = get_meteo()
|
||||
if current_row:
|
||||
parts.append(f"| | Condition | Temp. | Ressenti | Vent | Humidité |")
|
||||
parts.append(f"|:-:|:---------:|:-----:|:--------:|:----:|:--------:|")
|
||||
parts.append(current_row)
|
||||
parts.append("")
|
||||
|
||||
if forecast_rows:
|
||||
parts.append(f"| | 🌡️ Min | 🌡️ Max |")
|
||||
parts.append(f"|:-:|:------:|:------:|")
|
||||
parts.extend(forecast_rows)
|
||||
parts.append("")
|
||||
else:
|
||||
parts.append("🌤️ Météo non disponible.\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- System ----
|
||||
parts.append(f"# 🖥️ HermesBerryPi\n")
|
||||
svc_status, svc_rows, cpu, ram, disk, uptime = get_system()
|
||||
sys_adv = get_system_advanced()
|
||||
parts.append(f"Services — {svc_status}\n")
|
||||
parts.append(f"| Service | Statut |")
|
||||
parts.append(f"|:--------|:------:|")
|
||||
parts.extend(svc_rows)
|
||||
parts.append("")
|
||||
parts.append(f"Ressources système\n")
|
||||
parts.append(f"| CPU | RAM | Disque | Swap | Uptime |")
|
||||
parts.append(f"|:---:|:---:|:------:|:----:|:------:|")
|
||||
parts.append(f"| {cpu} | {ram} | {disk} | {sys_adv.get('swap', '?')} | {sys_adv.get('uptime', uptime)} |")
|
||||
parts.append("")
|
||||
parts.append(f"| 🌡️ Temp. CPU | 📊 Load (1/5/15) | 💾 I/O Disque |")
|
||||
parts.append(f"|:-------------:|:-----------------:|:-------------:|")
|
||||
parts.append(f"| {sys_adv.get('cpu_temp', '?')} | {sys_adv.get('load', '?')} | {sys_adv.get('disk_io', '?')} |")
|
||||
parts.append("")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Docker ----
|
||||
parts.append(f"# 🐳 Docker\n")
|
||||
containers, nb_containers = get_docker_advanced()
|
||||
if containers:
|
||||
stats = get_docker_stats() or []
|
||||
stat_map = {s[0]: s for s in stats}
|
||||
|
||||
up_count = sum(1 for _, e, _, _, _ in containers if e == "🟢")
|
||||
parts.append(f"Services — 🟢 {up_count}/{nb_containers} conteneurs actifs\n")
|
||||
parts.append(f"| Conteneur | Image | Statut | CPU | RAM | Réseau IN | Ports |")
|
||||
parts.append(f"|:----------|:------|:------:|:---:|:---:|:----------:|:------|")
|
||||
for name, emoji, status, image, ports in containers:
|
||||
cpu = mem = net = "—"
|
||||
if name in stat_map:
|
||||
_, cpu, mem, net = stat_map[name]
|
||||
# Shorten image name
|
||||
img_short = image.split("/")[-1] if "/" in image else image
|
||||
if ":" in img_short:
|
||||
img_short = img_short.split(":")[0]
|
||||
parts.append(f"| {name} | {img_short} | {emoji} {status} | {cpu} | {mem} | {net} | {ports} |")
|
||||
parts.append("")
|
||||
else:
|
||||
parts.append("🐳 Docker non disponible.\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Honcho ----
|
||||
parts.append(f"# 🧠 Honcho (mémoire IA)\n")
|
||||
honcho = get_honcho_advanced()
|
||||
if honcho:
|
||||
api_emoji = "🟢" if honcho["api"] else "🔴"
|
||||
parts.append(f"| Métrique | Valeur |")
|
||||
parts.append(f"|:---------|:------:|")
|
||||
parts.append(f"| API | {api_emoji} {'OK' if honcho['api'] else 'HORS-LIGNE'} |")
|
||||
parts.append(f"| Peers | {honcho['peers']} |")
|
||||
parts.append(f"| Sessions (total) | {honcho['sessions']} |")
|
||||
parts.append(f"| Sessions (7j) | {honcho.get('sessions_recent', '?')} |")
|
||||
parts.append(f"| Conclusions | {honcho['conclusions']} |")
|
||||
parts.append(f"| Stockage DB | {honcho.get('db_size', '?')} |")
|
||||
parts.append("")
|
||||
else:
|
||||
parts.append("🧠 Honcho non disponible.\n")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Priorities ----
|
||||
parts.append(f"# 🎯 Priorités du jour\n")
|
||||
priorities = get_priorities()
|
||||
if priorities:
|
||||
parts.append(priorities)
|
||||
else:
|
||||
parts.append("")
|
||||
parts.append("⚠️ Aucune priorité définie — pensez à planifier votre journée !")
|
||||
parts.append("")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Actualités RSS (FreshRSS prioritaire, fallback flux directs) ----
|
||||
parts.append(f"# 📡 Actualités RSS\n")
|
||||
parts.append("")
|
||||
|
||||
# Try FreshRSS first
|
||||
freshrss_news = get_news_freshrss()
|
||||
if freshrss_news:
|
||||
total_articles = sum(len(v) for v in freshrss_news.values())
|
||||
for cat_name, articles in freshrss_news.items():
|
||||
if not articles:
|
||||
continue
|
||||
emoji = articles[0][3] if articles else "📰"
|
||||
parts.append(f"{emoji} {cat_name}\n")
|
||||
parts.append(f"| | Titre | Source |")
|
||||
parts.append(f"|:-:|:------|:------:|")
|
||||
for title, url, source, _ in articles[:4]:
|
||||
parts.append(f"| 📰 | [**{title}**]({url}) | {source} |")
|
||||
parts.append("")
|
||||
parts.append(f"_📡 {total_articles} articles via FreshRSS ({len(freshrss_news)} catégories)_")
|
||||
else:
|
||||
# Fallback to direct RSS feeds
|
||||
news = get_news_rss()
|
||||
if news:
|
||||
# Grouper par catégorie
|
||||
categories = {}
|
||||
for title, url, source, cat in news:
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append((title, url, source))
|
||||
|
||||
cat_labels = {"🔬": "Recherche & Produits", "🏛️": "Juridique & Business", "🛡️": "Sécurité & Alignement", "💻": "Tech & Industrie"}
|
||||
for cat, cat_label in cat_labels.items():
|
||||
if cat in categories:
|
||||
parts.append(f"{cat} {cat_label}\n")
|
||||
parts.append(f"| | Titre | Source |")
|
||||
parts.append(f"|:-:|:------|:------:|")
|
||||
for title, url, source in categories[cat][:4]:
|
||||
parts.append(f"| 📰 | [**{title}**]({url}) | {source} |")
|
||||
parts.append("")
|
||||
|
||||
parts.append(f"_📡 {len(news)} articles collectés via flux RSS directs_")
|
||||
else:
|
||||
parts.append("📡 Aucune actualité disponible (FreshRSS et flux RSS inaccessibles).\n")
|
||||
parts.append("")
|
||||
parts.append("---\n")
|
||||
|
||||
# ---- Footer ----
|
||||
parts.append("Bonne journée! ☀️\n")
|
||||
|
||||
return "\n".join(parts), nb_emails, nb_events
|
||||
|
||||
|
||||
def main():
|
||||
date_id_str = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y%m%d")
|
||||
date_id_str, jour, _, date_complete = get_date(date_id_str)
|
||||
|
||||
year = date_id_str[:4]
|
||||
month = date_id_str[4:6]
|
||||
|
||||
# Build markdown
|
||||
md, nb_emails, nb_events = build_markdown(date_id_str, jour, date_complete)
|
||||
|
||||
# Save
|
||||
target_dir = BRIEFING_DIR / year / month
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
filepath = target_dir / f"briefing_{date_id_str}.md"
|
||||
filepath.write_text(md)
|
||||
|
||||
# ObsiGate link
|
||||
rel_path = f"05_Journal_Quotidien/5.1_Quotidien/HermesBerriPi/briefing-matin/{year}/{month}/briefing_{date_id_str}.md"
|
||||
encoded = rel_path.replace("/", "%2F")
|
||||
link = f"{OBSIGATE_BASE}/{encoded}"
|
||||
|
||||
# Output JSON for cron
|
||||
result = {
|
||||
"date_id": date_id_str,
|
||||
"file": str(filepath),
|
||||
"link": link,
|
||||
"emails": nb_emails,
|
||||
"events": nb_events,
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List pending tasks from ALL Google Task lists.
|
||||
|
||||
Usage:
|
||||
python3 tasks_all_lists.py # all non-completed tasks
|
||||
python3 tasks_all_lists.py --all # include completed tasks
|
||||
|
||||
Outputs JSON array of tasks with added 'tasklist' field.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Paths
|
||||
# ----------------------------------------------------------------
|
||||
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
||||
TOKEN_PATH = HERMES_HOME / "google_token.json"
|
||||
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/tasks",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/drive",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents",
|
||||
]
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Load and refresh credentials."""
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
if not TOKEN_PATH.exists():
|
||||
print("[]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
|
||||
if creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
# Save refreshed token
|
||||
TOKEN_PATH.write_text(
|
||||
json.dumps(
|
||||
{**json.loads(creds.to_json()), "type": "authorized_user"},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return creds
|
||||
|
||||
|
||||
def get_all_tasks(include_completed=False):
|
||||
"""
|
||||
Discover all task lists and get pending tasks from each.
|
||||
Returns list of tasks with 'tasklist' field added.
|
||||
"""
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
creds = get_credentials()
|
||||
service = build("tasks", "v1", credentials=creds)
|
||||
|
||||
# 1. Discover all task lists
|
||||
tl_result = service.tasklists().list(maxResults=20).execute()
|
||||
tasklists = tl_result.get("items", [])
|
||||
|
||||
all_tasks = []
|
||||
for tl in tasklists:
|
||||
tl_id = tl["id"]
|
||||
tl_name = tl.get("title", tl_id)
|
||||
|
||||
try:
|
||||
kwargs = {
|
||||
"tasklist": tl_id,
|
||||
"maxResults": 100,
|
||||
}
|
||||
if not include_completed:
|
||||
kwargs["showCompleted"] = False
|
||||
kwargs["showHidden"] = False
|
||||
|
||||
result = service.tasks().list(**kwargs).execute()
|
||||
except Exception:
|
||||
continue # skip inaccessible lists
|
||||
|
||||
for task in result.get("items", []):
|
||||
# Skip completed unless requested
|
||||
if task.get("status") == "completed" and not include_completed:
|
||||
continue
|
||||
|
||||
due = task.get("due", "")
|
||||
# Format due date nicely
|
||||
if due:
|
||||
try:
|
||||
dt = datetime.fromisoformat(due.replace("Z", "+00:00"))
|
||||
due_display = dt.strftime("%Y-%m-%d")
|
||||
# Add overdue marker
|
||||
if dt < datetime.now(timezone.utc) and task.get("status") != "completed":
|
||||
due_display = f"⚠️ {due_display}"
|
||||
except Exception:
|
||||
due_display = due[:10] if len(due) >= 10 else due
|
||||
else:
|
||||
due_display = ""
|
||||
|
||||
all_tasks.append({
|
||||
"id": task["id"],
|
||||
"title": task.get("title", "(no title)"),
|
||||
"notes": task.get("notes", ""),
|
||||
"status": task.get("status", "needsAction"),
|
||||
"due": due_display,
|
||||
"tasklist": tl_name,
|
||||
})
|
||||
|
||||
# Sort: tasks with due dates first, then by due date ascending
|
||||
def sort_key(t):
|
||||
has_due = 0 if t["due"] else 1
|
||||
due_str = t["due"].replace("⚠️ ", "") if t["due"] else "9999"
|
||||
return (has_due, due_str)
|
||||
|
||||
all_tasks.sort(key=sort_key)
|
||||
return all_tasks
|
||||
|
||||
|
||||
def main():
|
||||
include_completed = "--all" in sys.argv
|
||||
tasks = get_all_tasks(include_completed=include_completed)
|
||||
print(json.dumps(tasks, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user