Files
AutoMood/scraper.py
2026-06-13 13:32:38 +02:00

256 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 <url-facebook>")
sys.exit(1)
resultat = scrape(sys.argv[1])
print(f"=== TITRE ===\n{resultat['titre']}\n=== TEXTE ===\n{resultat['texte']}")