From 528d994ea8d47c16d4298787e0f620d8111d9a83 Mon Sep 17 00:00:00 2001 From: jerem Date: Sat, 13 Jun 2026 13:32:38 +0200 Subject: [PATCH] Initial commit --- .gitignore | 4 + README.md | 26 ++++ app.py | 141 ++++++++++++++++++++++ departements.py | 57 +++++++++ extractor.py | 198 ++++++++++++++++++++++++++++++ requirements.txt | 2 + run.sh | 20 ++++ scraper.py | 255 +++++++++++++++++++++++++++++++++++++++ static/index.html | 300 ++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1003 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 departements.py create mode 100644 extractor.py create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 scraper.py create mode 100644 static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39cda27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +fb_profile/ +prospects.csv +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ad9caf --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# AutoMood — Prospection de lieux de concert + +Petit outil local pour constituer un fichier CSV de prospects (bars, restaurants, campings…) à partir de liens de pages Facebook. + +## Lancement + +```bash +./run.sh +``` + +La première fois, l'installation prend quelques minutes. Ensuite le serveur démarre et l'interface s'ouvre dans votre navigateur sur http://127.0.0.1:5000. Arrêt : `Ctrl-C` dans le terminal. + +## Utilisation + +1. **Une seule fois** : cliquez sur **🔑 Connexion Facebook**. Une fenêtre Chromium s'ouvre sur la page de connexion : connectez-vous tranquillement (vous avez 5 minutes, 2FA compris), la fenêtre se ferme toute seule et la session est mémorisée (dossier `fb_profile/`). +2. Collez le lien Facebook d'un lieu et cliquez sur **Analyser** : une fenêtre Chromium s'ouvre quelques secondes et visite la page « À propos » du lieu. +3. Vérifiez/corrigez les champs pré-remplis, puis **Ajouter au fichier**. +4. Dans le tableau, cliquez sur une cellule pour la modifier (utile pour « Date de contact » et « Nom de contact » plus tard). + +Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s'ouvre directement dans Excel ou Numbers par double-clic. + +## À savoir + +- ⚠️ N'éditez pas `prospects.csv` dans Excel pendant que l'app tourne : toute modification via l'interface réécrit le fichier et écraserait vos changements Excel. Ouvrez-le en lecture seulement, ou éditez via l'interface. +- Les champs introuvables sur la page Facebook restent vides : complétez-les à la main dans le formulaire. +- Si Facebook affiche une vérification de sécurité, résolvez-la dans la fenêtre Chromium puis relancez l'analyse. diff --git a/app.py b/app.py new file mode 100644 index 0000000..8b39a19 --- /dev/null +++ b/app.py @@ -0,0 +1,141 @@ +"""Serveur local AutoMood : interface web + API + stockage CSV.""" + +import csv +import os +import threading +import webbrowser +from datetime import date +from pathlib import Path + +from flask import Flask, jsonify, request, send_file + +import extractor +import scraper + +DOSSIER = Path(__file__).parent +CSV_PATH = DOSSIER / "prospects.csv" +COLONNES = [ + "Nom du prospect", "Département", "Ville", "Code Postal", "Adresse", + "Date d'ajout", "Date de contact", "Nom de contact", + "Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", +] + +app = Flask(__name__, static_folder="static") +verrou_scrape = threading.Lock() +verrou_csv = threading.Lock() + + +def lire_prospects(): + if not CSV_PATH.exists(): + ecrire_prospects([]) + return [] + with open(CSV_PATH, encoding="utf-8-sig", newline="") as f: + lecteur = csv.DictReader(f, delimiter=";") + return [{col: (ligne.get(col) or "") for col in COLONNES} for ligne in lecteur] + + +def ecrire_prospects(lignes): + tmp = CSV_PATH.with_suffix(".csv.tmp") + with open(tmp, "w", encoding="utf-8-sig", newline="") as f: + ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore") + ecrivain.writeheader() + ecrivain.writerows(lignes) + os.replace(tmp, CSV_PATH) + + +@app.get("/") +def accueil(): + return app.send_static_file("index.html") + + +@app.post("/api/login") +def api_login(): + if not verrou_scrape.acquire(blocking=False): + return jsonify({"error": "occupe", "message": "Une autre opération est en cours, patientez."}), 429 + try: + if scraper.connexion(): + return jsonify({"ok": True, "message": "Connexion Facebook enregistrée."}) + return jsonify({ + "error": "login_timeout", + "message": "Connexion non détectée dans le temps imparti. Réessayez.", + }), 408 + except Exception as e: + return jsonify({"error": "erreur", "message": f"Échec de la connexion : {e}"}), 500 + finally: + verrou_scrape.release() + + +@app.post("/api/scrape") +def api_scrape(): + url = (request.get_json(silent=True) or {}).get("url", "").strip() + if not url: + return jsonify({"error": "url_manquante", "message": "Collez un lien Facebook."}), 400 + if not verrou_scrape.acquire(blocking=False): + return jsonify({"error": "occupe", "message": "Une analyse est déjà en cours, patientez."}), 429 + try: + resultat = scraper.scrape(url) + champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["url"]) + return jsonify(champs) + except scraper.ErreurScrape as e: + statuts = {"login_required": 409, "page_introuvable": 404, "url_invalide": 400, "redirection": 422} + return jsonify({"error": e.code, "message": str(e)}), statuts.get(e.code, 500) + except Exception as e: + return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500 + finally: + verrou_scrape.release() + + +@app.get("/api/prospects") +def api_lister(): + lignes = lire_prospects() + return jsonify([{"index": i, **ligne} for i, ligne in enumerate(lignes)]) + + +@app.post("/api/prospects") +def api_ajouter(): + donnees = request.get_json(silent=True) or {} + ligne = {col: str(donnees.get(col, "")).strip() for col in COLONNES} + if not ligne["Date d'ajout"]: + ligne["Date d'ajout"] = date.today().strftime("%d/%m/%Y") + with verrou_csv: + lignes = lire_prospects() + lignes.append(ligne) + ecrire_prospects(lignes) + return jsonify({"ok": True, "index": len(lignes) - 1}) + + +@app.put("/api/prospects/") +def api_modifier(idx): + donnees = request.get_json(silent=True) or {} + with verrou_csv: + lignes = lire_prospects() + if not 0 <= idx < len(lignes): + return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404 + for col in COLONNES: + if col in donnees: + lignes[idx][col] = str(donnees[col]).strip() + ecrire_prospects(lignes) + return jsonify({"ok": True}) + + +@app.delete("/api/prospects/") +def api_supprimer(idx): + with verrou_csv: + lignes = lire_prospects() + if not 0 <= idx < len(lignes): + return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404 + lignes.pop(idx) + ecrire_prospects(lignes) + return jsonify({"ok": True}) + + +@app.get("/api/export") +def api_export(): + lire_prospects() # crée le fichier si absent + return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv") + + +if __name__ == "__main__": + lire_prospects() + threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start() + app.run(host="127.0.0.1", port=5000) diff --git a/departements.py b/departements.py new file mode 100644 index 0000000..e90fe43 --- /dev/null +++ b/departements.py @@ -0,0 +1,57 @@ +"""Table des départements français et résolution depuis un code postal.""" + +DEPARTEMENTS = { + "01": "Ain", "02": "Aisne", "03": "Allier", "04": "Alpes-de-Haute-Provence", + "05": "Hautes-Alpes", "06": "Alpes-Maritimes", "07": "Ardèche", "08": "Ardennes", + "09": "Ariège", "10": "Aube", "11": "Aude", "12": "Aveyron", + "13": "Bouches-du-Rhône", "14": "Calvados", "15": "Cantal", "16": "Charente", + "17": "Charente-Maritime", "18": "Cher", "19": "Corrèze", + "2A": "Corse-du-Sud", "2B": "Haute-Corse", + "21": "Côte-d'Or", "22": "Côtes-d'Armor", "23": "Creuse", "24": "Dordogne", + "25": "Doubs", "26": "Drôme", "27": "Eure", "28": "Eure-et-Loir", + "29": "Finistère", "30": "Gard", "31": "Haute-Garonne", "32": "Gers", + "33": "Gironde", "34": "Hérault", "35": "Ille-et-Vilaine", "36": "Indre", + "37": "Indre-et-Loire", "38": "Isère", "39": "Jura", "40": "Landes", + "41": "Loir-et-Cher", "42": "Loire", "43": "Haute-Loire", "44": "Loire-Atlantique", + "45": "Loiret", "46": "Lot", "47": "Lot-et-Garonne", "48": "Lozère", + "49": "Maine-et-Loire", "50": "Manche", "51": "Marne", "52": "Haute-Marne", + "53": "Mayenne", "54": "Meurthe-et-Moselle", "55": "Meuse", "56": "Morbihan", + "57": "Moselle", "58": "Nièvre", "59": "Nord", "60": "Oise", + "61": "Orne", "62": "Pas-de-Calais", "63": "Puy-de-Dôme", + "64": "Pyrénées-Atlantiques", "65": "Hautes-Pyrénées", "66": "Pyrénées-Orientales", + "67": "Bas-Rhin", "68": "Haut-Rhin", "69": "Rhône", "70": "Haute-Saône", + "71": "Saône-et-Loire", "72": "Sarthe", "73": "Savoie", "74": "Haute-Savoie", + "75": "Paris", "76": "Seine-Maritime", "77": "Seine-et-Marne", "78": "Yvelines", + "79": "Deux-Sèvres", "80": "Somme", "81": "Tarn", "82": "Tarn-et-Garonne", + "83": "Var", "84": "Vaucluse", "85": "Vendée", "86": "Vienne", + "87": "Haute-Vienne", "88": "Vosges", "89": "Yonne", "90": "Territoire de Belfort", + "91": "Essonne", "92": "Hauts-de-Seine", "93": "Seine-Saint-Denis", + "94": "Val-de-Marne", "95": "Val-d'Oise", + "971": "Guadeloupe", "972": "Martinique", "973": "Guyane", + "974": "La Réunion", "976": "Mayotte", +} + + +def departement_depuis_cp(cp): + """Retourne « NN - Nom » depuis un code postal à 5 chiffres, ou "".""" + cp = (cp or "").strip() + if len(cp) != 5 or not cp.isdigit(): + return "" + if cp.startswith("97"): + cle = cp[:3] + elif cp.startswith("20"): + # Corse : 20000-20199 → Corse-du-Sud, 20200-20699 → Haute-Corse (approximation usuelle) + cle = "2A" if int(cp) < 20200 else "2B" + else: + cle = cp[:2] + nom = DEPARTEMENTS.get(cle) + return f"{cle} - {nom}" if nom else "" + + +if __name__ == "__main__": + for cp, attendu in [("29200", "29 - Finistère"), ("20090", "2A - Corse-du-Sud"), + ("20600", "2B - Haute-Corse"), ("97400", "974 - La Réunion"), + ("75011", "75 - Paris"), ("00000", ""), ("abc", "")]: + resultat = departement_depuis_cp(cp) + statut = "OK" if resultat == attendu else f"ECHEC (attendu {attendu!r})" + print(f"{cp} -> {resultat!r} {statut}") diff --git a/extractor.py b/extractor.py new file mode 100644 index 0000000..bed9271 --- /dev/null +++ b/extractor.py @@ -0,0 +1,198 @@ +"""Extraction par règles : texte brut d'une page Facebook → champs du prospect.""" + +import re +import unicodedata +from datetime import date + +from departements import DEPARTEMENTS, departement_depuis_cp + +RE_TELEPHONE = re.compile(r"(?:\+33\s?[1-9]|0[1-9])(?:[\s.\-]?\d{2}){4}") +RE_EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") +RE_CP_VILLE = re.compile(r"(?= 0: + segments = segments[:dernier_voie + 1] + else: + segments = [s for s in segments if s.lower() != "france"] + return ", ".join(segments).strip(" ,-–") + + +def _adresse(texte): + lignes = [l.strip() for l in texte.split("\n")] + # libellé « Adresse » de la section Coordonnées (mais pas « Adresse e-mail ») + for i, ligne in enumerate(lignes): + if ligne.lower() in ("adresse", "address"): + for suite in lignes[i + 1:i + 3]: + if suite and not re.match(r"^(adresse|address|e-?mail|téléphone|phone|site|web|messenger)", suite, re.I): + return _nettoyer_adresse(suite) + m = RE_ADRESSE.search(texte) + return _nettoyer_adresse(m.group(1)) if m else "" + + +def _ville_dept_parentheses(texte): + """Repli quand il n'y a pas d'adresse postale : « Saint-Omer-de-Blain (44) ».""" + for m in RE_VILLE_DEPT.finditer(texte): + cle = m.group(2).upper() + nom = DEPARTEMENTS.get(cle) + if not nom: + continue + ville = (m.group(1) or "").strip("-'’ ") + if len(ville) < 3 or ville.lower() in MOTS_INTERFACE or ville.lower() == "france": + ville = "" + return ville, f"{cle} - {nom}" + return "", "" + + +def _type_lieu(titre, texte): + # La catégorie de la page apparaît en général dans les premiers caractères du texte + for zone in (titre, texte[:500], texte): + zone_norm = _sans_accents(zone.lower()) + for mot, type_lieu in TYPES: + if _chercher_mot(mot, zone_norm): + return type_lieu + return "" + + +def _infos_lieu(texte): + texte_norm = _sans_accents(texte.lower()) + trouves = [mot for mot in INFOS_LIEU if _chercher_mot(mot, texte_norm)] + return ", ".join(trouves[:5]) + + +def extraire(titre, texte, url): + cp, ville = _cp_ville(texte) + departement = departement_depuis_cp(cp) + if not cp: + ville_repli, departement = _ville_dept_parentheses(texte) + ville = ville or ville_repli + return { + "Nom du prospect": titre.strip(), + "Département": departement, + "Ville": ville, + "Code Postal": cp, + "Adresse": _adresse(texte), + "Date d'ajout": date.today().strftime("%d/%m/%Y"), + "Date de contact": "", + "Nom de contact": "", + "Téléphone": _telephone(texte), + "Email": _email(texte), + "Infos du lieu": _infos_lieu(texte), + "Type": _type_lieu(titre, texte), + "Lien Facebook": url, + } + + +if __name__ == "__main__": + exemple = """Le Vieux Gréement + Bar · Restaurant + 12 Quai du Port, 29200 Brest, France + 06 12 34 56 78 + contact@vieuxgreement.fr + Grande terrasse avec vue sur le port, concerts en plein air l'été dans le jardin. + """ + import json + champs = extraire("Le Vieux Gréement", exemple, "https://www.facebook.com/vieuxgreement") + print(json.dumps(champs, ensure_ascii=False, indent=2)) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cc714db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +playwright>=1.45 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..13e0173 --- /dev/null +++ b/run.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" + +# Python Homebrew en priorité (le python système 3.9 est trop vieux pour Playwright récent) +if [ -x /opt/homebrew/bin/python3 ]; then + PY=/opt/homebrew/bin/python3 +else + PY=$(command -v python3) +fi + +if [ ! -d .venv ]; then + echo "Première installation (environnement Python + Chromium), patientez quelques minutes..." + "$PY" -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -r requirements.txt + .venv/bin/playwright install chromium +fi + +exec .venv/bin/python app.py diff --git a/scraper.py b/scraper.py new file mode 100644 index 0000000..2e9e18b --- /dev/null +++ b/scraper.py @@ -0,0 +1,255 @@ +"""Scraping des pages « À propos » Facebook avec une session Chromium persistante.""" + +import re +import sys +import time +from pathlib import Path +from urllib.parse import urlparse, parse_qs + +from playwright.sync_api import sync_playwright + +PROFIL = str(Path(__file__).parent / "fb_profile") +ATTENTE_LOGIN_S = 300 + + +class ErreurScrape(Exception): + def __init__(self, code, message): + super().__init__(message) + self.code = code + + +# Liens de partage/contenu qui ne désignent pas directement une page : à résoudre en les visitant +SLUGS_A_RESOUDRE = {"share", "reel", "reels", "story.php", "photo", "photo.php", "video.php", "watch", "posts", "l.php"} + + +def _urls_pour_id(page_id): + # marqueur = les chiffres seuls : Facebook réécrit souvent profile.php?id=N + # en /people/Nom-De-Page/N/, où « id= » disparaît mais pas le numéro + canonique = f"https://www.facebook.com/profile.php?id={page_id}" + return {"canonique": canonique, "about": f"{canonique}&sk=about", "marqueur": str(page_id)} + + +def normaliser_url(url): + """Retourne {canonique, abouts, marqueur} ou {resoudre: url} ; lève ErreurScrape sinon. + + « marqueur » est un fragment qui doit rester présent dans l'URL après navigation : + il sert à détecter les redirections de Facebook (vers le fil, votre profil...). + """ + url = url.strip() + if not url.startswith("http"): + url = "https://" + url + p = urlparse(url) + if not re.search(r"(?:^|\.)(facebook|fb)\.com$", p.netloc.lower().split(":")[0]): + raise ErreurScrape("url_invalide", "Ce n'est pas un lien Facebook.") + + segments = [s for s in p.path.split("/") if s] + + if segments and segments[0] == "profile.php" or p.path.rstrip("/").endswith("profile.php"): + page_id = parse_qs(p.query).get("id", [""])[0] + if not page_id: + raise ErreurScrape("url_invalide", "Lien profile.php sans identifiant.") + return _urls_pour_id(page_id) + + if not segments: + raise ErreurScrape("url_invalide", "Lien Facebook sans nom de page.") + + premier = segments[0].lower() + + if premier in SLUGS_A_RESOUDRE: + return {"resoudre": url} + + # facebook.com/p/Nom-De-Page-100063123456789 (nouvelles pages) + if premier == "p" and len(segments) >= 2: + m = re.search(r"(\d{5,})/?$", segments[1]) + if m: + return _urls_pour_id(m.group(1)) + raise ErreurScrape("url_invalide", "Lien /p/ sans identifiant numérique reconnaissable.") + + # facebook.com/people/Nom/123456789 et facebook.com/pages/Nom/123456789 (anciens formats) + if premier in ("people", "pages"): + for seg in reversed(segments): + if re.fullmatch(r"\d{5,}", seg): + return _urls_pour_id(seg) + raise ErreurScrape("url_invalide", f"Lien /{premier}/ sans identifiant numérique.") + + slug = segments[0] + canonique = f"https://www.facebook.com/{slug}" + return {"canonique": canonique, "about": f"{canonique}/about", "marqueur": slug.lower()} + + +def _sur_la_bonne_page(page, marqueur): + return marqueur in page.url.lower() + + +def _refuser_cookies(page): + try: + bouton = page.get_by_role( + "button", + name=re.compile(r"refuser les cookies optionnels|decline optional cookies|autoriser tous les cookies", re.I), + ).first + bouton.click(timeout=3000) + page.wait_for_timeout(1000) + except Exception: + pass + + +def _mur_de_connexion(page): + if "/login" in page.url: + return True + try: + corps = page.inner_text("body", timeout=5000) + except Exception: + return False + return bool(re.search(r"connectez-vous pour continuer|log into facebook|vous devez vous connecter", corps, re.I)) + + +def _est_connecte(contexte): + """Le cookie de session « c_user » n'existe que si un compte est connecté.""" + try: + return any(c["name"] == "c_user" for c in contexte.cookies("https://www.facebook.com")) + except Exception: + return False + + +def connexion(): + """Ouvre Facebook dans la fenêtre persistante et attend que l'utilisateur se connecte. + + Aucune autre action ne tourne pendant ce temps : l'utilisateur a jusqu'à + ATTENTE_LOGIN_S secondes pour se connecter tranquillement (captcha, 2FA...). + Retourne True si la session est établie. + """ + with sync_playwright() as pw: + contexte = pw.chromium.launch_persistent_context( + user_data_dir=PROFIL, headless=False, locale="fr-FR", + viewport={"width": 1280, "height": 900}, + ) + try: + if _est_connecte(contexte): + return True + page = contexte.pages[0] if contexte.pages else contexte.new_page() + page.goto("https://www.facebook.com/login", wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(2000) + _refuser_cookies(page) + debut = time.time() + while time.time() - debut < ATTENTE_LOGIN_S: + if _est_connecte(contexte): + page.wait_for_timeout(2000) # laisser FB finir d'écrire la session + return True + page.wait_for_timeout(2000) + return False + finally: + contexte.close() + + +def _texte_page(page): + try: + return page.locator('div[role="main"]').first.inner_text(timeout=5000) + except Exception: + try: + return page.inner_text("body", timeout=5000) + except Exception: + return "" + + +def scrape(url): + """Visite les pages « À propos » et retourne {"titre", "texte", "url"}.""" + info = normaliser_url(url) + with sync_playwright() as pw: + contexte = pw.chromium.launch_persistent_context( + user_data_dir=PROFIL, headless=False, locale="fr-FR", + viewport={"width": 1280, "height": 900}, + ) + try: + if not _est_connecte(contexte): + raise ErreurScrape( + "login_required", + "Aucune session Facebook : cliquez d'abord sur « Connexion Facebook ».", + ) + page = contexte.pages[0] if contexte.pages else contexte.new_page() + + # Lien de partage (/share/, /reel/...) : le visiter pour découvrir la vraie page + if info.get("resoudre"): + page.goto(info["resoudre"], wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + _refuser_cookies(page) + info = normaliser_url(page.url) + if info.get("resoudre"): + raise ErreurScrape( + "url_invalide", + "Ce lien de partage ne mène pas à une page de lieu : ouvrez la page du lieu " + "dans Facebook et copiez son adresse directe.", + ) + + # Page d'accueil du lieu : la carte « Intro » contient souvent l'adresse, + # qui n'apparaît dans aucune sous-section « À propos » + page.goto(info["canonique"], wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(2500) + _refuser_cookies(page) + if _mur_de_connexion(page): + raise ErreurScrape( + "login_required", + "Session Facebook expirée : cliquez sur « Connexion Facebook » pour vous reconnecter.", + ) + if not _sur_la_bonne_page(page, info["marqueur"]): + # profile.php?id=N est parfois réécrit vers l'adresse « vanity » de la + # page : si on a atterri sur un profil/une page plausible, adopter ce slug. + nouveau = urlparse(page.url).path.strip("/").split("/")[0].lower() + if (info["marqueur"].isdigit() and nouveau + and nouveau not in SLUGS_A_RESOUDRE | {"p", "people", "pages", "profile.php"}): + info["marqueur"] = nouveau + else: + # Redirigé ailleurs (fil d'actualité, votre profil...) : + # surtout ne pas extraire ce texte, ce serait VOS infos. + raise ErreurScrape( + "redirection", + f"Facebook a redirigé vers {page.url} au lieu de la page demandée. " + "Ouvrez la page du lieu dans Facebook et copiez son adresse directe.", + ) + titre = "" + for _ in range(6): # le titre met parfois quelques secondes à refléter la page + titre = re.sub(r"\s*[|\-–]\s*(Facebook|À propos|About).*$", "", page.title()).strip() + titre = re.sub(r"^\(\d+\+?\)\s*", "", titre) # compteur de notifications « (20+) » + if titre and titre.lower() != "facebook": + break + page.wait_for_timeout(500) + corps = _texte_page(page) + if re.search(r"ce contenu n'est pas disponible|this content isn'?t available|page introuvable", corps, re.I): + raise ErreurScrape("page_introuvable", "Cette page Facebook est introuvable ou inaccessible.") + if not titre or titre.lower() == "facebook": + # repli : la première ligne du contenu est le nom du lieu + titre = next((l.strip() for l in corps.split("\n") if l.strip()), "") + textes = [corps] + + # Page « À propos » + page.goto(info["about"], wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(2500) + if _sur_la_bonne_page(page, info["marqueur"]): + textes.append(_texte_page(page)) + + # Sous-section « Coordonnées » : on CLIQUE l'élément dans la page plutôt que + # de deviner l'URL — l'URL directe n'existe pas pour les nouvelles pages et + # Facebook redirige alors vers VOTRE propre profil. Selon le style de page, + # c'est un onglet (nouvelles pages) ou un lien (pages classiques). + for role in ("tab", "link"): + try: + page.locator('div[role="main"]').get_by_role( + role, name=re.compile(r"coordonnées|contact and basic info", re.I) + ).first.click(timeout=4000) + page.wait_for_timeout(2500) + if _sur_la_bonne_page(page, info["marqueur"]): + textes.append(_texte_page(page)) + break + except Exception: + continue # pas trouvé sous ce rôle ; sinon on garde le texte À propos + + return {"titre": titre, "texte": "\n".join(textes), "url": info["canonique"]} + finally: + contexte.close() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage : python scraper.py ") + sys.exit(1) + resultat = scrape(sys.argv[1]) + print(f"=== TITRE ===\n{resultat['titre']}\n=== TEXTE ===\n{resultat['texte']}") diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..212037e --- /dev/null +++ b/static/index.html @@ -0,0 +1,300 @@ + + + + + +AutoMood — Prospection + + + +

🎸 AutoMood — Prospection de lieux de concert

+
+ +
+

Nouveau prospect

+
+ + + +
+
+
+
+
+ + +
+
+
+ +
+
+

Liste des prospects

+ +
+ +
+
+ +
+ + +