Aller au contenu

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=...
Résultat : 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)
Plus robuste car on attend l'élément réel, pas le silence réseau.


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_selector et link_pattern)
  • [ ] Stocker les credentials dans /home/kanmaber/.<nom_site>_creds.json avec chmod 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: true signifie 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. Mettre false pour 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_pattern sont 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')"