Correctifs audit : fix 500 threads, durées sync, twitch sauté proprement, onlyqueb RSC, avatars YouTube, balados enrichis, archivage §13
- web.py : connexion SQLite PAR THREAD (threading.local) — corrige le 500 intermittent sur /api/stats (curseurs entrelacés sur connexion partagée) - db.py : sync_log.seconds/errors réels persistés ; log des comptes retirés dans _write_creator ; archive_missing() (§13 — non revu depuis 72 h → inactive, réversible au retour, opt-out intouchés) - ingest.py : durée+erreurs transmises à sync_source ; archivage en fin de passage complet ; SkipSource journalisé sans fausse alerte - twitch : clés absentes → SkipSource (« sauté : clés manquantes »), plus de run 0 ni d alerte « source bloquée » - onlyqueb : parseur du payload Next.js/RSC — displayName réel, bio+headline, categories→niches, mym/fansly/ofUrl→comptes, websiteUrl→compte site-web - normalize : plateformes mym + fansly (URL canoniques + détection) - dedup : le statut de l observation fraîche gagne (dormant/retour) ; un vrai nom public remplace un display_name pseudo-handle - youtube-recherche : avatar_url extrait du channelRenderer (meilleure rés.) - balados-itunes : feed_url/artist_id/last_episode dans le doc, genres[] complets → niches, dormant >12 mois → status inactive (§13) - wikidata-qc : échec SPARQL journalisé + compté dans sync_log.errors - frontend : libellés MYM/Fansly Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 changed files +227 −31
modified
creaka/connectors/balados_itunes.py
+33 −4
@@ -14,6 +14,8 @@ Apple Podcasts. Le balado est le compte SOURCE de la fiche → profil_source. | ||
| 14 | 14 | """ |
| 15 | 15 | from __future__ import annotations |
| 16 | 16 | |
| 17 | +from datetime import datetime, timedelta, timezone | |
| 18 | + | |
| 17 | 19 | from ..identity import account |
| 18 | 20 | from ..normalize import slugify |
| 19 | 21 | from ..schema import Creator |
@@ -22,6 +24,21 @@ from .youtube_recherche import is_quebec | ||
| 22 | 24 | |
| 23 | 25 | SEARCH_URL = "https://itunes.apple.com/search" |
| 24 | 26 | |
| 27 | +# au-delà de 12 mois sans épisode : balado dormant (flag public dans le doc ; | |
| 28 | +# la fiche reste dans l'annuaire — §13, on ne supprime jamais brutalement) | |
| 29 | +DORMANT_AFTER_DAYS = 365 | |
| 30 | + | |
| 31 | + | |
| 32 | +def _dormant(release_date: str | None) -> bool: | |
| 33 | + """Vrai si le dernier épisode (releaseDate iTunes) date de > 12 mois.""" | |
| 34 | + if not release_date: | |
| 35 | + return False | |
| 36 | + try: | |
| 37 | + last = datetime.fromisoformat(release_date.replace("Z", "+00:00")) | |
| 38 | + except ValueError: | |
| 39 | + return False | |
| 40 | + return datetime.now(timezone.utc) - last > timedelta(days=DORMANT_AFTER_DAYS) | |
| 41 | + | |
| 25 | 42 | QUERIES = [ |
| 26 | 43 | "québécois", "québécoise", "balado québec", "podcast québec", "montréal", |
| 27 | 44 | "humour québécois", "balado montréal", "saguenay", "gatineau", "sherbrooke", |
@@ -90,24 +107,36 @@ class BaladosItunesConnector(BaseConnector): | ||
| 90 | 107 | if not is_quebec(f"{name} {artist}"): |
| 91 | 108 | continue |
| 92 | 109 | seen.add(cid) |
| 93 | − url = r.get("collectionViewUrl") or r.get("feedUrl") or "" | |
| 110 | + feed = r.get("feedUrl") or "" | |
| 111 | + url = r.get("collectionViewUrl") or feed | |
| 94 | 112 | if not url: |
| 95 | 113 | continue |
| 96 | 114 | acc = account("podcast", str(cid), "profil_source", url=url) |
| 115 | + dormant = _dormant(r.get("releaseDate")) | |
| 97 | 116 | acc.metrics = {k: v for k, v in { |
| 98 | 117 | "episodes": r.get("trackCount"), |
| 99 | 118 | "genre": r.get("primaryGenreName"), |
| 119 | + "feed_url": feed, # site du balado (RSS) | |
| 120 | + "artist_id": r.get("artistId"), | |
| 121 | + "last_episode": r.get("releaseDate"), | |
| 122 | + "dormant": dormant or None, # >12 mois sans épisode | |
| 100 | 123 | }.items() if v} |
| 101 | − niche = _GENRE_NICHE.get(r.get("primaryGenreName") or "", | |
| 102 | − "actualite-opinion") | |
| 124 | + # genres[] complets → niches §6.1 (le genre principal inclus) | |
| 125 | + genres = [g for g in (r.get("genres") or []) if isinstance(g, str)] | |
| 126 | + niches = sorted({_GENRE_NICHE[g] for g in | |
| 127 | + [r.get("primaryGenreName"), *genres] | |
| 128 | + if g in _GENRE_NICHE}) or ["actualite-opinion"] | |
| 103 | 129 | creators.append(Creator( |
| 104 | 130 | source=self.source_id, |
| 105 | 131 | external_id=str(cid), |
| 106 | 132 | display_name=name, |
| 107 | 133 | bio=f"Balado de {artist}." if artist and artist != name else "", |
| 108 | − niches=[niche], | |
| 134 | + niches=niches, | |
| 109 | 135 | creator_type="podcasteur", |
| 110 | 136 | avatar_url=r.get("artworkUrl600") or r.get("artworkUrl100"), |
| 137 | + # §13 : plus de publication depuis 12 mois → inactive | |
| 138 | + # (réversible : redevient actif dès un nouvel épisode) | |
| 139 | + status="inactive" if dormant else "active", | |
| 111 | 140 | platforms=[acc], |
| 112 | 141 | notes="Référencé sur Apple Podcasts (API de recherche officielle).", |
| 113 | 142 | source_ids=[f"{self.source_id}:{cid}"], |
modified
creaka/connectors/base.py
+8 −0
@@ -20,6 +20,14 @@ FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" | ||
| 20 | 20 | SCRAPFLY_API = "https://api.scrapfly.io/scrape" |
| 21 | 21 | |
| 22 | 22 | |
| 23 | +class SkipSource(Exception): | |
| 24 | + """Passage sauté VOLONTAIREMENT (ex. clés API absentes) — pas un échec. | |
| 25 | + | |
| 26 | + Le pipeline (ingest.py) le journalise clairement, sans alerte de blocage | |
| 27 | + ni run « 0 créateur » dans sync_log (§16, §18 monitoring). | |
| 28 | + """ | |
| 29 | + | |
| 30 | + | |
| 23 | 31 | class BaseConnector: |
| 24 | 32 | """Un connecteur = un adaptateur propre à une source (CLAUDE.md §8). |
| 25 | 33 | |
modified
creaka/connectors/onlyqueb.py
+61 −4
@@ -20,7 +20,7 @@ import json | ||
| 20 | 20 | import re |
| 21 | 21 | |
| 22 | 22 | from ..identity import account |
| 23 | −from ..normalize import platform_from_url, slugify | |
| 23 | +from ..normalize import map_niche, platform_from_url, slugify | |
| 24 | 24 | from ..schema import Creator |
| 25 | 25 | from .base import BaseConnector |
| 26 | 26 | |
@@ -28,6 +28,13 @@ SITEMAP_URL = "https://onlyqueb.com/sitemap.xml" | ||
| 28 | 28 | _LOC_RE = re.compile(r"<loc>([^<]+)</loc>") |
| 29 | 29 | _LD_RE = re.compile(r'application/ld\+json"[^>]*>(.*?)</script>', re.S) |
| 30 | 30 | _EXCLUDED = ("/en", "/categories", "/explore", "/seo", "/signup", "/blog") |
| 31 | +# fragments du payload Next.js/RSC embarqué dans la page (self.__next_f.push) | |
| 32 | +_PUSH_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') | |
| 33 | + | |
| 34 | +# catégories OnlyQueb → niches §6.1 quand le lien de sens existe ; les | |
| 35 | +# descripteurs physiques (blonde, petite…) ne sont PAS des niches → « autre » | |
| 36 | +_CAT_NICHE = {"cosplay": "arts", "gamer": "gaming", "gaming": "gaming", | |
| 37 | + "fitness": "sport-fitness", "athletic": "sport-fitness"} | |
| 31 | 38 | |
| 32 | 39 | |
| 33 | 40 | def parse_profile_ld(html: str) -> dict | None: |
@@ -49,6 +56,32 @@ def parse_profile_ld(html: str) -> dict | None: | ||
| 49 | 56 | return None |
| 50 | 57 | |
| 51 | 58 | |
| 59 | +def parse_profile_next(html: str) -> dict: | |
| 60 | + """Page profil → objet `profile` du payload Next.js/RSC ({} si absent). | |
| 61 | + | |
| 62 | + La même page expose, au-delà du JSON-LD, la fiche complète auto-déclarée : | |
| 63 | + displayName réel, bio, headline, categories[], mymUrl/fanslyUrl/ofUrl, | |
| 64 | + websiteUrl, updatedAt. Chaque fragment `self.__next_f.push([1,"…"])` est | |
| 65 | + une chaîne JS → décodée comme chaîne JSON, puis l'objet `"profile":{…}` | |
| 66 | + est extrait par décodage équilibré (raw_decode). | |
| 67 | + """ | |
| 68 | + for m in _PUSH_RE.finditer(html): | |
| 69 | + try: | |
| 70 | + chunk = json.loads(f'"{m.group(1)}"') | |
| 71 | + except Exception: | |
| 72 | + continue | |
| 73 | + i = chunk.find('"profile":{') | |
| 74 | + if i < 0: | |
| 75 | + continue | |
| 76 | + try: | |
| 77 | + obj, _ = json.JSONDecoder().raw_decode(chunk, i + len('"profile":')) | |
| 78 | + except Exception: | |
| 79 | + continue | |
| 80 | + if isinstance(obj, dict) and obj.get("displayName") is not None: | |
| 81 | + return obj | |
| 82 | + return {} | |
| 83 | + | |
| 84 | + | |
| 52 | 85 | class OnlyQuebConnector(BaseConnector): |
| 53 | 86 | source_id = "onlyqueb" |
| 54 | 87 | kind = "discovery" |
@@ -75,9 +108,15 @@ class OnlyQuebConnector(BaseConnector): | ||
| 75 | 108 | prof = parse_profile_ld(html) |
| 76 | 109 | if not prof or not prof["name"]: |
| 77 | 110 | continue |
| 111 | + extra = parse_profile_next(html) # payload RSC : fiche complète | |
| 112 | + links = list(prof["links"]) | |
| 113 | + for key in ("ofUrl", "mymUrl", "fanslyUrl", "instagramUrl", | |
| 114 | + "xUrl", "tiktokUrl", "snapchatUrl"): | |
| 115 | + if (extra.get(key) or "").startswith("http"): | |
| 116 | + links.append(extra[key]) | |
| 78 | 117 | accounts = [] |
| 79 | 118 | seen: set[str] = set() |
| 80 | − for link in prof["links"]: | |
| 119 | + for link in links: | |
| 81 | 120 | hit = platform_from_url(link) |
| 82 | 121 | if not hit: |
| 83 | 122 | continue |
@@ -86,14 +125,32 @@ class OnlyQuebConnector(BaseConnector): | ||
| 86 | 125 | continue |
| 87 | 126 | seen.add(f"{platform}:{handle}") |
| 88 | 127 | accounts.append(account(platform, handle, "cross_link", url=link)) |
| 128 | + # site web personnel auto-déclaré → compte site-web (annuaire de liens) | |
| 129 | + site = (extra.get("websiteUrl") or "").strip() | |
| 130 | + if site.startswith("http") and not platform_from_url(site): | |
| 131 | + domain = site.split("://", 1)[-1].split("/", 1)[0] | |
| 132 | + domain = domain.removeprefix("www.") | |
| 133 | + if domain and f"site-web:{domain}" not in seen: | |
| 134 | + seen.add(f"site-web:{domain}") | |
| 135 | + accounts.append(account("site-web", domain, "cross_link", | |
| 136 | + url=site)) | |
| 89 | 137 | if not accounts: |
| 90 | 138 | continue # sans lien vérifiable, pas de fiche (annuaire de liens) |
| 139 | + name = (extra.get("displayName") or "").strip() or prof["name"] | |
| 140 | + bio = "\n".join( | |
| 141 | + t.strip() for t in ((extra.get("headline") or ""), | |
| 142 | + (extra.get("bio") or "")) if t.strip()) | |
| 143 | + cats = [c for c in (extra.get("categories") or []) | |
| 144 | + if isinstance(c, str)] | |
| 145 | + niches = sorted({_CAT_NICHE.get(c, map_niche(c)) for c in cats}) | |
| 146 | + niches = [n for n in niches if n != "autre"] or ["autre"] | |
| 91 | 147 | slug = slugify(prof["name"]) |
| 92 | 148 | creators.append(Creator( |
| 93 | 149 | source=self.source_id, |
| 94 | 150 | external_id=slug, |
| 95 | − display_name=prof["name"], | |
| 96 | − niches=["autre"], | |
| 151 | + display_name=name, | |
| 152 | + bio=bio, | |
| 153 | + niches=niches, | |
| 97 | 154 | creator_type="influenceur", |
| 98 | 155 | avatar_url=prof["image"], |
| 99 | 156 | platforms=accounts, |
modified
creaka/connectors/twitch.py
+5 −2
@@ -16,7 +16,7 @@ from __future__ import annotations | ||
| 16 | 16 | import os |
| 17 | 17 | |
| 18 | 18 | from ..schema import Creator, now_iso |
| 19 | −from .base import BaseConnector | |
| 19 | +from .base import BaseConnector, SkipSource | |
| 20 | 20 | |
| 21 | 21 | TOKEN_URL = "https://id.twitch.tv/oauth2/token" |
| 22 | 22 | USERS_URL = "https://api.twitch.tv/helix/users" |
@@ -38,7 +38,10 @@ class TwitchConnector(BaseConnector): | ||
| 38 | 38 | cid = os.environ.get("TWITCH_CLIENT_ID", "") |
| 39 | 39 | secret = os.environ.get("TWITCH_CLIENT_SECRET", "") |
| 40 | 40 | if not cid or not secret: |
| 41 | − return [] # pas de clés → on saute (jamais de contournement, §15) | |
| 41 | + # pas de clés → passage sauté PROPREMENT (jamais de contournement, | |
| 42 | + # §15) ; SkipSource évite la fausse alerte « source bloquée » | |
| 43 | + raise SkipSource("clés TWITCH_CLIENT_ID/TWITCH_CLIENT_SECRET " | |
| 44 | + "manquantes (.env)") | |
| 42 | 45 | token = self._token(cid, secret) |
| 43 | 46 | headers = {"Client-Id": cid, "Authorization": f"Bearer {token}"} |
| 44 | 47 | enriched: list[Creator] = [] |
modified
creaka/connectors/wikidata_qc.py
+8 −2
@@ -18,6 +18,7 @@ Prudence mineurs (§15) : l'année de naissance publique permet de lever | ||
| 18 | 18 | """ |
| 19 | 19 | from __future__ import annotations |
| 20 | 20 | |
| 21 | +import sys | |
| 21 | 22 | from datetime import datetime, timezone |
| 22 | 23 | |
| 23 | 24 | from ..identity import account |
@@ -71,11 +72,16 @@ class WikidataQcConnector(BaseConnector): | ||
| 71 | 72 | |
| 72 | 73 | def fetch(self) -> list[Creator]: |
| 73 | 74 | rows: list[dict] = [] |
| 75 | + self.errors = 0 # comptabilisé dans sync_log.errors par le pipeline (§16) | |
| 74 | 76 | for prop in ("P19", "P551"): # naissance, résidence |
| 75 | 77 | try: |
| 76 | 78 | rows.extend(self._query(prop)) |
| 77 | − except Exception: | |
| 78 | − continue # une branche en échec ne bloque pas l'autre | |
| 79 | + except Exception as exc: | |
| 80 | + # une branche en échec ne bloque pas l'autre — mais JAMAIS en | |
| 81 | + # silence : la couverture chute si une requête SPARQL casse | |
| 82 | + self.errors += 1 | |
| 83 | + print(f"[crea-ka] ⚠ wikidata-qc : requête SPARQL {prop} en " | |
| 84 | + f"échec : {exc}", file=sys.stderr) | |
| 79 | 85 | by_person: dict[str, dict] = {} |
| 80 | 86 | for r in rows: |
| 81 | 87 | def val(key): |
modified
creaka/connectors/youtube_recherche.py
+11 −1
@@ -248,9 +248,18 @@ def channels_from_data(data) -> list[dict]: | ||
| 248 | 248 | ((r.get("descriptionSnippet") or {}).get("runs") or [])) |
| 249 | 249 | subs_txt = ((r.get("videoCountText") or {}).get("simpleText")) or \ |
| 250 | 250 | ((r.get("subscriberCountText") or {}).get("simpleText")) or "" |
| 251 | + # avatar « gratuit » : le renderer embarque les miniatures du profil | |
| 252 | + # → meilleure résolution (URLs souvent protocol-relative « //yt3… ») | |
| 253 | + thumbs = (r.get("thumbnail") or {}).get("thumbnails") or [] | |
| 254 | + avatar = None | |
| 255 | + if thumbs: | |
| 256 | + avatar = max(thumbs, | |
| 257 | + key=lambda t: t.get("width") or 0).get("url") | |
| 258 | + if avatar and avatar.startswith("//"): | |
| 259 | + avatar = "https:" + avatar | |
| 251 | 260 | channels.append({"handle": handle, "channel_id": r.get("channelId", ""), |
| 252 | 261 | "name": name, "subs": parse_count(subs_txt), |
| 253 | − "description": desc}) | |
| 262 | + "description": desc, "avatar": avatar}) | |
| 254 | 263 | except Exception: |
| 255 | 264 | continue |
| 256 | 265 | return channels |
@@ -287,6 +296,7 @@ class YouTubeRechercheConnector(BaseConnector): | ||
| 287 | 296 | bio=ch["description"][:1200], |
| 288 | 297 | niches=[niche], |
| 289 | 298 | creator_type="youtubeur", |
| 299 | + avatar_url=ch.get("avatar"), | |
| 290 | 300 | platforms=[acc], |
| 291 | 301 | source_ids=[f"{self.source_id}:{key}"], |
| 292 | 302 | )) |
modified
creaka/db.py
+48 −4
@@ -20,7 +20,10 @@ from __future__ import annotations | ||
| 20 | 20 | |
| 21 | 21 | import json |
| 22 | 22 | import sqlite3 |
| 23 | +import sys | |
| 24 | +import time | |
| 23 | 25 | from dataclasses import asdict |
| 26 | +from datetime import datetime, timedelta, timezone | |
| 24 | 27 | from pathlib import Path |
| 25 | 28 | |
| 26 | 29 | from . import ethics |
@@ -135,6 +138,14 @@ def _unique_id(con: sqlite3.Connection, base: str) -> str: | ||
| 135 | 138 | |
| 136 | 139 | def _write_creator(con: sqlite3.Connection, cid: str, cr: Creator, |
| 137 | 140 | first_seen: str, ts: str) -> None: |
| 141 | + # comptes retirés de la fiche : jamais en silence (§13 — historique) ; | |
| 142 | + # merge_accounts fait l'union, donc une perte ici est anormale → journalisée | |
| 143 | + old_keys = {f"{r['platform']}:{r['handle']}" for r in con.execute( | |
| 144 | + "SELECT platform, handle FROM accounts WHERE creator_id=?", (cid,))} | |
| 145 | + lost = old_keys - {a.key for a in cr.platforms} | |
| 146 | + if lost: | |
| 147 | + print(f"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : " | |
| 148 | + f"{', '.join(sorted(lost))}", file=sys.stderr) | |
| 138 | 149 | con.execute( |
| 139 | 150 | """INSERT INTO creators (id, display_name, status, is_minor, region, city, |
| 140 | 151 | niches, languages, creator_type, primary_platform, audience_tier, |
@@ -186,8 +197,14 @@ def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None: | ||
| 186 | 197 | |
| 187 | 198 | |
| 188 | 199 | def sync_source(con: sqlite3.Connection, source_id: str, |
| 189 | − creators: list[Creator]) -> dict: | |
| 190 | − """Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.""" | |
| 200 | + creators: list[Creator], *, started: float | None = None, | |
| 201 | + errors: int = 0) -> dict: | |
| 202 | + """Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out. | |
| 203 | + | |
| 204 | + `started` (time.time() du début du passage) → durée réelle persistée dans | |
| 205 | + sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le | |
| 206 | + connecteur (§16 logging structuré). | |
| 207 | + """ | |
| 191 | 208 | ts = now_iso() |
| 192 | 209 | added = updated = skipped_optout = 0 |
| 193 | 210 | confs: list[float] = [] |
@@ -226,15 +243,42 @@ def sync_source(con: sqlite3.Connection, source_id: str, | ||
| 226 | 243 | if not creators: |
| 227 | 244 | alert = "0 créateur retourné — source possiblement bloquée" |
| 228 | 245 | stats["alert"] = alert |
| 246 | + seconds = round(time.time() - started, 1) if started else 0.0 | |
| 247 | + stats["seconds"] = seconds | |
| 248 | + if errors: | |
| 249 | + stats["errors"] = errors | |
| 229 | 250 | con.execute( |
| 230 | 251 | "INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence," |
| 231 | − " added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,0,0,?)", | |
| 252 | + " added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)", | |
| 232 | 253 | (ts, source_id, len(creators), n_acc, stats["avg_confidence"], |
| 233 | − added, updated, alert)) | |
| 254 | + added, updated, errors, seconds, alert)) | |
| 234 | 255 | con.commit() |
| 235 | 256 | return stats |
| 236 | 257 | |
| 237 | 258 | |
| 259 | +def archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int: | |
| 260 | + """Archivage des disparus (§13, politique de grâce). | |
| 261 | + | |
| 262 | + Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages | |
| 263 | + complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`. | |
| 264 | + Réversible : dès qu'une source le revoit, la fusion (sync_source → | |
| 265 | + _write_creator) réécrit son statut actif et son `last_seen`. | |
| 266 | + Les opt-out ne sont JAMAIS touchés (status != 'active'). | |
| 267 | + """ | |
| 268 | + cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \ | |
| 269 | + .strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 270 | + ts = now_iso() | |
| 271 | + cur = con.execute( | |
| 272 | + "UPDATE creators SET status='inactive', updated_at=? " | |
| 273 | + "WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?", | |
| 274 | + (ts, cutoff)) | |
| 275 | + con.commit() | |
| 276 | + if cur.rowcount: | |
| 277 | + print(f"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) " | |
| 278 | + f"depuis {hours} h → inactive (réversible au retour)") | |
| 279 | + return cur.rowcount | |
| 280 | + | |
| 281 | + | |
| 238 | 282 | def log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None: |
| 239 | 283 | con.execute( |
| 240 | 284 | "INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)" |
modified
creaka/dedup.py
+12 −1
@@ -45,6 +45,13 @@ def merge_creators(canon: Creator, other: Creator) -> Creator: | ||
| 45 | 45 | """ |
| 46 | 46 | canon.platforms = merge_accounts(canon.platforms, other.platforms) |
| 47 | 47 | canon.source_ids = sorted(set(canon.source_ids) | set(other.source_ids)) |
| 48 | + # nom d'affichage : un VRAI nom public remplace un simple pseudo-handle | |
| 49 | + # (ex. « misslavoie » → « Jade Lavoie ») ; jamais l'inverse | |
| 50 | + handles = {a.handle for a in canon.platforms} | |
| 51 | + if (other.display_name and other.display_name != canon.display_name | |
| 52 | + and canon.display_name.lower() in handles | |
| 53 | + and other.display_name.lower() not in handles): | |
| 54 | + canon.display_name = other.display_name | |
| 48 | 55 | if len(other.bio or "") > len(canon.bio or ""): |
| 49 | 56 | canon.bio = other.bio |
| 50 | 57 | if len(other.notes or "") > len(canon.notes or ""): |
@@ -57,9 +64,13 @@ def merge_creators(canon: Creator, other: Creator) -> Creator: | ||
| 57 | 64 | setattr(canon, field_, getattr(other, field_)) |
| 58 | 65 | # prudence maximale : si UNE source signale un mineur, le régime s'applique |
| 59 | 66 | canon.is_minor = canon.is_minor or other.is_minor |
| 60 | − # opt-out prime sur tout (§15) | |
| 67 | + # opt-out prime sur tout (§15) ; sinon l'observation la plus FRAÎCHE | |
| 68 | + # (`other`) fixe le statut — un balado dormant passe inactive, une fiche | |
| 69 | + # archivée (§13) redevient active dès qu'une source la revoit | |
| 61 | 70 | if "opted_out" in (canon.status, other.status): |
| 62 | 71 | canon.status = "opted_out" |
| 72 | + elif other.status: | |
| 73 | + canon.status = other.status | |
| 63 | 74 | # recalculer les agrégats (tier, portée, plateforme principale) |
| 64 | 75 | canon.audience_tier = "" |
| 65 | 76 | canon.primary_platform = "" |
modified
creaka/ingest.py
+10 −2
@@ -24,6 +24,7 @@ import traceback | ||
| 24 | 24 | |
| 25 | 25 | from . import db |
| 26 | 26 | from .connectors import CONNECTORS |
| 27 | +from .connectors.base import SkipSource | |
| 27 | 28 | from .dedup import dedupe |
| 28 | 29 | |
| 29 | 30 | # ordre d'exécution des connecteurs d'enrichissement (link-in-bio en dernier) |
@@ -74,18 +75,25 @@ def run(sources: list[str] | None = None) -> list[dict]: | ||
| 74 | 75 | batch = connector.enrich(load_active(con)) |
| 75 | 76 | finalized, dropped = _finalize_batch(batch) |
| 76 | 77 | finalized = dedupe(finalized) # dédup intra-lot avant la base |
| 77 | − stats = db.sync_source(con, sid, finalized) | |
| 78 | − stats["seconds"] = round(time.time() - t0, 1) | |
| 78 | + stats = db.sync_source(con, sid, finalized, started=t0, | |
| 79 | + errors=getattr(connector, "errors", 0)) | |
| 79 | 80 | if dropped: |
| 80 | 81 | stats["dropped"] = dropped |
| 81 | 82 | if stats.get("alert") and cls.kind == "discovery": |
| 82 | 83 | print(f"[crea-ka] ⚠ ALERTE {sid} : {stats['alert']}") |
| 83 | 84 | print(f"[crea-ka] {stats}") |
| 84 | 85 | results.append(stats) |
| 86 | + except SkipSource as exc: | |
| 87 | + # passage sauté volontairement (ex. clés API absentes) : log clair, | |
| 88 | + # PAS d'alerte de blocage ni de run compté à 0 dans sync_log | |
| 89 | + print(f"[crea-ka] ↷ {sid} sauté : {exc}") | |
| 90 | + results.append({"source": sid, "skipped": str(exc)}) | |
| 85 | 91 | except Exception as exc: # robustesse : une source ne bloque pas les autres |
| 86 | 92 | db.log_failure(con, sid, f"{exc}") |
| 87 | 93 | traceback.print_exc() |
| 88 | 94 | results.append({"source": sid, "error": str(exc)}) |
| 95 | + if not sources: # passage COMPLET seulement : archivage des disparus (§13) | |
| 96 | + db.archive_missing(con) | |
| 89 | 97 | return results |
| 90 | 98 | |
| 91 | 99 | |
modified
creaka/normalize.py
+7 −3
@@ -24,8 +24,8 @@ NICHES = { | ||
| 24 | 24 | |
| 25 | 25 | PLATFORMS = { |
| 26 | 26 | "instagram", "tiktok", "youtube", "twitch", "kick", "x", "facebook", |
| 27 | − "snapchat", "substack", "patreon", "onlyfans", "linkedin", "threads", | |
| 28 | − "podcast", "site-web", "autre", | |
| 27 | + "snapchat", "substack", "patreon", "onlyfans", "mym", "fansly", | |
| 28 | + "linkedin", "threads", "podcast", "site-web", "autre", | |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | CREATOR_TYPES = { |
@@ -57,6 +57,8 @@ _URL_TEMPLATES: dict[str, str | None] = { | ||
| 57 | 57 | "substack": "https://{h}.substack.com", |
| 58 | 58 | "patreon": "https://www.patreon.com/{h}", |
| 59 | 59 | "onlyfans": "https://onlyfans.com/{h}", |
| 60 | + "mym": "https://mym.fans/{h}", | |
| 61 | + "fansly": "https://fansly.com/{h}", | |
| 60 | 62 | "linkedin": "https://www.linkedin.com/in/{h}", |
| 61 | 63 | "threads": "https://www.threads.net/@{h}", |
| 62 | 64 | "podcast": None, |
@@ -78,6 +80,8 @@ _URL_PLATFORM_RES: list[tuple[str, re.Pattern]] = [ | ||
| 78 | 80 | ("substack", re.compile(r"https?://([\w\-]+)\.substack\.com", re.I)), |
| 79 | 81 | ("patreon", re.compile(r"patreon\.com/([\w.\-]+)", re.I)), |
| 80 | 82 | ("onlyfans", re.compile(r"onlyfans\.com/([\w.\-]+)", re.I)), |
| 83 | + ("mym", re.compile(r"mym\.fans/([\w.\-]+)", re.I)), | |
| 84 | + ("fansly", re.compile(r"fansly\.com/([\w.\-]+)", re.I)), | |
| 81 | 85 | ("linkedin", re.compile(r"linkedin\.com/in/([\w.\-]+)", re.I)), |
| 82 | 86 | ("threads", re.compile(r"threads\.net/@?([\w.\-]+)", re.I)), |
| 83 | 87 | ] |
@@ -136,7 +140,7 @@ def normalize_platform(raw: str | None) -> str: | ||
| 136 | 140 | "facebook": "facebook", "fb": "facebook", |
| 137 | 141 | "snapchat": "snapchat", "snap": "snapchat", |
| 138 | 142 | "substack": "substack", "patreon": "patreon", "onlyfans": "onlyfans", |
| 139 | − "of": "onlyfans", | |
| 143 | + "of": "onlyfans", "mym": "mym", "fansly": "fansly", | |
| 140 | 144 | "linkedin": "linkedin", "threads": "threads", |
| 141 | 145 | "podcast": "podcast", "balado": "podcast", |
| 142 | 146 | "site": "site-web", "site-web": "site-web", "website": "site-web", |
modified
creaka/web.py
+20 −8
@@ -7,6 +7,7 @@ | ||
| 7 | 7 | from __future__ import annotations |
| 8 | 8 | |
| 9 | 9 | import json |
| 10 | +import threading | |
| 10 | 11 | from pathlib import Path |
| 11 | 12 | |
| 12 | 13 | from fastapi import FastAPI, HTTPException, Query |
@@ -30,7 +31,18 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], | ||
| 30 | 31 | allow_methods=["*"], allow_headers=["*"]) |
| 31 | 32 | app.add_middleware(GZipMiddleware, minimum_size=1000) |
| 32 | 33 | |
| 33 | −_con = db.connect() | |
| 34 | +# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans | |
| 35 | +# un pool de threads — une connexion partagée (check_same_thread=False) fait | |
| 36 | +# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType' | |
| 37 | +# object is not subscriptable »). threading.local = une connexion par thread. | |
| 38 | +_local = threading.local() | |
| 39 | + | |
| 40 | + | |
| 41 | +def _db(): | |
| 42 | + con = getattr(_local, "con", None) | |
| 43 | + if con is None: | |
| 44 | + con = _local.con = db.connect() | |
| 45 | + return con | |
| 34 | 46 | |
| 35 | 47 | # connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py |
| 36 | 48 | app.include_router(auth.router) |
@@ -43,7 +55,7 @@ def healthz(): | ||
| 43 | 55 | |
| 44 | 56 | @app.get("/api/health") |
| 45 | 57 | def health(): |
| 46 | − s = db.stats(_con) | |
| 58 | + s = db.stats(_db()) | |
| 47 | 59 | return {"ok": True, "creators": s["creators"], "accounts": s["accounts"], |
| 48 | 60 | "last_sync": s["last_sync"]} |
| 49 | 61 | |
@@ -53,14 +65,14 @@ def list_creators(q: str = "", niche: str = "", region: str = "", | ||
| 53 | 65 | langue: str = "", plateforme: str = "", tier: str = "", |
| 54 | 66 | sort: str = "reach", |
| 55 | 67 | limit: int = Query(60, le=200), offset: int = Query(0, ge=0)): |
| 56 | − return db.search(_con, q=q.strip(), niche=niche, region=region, | |
| 68 | + return db.search(_db(), q=q.strip(), niche=niche, region=region, | |
| 57 | 69 | langue=langue, plateforme=plateforme, tier=tier, |
| 58 | 70 | sort=sort, limit=limit, offset=offset) |
| 59 | 71 | |
| 60 | 72 | |
| 61 | 73 | @app.get("/api/creators/{cid}") |
| 62 | 74 | def get_creator(cid: str): |
| 63 | − creator = db.get_creator(_con, cid) | |
| 75 | + creator = db.get_creator(_db(), cid) | |
| 64 | 76 | if creator is None: |
| 65 | 77 | raise HTTPException(404, "créateur introuvable") |
| 66 | 78 | return creator |
@@ -68,7 +80,7 @@ def get_creator(cid: str): | ||
| 68 | 80 | |
| 69 | 81 | @app.get("/api/stats") |
| 70 | 82 | def get_stats(): |
| 71 | − return db.stats(_con) | |
| 83 | + return db.stats(_db()) | |
| 72 | 84 | |
| 73 | 85 | |
| 74 | 86 | _PERIODS_OK = {"auj", "7j", "30j", "3m", "6m", "12m", "annee", "tout"} |
@@ -82,7 +94,7 @@ def stats_dashboard(period: str = "30j", | ||
| 82 | 94 | if period not in _PERIODS_OK and not (date_from and date_to): |
| 83 | 95 | raise HTTPException(400, "période inconnue " |
| 84 | 96 | "(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)") |
| 85 | − return stats_mod.dashboard(_con, period=period, | |
| 97 | + return stats_mod.dashboard(_db(), period=period, | |
| 86 | 98 | date_from=date_from, date_to=date_to) |
| 87 | 99 | |
| 88 | 100 | |
@@ -95,7 +107,7 @@ def stats_report(period: str = "30j", mode: str = "complet", | ||
| 95 | 107 | raise HTTPException(400, "mode invalide (complet|synthese)") |
| 96 | 108 | if period not in _PERIODS_OK and not (date_from and date_to): |
| 97 | 109 | raise HTTPException(400, "période inconnue") |
| 98 | − dash = stats_mod.dashboard(_con, period=period, | |
| 110 | + dash = stats_mod.dashboard(_db(), period=period, | |
| 99 | 111 | date_from=date_from, date_to=date_to) |
| 100 | 112 | pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(), |
| 101 | 113 | dashboard=dash, mode=mode).build() |
@@ -142,7 +154,7 @@ def optout(req: OptOutRequest): | ||
| 142 | 154 | raise HTTPException(400, "préciser un nom ou un compte (plateforme:handle)") |
| 143 | 155 | ethics.add_optout(name=name, account=acc, reason=req.reason, |
| 144 | 156 | contact=req.contact) |
| 145 | − masked = db.apply_optout(_con, name=name, account=acc) | |
| 157 | + masked = db.apply_optout(_db(), name=name, account=acc) | |
| 146 | 158 | return {"ok": True, "masked": masked, |
| 147 | 159 | "message": "Fiche masquée. La demande sera vérifiée ; merci."} |
| 148 | 160 | |
modified
frontend/dist/index.html
+2 −0
@@ -950,6 +950,8 @@ const PLAT={ | ||
| 950 | 950 | substack:{label:"Substack",color:"#ff6719"}, |
| 951 | 951 | patreon:{label:"Patreon",color:"#f1465a"}, |
| 952 | 952 | onlyfans:{label:"OnlyFans",color:"#00aff0"}, |
| 953 | + mym:{label:"MYM",color:"#141814"}, | |
| 954 | + fansly:{label:"Fansly",color:"#2699f7"}, | |
| 953 | 955 | linkedin:{label:"LinkedIn",color:"#0a66c2"}, |
| 954 | 956 | threads:{label:"Threads",color:"#141814"}, |
| 955 | 957 | podcast:{label:"Balado",color:"#8940fa"}, |
modified
frontend/src/index.template.html
+2 −0
@@ -492,6 +492,8 @@ const PLAT={ | ||
| 492 | 492 | substack:{label:"Substack",color:"#ff6719"}, |
| 493 | 493 | patreon:{label:"Patreon",color:"#f1465a"}, |
| 494 | 494 | onlyfans:{label:"OnlyFans",color:"#00aff0"}, |
| 495 | + mym:{label:"MYM",color:"#141814"}, | |
| 496 | + fansly:{label:"Fansly",color:"#2699f7"}, | |
| 495 | 497 | linkedin:{label:"LinkedIn",color:"#0a66c2"}, |
| 496 | 498 | threads:{label:"Threads",color:"#141814"}, |
| 497 | 499 | podcast:{label:"Balado",color:"#8940fa"}, |
| 498 | 500 | |