commit ecf9baaa4d668fd8a758e584c2bf87fcccaf5ae6 Author: para <157126561+mdegallaix@users.noreply.github.com> Date: Fri Sep 25 21:36:39 2026 +0200 Add RSS feed generator for commune de Silly (actualites + agenda) Scrapes the imio.smartweb JSON API used by www.silly.be (found via the smartweb-news/smartweb-events web components in the raw HTML) instead of needing a headless browser. Shells out to curl since Cloudflare silently blocks Python's urllib/requests TLS fingerprint. Includes Yunohost cron and nginx deployment snippets. Co-Authored-By: Claude Sonnet 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a6ebe4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.xml +*.log +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a4be0c --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Flux RSS - Commune de Silly + +Genere deux flux RSS 2.0 (actualites + agenda) pour la commune de Silly +(Belgique), a partir de l'API JSON REST (Plone / `imio.smartweb`) utilisee +par www.silly.be — sans navigateur headless. + +Voir [silly_rss.py](silly_rss.py) pour le detail technique (empreinte +Cloudflare, contrainte `b_size=20`, etc.). + +## Utilisation manuelle + +```bash +python3 silly_rss.py /chemin/de/sortie +``` + +Genere `actualites.xml` et `agenda.xml` dans le dossier indique (`.` par +defaut). Aucune dependance externe : Python 3.10+ (stdlib) et `curl` +(present par defaut sur Yunohost). + +## Deploiement sur Yunohost + +1. **Dossier de sortie** + + ```bash + sudo mkdir -p /var/www/silly-rss + sudo chown $(whoami):www-data /var/www/silly-rss + ``` + +2. **Cron** — voir [deploy/silly-rss.cron](deploy/silly-rss.cron) pour la + ligne a ajouter via `crontab -e` (execution toutes les heures). + +3. **nginx** — voir [deploy/nginx-silly-rss.conf](deploy/nginx-silly-rss.conf), + a copier dans `/etc/nginx/conf.d/.d/silly-rss.conf` puis : + + ```bash + sudo nginx -t && sudo systemctl reload nginx + ``` + +4. **Premiere generation** (avant le premier passage du cron) : + + ```bash + python3 silly_rss.py /var/www/silly-rss + ``` + +5. **FreshRSS** — ajouter comme sources : + - `https:///silly-rss/actualites.xml` + - `https:///silly-rss/agenda.xml` + +## Facebook (page communedesilly) + +Pas de solution fiable trouvee : le HTML de facebook.com est vide de +contenu exploitable sans authentification, et l'instance RSS-Bridge testee +(`FacebookBridge`) renvoie une erreur 400. Recommandation : un service +tiers specialise (RSS.app, Fetch RSS) plutot qu'un scraper maison, qui +sera cassé au premier changement de Facebook. diff --git a/deploy/nginx-silly-rss.conf b/deploy/nginx-silly-rss.conf new file mode 100644 index 0000000..262d85a --- /dev/null +++ b/deploy/nginx-silly-rss.conf @@ -0,0 +1,32 @@ +# Extrait nginx pour servir les flux RSS generes par silly_rss.py. +# +# Sur Yunohost, chaque domaine a son propre dossier de fragments de conf, +# automatiquement inclus par le nginx gere par Yunohost : +# /etc/nginx/conf.d/.d/ +# +# Installation : +# 1. Copier ce fichier vers : +# /etc/nginx/conf.d/.d/silly-rss.conf +# 2. Adapter le chemin "alias" ci-dessous si besoin. +# 3. Tester puis recharger nginx : +# sudo nginx -t && sudo systemctl reload nginx +# +# Les flux seront alors accessibles en : +# https:///silly-rss/actualites.xml +# https:///silly-rss/agenda.xml + +location ^~ /silly-rss/ { + alias /var/www/silly-rss/; + autoindex off; + + location ~ \.xml$ { + add_header Content-Type "application/rss+xml; charset=utf-8"; + add_header Cache-Control "public, max-age=300"; + } + + # Bloque tout ce qui n'est pas un flux XML (script, log, etc.) + location ~ \.(py|log|pyc)$ { + deny all; + return 404; + } +} diff --git a/deploy/silly-rss.cron b/deploy/silly-rss.cron new file mode 100644 index 0000000..7a7c46a --- /dev/null +++ b/deploy/silly-rss.cron @@ -0,0 +1,10 @@ +# Cron job pour regenerer les flux RSS de Silly toutes les heures. +# +# Installation (en tant qu'utilisateur qui a les droits d'ecriture sur +# /var/www/silly-rss, par ex. root ou un utilisateur dedie) : +# +# sudo crontab -e +# +# puis coller la ligne ci-dessous en adaptant le chemin du depot : + +0 * * * * flock -n /tmp/silly_rss.lock /usr/bin/python3 /home/para/Code/fluxRss/silly_rss.py /var/www/silly-rss >> /var/log/silly_rss.log 2>&1 diff --git a/silly_rss.py b/silly_rss.py new file mode 100644 index 0000000..09e0996 --- /dev/null +++ b/silly_rss.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Genere deux flux RSS 2.0 (actualites + agenda) pour la commune de Silly (Belgique) +a partir de l'API JSON REST (Plone/imio.smartweb) utilisee par www.silly.be. + +Aucun navigateur headless n'est necessaire : le HTML de www.silly.be/actualites et +/agenda contient les balises / dont l'attribut +query-url pointe directement vers l'API JSON (@results) qui retourne les items. + +Sortie : deux fichiers XML (actualites.xml, agenda.xml) a servir statiquement. +""" +import html +import json +import re +import subprocess +import sys +from datetime import datetime, timezone +from email.utils import format_datetime +from xml.sax.saxutils import escape + +# Le site www.silly.be est derriere Cloudflare, dont le WAF laisse passer +# curl (empreinte TLS/HTTP2 "navigateur") mais bloque silencieusement +# (reponse HTTP 200 avec un corps "{}") les requetes faites avec +# urllib/requests en Python. On shell-oute donc vers curl, qui est +# disponible par defaut sur toute machine Yunohost. +# +# Autre contrainte observee : le WAF ne laisse passer que b_size=20 +# exactement (la valeur utilisee par le composant JS du site) ; toute +# autre valeur de b_size renvoie un corps vide. La pagination se fait +# donc avec b_size=20 fixe + b_start croissant. +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +SITE = "https://www.silly.be" +PAGE_SIZE = 20 +MAX_PAGES = 2 # jusqu'a 40 items par flux + +NEWS_API = f"{SITE}/actualites/@results" +EVENTS_API = f"{SITE}/agenda/@results" + +NEWS_LINK_TMPL = f"{SITE}/actualites?news={{id}}" +EVENT_LINK_TMPL = f"{SITE}/agenda?event={{id}}" + + +def fetch_json(url: str) -> dict: + result = subprocess.run( + ["curl", "-s", "-A", USER_AGENT, "-H", "Accept: application/json", "--fail", url], + capture_output=True, + timeout=30, + check=True, + ) + return json.loads(result.stdout) + + +def fetch_all_items(base_url: str) -> list[dict]: + items: list[dict] = [] + for page in range(MAX_PAGES): + b_start = page * PAGE_SIZE + data = fetch_json(f"{base_url}?b_size={PAGE_SIZE}&b_start={b_start}") + page_items = data.get("items", []) + if not page_items: + break + items.extend(page_items) + if len(items) >= data.get("items_total", 0): + break + return items + + +def strip_html(text: str | None) -> str: + if not text: + return "" + text = re.sub(r"<[^>]+>", " ", text) + text = html.unescape(text) + return re.sub(r"\s+", " ", text).strip() + + +def parse_dt(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def rfc822(dt: datetime | None) -> str: + if dt is None: + dt = datetime.now(timezone.utc) + return format_datetime(dt) + + +def build_news_items() -> list[str]: + entries = [] + for item in fetch_all_items(NEWS_API): + item_id = item.get("id", "") + link = NEWS_LINK_TMPL.format(id=item_id) + title = item.get("title_fr") or item.get("title") or "(sans titre)" + summary = strip_html(item.get("description_fr") or item.get("description")) + body = strip_html(item.get("text_fr", {}).get("data") if isinstance(item.get("text_fr"), dict) else None) + description = summary or body + pub_dt = parse_dt(item.get("effective") or item.get("created")) + guid = item.get("UID") or item_id + entries.append( + " \n" + f" {escape(title)}\n" + f" {escape(link)}\n" + f" {escape(guid)}\n" + f" {rfc822(pub_dt)}\n" + f" {escape(description)}\n" + " " + ) + return entries + + +def format_event_meta(item: dict) -> str: + parts = [] + start = parse_dt(item.get("start")) + end = parse_dt(item.get("end")) + if start: + if item.get("whole_day"): + parts.append(f"Le {start.strftime('%d/%m/%Y')}") + else: + parts.append(f"Le {start.strftime('%d/%m/%Y a %H:%M')}") + if end and end.date() != (start.date() if start else None): + parts.append(f"jusqu'au {end.strftime('%d/%m/%Y %H:%M')}") + address_bits = [item.get("street"), item.get("zipcode"), item.get("city")] + address = ", ".join(b for b in address_bits if b) + if address: + parts.append(f"Lieu : {address}") + return " — ".join(parts) + + +def build_event_items() -> list[str]: + entries = [] + for item in fetch_all_items(EVENTS_API): + event_id = item.get("@id", "").rstrip("/").split("/")[-1] + link = EVENT_LINK_TMPL.format(id=event_id) + title = item.get("title_fr") or item.get("title") or "(sans titre)" + summary = strip_html(item.get("description_fr") or item.get("description")) + meta = format_event_meta(item) + description = " | ".join(p for p in (meta, summary) if p) + pub_dt = parse_dt(item.get("created") or item.get("start")) + guid = item.get("UID") or event_id + entries.append( + " \n" + f" {escape(title)}\n" + f" {escape(link)}\n" + f" {escape(guid)}\n" + f" {rfc822(pub_dt)}\n" + f" {escape(description)}\n" + " " + ) + return entries + + +def write_feed(path: str, title: str, link: str, description: str, items: list[str]) -> None: + now = rfc822(datetime.now(timezone.utc)) + xml = ( + '\n' + '\n' + " \n" + f" {escape(title)}\n" + f" {escape(link)}\n" + f" {escape(description)}\n" + " fr-be\n" + f" {now}\n" + + "\n".join(items) + "\n" + " \n" + "\n" + ) + with open(path, "w", encoding="utf-8") as f: + f.write(xml) + + +def main() -> None: + out_dir = sys.argv[1] if len(sys.argv) > 1 else "." + + news_items = build_news_items() + write_feed( + f"{out_dir}/actualites.xml", + title="Silly.be - Actualités", + link=f"{SITE}/actualites", + description="Actualités de la commune de Silly (Belgique)", + items=news_items, + ) + print(f"[OK] actualites.xml : {len(news_items)} items") + + event_items = build_event_items() + write_feed( + f"{out_dir}/agenda.xml", + title="Silly.be - Agenda", + link=f"{SITE}/agenda", + description="Agenda / événements de la commune de Silly (Belgique)", + items=event_items, + ) + print(f"[OK] agenda.xml : {len(event_items)} items") + + +if __name__ == "__main__": + main()