# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/linkinbio.py # Desc: Connecteur ENRICHISSEMENT générique « link-in-bio » (templatisé) — # Linktree, Beacons, Bio.link, Milkshake, Campsite, Snipfeed. # Mode d'accès : requêtes directes (pages publiques SSR), repli # Firecrawl. Palier 1 du catalogue (§9) — le levier nº 1 : une page # de liens révèle D'UN COUP tous les comptes d'un créateur. # ============================================================================== """Enrichissement par pages de liens (link-in-bio). Pour chaque créateur dont `link_in_bio_url` est connue (déclarée par le créateur dans une de ses bios — jamais devinée), on récupère la page et on en extrait les comptes de plateformes. Signal d'identité : `link_in_bio` (le créateur liste lui-même ses comptes) → confidence 0.95, le plus fort (§12.1). Templatisé : chaque service a son extracteur ; le repli générique parcourt les ancres de la page et classe chaque URL via normalize.platform_from_url. """ from __future__ import annotations import json import re from ..identity import account, merge_accounts from ..normalize import platform_from_url from ..schema import Creator from .base import BaseConnector # services couverts par le gabarit (§9 palier 1) SERVICES = ("linktr.ee", "beacons.ai", "bio.link", "msha.ke", "campsite.bio", "snipfeed.co", "linkin.bio") _NEXT_DATA_RE = re.compile( r'', re.S) _HREF_RE = re.compile(r'href="([^"]+)"', re.I) def is_supported(url: str | None) -> bool: return bool(url) and any(s in url for s in SERVICES) def parse_links(html: str) -> list[str]: """Extrait toutes les URLs sortantes d'une page link-in-bio. Linktree/Beacons sont des apps Next.js : les liens vivent dans le JSON __NEXT_DATA__ (plus fiable que le DOM). Repli : ancres du HTML. """ urls: list[str] = [] m = _NEXT_DATA_RE.search(html) if m: try: data = json.loads(m.group(1)) urls.extend(_walk_urls(data)) except Exception: pass for href in _HREF_RE.findall(html): if href.startswith("http"): urls.append(href) # dédupliquer en conservant l'ordre (l'ordre de la page = priorité du créateur) seen: set[str] = set() out = [] for u in urls: u = u.split("?utm")[0].rstrip("/") if u not in seen: seen.add(u) out.append(u) return out def _walk_urls(node) -> list[str]: """Parcourt récursivement le JSON Next.js et récolte toute valeur 'url'.""" found: list[str] = [] if isinstance(node, dict): for k, v in node.items(): if k in ("url", "href") and isinstance(v, str) and v.startswith("http"): found.append(v) else: found.extend(_walk_urls(v)) elif isinstance(node, list): for item in node: found.extend(_walk_urls(item)) return found def extract_accounts(html: str) -> list: """Page link-in-bio → comptes de plateformes (signal fort link_in_bio).""" accounts = [] seen: set[str] = set() for url in parse_links(html): hit = platform_from_url(url) if not hit: continue platform, handle = hit key = f"{platform}:{handle}" if key in seen: continue seen.add(key) accounts.append(account(platform, handle, "link_in_bio", url=url)) return accounts class LinkInBioConnector(BaseConnector): source_id = "link-in-bio" kind = "enrichment" request_delay = 1.2 max_pages = 500 # garde-fou par passage def enrich(self, creators: list[Creator]) -> list[Creator]: enriched: list[Creator] = [] fetched = 0 for cr in creators: if fetched >= self.max_pages: break if not is_supported(cr.link_in_bio_url): continue try: html = self.get(cr.link_in_bio_url).text except Exception: try: html = self.get_rendered(cr.link_in_bio_url) except Exception: continue fetched += 1 accounts = extract_accounts(html) if not accounts: continue cr.platforms = merge_accounts(cr.platforms, [a.finalize() for a in accounts]) enriched.append(cr) return enriched