Intégration du Portail des Partenaires de l'ONU dans la veille financement¶
Date : 2026-05-22
Auteur : Bernard + Claude Code
Statut : ✅ Opérationnel en production
1. Contexte et objectif¶
Système de veille existant¶
Le script veille_appels_projets.py tourne en cron toutes les 48h (09h00) et surveille des sources RSS pour détecter des appels à projets/financements pertinents pour Caritas Bénin. Il filtre par mots-clés (géo, thème, financement) et envoie un email de synthèse.
Besoin¶
Intégrer le Portail des Partenaires de l'ONU (unpartnerportal.org) dans cette veille. Ce portail publie des CFEI (Call for Expression of Interest) — appels à propositions des agences ONU (UNICEF, PNUD, WFP, FAO…) ouverts aux ONGs partenaires enregistrées.
Deux types de CFEI pertinents :
- /cfei/open/ — appels ouverts à toutes les ONGs éligibles
- /cfei/direct/ — invitations directes adressées spécifiquement à Caritas Bénin par une agence ONU (priorité absolue)
2. Architecture du système de veille (état final)¶
Fichiers concernés¶
| Fichier | Rôle |
|---|---|
/home/kanmaber/scripts/veille_appels_projets.py |
Script principal — cron 48h |
/home/kanmaber/scripts/appels_projets_config.json |
Configuration des sources (RSS + scraping) |
/home/kanmaber/scripts/decouvrir_partenaires.py |
Script de découverte manuelle de nouvelles sources |
/home/kanmaber/.portail_onu_creds.json |
Credentials Portail ONU (chmod 600) |
Cron¶
0 9 */2 * * python3 /home/kanmaber/scripts/veille_appels_projets.py >> /home/kanmaber/scripts/appels_projets.log 2>&1
Modes de sources supportés dans la config¶
mode |
Mécanisme | Usage |
|---|---|---|
rss (défaut) |
feedparser | Sites avec flux RSS |
scraping_auth |
requests + BeautifulSoup | Sites avec formulaire HTML simple |
scraping_playwright |
Playwright + Chromium headless | Sites avec OAuth/SSO/JavaScript |
Structure d'une source dans appels_projets_config.json¶
{
"nom": "Nom affiché dans l'email",
"url": "URL de la page à surveiller",
"type": ["financement"], // tableau (peut combiner plusieurs types)
"mode": "scraping_playwright", // rss | scraping_auth | scraping_playwright
// Champs spécifiques à scraping_playwright :
"creds_file": "/chemin/vers/creds.json",
"login_url": "https://site.org/login",
"email_selector": "input#email",
"next_selector": "button:has-text('Next')",
"password_selector": "input[type='password']",
"submit_selector": "button:has-text('Sign in')",
"success_url_pattern": "**/dashboard**",
"link_selector": "a[href*='/cfei/']",
"link_pattern": "/cfei/\\w+/\\d+",
"bypass_filter": true
}
Le champ type est un tableau de chaînes : une même source peut avoir ["financement", "benin"] et les deux règles de filtrage s'appliquent en OR.
3. Difficultés rencontrées et solutions¶
3.1 Pas de flux RSS sur le Portail ONU¶
Problème : Le script decouvrir_partenaires.py n'a trouvé aucun flux RSS sur unpartnerportal.org.
Analyse : Le contenu pertinent (CFEIs) est derrière authentification. Aucun flux public n'est exposé.
Solution : Scraping authentifié — accéder au portail avec le compte Caritas.
3.2 Site rendu en JavaScript (React SPA)¶
Problème : La bibliothèque requests + BeautifulSoup ne trouve aucun formulaire de login — le HTML brut ne contient pas de <form>. La page est une SPA React qui rend le DOM côté client.
Diagnostic :
soup.find_all("form") # → liste vide
page.query_selector_all("input") # → liste vide avec requests
Solution : Utiliser un vrai navigateur (Playwright) qui exécute le JavaScript.
3.3 Authentification Azure AD B2C (OAuth2 PKCE)¶
Problème : Le portail n'utilise pas un formulaire classique mais Microsoft Azure Active Directory B2C avec le flow Authorization Code + PKCE (Proof Key for Code Exchange).
Comment c'est découvert : En inspectant le bundle JS (/static/js/main.3ee9a128.js) avec regex sur les URLs d'API :
/api/auth/authorize/login/csopartner_authn/?email= → 302 vers :
https://unitednationspartners.b2clogin.com/unitednationspartners.onmicrosoft.com/
b2c_1_unpp_susi/oauth2/v2.0/authorize?
client_id=63fac912-e373-42bf-92f7-d5e3551fe89c&
response_type=code&
code_challenge=...& ← PKCE
code_challenge_method=S256
Tentative ROPC échouée : Azure B2C supporte en théorie le grant ROPC (username/password direct). Tenté avec :
POST https://unitednationspartners.b2clogin.com/.../token
grant_type=password&username=...&password=...
AADB2C90108 — la politique b2c_1_unpp_susi utilise un IdP externe à l'étape 1, incompatible avec ROPC.
Solution finale : Playwright simule un vrai navigateur qui suit tous les redirects OAuth, exécute le JavaScript, remplit les formulaires et maintient les cookies de session.
3.4 URL de login incorrecte¶
Problème : Première tentative sur /en/login/ → 404.
Diagnostic : En naviguant sur la page racine /, on voit le bouton "Sign In" qui mène à /login (sans /en/).
Solution : Utiliser https://www.unpartnerportal.org/login comme login_url.
3.5 Timeout networkidle sur la page CFEIs¶
Problème : page.wait_for_load_state("networkidle") timeout après le login — le portail continue de faire des requêtes réseau en arrière-plan indéfiniment (polling, analytics…).
Solution : Remplacer networkidle par wait_for_selector ciblé sur l'élément attendu :
page.wait_for_selector("a[href*='/cfei/']", timeout=20000)
3.6 Playwright sans droits sudo pour les dépendances système¶
Problème : python3 -m playwright install-deps chromium échoue (besoin de sudo pour les libs système).
Diagnostic : Le Chromium embarqué dans Playwright a quand même fonctionné — les dépendances manquantes n'ont pas bloqué le test en pratique sur ce VPS Ubuntu.
À faire si Chromium ne démarre pas sur un nouveau serveur :
sudo python3 -m playwright install-deps chromium
# ou :
sudo apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2
3.7 Bugs d'indentation dans decouvrir_partenaires.py¶
Problème : Trois erreurs d'indentation successives dans le bloc try/except — corrigées une par une au lieu de lire tout le bloc d'un coup.
Leçon : Avant toute correction de syntaxe, lire le bloc complet (pas seulement la ligne en erreur) pour identifier toutes les anomalies en une passe.
4. Flow d'authentification complet (Portail ONU)¶
1. Playwright → GET https://www.unpartnerportal.org/login
2. React rend le formulaire email
3. fill(input#email, "highteck@caritasbenin.com")
4. click(button "Next")
5. Portal → GET /api/auth/authorize/login/csopartner_authn/?email=...
6. → 302 → https://unitednationspartners.b2clogin.com/...
7. Azure B2C rend le formulaire password
8. fill(input[type='password'], "***")
9. click(button "Sign in")
10. Azure B2C valide → 302 → https://www.unpartnerportal.org/api/auth/authorize/callback?code=...
11. Portal backend échange le code → crée session Django → 302 → /dashboard
12. Playwright → GET /cfei/open/ (avec cookies de session)
13. Scraping des liens a[href*='/cfei/']
5. Leçons apprises¶
| # | Leçon |
|---|---|
| 1 | Toujours vérifier si un site est une SPA JS avant d'essayer requests |
| 2 | Inspecter le bundle JS pour trouver les vrais endpoints API (/api/auth/...) |
| 3 | Azure AD B2C = PKCE obligatoire → requests ne peut pas gérer ça, il faut Playwright |
| 4 | wait_for_selector est plus fiable que wait_for_load_state("networkidle") pour les SPAs |
| 5 | Les credentials vont dans un fichier séparé chmod 600, jamais dans le code |
| 6 | Tester la syntaxe Python avec python3 -m py_compile avant tout test réel |
| 7 | Lire tout le bloc de code concerné avant de corriger — pas ligne par ligne |
| 8 | Concevoir en généralisant depuis le cas isolé : ce qui marche pour un portail doit marcher pour tous |
6. Dispositions pour la prochaine intégration de portail avec login¶
Checklist avant d'écrire du code¶
- [ ] Le site a-t-il un flux RSS ? →
decouvrir_partenaires.py <url> "nom"pour le détecter automatiquement - [ ] Le site est-il une SPA JS ? → Inspecter le HTML brut : si pas de
<form>, c'est une SPA - [ ] Quel mécanisme d'auth ?
- Formulaire HTML classique →
mode: "scraping_auth"(requests) - OAuth/SSO/Azure B2C/Google →
mode: "scraping_playwright"(Playwright) - [ ] Identifier les sélecteurs CSS du formulaire de login (email, password, bouton submit)
- [ ] Identifier l'URL cible après login (pour
link_selectoretlink_pattern) - [ ] Stocker les credentials dans
/home/kanmaber/.<nom_site>_creds.jsonavecchmod 600
Comment identifier les sélecteurs CSS avec Playwright¶
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://site.org/login", timeout=30000)
page.wait_for_timeout(3000)
for inp in page.query_selector_all("input"):
print(f"id={inp.get_attribute('id')} type={inp.get_attribute('type')} name={inp.get_attribute('name')}")
print([b.inner_text() for b in page.query_selector_all("button")])
browser.close()
Comment identifier le flow OAuth (si présent)¶
# Chercher les endpoints API dans le bundle JS
import requests, re
r = requests.get("https://site.org/static/js/main.xxx.js")
print(re.findall(r'["\`](/api/[^"\'`\s]{3,80})["\`]', r.text))
Ajouter la source dans la config (template)¶
{
"nom": "Nom du portail",
"url": "https://portail.org/opportunities/",
"type": ["financement"],
"mode": "scraping_playwright",
"creds_file": "/home/kanmaber/.<portail>_creds.json",
"login_url": "https://portail.org/login",
"email_selector": "input#email",
"next_selector": "button:has-text('Next')",
"password_selector": "input[type='password']",
"submit_selector": "button:has-text('Sign in')",
"success_url_pattern": "**/dashboard**",
"link_selector": "a[href*='/opportunity/']",
"link_pattern": "/opportunity/\\d+",
"bypass_filter": true
}
bypass_filter: truesignifie que tous les résultats visibles par le compte Caritas sont inclus sans filtrage par mots-clés. À utiliser pour les portails spécialisés où le contenu est déjà pré-filtré pour les ONGs. Mettrefalsepour les portails généralistes.
7. Configuration actuelle du Portail ONU¶
Fichier credentials¶
/home/kanmaber/.portail_onu_creds.json (chmod 600)
{
"email": "highteck@caritasbenin.com",
"password": "***"
}
Sources dans appels_projets_config.json¶
{
"nom": "Portail ONU (CFEI ouverts)",
"url": "https://www.unpartnerportal.org/cfei/open/",
"type": ["financement"],
"mode": "scraping_playwright",
"link_selector": "a[href*='/cfei/']",
"link_pattern": "/cfei/\\w+/\\d+",
"bypass_filter": true
},
{
"nom": "Portail ONU (CFEI directs)",
"url": "https://www.unpartnerportal.org/cfei/direct/",
"type": ["financement"],
"mode": "scraping_playwright",
"link_selector": "a[href*='/cfei/']",
"link_pattern": "/cfei/\\w+/\\d+",
"bypass_filter": true
}
Les champs
creds_file,login_url,email_selector,next_selector,password_selector,submit_selector,success_url_patternsont identiques pour les deux et correspondent aux valeurs par défaut du moteur.
8. Commandes utiles¶
# Tester manuellement une URL (sans email)
python3 /home/kanmaber/scripts/decouvrir_partenaires.py https://site.org/ "Nom"
# Lancer la veille complète manuellement
python3 /home/kanmaber/scripts/veille_appels_projets.py
# Voir le log de la dernière exécution
tail -50 /home/kanmaber/scripts/appels_projets.log
# Vérifier la syntaxe après modification
python3 -m py_compile /home/kanmaber/scripts/veille_appels_projets.py
# Vérifier la config JSON
python3 -c "import json; json.load(open('/home/kanmaber/scripts/appels_projets_config.json')); print('OK')"