commit 528d994ea8d47c16d4298787e0f620d8111d9a83 Author: jerem Date: Sat Jun 13 13:32:38 2026 +0200 Initial commit 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

+ +
+ +
+
+ +
+ + +