Initial commit
This commit is contained in:
255
scraper.py
Normal file
255
scraper.py
Normal 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']}")
|
||||
Reference in New Issue
Block a user