# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/onlyqueb.py # Desc: Connecteur DÉCOUVERTE — OnlyQueb (onlyqueb.com), annuaire public de # créatrices/créateurs québécois sur abonnement. Mode d'accès : requêtes # directes (sitemap.xml + JSON-LD des pages profil, SSR). Palier 3 (§9). # Les liens `sameAs` sont AUTO-DÉCLARÉS par la personne sur son profil # → signal cross_link (0.90, §12.1). # ============================================================================== """Découverte via le hub OnlyQueb. Chaque page profil expose un JSON-LD schema.org ProfilePage propre : nom public, photo de profil publique et liste `sameAs` des comptes (OnlyFans, Instagram, TikTok, X…). Adultes uniquement (plateforme 18+). On n'agrège que ce que la personne affiche elle-même sur son profil (§15). """ from __future__ import annotations import json import re from ..identity import account from ..normalize import map_niche, platform_from_url, slugify from ..schema import Creator from .base import BaseConnector SITEMAP_URL = "https://onlyqueb.com/sitemap.xml" _LOC_RE = re.compile(r"([^<]+)") _LD_RE = re.compile(r'application/ld\+json"[^>]*>(.*?)', re.S) _EXCLUDED = ("/en", "/categories", "/explore", "/seo", "/signup", "/blog") # fragments du payload Next.js/RSC embarqué dans la page (self.__next_f.push) _PUSH_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') # catégories OnlyQueb → niches §6.1 quand le lien de sens existe ; les # descripteurs physiques (blonde, petite…) ne sont PAS des niches → « autre » _CAT_NICHE = {"cosplay": "arts", "gamer": "gaming", "gaming": "gaming", "fitness": "sport-fitness", "athletic": "sport-fitness"} def parse_profile_ld(html: str) -> dict | None: """Page profil → {name, image, links[]} depuis le JSON-LD (None si absent).""" for blob in _LD_RE.findall(html): try: data = json.loads(blob) except Exception: continue if data.get("@type") != "ProfilePage": continue person = data.get("mainEntity") or {} if person.get("@type") != "Person": continue return {"name": (person.get("name") or "").strip(), "image": person.get("image") or None, "links": [u for u in (person.get("sameAs") or []) if isinstance(u, str)]} return None def parse_profile_next(html: str) -> dict: """Page profil → objet `profile` du payload Next.js/RSC ({} si absent). La même page expose, au-delà du JSON-LD, la fiche complète auto-déclarée : displayName réel, bio, headline, categories[], mymUrl/fanslyUrl/ofUrl, websiteUrl, updatedAt. Chaque fragment `self.__next_f.push([1,"…"])` est une chaîne JS → décodée comme chaîne JSON, puis l'objet `"profile":{…}` est extrait par décodage équilibré (raw_decode). """ for m in _PUSH_RE.finditer(html): try: chunk = json.loads(f'"{m.group(1)}"') except Exception: continue i = chunk.find('"profile":{') if i < 0: continue try: obj, _ = json.JSONDecoder().raw_decode(chunk, i + len('"profile":')) except Exception: continue if isinstance(obj, dict) and obj.get("displayName") is not None: return obj return {} class OnlyQuebConnector(BaseConnector): source_id = "onlyqueb" kind = "discovery" request_delay = 1.0 max_profiles = 600 # garde-fou par passage def _profile_urls(self) -> list[str]: xml = self.get(SITEMAP_URL).text urls = [] for u in _LOC_RE.findall(xml): path = u.split("onlyqueb.com", 1)[-1] if path and path != "/" and path.count("/") == 1 \ and not any(path.startswith(e) for e in _EXCLUDED): urls.append(u) return urls[:self.max_profiles] def fetch(self) -> list[Creator]: creators: list[Creator] = [] for url in self._profile_urls(): try: html = self.get(url).text except Exception: continue prof = parse_profile_ld(html) if not prof or not prof["name"]: continue extra = parse_profile_next(html) # payload RSC : fiche complète links = list(prof["links"]) for key in ("ofUrl", "mymUrl", "fanslyUrl", "instagramUrl", "xUrl", "tiktokUrl", "snapchatUrl"): if (extra.get(key) or "").startswith("http"): links.append(extra[key]) accounts = [] seen: set[str] = set() for link in links: hit = platform_from_url(link) if not hit: continue platform, handle = hit if f"{platform}:{handle}" in seen: continue seen.add(f"{platform}:{handle}") accounts.append(account(platform, handle, "cross_link", url=link)) # site web personnel auto-déclaré → compte site-web (annuaire de liens) site = (extra.get("websiteUrl") or "").strip() if site.startswith("http") and not platform_from_url(site): domain = site.split("://", 1)[-1].split("/", 1)[0] domain = domain.removeprefix("www.") if domain and f"site-web:{domain}" not in seen: seen.add(f"site-web:{domain}") accounts.append(account("site-web", domain, "cross_link", url=site)) if not accounts: continue # sans lien vérifiable, pas de fiche (annuaire de liens) name = (extra.get("displayName") or "").strip() or prof["name"] bio = "\n".join( t.strip() for t in ((extra.get("headline") or ""), (extra.get("bio") or "")) if t.strip()) cats = [c for c in (extra.get("categories") or []) if isinstance(c, str)] niches = sorted({_CAT_NICHE.get(c, map_niche(c)) for c in cats}) niches = [n for n in niches if n != "autre"] or ["autre"] slug = slugify(prof["name"]) creators.append(Creator( source=self.source_id, external_id=slug, display_name=name, bio=bio, niches=niches, creator_type="influenceur", avatar_url=prof["image"], platforms=accounts, notes="Profil auto-déclaré sur l'annuaire OnlyQueb (créateurs " "québécois sur abonnement, 18+).", source_ids=[f"{self.source_id}:{slug}"], )) return creators