Initial commit

This commit is contained in:
jerem
2026-06-13 13:32:38 +02:00
commit 528d994ea8
9 changed files with 1003 additions and 0 deletions

255
scraper.py Normal file
View File

@@ -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 <url-facebook>")
sys.exit(1)
resultat = scrape(sys.argv[1])
print(f"=== TITRE ===\n{resultat['titre']}\n=== TEXTE ===\n{resultat['texte']}")