|
1 |
+{"type":"user_prompt","text":"J’ai remarquer que beaucoup de créateur dans crea-ka on des info pas trop complète pour les différent média sociaux. Je veux que tu fasse un major upgrade des apify actor pour qu’il capture le plus info possible et présente plus image . De plus pour chaque créateur québécois tu dois ajouté des metric et stats sur celui ci ça doit être légendaire","ts":1787273528805} |
|
2 |
+{"type":"status","state":"running"} |
|
3 |
+{"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/crea-ka","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","ToolSearch","WebFetch","WebSearch","Write"],"mcp_servers":[],"model":"claude-fable-5","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api","clear","compact","context","cost","heapdump","init","review","security-review","insights","team-onboarding"],"apiKeySource":"ANTHROPIC_API_KEY","claude_code_version":"2.1.117","output_style":"default","agents":["Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api"],"plugins":[],"uuid":"92841a74-7f6c-4c9f-b6f3-7603c752ca2c","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/"},"fast_mode_state":"off"}} |
|
4 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"44cc5f3e-f7bc-4ac5-9abd-c93e21b7eeb9","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
5 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyhGKabU81J4kRdtaXc","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISgQcKpAEIERgCKkDlueivqybOtq3xRIORCntvr5rhI2xn6gESz/44jZkG0W9HXv395PyD5mWjl/n74dDF5pE5vlnOYDOC+uD1cLVkMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHAwp7UBhIMdlm4wQpNwadjfGAOGgw2eIERbDQgCUEnzEMiMKg+G8Z6vdQgpyC+DhsC79hzeqPwCpDq2R4005NkoZpjPdCAZfnC3FZ/fnYAFSmyziqJBa/JCOZE2epjA2mZgMmMjcxUJ+g4fjLEW1BXDupsnLXnyCBREye3/au67pdmYmtufpfOeXKd7T0d1dpBDYgFmdefVAJ4HpT2wgCaOFugRuIiLHx9aLh2OmkF5I0XWW+eZlLrwtO+Z+YlCHX81BbOe5GYwkVwLuaUqjniBAFmbWsfRaiC2iuWpPOSRO3bXoUUTNANn/+ncWN+T82UzcAlo+7dMwSHrd3giAWkMDZgCVOa5jJSaZlIYkXTLDVzsueEKgIzft7Zx4iwwLCuIajgKoQWc59JJqXiA/7eqMkGwQIpPnY3BKLYkglPaSi0o3wDZpESTrVfTATnAru212pSIvJYn0gtZtR0S+AMLXIatIG91qOsLzJusQRtelpO9zdrktvpfLwdON79avP5c2/Ks2SUnyTyf0mtJWDU49NEYy/NxYh9LTGRIuXOoHSt8p+AwBT4Mut502ZfkKp70UwWAlxXQ1myjIiQkCX3KXeKmOsrXMYMA8oe7HSS9hirnDzso4x0yZXlm6T2TyriUpxLpDUjLy/YfCp1B4zoo5O4e9DmtOxZOhQ5LF5I9sdCe5OoTq8uI1lryaebEnpBCPpNz2oBo7aWrpnkDERcb8OhJreMJQrz4BqREOCE4Bld1plYbsbHS3zg+GVOLboOtOW7OVeytenfmksQBUBkmDSXESMVjd7STlcEswGbJ8dzXIKQ2ou6HOiAa6J60QfKgV3eNP5i/yxik47BZcB5NK4uhj/v4woBGMFEz8cnEl4jByFF/pujKJF/1BE2Vwizj4iF6QePZGYS/mI55PRY46ggFhXmhjpFs0oh1BUOGUnUUDLxUOqmdBmn2z+ZoqBvUIbcU3c8q/XRim51fEwYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44445,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fa6bab32-2ddc-44ad-a01e-9376fe9a218d"}} |
|
6 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyhGKabU81J4kRdtaXc","type":"message","role":"assistant","content":[{"type":"text","text":"Je vais d'abord cartographier l'existant : les acteurs Apify maison, le pipeline d'enrichissement de crea-ka, et ce qui est affiché sur les fiches créateurs."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44445,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0c45ad11-cfa1-4ebc-af4a-5424387bf143"}} |
|
7 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyhGKabU81J4kRdtaXc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012VT8jmnR58ukm8iJQZT1VJ","name":"Bash","input":{"command":"ls ~/apps/crea-ka","description":"List crea-ka repo contents"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44445,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"612f4d41-1b85-48ac-8e6d-172afa6236d7"}} |
|
8 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012VT8jmnR58ukm8iJQZT1VJ","type":"tool_result","content":"CLAUDE.md\nREADME.md\ncreaka\ndata\ndocs\nfrontend\nrequirements.txt\nrun.py\nscripts\ntests","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5babaadb-33d8-41c9-aaf5-881c2f9417f8","timestamp":"2026-08-21T00:52:18.479Z","tool_use_result":{"stdout":"CLAUDE.md\nREADME.md\ncreaka\ndata\ndocs\nfrontend\nrequirements.txt\nrun.py\nscripts\ntests","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
9 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyhGKabU81J4kRdtaXc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AuQ9hzh1BnVkubp8arJgT3","name":"Bash","input":{"command":"ls -d ~/apify* ~/apps/apify* ~/ka-actors* 2>/dev/null; find ~ -maxdepth 3 -name \".actor\" -type d 2>/dev/null | head -20","description":"Locate Apify actor sources on this node"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44445,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"55ff5851-1433-47f6-8071-97529a86e556"}} |
|
10 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AuQ9hzh1BnVkubp8arJgT3","type":"tool_result","content":"(eval):1: no matches found: /Users/simon-pierreboucher/apify*","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"95f2d710-2b18-4e88-8438-eb35160a4397","timestamp":"2026-08-21T00:52:20.097Z","tool_use_result":{"stdout":"(eval):1: no matches found: /Users/simon-pierreboucher/apify*","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
11 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyhGKabU81J4kRdtaXc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016QncHnnqkrCN5ndqHiWFkx","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_moisson_seeds.md"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44445,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44445,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3f777679-d2ef-4a40-acfa-be1fb670f1e2"}} |
|
12 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016QncHnnqkrCN5ndqHiWFkx","type":"tool_result","content":"1\t---\n2\tname: Campagne moisson seeds 2026-08-20\n3\tdescription: Workflow validé pour élargir massivement la base créateurs via qc_creator_seeds.jsonl (Serper+Tavily+linktree), avec règles qualité durement acquises\n4\ttype: project\n5\toriginSessionId: 88fcb292-ac69-4ad0-907c-8ebda004af3a\n6\t---\n7\tCampagne du 2026-08-20 : +1043 seeds (commit a4b7446), base passée de 13 735 à 14 701 créateurs après `run.py sync tavily-seed` ; enrichissement Apify délégué au watch PM2 `crea-ka-sync` (un restart déclenche un passage complet immédiat).\n8\t\n9\t**Why:** demande d'un « major upgrade » du nombre de créateurs ; le levier le plus efficace est la moisson de handles vers `data/seed/qc_creator_seeds.jsonl` (`{\"platform\",\"handle\"}` par ligne, signal listes_medias 0.85, handles morts archivés par `archive_missing()` lors des passages complets).\n10\t\n11\t**How to apply:** pour une prochaine vague, reprendre les outils `/tmp/crea-harvest/*.py` (harvest Serper gl:ca hl:fr + Tavily extract ; linktree_pass ; merge_qc). Règles qualité à respecter :\n12\t- Réutiliser `creaka.normalize.platform_from_url` ; rejeter youtu.be (vidéos), IDs YouTube tronqués (uc… ≠ 24 chars), handles avec \"..\".\n13\t- Écarter les domaines France/monde/pancanadiens (culture-rp, woo.paris, marathons.com, metricool, posterjack, faulhaber, kolsquare, reech…) ; outils géo (feedspot, modash, infludata, dailyhive) seulement si marqueur QC dans l'URL ; domaine inconnu ⇒ marqueur QC dans l'URL obligatoire.\n14\t- Passe linktr.ee/beacons : très bruitée (politiciens, organismes, commerces, festivals) ⇒ whitelist manuelle des pages créateurs uniquement.\n15\t- Bloquer les handles de médias/marques QC (lapresse, iciartv, tva…) et les comptes de partis/députés.\n16\t- Dump des clés existantes : `sqlite3 data/creaka.db \"SELECT lower(platform)||':'||lower(handle) FROM accounts;\"` + seeds actuels.\n17\t\n18\tVague 3 (2026-08-20 soir, commit e7c36ce) : +392 seeds sur 1339 candidats (947 écartés — taux de rejet ~70 %, normal). Pièges supplémentaires durement acquis (encodés dans `/tmp/crea-harvest/merge_qc3.py`) :\n19\t- Pages « murales/street art MTL » (wall2wallmtl.com : 434 handles, mtl.org/murales, muralfestival) = artistes INTERNATIONAUX venus peindre à MTL ⇒ bloquer en bloc.\n20\t- Les origines `serper:` ne sont PAS sûres pour autant : crible regex anti-organismes sur les HANDLES obligatoire (ville|tourisme|cegep|ecole|salon|agence|federation|marche|boutique|centre|club|radio…) — a attrapé ~120 villes/fédés/commerces (villethetford, recyc_quebec, quebecsolidaire, air.inuit, hockeyquebec…). Attention faux positifs : « ville » matche sasseVILLE, « salle » etc. ⇒ relire les hits un à un.\n21\t- Listes QC mixtes : jecoursqc listait Kipchoge/Mo Farah à côté des athlètes QC ; urbania-finances listait gothamchess/texasbeeworks ; lechiquiers.com = streamers échecs FRANÇAIS (domingo, sardoche) ⇒ épurer handle par handle, jamais confiance aveugle à un domaine QC.\n22\t- Les pages passant le marqueur QC livrent souvent les comptes DE L'ORGANISME source (vivreengaspesie, espaceverre) ou du blogue lui-même (velomag_, la_journaliste) ⇒ toujours bloquer ces clés.\n23\t- En cas de doute résiduel : 1 requête Serper par handle tranche vite (dinettemarcella=resto, latelierbourque=créateur ébéniste QC, drole_de_biologiste=Française).\n24\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"17e65198-896d-453a-a274-a8bd45a99fc0","timestamp":"2026-08-21T00:52:20.924Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_moisson_seeds.md","content":"---\nname: Campagne moisson seeds 2026-08-20\ndescription: Workflow validé pour élargir massivement la base créateurs via qc_creator_seeds.jsonl (Serper+Tavily+linktree), avec règles qualité durement acquises\ntype: project\noriginSessionId: 88fcb292-ac69-4ad0-907c-8ebda004af3a\n---\nCampagne du 2026-08-20 : +1043 seeds (commit a4b7446), base passée de 13 735 à 14 701 créateurs après `run.py sync tavily-seed` ; enrichissement Apify délégué au watch PM2 `crea-ka-sync` (un restart déclenche un passage complet immédiat).\n\n**Why:** demande d'un « major upgrade » du nombre de créateurs ; le levier le plus efficace est la moisson de handles vers `data/seed/qc_creator_seeds.jsonl` (`{\"platform\",\"handle\"}` par ligne, signal listes_medias 0.85, handles morts archivés par `archive_missing()` lors des passages complets).\n\n**How to apply:** pour une prochaine vague, reprendre les outils `/tmp/crea-harvest/*.py` (harvest Serper gl:ca hl:fr + Tavily extract ; linktree_pass ; merge_qc). Règles qualité à respecter :\n- Réutiliser `creaka.normalize.platform_from_url` ; rejeter youtu.be (vidéos), IDs YouTube tronqués (uc… ≠ 24 chars), handles avec \"..\".\n- Écarter les domaines France/monde/pancanadiens (culture-rp, woo.paris, marathons.com, metricool, posterjack, faulhaber, kolsquare, reech…) ; outils géo (feedspot, modash, infludata, dailyhive) seulement si marqueur QC dans l'URL ; domaine inconnu ⇒ marqueur QC dans l'URL obligatoire.\n- Passe linktr.ee/beacons : très bruitée (politiciens, organismes, commerces, festivals) ⇒ whitelist manuelle des pages créateurs uniquement.\n- Bloquer les handles de médias/marques QC (lapresse, iciartv, tva…) et les comptes de partis/députés.\n- Dump des clés existantes : `sqlite3 data/creaka.db \"SELECT lower(platform)||':'||lower(handle) FROM accounts;\"` + seeds actuels.\n\nVague 3 (2026-08-20 soir, commit e7c36ce) : +392 seeds sur 1339 candidats (947 écartés — taux de rejet ~70 %, normal). Pièges supplémentaires durement acquis (encodés dans `/tmp/crea-harvest/merge_qc3.py`) :\n- Pages « murales/street art MTL » (wall2wallmtl.com : 434 handles, mtl.org/murales, muralfestival) = artistes INTERNATIONAUX venus peindre à MTL ⇒ bloquer en bloc.\n- Les origines `serper:` ne sont PAS sûres pour autant : crible regex anti-organismes sur les HANDLES obligatoire (ville|tourisme|cegep|ecole|salon|agence|federation|marche|boutique|centre|club|radio…) — a attrapé ~120 villes/fédés/commerces (villethetford, recyc_quebec, quebecsolidaire, air.inuit, hockeyquebec…). Attention faux positifs : « ville » matche sasseVILLE, « salle » etc. ⇒ relire les hits un à un.\n- Listes QC mixtes : jecoursqc listait Kipchoge/Mo Farah à côté des athlètes QC ; urbania-finances listait gothamchess/texasbeeworks ; lechiquiers.com = streamers échecs FRANÇAIS (domingo, sardoche) ⇒ épurer handle par handle, jamais confiance aveugle à un domaine QC.\n- Les pages passant le marqueur QC livrent souvent les comptes DE L'ORGANISME source (vivreengaspesie, espaceverre) ou du blogue lui-même (velomag_, la_journaliste) ⇒ toujours bloquer ces clés.\n- En cas de doute résiduel : 1 requête Serper par handle tranche vite (dinettemarcella=resto, latelierbourque=créateur ébéniste QC, drole_de_biologiste=Française).\n","numLines":24,"startLine":1,"totalLines":24}}}} |
|
13 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"bd901665-9d5b-4687-b905-5b30fb6f62ae","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
14 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyi8jwkrunWVMafnj1W","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4gUKpAEIERgCKkBVIDJQBXVEDN6mApHCS2WNnh58z8812Po4zK3n6VV3huYu/lXZrFiu34ypzX8cVzyQnaLvEE9XF/DAe5Q7a3L/Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHMwp7UBhIMVTYGjucQRFNUxHivGgwB1548F/y5AAGfx3ciMP/aURzkMQIA2dP/xLS4gTJvExcKy9ZC8QCrIc1ZRd5Cnt1sC4puJDSNA723vsZoKCrqA/1G+a1KBZqVWNghtUVcI/vzZ9HuG8/bNuXn0Jl2TfOCymNV9O/YO3H0hlNGRr95CKR2a/lgC3mdmoZ6bmqjuS39MfMAVN9OraSgHVLwaxaQPm6Gnwl52gp0wvXi1tt91vOQInvIXZWNRDKJ+5EZ5QCGYa0D7Y3wEYZnLJwG6VvpKlIjp4WG+k8nOzYW5p7IW3hg5hRJX5QolCwRew06D5fIhdWKRfa+I5ZRg9av3vJFwnaeLjDFdvMzYgcxpEKs4yk4x0td4rBqGN0CUjlHdWmJAGh8bbTn5uLp4RQAOcHcOSDTbuUZG50ld8JzGGJQcBMrK9AO4Fjf7Aro3jFbDzU2eY2hsYW72dO/o92LI1OGO22JExPVkALcrnYXGutP1ngV4Xj++8REZUqN1COp2Tm9YVSLNJRzd4sMmxJtiVz5zoCdD7yWKl8phpQdQ8eWxNVYSdUsH1KNhcD/iABwOP8wz9PQAr3PGA3oq1QuMyrx+ZO6S3HZmmDCmHufL/vdg2PiS99jqGv70uVXnvTGu9asNIQ4aIIjLDhaagXEu2lDVpAbxQ5p7hupC4P1mqqE8WVtM2bTZAgjFmJtBnVWW98Xw1nSdsq+lbPDKTE1oFDyEOnTS823kS0IR2Ga6PA0wweBSE7Gdw9P/ToYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2337,"cache_read_input_tokens":44445,"cache_creation":{"ephemeral_5m_input_tokens":2337,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"482d13aa-e28a-4606-a670-b73eade11a8b"}} |
|
15 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyi8jwkrunWVMafnj1W","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017SSA1qveZkeBNw3Kg1n17m","name":"Bash","input":{"command":"find ~ -maxdepth 4 -name \"actor.json\" -not -path \"*/node_modules/*\" 2>/dev/null | head; ls ~/apps 2>/dev/null","description":"Search for Apify actor.json files on node"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2337,"cache_read_input_tokens":44445,"cache_creation":{"ephemeral_5m_input_tokens":2337,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"286e265e-f4ef-44c6-a7d3-455fc2a6ef6a"}} |
|
16 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017SSA1qveZkeBNw3Kg1n17m","type":"tool_result","content":"IMM1003-20\nIMM1033-20\nairiskindex\nanomaly-atlas\ncoinexplorer\ncrea-ka\ndraft2027\nearth-now\ngroupe-ka\nimmbot-ai\nka-maps\nkhaelor-sh\nllmindex\nlou-ka\noutput\nresto-ka\nsvgarden\nvoyage2026","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"402f689f-347e-48f7-a739-776b572f9344","timestamp":"2026-08-21T00:52:30.624Z","tool_use_result":{"stdout":"IMM1003-20\nIMM1033-20\nairiskindex\nanomaly-atlas\ncoinexplorer\ncrea-ka\ndraft2027\nearth-now\ngroupe-ka\nimmbot-ai\nka-maps\nkhaelor-sh\nllmindex\nlou-ka\noutput\nresto-ka\nsvgarden\nvoyage2026","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
17 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyi8jwkrunWVMafnj1W","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","name":"Agent","input":{"description":"Explorer pipeline enrichissement crea-ka","subagent_type":"Explore","prompt":"Explore le repo /Users/simon-pierreboucher/apps/crea-ka (thoroughness: very thorough). C'est un agrégateur de créateurs québécois. Je prépare un « major upgrade » des acteurs Apify (ka-instagram, ka-tiktok, ka-youtube, ka-twitch, ka-x, ka-kick, etc.) pour capturer plus d'infos/images, et l'ajout de métriques/stats par créateur. J'ai besoin d'une cartographie précise :\n\n1. Comment le pipeline appelle les acteurs Apify : quels fichiers (cherche APIFY, apify, acts, run-sync, dataset dans creaka/), quels inputs envoyés, quels champs des résultats sont consommés/stockés.\n2. Le schéma de la base SQLite (data/creaka.db) : tables, colonnes — surtout accounts/creators/metrics s'il y en a. Lis le code de création de schéma (creaka/db.py ou similaire).\n3. Ce que le frontend affiche sur une fiche créateur (frontend/ — page créateur, champs affichés, images/avatars, stats). Quel serveur sert le frontend (API, port, PM2 name si trouvable dans les scripts ou ecosystem).\n4. Comment les images/avatars sont gérées actuellement (stockées ? hotlinkées ? proxy ?).\n5. Y a-t-il déjà une notion de métriques historisées / snapshots d'audience ?\n6. run.py : quelles commandes existent (sync, enrich, etc.).\n\nRapporte avec chemins de fichiers + numéros de lignes clés, noms exacts des colonnes DB, champs Apify consommés par plateforme, et tout gotcha visible. Réponse structurée, max ~800 mots."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2337,"cache_read_input_tokens":44445,"cache_creation":{"ephemeral_5m_input_tokens":2337,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"66cf6794-7d38-4f5e-9e9f-e7a8c27da8d5"}} |
|
18 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Explorer pipeline enrichissement crea-ka","task_type":"local_agent","prompt":"Explore le repo /Users/simon-pierreboucher/apps/crea-ka (thoroughness: very thorough). C'est un agrégateur de créateurs québécois. Je prépare un « major upgrade » des acteurs Apify (ka-instagram, ka-tiktok, ka-youtube, ka-twitch, ka-x, ka-kick, etc.) pour capturer plus d'infos/images, et l'ajout de métriques/stats par créateur. J'ai besoin d'une cartographie précise :\n\n1. Comment le pipeline appelle les acteurs Apify : quels fichiers (cherche APIFY, apify, acts, run-sync, dataset dans creaka/), quels inputs envoyés, quels champs des résultats sont consommés/stockés.\n2. Le schéma de la base SQLite (data/creaka.db) : tables, colonnes — surtout accounts/creators/metrics s'il y en a. Lis le code de création de schéma (creaka/db.py ou similaire).\n3. Ce que le frontend affiche sur une fiche créateur (frontend/ — page créateur, champs affichés, images/avatars, stats). Quel serveur sert le frontend (API, port, PM2 name si trouvable dans les scripts ou ecosystem).\n4. Comment les images/avatars sont gérées actuellement (stockées ? hotlinkées ? proxy ?).\n5. Y a-t-il déjà une notion de métriques historisées / snapshots d'audience ?\n6. run.py : quelles commandes existent (sync, enrich, etc.).\n\nRapporte avec chemins de fichiers + numéros de lignes clés, noms exacts des colonnes DB, champs Apify consommés par plateforme, et tout gotcha visible. Réponse structurée, max ~800 mots.","uuid":"f7962d10-d760-4011-abf6-9d2eba103c80","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
19 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Explore le repo /Users/simon-pierreboucher/apps/crea-ka (thoroughness: very thorough). C'est un agrégateur de créateurs québécois. Je prépare un « major upgrade » des acteurs Apify (ka-instagram, ka-tiktok, ka-youtube, ka-twitch, ka-x, ka-kick, etc.) pour capturer plus d'infos/images, et l'ajout de métriques/stats par créateur. J'ai besoin d'une cartographie précise :\n\n1. Comment le pipeline appelle les acteurs Apify : quels fichiers (cherche APIFY, apify, acts, run-sync, dataset dans creaka/), quels inputs envoyés, quels champs des résultats sont consommés/stockés.\n2. Le schéma de la base SQLite (data/creaka.db) : tables, colonnes — surtout accounts/creators/metrics s'il y en a. Lis le code de création de schéma (creaka/db.py ou similaire).\n3. Ce que le frontend affiche sur une fiche créateur (frontend/ — page créateur, champs affichés, images/avatars, stats). Quel serveur sert le frontend (API, port, PM2 name si trouvable dans les scripts ou ecosystem).\n4. Comment les images/avatars sont gérées actuellement (stockées ? hotlinkées ? proxy ?).\n5. Y a-t-il déjà une notion de métriques historisées / snapshots d'audience ?\n6. run.py : quelles commandes existent (sync, enrich, etc.).\n\nRapporte avec chemins de fichiers + numéros de lignes clés, noms exacts des colonnes DB, champs Apify consommés par plateforme, et tout gotcha visible. Réponse structurée, max ~800 mots."}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8cfd2715-80c6-4681-aba5-6e5767537c94","timestamp":"2026-08-21T00:52:40.920Z"}} |
|
20 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running find /Users/simon-pierreboucher/apps/crea-ka -typ…","usage":{"total_tokens":8576,"tool_uses":1,"duration_ms":1371},"last_tool_name":"Bash","uuid":"2feffb4f-8744-47c9-8584-67c05b18ba52","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
21 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjbsd2ysS3kWT94ewb","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AKiEcnJgAKi7geAhZ11bNZ","name":"Bash","input":{"command":"find /Users/simon-pierreboucher/apps/crea-ka -type f -name \"*.py\" -o -name \"*.json\" -o -name \"*.js\" -o -name \"*.ts\" | head -50"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":8563,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":8563,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a7eefed3-8729-4753-a8dc-e18e88d7a912"}} |
|
22 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AKiEcnJgAKi7geAhZ11bNZ","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/run.py\n/Users/simon-pierreboucher/apps/crea-ka/frontend/dist/ka-agent.js\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka-agent.js\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/icons.json\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka/ecosystem.json\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka/stats/kapdf.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/wikipedia_qc.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/kick_decouverte.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/twitch_decouverte.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/palmares_apple.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/listes_medias.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/linkinbio.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/__init__.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/onlyqueb.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/podcastindex.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/bio_liens.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/balados_rss.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/tavily_seed.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/balados_itunes.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/instagram.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/wikidata_qc.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/tiktok.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/snowball.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/twitch.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube_recherche.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/base.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/region_bio.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/auth.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/ingest.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/kapdf.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/normalize.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/__init__.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/dedup.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/ethics.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/identity.py\n/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_ethics.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_vague3.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_perimetre_qc.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_vague2.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_schema.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_identity.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_linkinbio.py\n/Users/simon-pierreboucher/apps/crea-ka/tests/test_normalize.py","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a4fa3eb8-c216-4f22-8363-6541ee65c51d","timestamp":"2026-08-21T00:52:42.344Z"}} |
|
23 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading creaka/db.py","usage":{"total_tokens":11001,"tool_uses":2,"duration_ms":3065},"last_tool_name":"Read","uuid":"dd65299e-cf20-4b0a-8998-663d5ab6dc5d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
24 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjhwFSXirJm5Nu66Wq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SktPuwme1wvre6i4bjUYMM","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2419,"cache_read_input_tokens":8563,"cache_creation":{"ephemeral_5m_input_tokens":2419,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"df4be8e5-93a4-4c72-851e-ba8c4ba426a7"}} |
|
25 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SktPuwme1wvre6i4bjUYMM","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/db.py\n4\t# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n5\t# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n6\t# ==============================================================================\n7\t\"\"\"Base de données Créa-Ka (SQLite, WAL).\n8\t\n9\t- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n10\t indexées pour la recherche).\n11\t- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n12\t l'index qui permet la déduplication inter-sources (§12.2).\n13\t- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n14\t\n15\tPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\n16\thorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\n17\tpasse `opted_out` (masqué, jamais ré-agrégé).\n18\t\"\"\"\n19\tfrom __future__ import annotations\n20\t\n21\timport json\n22\timport sqlite3\n23\timport sys\n24\timport time\n25\tfrom dataclasses import asdict\n26\tfrom datetime import datetime, timedelta, timezone\n27\tfrom pathlib import Path\n28\t\n29\tfrom . import ethics\n30\tfrom .dedup import merge_creators\n31\tfrom .identity import needs_review\n32\tfrom .normalize import slugify\n33\tfrom .schema import Creator, PlatformAccount, now_iso\n34\t\n35\tROOT = Path(__file__).resolve().parent.parent\n36\tDB_PATH = ROOT / \"data\" / \"creaka.db\"\n37\t\n38\t_SCHEMA = \"\"\"\n39\tCREATE TABLE IF NOT EXISTS creators (\n40\t id TEXT PRIMARY KEY,\n41\t display_name TEXT NOT NULL,\n42\t status TEXT NOT NULL DEFAULT 'active',\n43\t is_minor INTEGER NOT NULL DEFAULT 0,\n44\t region TEXT, city TEXT,\n45\t niches TEXT, languages TEXT,\n46\t creator_type TEXT, primary_platform TEXT,\n47\t audience_tier TEXT, total_reach INTEGER,\n48\t bio TEXT,\n49\t first_seen TEXT, last_seen TEXT, updated_at TEXT,\n50\t doc TEXT NOT NULL\n51\t);\n52\tCREATE TABLE IF NOT EXISTS accounts (\n53\t platform TEXT NOT NULL,\n54\t handle TEXT NOT NULL,\n55\t creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n56\t url TEXT NOT NULL,\n57\t followers INTEGER,\n58\t verified INTEGER,\n59\t confidence REAL NOT NULL,\n60\t signal TEXT,\n61\t needs_review INTEGER NOT NULL DEFAULT 0,\n62\t last_checked TEXT,\n63\t metrics TEXT,\n64\t PRIMARY KEY (platform, handle)\n65\t);\n66\tCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\n67\tCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\n68\tCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\n69\tCREATE TABLE IF NOT EXISTS sync_log (\n70\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n71\t ts TEXT NOT NULL,\n72\t source TEXT NOT NULL,\n73\t creators INTEGER, accounts INTEGER, avg_confidence REAL,\n74\t added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n75\t alert TEXT\n76\t);\n77\t\"\"\"\n78\t\n79\t\n80\tdef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n81\t Path(path).parent.mkdir(parents=True, exist_ok=True)\n82\t con = sqlite3.connect(path, check_same_thread=False)\n83\t con.row_factory = sqlite3.Row\n84\t con.execute(\"PRAGMA journal_mode=WAL\")\n85\t con.execute(\"PRAGMA foreign_keys=ON\")\n86\t con.executescript(_SCHEMA)\n87\t # migrations légères (bases existantes)\n88\t try:\n89\t con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n90\t except sqlite3.OperationalError:\n91\t pass\n92\t return con\n93\t\n94\t\n95\t# --- (dé)sérialisation ----------------------------------------------------------\n96\t\n97\tdef creator_to_doc(cr: Creator) -> str:\n98\t return json.dumps(asdict(cr), ensure_ascii=False)\n99\t\n100\t\n101\tdef creator_from_row(row: sqlite3.Row) -> Creator:\n102\t doc = json.loads(row[\"doc\"])\n103\t doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n104\t cr = Creator(**doc)\n105\t return cr\n106\t\n107\t\n108\tdef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n109\t \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n110\t doc = json.loads(row[\"doc\"])\n111\t doc[\"id\"] = row[\"id\"]\n112\t doc[\"first_seen\"] = row[\"first_seen\"]\n113\t doc[\"last_seen\"] = row[\"last_seen\"]\n114\t doc[\"updated_at\"] = row[\"updated_at\"]\n115\t doc[\"platforms\"] = [\n116\t {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n117\t \"followers\": a[\"followers\"],\n118\t \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n119\t \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n120\t \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n121\t for a in accounts if not a[\"needs_review\"]]\n122\t # champs internes / sensibles jamais exposés tels quels\n123\t doc.pop(\"source\", None)\n124\t doc.pop(\"external_id\", None)\n125\t doc.pop(\"legal_name\", None)\n126\t return doc\n127\t\n128\t\n129\t# --- écriture -------------------------------------------------------------------\n130\t\n131\tdef _unique_id(con: sqlite3.Connection, base: str) -> str:\n132\t cid, n = base, 2\n133\t while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n134\t cid = f\"{base}-{n}\"\n135\t n += 1\n136\t return cid\n137\t\n138\t\n139\tdef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n140\t first_seen: str, ts: str) -> None:\n141\t # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n142\t # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n143\t old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n144\t \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n145\t lost = old_keys - {a.key for a in cr.platforms}\n146\t if lost:\n147\t print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n148\t f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n149\t con.execute(\n150\t \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n151\t niches, languages, creator_type, primary_platform, audience_tier,\n152\t total_reach, bio, first_seen, last_seen, updated_at, doc)\n153\t VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n154\t ON CONFLICT(id) DO UPDATE SET\n155\t display_name=excluded.display_name, status=excluded.status,\n156\t is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n157\t niches=excluded.niches, languages=excluded.languages,\n158\t creator_type=excluded.creator_type,\n159\t primary_platform=excluded.primary_platform,\n160\t audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n161\t bio=excluded.bio, last_seen=excluded.last_seen,\n162\t updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n163\t (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n164\t \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n165\t cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n166\t first_seen, ts, ts, creator_to_doc(cr)))\n167\t con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n168\t for acc in cr.platforms:\n169\t con.execute(\n170\t \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n171\t verified, confidence, signal, needs_review, last_checked, metrics)\n172\t VALUES (?,?,?,?,?,?,?,?,?,?,?)\n173\t ON CONFLICT(platform, handle) DO UPDATE SET\n174\t creator_id=excluded.creator_id, url=excluded.url,\n175\t followers=excluded.followers, verified=excluded.verified,\n176\t confidence=excluded.confidence, signal=excluded.signal,\n177\t needs_review=excluded.needs_review,\n178\t last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n179\t (acc.platform, acc.handle, cid, acc.url, acc.followers,\n180\t None if acc.verified is None else int(acc.verified),\n181\t acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n182\t json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n183\t\n184\t\n185\tdef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n186\t \"\"\"Fiche existante possédant déjà un des comptes entrants.\n187\t\n188\t Même plateforme + même handle = MÊME compte : la table accounts n'admet\n189\t qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n190\t fait donc sur l'identité exacte du compte, sans condition de confiance —\n191\t exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n192\t 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n193\t snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n194\t comptes lui réassignait le compte, laissant l'originale orpheline\n195\t (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n196\t l'égalité stricte plateforme+handle identifie le compte lui-même.\n197\t \"\"\"\n198\t for acc in cr.platforms:\n199\t row = con.execute(\n200\t \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n201\t (acc.platform, acc.handle)).fetchone()\n202\t if row:\n203\t return row[\"creator_id\"]\n204\t return None\n205\t\n206\t\n207\tdef sync_source(con: sqlite3.Connection, source_id: str,\n208\t creators: list[Creator], *, started: float | None = None,\n209\t errors: int = 0) -> dict:\n210\t \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n211\t\n212\t `started` (time.time() du début du passage) → durée réelle persistée dans\n213\t sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n214\t connecteur (§16 logging structuré).\n215\t \"\"\"\n216\t ts = now_iso()\n217\t added = updated = skipped_optout = 0\n218\t confs: list[float] = []\n219\t for cr in creators:\n220\t cr = ethics.scrub(cr)\n221\t # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n222\t if ethics.is_opted_out(cr):\n223\t skipped_optout += 1\n224\t existing = _find_existing(con, cr)\n225\t if existing:\n226\t con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n227\t \"WHERE id=?\", (ts, existing))\n228\t continue\n229\t confs.extend(a.confidence or 0 for a in cr.platforms)\n230\t existing_id = _find_existing(con, cr)\n231\t if existing_id:\n232\t row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n233\t (existing_id,)).fetchone()\n234\t current = creator_from_row(row)\n235\t if current.status == \"opted_out\":\n236\t continue # masqué : on ne ré-agrège pas\n237\t # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n238\t # partagé : c'est le même compte, la fusion est toujours sûre —\n239\t # pas une fusion sur le nom (§12.2)\n240\t merged = merge_creators(current, cr)\n241\t _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n242\t updated += 1\n243\t continue\n244\t cid = _unique_id(con, slugify(cr.display_name))\n245\t _write_creator(con, cid, cr, ts, ts)\n246\t added += 1\n247\t con.commit()\n248\t n_acc = sum(len(c.platforms) for c in creators)\n249\t stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n250\t \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n251\t \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n252\t alert = None\n253\t if not creators:\n254\t alert = \"0 créateur retourné — source possiblement bloquée\"\n255\t stats[\"alert\"] = alert\n256\t seconds = round(time.time() - started, 1) if started else 0.0\n257\t stats[\"seconds\"] = seconds\n258\t if errors:\n259\t stats[\"errors\"] = errors\n260\t con.execute(\n261\t \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n262\t \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n263\t (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n264\t added, updated, errors, seconds, alert))\n265\t con.commit()\n266\t return stats\n267\t\n268\t\n269\tdef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n270\t \"\"\"Archivage des disparus (§13, politique de grâce).\n271\t\n272\t Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n273\t complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n274\t Réversible : dès qu'une source le revoit, la fusion (sync_source →\n275\t _write_creator) réécrit son statut actif et son `last_seen`.\n276\t Les opt-out ne sont JAMAIS touchés (status != 'active').\n277\t \"\"\"\n278\t cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n279\t .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n280\t ts = now_iso()\n281\t cur = con.execute(\n282\t \"UPDATE creators SET status='inactive', updated_at=? \"\n283\t \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n284\t (ts, cutoff))\n285\t con.commit()\n286\t if cur.rowcount:\n287\t print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n288\t f\"depuis {hours} h → inactive (réversible au retour)\")\n289\t return cur.rowcount\n290\t\n291\t\n292\tdef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n293\t con.execute(\n294\t \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n295\t \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n296\t con.commit()\n297\t\n298\t\n299\tdef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n300\t account: str | None = None) -> int:\n301\t \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n302\t ids: set[str] = set()\n303\t if account and \":\" in account:\n304\t platform, handle = account.lower().split(\":\", 1)\n305\t for row in con.execute(\n306\t \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n307\t (platform, handle.lstrip(\"@\"))):\n308\t ids.add(row[\"creator_id\"])\n309\t if name:\n310\t from .dedup import name_key\n311\t for row in con.execute(\"SELECT id, display_name FROM creators\"):\n312\t if name_key(row[\"display_name\"]) == name_key(name):\n313\t ids.add(row[\"id\"])\n314\t ts = now_iso()\n315\t for cid in ids:\n316\t con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n317\t (ts, cid))\n318\t con.commit()\n319\t return len(ids)\n320\t\n321\t\n322\t# --- lecture (API) ----------------------------------------------------------------\n323\t\n324\t_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n325\t\n326\t\n327\tdef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n328\t region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n329\t tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n330\t offset: int = 0) -> dict:\n331\t \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n332\t\n333\t Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n334\t exclues de l'annuaire public (prudence extrême, §15).\n335\t \"\"\"\n336\t where = [\"status='active'\", \"is_minor=0\"]\n337\t params: list = []\n338\t if q:\n339\t where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n340\t needle = f\"%{q.lower()}%\"\n341\t params += [needle, needle]\n342\t if niche:\n343\t where.append(\"(','||niches||',') LIKE ?\")\n344\t params.append(f\"%,{niche},%\")\n345\t if region:\n346\t where.append(\"region=?\")\n347\t params.append(region)\n348\t if langue:\n349\t where.append(\"(','||languages||',') LIKE ?\")\n350\t params.append(f\"%,{langue},%\")\n351\t if tier in _TIERS:\n352\t where.append(\"audience_tier=?\")\n353\t params.append(tier)\n354\t if plateforme:\n355\t where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n356\t \"AND needs_review=0)\")\n357\t params.append(plateforme)\n358\t order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n359\t \"nom\": \"display_name COLLATE NOCASE ASC\",\n360\t \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n361\t sql_where = \" AND \".join(where)\n362\t total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n363\t params).fetchone()[\"c\"]\n364\t rows = con.execute(\n365\t f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n366\t params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n367\t items = []\n368\t for row in rows:\n369\t accounts = con.execute(\n370\t \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n371\t (row[\"id\"],)).fetchall()\n372\t items.append(_public_dict(row, accounts))\n373\t return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n374\t\n375\t\n376\tdef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n377\t row = con.execute(\n378\t \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n379\t (cid,)).fetchone()\n380\t if not row:\n381\t return None\n382\t accounts = con.execute(\n383\t \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n384\t (row[\"id\"],)).fetchall()\n385\t return _public_dict(row, accounts)\n386\t\n387\t\n388\tdef stats(con: sqlite3.Connection) -> dict:\n389\t \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n390\t base = \"FROM creators WHERE status='active' AND is_minor=0\"\n391\t n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n392\t n_acc = con.execute(\n393\t \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n394\t \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n395\t ).fetchone()[\"c\"]\n396\t by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n397\t \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n398\t \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n399\t \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n400\t by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n401\t f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n402\t niches: dict[str, int] = {}\n403\t for row in con.execute(f\"SELECT niches {base}\"):\n404\t for nch in (row[\"niches\"] or \"\").split(\",\"):\n405\t if nch:\n406\t niches[nch] = niches.get(nch, 0) + 1\n407\t regions: dict[str, int] = {}\n408\t for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n409\t regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n410\t last_sync = con.execute(\n411\t \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n412\t return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n413\t \"by_tier\": by_tier,\n414\t \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n415\t \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n416\t \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n417\t\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fb247c81-ad91-4327-a0a9-6da171fdfd70","timestamp":"2026-08-21T00:52:43.994Z"}} |
|
26 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading run.py","usage":{"total_tokens":11003,"tool_uses":3,"duration_ms":3106},"last_tool_name":"Read","uuid":"8fa278dd-8590-42af-b83b-3c00db701d27","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
27 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjhwFSXirJm5Nu66Wq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MW6ncncD4F4CB3u1dSGVLG","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/run.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2419,"cache_read_input_tokens":8563,"cache_creation":{"ephemeral_5m_input_tokens":2419,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1a7bae9b-4f99-4dce-b32c-a17e661d0814"}} |
|
28 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MW6ncncD4F4CB3u1dSGVLG","type":"tool_result","content":"1\t#!/usr/bin/env python3\n2\t# ==============================================================================\n3\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n4\t# File: run.py\n5\t# Desc: Point d'entrée Créa-Ka — `sync`, `watch`, `serve` (calqué sur Lou-Ka)\n6\t# ==============================================================================\n7\t\"\"\"Utilisation :\n8\t python run.py sync [source ...] # synchronise (découverte + enrichissement)\n9\t python run.py watch [heures] # synchronise en boucle (défaut 24 h)\n10\t python run.py serve [port] # démarre l'API + le frontend (défaut 8160)\n11\t python run.py optout <nom|plateforme:handle> # retrait manuel immédiat\n12\t\"\"\"\n13\tfrom __future__ import annotations\n14\t\n15\timport os\n16\timport sys\n17\tfrom pathlib import Path\n18\t\n19\t# Charger .env (SCRAPFLY_KEY, FIRECRAWL_API_KEY, YOUTUBE_API_KEY…) sans dépendance\n20\t_env = Path(__file__).parent / \".env\"\n21\tif _env.exists():\n22\t for line in _env.read_text().splitlines():\n23\t line = line.strip()\n24\t if line and not line.startswith(\"#\") and \"=\" in line:\n25\t k, _, v = line.partition(\"=\")\n26\t os.environ.setdefault(k.strip(), v.strip())\n27\t\n28\t\n29\tdef main() -> None:\n30\t cmd = sys.argv[1] if len(sys.argv) > 1 else \"serve\"\n31\t if cmd == \"sync\":\n32\t from creaka import ingest\n33\t ingest.run(sys.argv[2:] or None)\n34\t elif cmd == \"watch\":\n35\t from creaka import ingest\n36\t hours = float(sys.argv[2]) if len(sys.argv) > 2 else 24.0\n37\t ingest.watch(int(hours * 3600))\n38\t elif cmd == \"optout\":\n39\t if len(sys.argv) < 3:\n40\t print(__doc__)\n41\t sys.exit(1)\n42\t from creaka import db, ethics\n43\t target = sys.argv[2]\n44\t is_account = \":\" in target\n45\t ethics.add_optout(name=None if is_account else target,\n46\t account=target if is_account else None,\n47\t reason=\"retrait manuel (CLI)\")\n48\t n = db.apply_optout(db.connect(),\n49\t name=None if is_account else target,\n50\t account=target if is_account else None)\n51\t print(f\"[crea-ka] opt-out enregistré, {n} fiche(s) masquée(s)\")\n52\t elif cmd == \"serve\":\n53\t import uvicorn\n54\t port = int(sys.argv[2]) if len(sys.argv) > 2 else 8160\n55\t uvicorn.run(\"creaka.web:app\", host=\"0.0.0.0\", port=port)\n56\t else:\n57\t print(__doc__)\n58\t sys.exit(1)\n59\t\n60\t\n61\tif __name__ == \"__main__\":\n62\t main()\n63\t\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b5db443c-09ae-441d-b410-db35bf67196a","timestamp":"2026-08-21T00:52:44.031Z"}} |
|
29 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading creaka/connectors/apify_social.py","usage":{"total_tokens":11005,"tool_uses":4,"duration_ms":3374},"last_tool_name":"Read","uuid":"16737113-6d7e-46cd-afb2-0c9175c1091a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
30 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjhwFSXirJm5Nu66Wq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BnKr6uipc1H4wvMYxwKJip","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2419,"cache_read_input_tokens":8563,"cache_creation":{"ephemeral_5m_input_tokens":2419,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7a26196d-6298-45eb-8147-842d14777e1a"}} |
|
31 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BnKr6uipc1H4wvMYxwKJip","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/connectors/apify_social.py\n4\t# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n5\t# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n6\t# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n7\t# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n8\t# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n9\t# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n10\t# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n11\t# résidentielles + empreinte TLS Chrome côté acteur).\n12\t# ==============================================================================\n13\t\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n14\t\n15\tChaque connecteur d'enrichissement :\n16\t1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n17\t que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n18\t2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n19\t (proxy résidentiel Apify forcé) et attend le dataset ;\n20\t3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n21\t récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n22\t `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n23\t\n24\tNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\n25\tUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n26\t\"\"\"\n27\tfrom __future__ import annotations\n28\t\n29\timport os\n30\timport random\n31\timport time\n32\tfrom concurrent.futures import ThreadPoolExecutor, as_completed\n33\tfrom datetime import datetime, timedelta, timezone\n34\t\n35\timport requests\n36\t\n37\tfrom ..identity import account, merge_accounts\n38\tfrom ..normalize import parse_count, platform_from_url\n39\tfrom ..schema import Creator, now_iso\n40\tfrom .base import BaseConnector, SkipSource\n41\tfrom .linkinbio import is_supported as is_linkinbio\n42\t\n43\tAPIFY_API = \"https://api.apify.com/v2\"\n44\tACTOR_OWNER = \"gorgeous_thistle\"\n45\tRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n46\t\n47\t# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n48\t# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n49\t# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n50\t# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n51\t# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n52\t# (large sous les 256 Go / 128 runs concurrents du plan).\n53\tSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\n54\tMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\n55\tRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\n56\tRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n57\t# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n58\t# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\n59\tREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n60\t\n61\t\n62\tdef _token() -> str:\n63\t token = os.environ.get(\"APIFY_TOKEN\")\n64\t if not token:\n65\t raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n66\t return token\n67\t\n68\t\n69\tdef _launch(name: str, run_input: dict, memory_mb: int,\n70\t retries: int = 6) -> str:\n71\t \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n72\t elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n73\t headers = {\"Authorization\": f\"Bearer {_token()}\"}\n74\t for attempt in range(retries):\n75\t resp = requests.post(\n76\t f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n77\t json=run_input, headers=headers,\n78\t params={\"memory\": memory_mb}, timeout=60)\n79\t if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n80\t if attempt < retries - 1:\n81\t # backoff + jitter : désynchronise les tranches (évite le\n82\t # lockstep où toutes redemandent la RAM au même instant)\n83\t time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n84\t continue\n85\t resp.raise_for_status()\n86\t return resp.json()[\"data\"][\"id\"]\n87\t resp.raise_for_status()\n88\t return resp.json()[\"data\"][\"id\"]\n89\t\n90\t\n91\tdef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n92\t headers = {\"Authorization\": f\"Bearer {_token()}\"}\n93\t t0 = time.time()\n94\t while True:\n95\t time.sleep(15)\n96\t r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n97\t headers=headers, timeout=60)\n98\t r.raise_for_status()\n99\t status = r.json()[\"data\"]\n100\t if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n101\t break\n102\t if time.time() - t0 > timeout_s:\n103\t requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n104\t headers=headers, timeout=60)\n105\t raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n106\t if status[\"status\"] != \"SUCCEEDED\":\n107\t raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n108\t dataset = status[\"defaultDatasetId\"]\n109\t items: list[dict] = []\n110\t offset = 0\n111\t while True:\n112\t r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n113\t params={\"offset\": offset, \"limit\": 1000,\n114\t \"clean\": \"true\"},\n115\t headers=headers, timeout=120)\n116\t r.raise_for_status()\n117\t chunk = r.json()\n118\t items.extend(chunk)\n119\t if len(chunk) < 1000:\n120\t return items\n121\t offset += 1000\n122\t\n123\t\n124\tdef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n125\t memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n126\t \"\"\"Un seul run (recherche, petit lot).\"\"\"\n127\t return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n128\t\n129\t\n130\tdef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n131\t timeout_s: int = 2400,\n132\t shard_size: int = SHARD_SIZE,\n133\t max_shards: int = MAX_SHARDS,\n134\t memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n135\t \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n136\t\n137\t Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n138\t plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n139\t 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n140\t n'annule pas les autres.\n141\t \"\"\"\n142\t if not usernames:\n143\t return []\n144\t n = max(1, min(max_shards,\n145\t (len(usernames) + shard_size - 1) // shard_size))\n146\t shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n147\t items: list[dict] = []\n148\t with ThreadPoolExecutor(max_workers=n) as pool:\n149\t futs = {pool.submit(run_actor, name,\n150\t {**base_input, \"usernames\": shard},\n151\t timeout_s=timeout_s, memory_mb=memory_mb): i\n152\t for i, shard in enumerate(shards) if shard}\n153\t for fut in as_completed(futs):\n154\t try:\n155\t items.extend(fut.result())\n156\t except Exception as exc: # une tranche morte ne bloque pas le reste\n157\t print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n158\t return items\n159\t\n160\t\n161\tclass _ApifySocialEnrich(BaseConnector):\n162\t \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n163\t\n164\t kind = \"enrichment\"\n165\t platform = \"\" # plateforme canonique (§6.2)\n166\t actor = \"\" # nom court de l'acteur (ka-instagram…)\n167\t cap = 3000 # comptes max par passage (shardé en parallèle)\n168\t revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n169\t content_key = \"\" # clé du contenu récent dans l'item acteur\n170\t # clés d'item copiées telles quelles dans account.metrics si non nulles\n171\t metric_map: dict[str, str] = {}\n172\t\n173\t @property\n174\t def _stamp_key(self) -> str:\n175\t # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n176\t # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n177\t # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n178\t return f\"{self.source_id}_at\"\n179\t\n180\t def _stale(self, acc) -> bool:\n181\t stamp = (acc.metrics or {}).get(self._stamp_key)\n182\t if not stamp:\n183\t return True\n184\t try:\n185\t seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n186\t except ValueError:\n187\t return True\n188\t return (datetime.now(timezone.utc) - seen\n189\t > timedelta(days=self.revisit_days))\n190\t\n191\t def extra_input(self) -> dict:\n192\t return {}\n193\t\n194\t def target_of(self, acc) -> str:\n195\t \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n196\t\n197\t Certaines plateformes exigent autre chose que le handle minusculé —\n198\t ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n199\t être repris depuis l'URL. Le rapprochement se fait ensuite en\n200\t minuscules des deux côtés.\n201\t \"\"\"\n202\t return acc.handle\n203\t\n204\t def enrich(self, creators: list[Creator]) -> list[Creator]:\n205\t targets: dict[str, list] = {}\n206\t for cr in creators:\n207\t acc = next((a for a in cr.platforms\n208\t if a.platform == self.platform and a.handle), None)\n209\t if acc is None or not self._stale(acc):\n210\t continue\n211\t targets.setdefault(self.target_of(acc), []).append((cr, acc))\n212\t if len(targets) >= self.cap:\n213\t break\n214\t if not targets:\n215\t return []\n216\t items = run_actor_sharded(\n217\t self.actor,\n218\t {\"proxyConfiguration\": RESIDENTIAL,\n219\t \"concurrency\": RUN_CONCURRENCY,\n220\t **self.extra_input()},\n221\t sorted(targets))\n222\t by_handle = {str(it.get(\"username\") or \"\").lower(): it\n223\t for it in items if it.get(\"kind\") == \"profile\"}\n224\t enriched: list[Creator] = []\n225\t self.errors = 0\n226\t for handle, pairs in targets.items():\n227\t it = by_handle.get(handle.lower())\n228\t if it is None:\n229\t continue\n230\t if not it.get(\"found\"):\n231\t if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n232\t self.errors += 1\n233\t continue\n234\t for cr, acc in pairs:\n235\t self.apply(cr, acc, it)\n236\t enriched.append(cr)\n237\t return enriched\n238\t\n239\t # -- application aux fiches ------------------------------------------------\n240\t def apply(self, cr: Creator, acc, it: dict) -> None:\n241\t acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n242\t if it.get(\"is_verified\") is not None:\n243\t acc.verified = bool(it[\"is_verified\"])\n244\t acc.last_checked = now_iso()\n245\t metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n246\t if it.get(src) is not None}\n247\t if self.content_key and it.get(self.content_key):\n248\t metrics[self.content_key] = it[self.content_key][:12]\n249\t metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n250\t acc.metrics.update(metrics)\n251\t if not cr.avatar_url and it.get(\"avatar\"):\n252\t cr.avatar_url = it[\"avatar\"]\n253\t if not cr.bio and it.get(\"biography\"):\n254\t cr.bio = it[\"biography\"]\n255\t self.cross_links(cr, it)\n256\t\n257\t def cross_links(self, cr: Creator, it: dict) -> None:\n258\t \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n259\t for url in self.bio_urls(it):\n260\t url = (url or \"\").strip()\n261\t if url and not url.startswith(\"http\"):\n262\t url = \"https://\" + url\n263\t if not url:\n264\t continue\n265\t if is_linkinbio(url) and not cr.link_in_bio_url:\n266\t cr.link_in_bio_url = url\n267\t continue\n268\t hit = platform_from_url(url)\n269\t if hit and hit[0] != self.platform:\n270\t cr.platforms = merge_accounts(cr.platforms, [\n271\t account(hit[0], hit[1], \"cross_link\",\n272\t url=url).finalize()])\n273\t\n274\t def bio_urls(self, it: dict) -> list[str]:\n275\t return []\n276\t\n277\t\n278\tclass ApifyInstagram(_ApifySocialEnrich):\n279\t source_id = \"instagram-apify\"\n280\t platform = \"instagram\"\n281\t actor = \"ka-instagram\"\n282\t cap = 1800\n283\t content_key = \"recent_posts\"\n284\t metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n285\t \"category\": \"category\", \"is_business\": \"is_business\",\n286\t \"business_category\": \"business_category\",\n287\t \"highlight_reels\": \"highlight_reels\",\n288\t \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n289\t \"avg_video_views\": \"avg_video_views\",\n290\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n291\t \"posts_per_week\": \"posts_per_week\",\n292\t \"last_post_at\": \"last_post_at\",\n293\t \"video_share_pct\": \"video_share_pct\",\n294\t \"top_post\": \"top_post\"}\n295\t\n296\t def bio_urls(self, it: dict) -> list[str]:\n297\t return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n298\t\n299\t def apply(self, cr, acc, it):\n300\t super().apply(cr, acc, it)\n301\t related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n302\t or []) if r.get(\"username\")]\n303\t if related: # matière première de découvertes futures (§12.1 mention)\n304\t acc.metrics[\"related_profiles\"] = related[:10]\n305\t\n306\t\n307\tclass ApifyTikTok(_ApifySocialEnrich):\n308\t source_id = \"tiktok-apify\"\n309\t platform = \"tiktok\"\n310\t actor = \"ka-tiktok\"\n311\t cap = 1800\n312\t content_key = \"recent_videos\"\n313\t metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n314\t \"videos_count\": \"videos\", \"region\": \"region\",\n315\t \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n316\t \"is_live_now\": \"is_live_now\",\n317\t \"commerce_category\": \"commerce_category\",\n318\t \"account_created_at\": \"account_created_at\",\n319\t \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n320\t \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n321\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n322\t \"top_video\": \"top_video\"}\n323\t\n324\t def bio_urls(self, it: dict) -> list[str]:\n325\t return [it.get(\"bio_link\") or \"\"]\n326\t\n327\t\n328\tclass ApifyX(_ApifySocialEnrich):\n329\t source_id = \"x-apify\"\n330\t platform = \"x\"\n331\t actor = \"ka-x\"\n332\t cap = 1800\n333\t content_key = \"recent_tweets\"\n334\t metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n335\t \"location\": \"location\", \"created_at\": \"created_at\",\n336\t \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n337\t \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n338\t \"avg_replies\": \"avg_replies\",\n339\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n340\t \"tweets_per_week\": \"tweets_per_week\",\n341\t \"last_tweet_at\": \"last_tweet_at\",\n342\t \"media_share_pct\": \"media_share_pct\",\n343\t \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n344\t\n345\t def extra_input(self) -> dict:\n346\t return {\"maxTweets\": 20}\n347\t\n348\t def bio_urls(self, it: dict) -> list[str]:\n349\t return [it.get(\"website\") or \"\"]\n350\t\n351\t\n352\tclass ApifyFacebook(_ApifySocialEnrich):\n353\t source_id = \"facebook-apify\"\n354\t platform = \"facebook\"\n355\t actor = \"ka-facebook\"\n356\t cap = 400\n357\t revisit_days = 7\n358\t metric_map = {\"category\": \"category\"}\n359\t\n360\t\n361\tclass ApifyThreads(_ApifySocialEnrich):\n362\t source_id = \"threads-apify\"\n363\t platform = \"threads\"\n364\t actor = \"ka-threads\"\n365\t cap = 60\n366\t revisit_days = 7\n367\t content_key = \"recent_posts\"\n368\t metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n369\t\n370\t\n371\tclass ApifySnapchat(_ApifySocialEnrich):\n372\t source_id = \"snapchat-apify\"\n373\t platform = \"snapchat\"\n374\t actor = \"ka-snapchat\"\n375\t cap = 60\n376\t revisit_days = 7\n377\t metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n378\t \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n379\t \"subcategory\": \"subcategory\",\n380\t \"publisher_type\": \"publisher_type\",\n381\t \"story_snaps_count\": \"story_snaps\",\n382\t \"lenses_count\": \"lenses\",\n383\t \"spotlight_highlights_count\": \"spotlight_highlights\"}\n384\t\n385\t def bio_urls(self, it: dict) -> list[str]:\n386\t return [it.get(\"website\") or \"\"]\n387\t\n388\t\n389\tclass ApifyYouTube(_ApifySocialEnrich):\n390\t source_id = \"youtube-apify\"\n391\t platform = \"youtube\"\n392\t actor = \"ka-youtube\"\n393\t cap = 1800\n394\t content_key = \"recent_videos\"\n395\t metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n396\t \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n397\t \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n398\t \"last_video_published\": \"last_video_published\"}\n399\t\n400\t\n401\tclass ApifyTwitch(_ApifySocialEnrich):\n402\t source_id = \"twitch-apify\"\n403\t platform = \"twitch\"\n404\t actor = \"ka-twitch\"\n405\t cap = 1200\n406\t content_key = \"recent_videos\"\n407\t metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n408\t \"team\": \"team\", \"created_at\": \"created_at\",\n409\t \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n410\t \"live_game\": \"live_game\",\n411\t \"last_broadcast_at\": \"last_broadcast_at\",\n412\t \"last_broadcast_game\": \"last_broadcast_game\",\n413\t \"avg_video_views\": \"avg_video_views\",\n414\t \"recent_games\": \"recent_games\"}\n415\t\n416\t\n417\tclass ApifyKick(_ApifySocialEnrich):\n418\t source_id = \"kick-apify\"\n419\t platform = \"kick\"\n420\t actor = \"ka-kick\"\n421\t cap = 600\n422\t metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n423\t \"subscription_enabled\": \"subscription_enabled\",\n424\t \"recent_categories\": \"recent_categories\"}\n425\t\n426\t def cross_links(self, cr: Creator, it: dict) -> None:\n427\t \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n428\t socials = it.get(\"social_links\") or {}\n429\t tmpl = {\"instagram\": \"https://instagram.com/{}\",\n430\t \"twitter\": \"https://x.com/{}\",\n431\t \"youtube\": \"https://youtube.com/{}\",\n432\t \"tiktok\": \"https://www.tiktok.com/@{}\",\n433\t \"facebook\": \"https://facebook.com/{}\",\n434\t \"discord\": \"https://discord.gg/{}\"}\n435\t for key, val in socials.items():\n436\t val = (val or \"\").strip()\n437\t if not val:\n438\t continue\n439\t url = val if val.startswith(\"http\") else \\\n440\t tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n441\t hit = platform_from_url(url)\n442\t if hit and hit[0] != self.platform:\n443\t cr.platforms = merge_accounts(cr.platforms, [\n444\t account(hit[0], hit[1], \"cross_link\",\n445\t url=url).finalize()])\n446\t\n447\t\n448\tclass ApifyOnlyFans(_ApifySocialEnrich):\n449\t source_id = \"onlyfans-apify\"\n450\t platform = \"onlyfans\"\n451\t actor = \"ka-onlyfans\"\n452\t cap = 100\n453\t revisit_days = 7\n454\t metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n455\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n456\t \"streams_count\": \"streams\",\n457\t \"subscribe_price_usd\": \"subscribe_price_usd\",\n458\t \"is_free\": \"is_free\", \"location\": \"location\"}\n459\t\n460\t def bio_urls(self, it: dict) -> list[str]:\n461\t return [it.get(\"website\") or \"\"]\n462\t\n463\t\n464\tclass ApifyFansly(_ApifySocialEnrich):\n465\t source_id = \"fansly-apify\"\n466\t platform = \"fansly\"\n467\t actor = \"ka-fansly\"\n468\t cap = 600\n469\t revisit_days = 7\n470\t metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n471\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n472\t \"following\": \"following\", \"location\": \"location\"}\n473\t\n474\t\n475\tclass ApifyPatreon(_ApifySocialEnrich):\n476\t source_id = \"patreon-apify\"\n477\t platform = \"patreon\"\n478\t actor = \"ka-patreon\"\n479\t cap = 400\n480\t revisit_days = 7\n481\t metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n482\t \"is_monthly\": \"is_monthly\"}\n483\t\n484\t\n485\tclass ApifyDiscord(_ApifySocialEnrich):\n486\t source_id = \"discord-apify\"\n487\t platform = \"discord\"\n488\t actor = \"ka-discord\"\n489\t cap = 400\n490\t revisit_days = 7\n491\t metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n492\t \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n493\t\n494\t def target_of(self, acc) -> str:\n495\t \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n496\t handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n497\t url = acc.url or \"\"\n498\t for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n499\t if sep in url:\n500\t return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n501\t return acc.handle\n502\t\n503\t\n504\t# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n505\t\n506\t# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n507\t# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n508\t_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n509\t \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n510\t \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n511\t \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n512\t \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n513\t \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n514\t \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n515\t \"créateur de contenu\", \"créatrice de contenu\"]\n516\t_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n517\t\n518\t\n519\tclass InstagramDecouverteApify(BaseConnector):\n520\t \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n521\t\n522\t L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n523\t mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n524\t (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n525\t résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n526\t EST la source de la fiche.\n527\t \"\"\"\n528\t\n529\t source_id = \"instagram-decouverte\"\n530\t kind = \"discovery\"\n531\t queries_per_pass = 40\n532\t\n533\t def build_queries(self) -> list[str]:\n534\t combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n535\t day = datetime.now(timezone.utc).timetuple().tm_yday\n536\t start = (day * self.queries_per_pass) % len(combos)\n537\t rotated = combos[start:] + combos[:start]\n538\t return rotated[:self.queries_per_pass]\n539\t\n540\t def fetch(self) -> list[Creator]:\n541\t # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n542\t # une session authentifiée (401 « Server Error » depuis toute IP non\n543\t # connectée). La découverte par mots-clés est donc suspendue proprement\n544\t # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n545\t # (instagram-apify) alimente `related_profiles` comme graine future.\n546\t raise SkipSource(\"topsearch Instagram exige une session connectée \"\n547\t \"(voie publique fermée le 2026-08-18)\")\n548\t items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n549\t \"usernames\": [],\n550\t \"queries\": self.build_queries(),\n551\t \"proxyConfiguration\": RESIDENTIAL,\n552\t })\n553\t creators: list[Creator] = []\n554\t seen: set[str] = set()\n555\t for it in items:\n556\t if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n557\t continue\n558\t handle = str(it[\"username\"]).lower()\n559\t if handle in seen:\n560\t continue\n561\t seen.add(handle)\n562\t acc = account(\"instagram\", handle, \"profil_source\",\n563\t followers=parse_count(it.get(\"followers\")),\n564\t verified=it.get(\"is_verified\"),\n565\t url=f\"https://www.instagram.com/{handle}/\")\n566\t creators.append(Creator(\n567\t source=self.source_id,\n568\t external_id=handle,\n569\t display_name=it.get(\"full_name\") or handle,\n570\t avatar_url=it.get(\"avatar\") or None,\n571\t platforms=[acc],\n572\t notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n573\t ))\n574\t return creators\n575\t\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a1ae9f5a-cc5f-4f94-9669-4b3580fbf376","timestamp":"2026-08-21T00:52:44.299Z"}} |
|
32 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading creaka/schema.py","usage":{"total_tokens":11007,"tool_uses":5,"duration_ms":3559},"last_tool_name":"Read","uuid":"45e21791-426b-466c-9901-c75f5da53ed6","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
33 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjhwFSXirJm5Nu66Wq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Jq9RGetM1SJyLWrSWTWLQb","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":5,"cache_creation_input_tokens":2419,"cache_read_input_tokens":8563,"cache_creation":{"ephemeral_5m_input_tokens":2419,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"429cbb48-48a2-41da-951d-e1a0a8bf1ec2"}} |
|
34 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Jq9RGetM1SJyLWrSWTWLQb","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/schema.py\n4\t# Desc: Modèle de données standardisé — Creator + PlatformAccount (CLAUDE.md §5)\n5\t# + normalisation centrale via finalize()\n6\t# ==============================================================================\n7\t\"\"\"Schéma standard d'une fiche créateur et de ses comptes de plateformes.\n8\t\n9\tChaque connecteur, peu importe la source, doit produire des objets `Creator`\n10\tconformes. `finalize()` applique la couche commune (creaka/normalize.py) :\n11\thandles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs\n12\trestent simples et remplissent les champs bruts.\n13\t\n14\tValidation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence`\n15\test REJETÉ à la finalisation.\n16\t\"\"\"\n17\tfrom __future__ import annotations\n18\t\n19\timport hashlib\n20\timport json\n21\tfrom dataclasses import dataclass, field, asdict\n22\tfrom datetime import datetime, timezone\n23\t\n24\tfrom .normalize import (\n25\t LANGUAGES,\n26\t NICHES,\n27\t PLATFORMS,\n28\t audience_tier,\n29\t canonical_url,\n30\t clean_bio,\n31\t clean_handle,\n32\t clean_name,\n33\t guess_language,\n34\t infer_creator_type,\n35\t map_niche,\n36\t normalize_platform,\n37\t slugify,\n38\t)\n39\t\n40\t__all__ = [\"Creator\", \"PlatformAccount\", \"now_iso\"]\n41\t\n42\t\n43\tdef now_iso() -> str:\n44\t return datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n45\t\n46\t\n47\t@dataclass\n48\tclass PlatformAccount:\n49\t \"\"\"Un compte public d'un créateur sur UNE plateforme (§5).\"\"\"\n50\t\n51\t platform: str # * canonique §6.2\n52\t handle: str # * sans @, minuscules\n53\t url: str = \"\" # * forme canonique\n54\t followers: int | None = None # abonnés (None = inconnu)\n55\t verified: bool | None = None # badge vérifié (None = inconnu)\n56\t confidence: float | None = None # * confiance du rattachement (§12)\n57\t last_checked: str = \"\" # * ISO-8601\n58\t signal: str = \"\" # signal d'origine du rattachement (§12.1)\n59\t metrics: dict = field(default_factory=dict) # métriques étendues publiques :\n60\t # following, posts, likes, views, videos, category… (selon la plateforme)\n61\t\n62\t def finalize(self) -> \"PlatformAccount\":\n63\t self.platform = normalize_platform(self.platform)\n64\t self.handle = clean_handle(self.handle)\n65\t self.url = canonical_url(self.platform, self.handle, fallback=self.url)\n66\t if not self.handle:\n67\t raise ValueError(\"compte sans handle\")\n68\t if not self.url:\n69\t raise ValueError(f\"compte {self.platform}:{self.handle} sans url\")\n70\t if self.confidence is None:\n71\t raise ValueError(f\"compte {self.platform}:{self.handle} sans confidence\")\n72\t self.confidence = max(0.0, min(1.0, float(self.confidence)))\n73\t if self.followers is not None:\n74\t self.followers = max(0, int(self.followers))\n75\t if not self.last_checked:\n76\t self.last_checked = now_iso()\n77\t return self\n78\t\n79\t @property\n80\t def key(self) -> str:\n81\t \"\"\"Clé d'unicité d'un compte : plateforme + handle normalisé.\"\"\"\n82\t return f\"{self.platform}:{self.handle}\"\n83\t\n84\t\n85\t@dataclass\n86\tclass Creator:\n87\t \"\"\"Fiche créateur canonique Créa-Ka (CLAUDE.md §5).\"\"\"\n88\t\n89\t source: str # id de la source (registre data/sources.json)\n90\t external_id: str # identifiant chez la source\n91\t display_name: str # * nom public / nom de scène\n92\t legal_name: str | None = None # SEULEMENT si rendu public par le créateur\n93\t bio: str = \"\"\n94\t niches: list[str] = field(default_factory=list)\n95\t creator_type: str = \"\"\n96\t region: str | None = None # une des 17 régions §7, sinon None\n97\t city: str | None = None\n98\t languages: list[str] = field(default_factory=list)\n99\t primary_platform: str = \"\"\n100\t platforms: list[PlatformAccount] = field(default_factory=list)\n101\t link_in_bio_url: str | None = None\n102\t avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n103\t total_reach: int | None = None\n104\t audience_tier: str = \"\"\n105\t business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n106\t agency: str | None = None\n107\t is_minor: bool = False\n108\t status: str = \"active\" # active | inactive | opted_out\n109\t notes: str = \"\" # contexte public (listes médias), affichable\n110\t source_ids: list[str] = field(default_factory=list)\n111\t\n112\t @property\n113\t def uid(self) -> str:\n114\t return f\"{self.source}:{self.external_id}\"\n115\t\n116\t def content_hash(self) -> str:\n117\t \"\"\"Hash du contenu pour la détection de changements.\"\"\"\n118\t payload = asdict(self)\n119\t for acc in payload[\"platforms\"]:\n120\t acc.pop(\"last_checked\", None) # horodatage ≠ changement de contenu\n121\t blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n122\t return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n123\t\n124\t def finalize(self) -> \"Creator\":\n125\t \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n126\t\n127\t Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n128\t Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas\n129\t sa place dans un annuaire de liens).\n130\t \"\"\"\n131\t self.display_name = clean_name(self.display_name)\n132\t if not self.display_name:\n133\t raise ValueError(\"créateur sans nom d'affichage\")\n134\t if not self.external_id:\n135\t self.external_id = slugify(self.display_name)\n136\t self.bio = clean_bio(self.bio)\n137\t\n138\t # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne)\n139\t seen: dict[str, PlatformAccount] = {}\n140\t for acc in self.platforms:\n141\t try:\n142\t acc = acc.finalize()\n143\t except ValueError:\n144\t continue # un compte invalide ne bloque pas la fiche (§18 : rejeté)\n145\t prev = seen.get(acc.key)\n146\t if prev is None or (acc.confidence or 0) > (prev.confidence or 0):\n147\t if prev is not None and prev.followers and not acc.followers:\n148\t acc.followers = prev.followers\n149\t if prev is not None:\n150\t acc.metrics = {**prev.metrics, **acc.metrics}\n151\t seen[acc.key] = acc\n152\t # tri : abonnés décroissants (None en dernier), pour primary_platform\n153\t self.platforms = sorted(\n154\t seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0)))\n155\t if not self.platforms:\n156\t raise ValueError(f\"{self.display_name}: aucun compte valide\")\n157\t\n158\t self.niches = [map_niche(n) for n in (self.niches or [\"autre\"])]\n159\t self.niches = sorted({n for n in self.niches if n in NICHES}) or [\"autre\"]\n160\t\n161\t if not self.primary_platform:\n162\t self.primary_platform = self.platforms[0].platform\n163\t self.primary_platform = normalize_platform(self.primary_platform)\n164\t if self.primary_platform not in PLATFORMS:\n165\t self.primary_platform = self.platforms[0].platform\n166\t\n167\t if not self.languages:\n168\t self.languages = [guess_language(self.bio, self.notes)]\n169\t self.languages = [l for l in self.languages if l in LANGUAGES] or [\"fr\"]\n170\t\n171\t if not self.creator_type:\n172\t self.creator_type = infer_creator_type(\n173\t [a.platform for a in self.platforms], self.niches[0])\n174\t\n175\t # portée totale : somme indicative des audiences connues\n176\t known = [a.followers for a in self.platforms if a.followers]\n177\t self.total_reach = sum(known) if known else None\n178\t primary = next((a.followers for a in self.platforms\n179\t if a.platform == self.primary_platform and a.followers), None)\n180\t if not self.audience_tier:\n181\t self.audience_tier = audience_tier(primary or (max(known) if known else None))\n182\t\n183\t if not self.source_ids:\n184\t self.source_ids = [self.uid]\n185\t\n186\t # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py\n187\t if self.is_minor:\n188\t self.business_contact = None\n189\t self.city = None\n190\t self.region = None\n191\t return self\n192\t\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f09ba9ed-58ae-46ce-80c6-5be6b93eead5","timestamp":"2026-08-21T00:52:44.484Z"}} |
|
35 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading creaka/ingest.py","usage":{"total_tokens":30623,"tool_uses":6,"duration_ms":5456},"last_tool_name":"Read","uuid":"e8a061b5-b13b-4159-a5c3-e319428669ed","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
36 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjsGaYDVbDu234nhTf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019qrTrvNWSEtdLP7EB6MEta","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/ingest.py","limit":150},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":19610,"cache_read_input_tokens":10982,"cache_creation":{"ephemeral_5m_input_tokens":19610,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5e860957-1b44-442d-a03f-093b6be0f4d9"}} |
|
37 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019qrTrvNWSEtdLP7EB6MEta","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/ingest.py\n4\t# Desc: Pipeline d'ingestion — découverte puis enrichissement par plateforme,\n5\t# synchronisation de la base (ajouts / fusions / mises à jour)\n6\t# ==============================================================================\n7\t\"\"\"Pipeline Créa-Ka (calqué sur louka/ingest.py).\n8\t\n9\tOrdre d'un passage complet :\n10\t1. connecteurs DÉCOUVERTE (listes médias, hashtags, agences…) → nouvelles fiches ;\n11\t2. connecteurs ENRICHISSEMENT par plateforme (instagram, tiktok, youtube,\n12\t twitch) → métriques fraîches + liens croisés, créateur par créateur ;\n13\t3. link-in-bio EN DERNIER : il consomme les `link_in_bio_url` découvertes à\n14\t l'étape 2 et révèle d'un coup tous les comptes restants (§8-9).\n15\t\n16\tLogging structuré par source : créateurs, comptes, confiance moyenne, durée,\n17\terreurs (§16) ; alerte si une source ne retourne plus rien (§18 monitoring).\n18\t\"\"\"\n19\tfrom __future__ import annotations\n20\t\n21\timport sys\n22\timport time\n23\timport traceback\n24\t\n25\tfrom . import db\n26\tfrom .connectors import CONNECTORS\n27\tfrom .connectors.base import SkipSource\n28\tfrom .dedup import dedupe\n29\t\n30\t# ordre d'exécution des connecteurs d'enrichissement — link-in-bio vers la\n31\t# fin (il consomme les link_in_bio_url des étapes précédentes) ; bio-liens en\n32\t# tout dernier (il relit les bios enrichies, sans aucune requête)\n33\tENRICH_ORDER = [\"instagram-apify\", \"snowball-ig\", \"tiktok-apify\", \"x-apify\",\n34\t \"facebook-apify\", \"threads-apify\", \"snapchat-apify\",\n35\t \"youtube-apify\", \"twitch-apify\", \"kick-apify\",\n36\t \"fansly-apify\", \"patreon-apify\", \"discord-apify\",\n37\t \"instagram-profil\", \"tiktok-profil\", \"youtube\", \"twitch\",\n38\t \"x-profil\", \"balados-rss\", \"podcastindex\",\n39\t \"link-in-bio\", \"bio-liens\", \"region-bio\"]\n40\t\n41\t\n42\tdef _finalize_batch(creators: list) -> tuple[list, int]:\n43\t finalized, dropped = [], 0\n44\t for cr in creators:\n45\t try:\n46\t finalized.append(cr.finalize())\n47\t except Exception: # une fiche malformée ne bloque pas la source\n48\t dropped += 1\n49\t return finalized, dropped\n50\t\n51\t\n52\tdef load_active(con) -> list:\n53\t \"\"\"Fiches actives (jamais les opted_out : exclues des ré-agrégations, §15).\"\"\"\n54\t rows = con.execute(\n55\t \"SELECT * FROM creators WHERE status='active' ORDER BY total_reach DESC\"\n56\t ).fetchall()\n57\t return [db.creator_from_row(r) for r in rows]\n58\t\n59\t\n60\tdef run(sources: list[str] | None = None) -> list[dict]:\n61\t \"\"\"Exécute l'ingestion pour toutes les sources (ou celles demandées).\"\"\"\n62\t con = db.connect()\n63\t results: list[dict] = []\n64\t discovery = [sid for sid, cls in CONNECTORS.items() if cls.kind == \"discovery\"]\n65\t enrichment = [sid for sid in ENRICH_ORDER if sid in CONNECTORS]\n66\t enrichment += [sid for sid, cls in CONNECTORS.items()\n67\t if cls.kind == \"enrichment\" and sid not in enrichment]\n68\t targets = sources or (discovery + enrichment)\n69\t\n70\t for sid in targets:\n71\t cls = CONNECTORS.get(sid)\n72\t if cls is None:\n73\t print(f\"[crea-ka] connecteur inconnu : {sid}\", file=sys.stderr)\n74\t continue\n75\t t0 = time.time()\n76\t print(f\"[crea-ka] sync {sid} ({cls.kind}) ...\")\n77\t try:\n78\t connector = cls()\n79\t if cls.kind == \"discovery\":\n80\t batch = connector.fetch()\n81\t else:\n82\t batch = connector.enrich(load_active(con))\n83\t finalized, dropped = _finalize_batch(batch)\n84\t finalized = dedupe(finalized) # dédup intra-lot avant la base\n85\t stats = db.sync_source(con, sid, finalized, started=t0,\n86\t errors=getattr(connector, \"errors\", 0))\n87\t if dropped:\n88\t stats[\"dropped\"] = dropped\n89\t if stats.get(\"alert\") and cls.kind == \"discovery\":\n90\t print(f\"[crea-ka] ⚠ ALERTE {sid} : {stats['alert']}\")\n91\t print(f\"[crea-ka] {stats}\")\n92\t results.append(stats)\n93\t except SkipSource as exc:\n94\t # passage sauté volontairement (ex. clés API absentes) : log clair,\n95\t # PAS d'alerte de blocage ni de run compté à 0 dans sync_log\n96\t print(f\"[crea-ka] ↷ {sid} sauté : {exc}\")\n97\t results.append({\"source\": sid, \"skipped\": str(exc)})\n98\t except Exception as exc: # robustesse : une source ne bloque pas les autres\n99\t db.log_failure(con, sid, f\"{exc}\")\n100\t traceback.print_exc()\n101\t results.append({\"source\": sid, \"error\": str(exc)})\n102\t if not sources: # passage COMPLET seulement : archivage des disparus (§13)\n103\t db.archive_missing(con)\n104\t return results\n105\t\n106\t\n107\tdef watch(interval_seconds: int = 86400) -> None:\n108\t \"\"\"Synchronisation en boucle (cadence §14 — défaut : quotidienne).\"\"\"\n109\t while True:\n110\t try:\n111\t run()\n112\t except Exception:\n113\t traceback.print_exc()\n114\t print(f\"[crea-ka] prochain passage dans {interval_seconds}s\")\n115\t time.sleep(interval_seconds)\n116\t\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"87e33af1-c271-4f54-ba5f-8b3d9cc490dd","timestamp":"2026-08-21T00:52:46.386Z"}} |
|
38 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading creaka/web.py","usage":{"total_tokens":30625,"tool_uses":7,"duration_ms":5800},"last_tool_name":"Read","uuid":"9f79998c-53d7-4342-ac7b-a3d0db9cd1c8","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
39 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjsGaYDVbDu234nhTf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019kDbQmjJv6mut5qnX37Zdc","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py","limit":150},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":19610,"cache_read_input_tokens":10982,"cache_creation":{"ephemeral_5m_input_tokens":19610,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"cf80b8f4-63d3-4c41-af3d-fc8e7a5f1b10"}} |
|
40 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019kDbQmjJv6mut5qnX37Zdc","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/web.py\n4\t# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —\n5\t# annuaire cherchable par niche, région, langue, plateforme, taille\n6\t# ==============================================================================\n7\tfrom __future__ import annotations\n8\t\n9\timport json\n10\timport threading\n11\tfrom pathlib import Path\n12\t\n13\tfrom fastapi import FastAPI, HTTPException, Query\n14\tfrom fastapi.middleware.cors import CORSMiddleware\n15\tfrom fastapi.middleware.gzip import GZipMiddleware\n16\tfrom fastapi.responses import FileResponse, Response\n17\tfrom pydantic import BaseModel, Field\n18\t\n19\tfrom . import auth, db, ethics, kapdf\n20\tfrom . import stats as stats_mod\n21\tfrom .normalize import NICHES, PLATFORMS, REGIONS\n22\t\n23\tROOT = Path(__file__).resolve().parent.parent\n24\tFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n25\tSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n26\t\n27\tapp = FastAPI(title=\"Créa-Ka API\", version=\"1.0\",\n28\t description=\"Annuaire public des créateurs de contenu québécois \"\n29\t \"— tous leurs comptes, toutes leurs plateformes.\")\n30\tapp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n31\t allow_methods=[\"*\"], allow_headers=[\"*\"])\n32\tapp.add_middleware(GZipMiddleware, minimum_size=1000)\n33\t\n34\t# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans\n35\t# un pool de threads — une connexion partagée (check_same_thread=False) fait\n36\t# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'\n37\t# object is not subscriptable »). threading.local = une connexion par thread.\n38\t_local = threading.local()\n39\t\n40\t\n41\tdef _db():\n42\t con = getattr(_local, \"con\", None)\n43\t if con is None:\n44\t con = _local.con = db.connect()\n45\t return con\n46\t\n47\t# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py\n48\tapp.include_router(auth.router)\n49\t\n50\t\n51\t@app.get(\"/healthz\")\n52\tdef healthz():\n53\t return {\"ok\": True}\n54\t\n55\t\n56\t@app.get(\"/api/health\")\n57\tdef health():\n58\t s = db.stats(_db())\n59\t return {\"ok\": True, \"creators\": s[\"creators\"], \"accounts\": s[\"accounts\"],\n60\t \"last_sync\": s[\"last_sync\"]}\n61\t\n62\t\n63\t@app.get(\"/api/creators\")\n64\tdef list_creators(q: str = \"\", niche: str = \"\", region: str = \"\",\n65\t langue: str = \"\", plateforme: str = \"\", tier: str = \"\",\n66\t sort: str = \"reach\",\n67\t limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):\n68\t return db.search(_db(), q=q.strip(), niche=niche, region=region,\n69\t langue=langue, plateforme=plateforme, tier=tier,\n70\t sort=sort, limit=limit, offset=offset)\n71\t\n72\t\n73\t@app.get(\"/api/creators/{cid}\")\n74\tdef get_creator(cid: str):\n75\t creator = db.get_creator(_db(), cid)\n76\t if creator is None:\n77\t raise HTTPException(404, \"créateur introuvable\")\n78\t return creator\n79\t\n80\t\n81\t@app.get(\"/api/stats\")\n82\tdef get_stats():\n83\t return db.stats(_db())\n84\t\n85\t\n86\t_PERIODS_OK = {\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"}\n87\t\n88\t\n89\t@app.get(\"/api/stats/dashboard\")\n90\tdef stats_dashboard(period: str = \"30j\",\n91\t date_from: str = Query(\"\", alias=\"from\"),\n92\t date_to: str = Query(\"\", alias=\"to\")):\n93\t \"\"\"Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min.\"\"\"\n94\t if period not in _PERIODS_OK and not (date_from and date_to):\n95\t raise HTTPException(400, \"période inconnue \"\n96\t \"(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)\")\n97\t return stats_mod.dashboard(_db(), period=period,\n98\t date_from=date_from, date_to=date_to)\n99\t\n100\t\n101\t@app.get(\"/api/stats/report\")\n102\tdef stats_report(period: str = \"30j\", mode: str = \"complet\",\n103\t date_from: str = Query(\"\", alias=\"from\"),\n104\t date_to: str = Query(\"\", alias=\"to\")):\n105\t \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) :\n106\t complet | synthese | tendances | repartitions | donnees.\n107\t Un mode inconnu retombe sur `complet` (rétrocompatible v1).\"\"\"\n108\t if mode not in kapdf.REPORT_MODES:\n109\t mode = \"complet\"\n110\t if period not in _PERIODS_OK and not (date_from and date_to):\n111\t raise HTTPException(400, \"période inconnue\")\n112\t dash = stats_mod.dashboard(_db(), period=period,\n113\t date_from=date_from, date_to=date_to)\n114\t pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),\n115\t dashboard=dash, mode=mode).build()\n116\t fname = kapdf.filename(\"crea-ka\", period, mode)\n117\t return Response(content=pdf, media_type=\"application/pdf\",\n118\t headers={\"Content-Disposition\":\n119\t f'attachment; filename=\"{fname}\"'})\n120\t\n121\t\n122\t@app.get(\"/api/taxonomies\")\n123\tdef taxonomies():\n124\t return {\"niches\": sorted(NICHES), \"plateformes\": sorted(PLATFORMS),\n125\t \"regions\": sorted(REGIONS),\n126\t \"tiers\": [\"nano\", \"micro\", \"macro\", \"mega\"],\n127\t \"langues\": [\"fr\", \"en\", \"bilingue\"]}\n128\t\n129\t\n130\t@app.get(\"/api/sources\")\n131\tdef sources():\n132\t if SOURCES_PATH.exists():\n133\t return json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))\n134\t return {\"sources\": []}\n135\t\n136\t\n137\tclass OptOutRequest(BaseModel):\n138\t \"\"\"Demande de retrait (§15 — opt-out obligatoire et respecté).\"\"\"\n139\t name: str = Field(\"\", max_length=200)\n140\t account: str = Field(\"\", max_length=200,\n141\t description=\"plateforme:handle, ex. instagram:monnom\")\n142\t reason: str = Field(\"\", max_length=500)\n143\t contact: str = Field(\"\", max_length=200)\n144\t\n145\t\n146\t@app.post(\"/api/optout\")\n147\tdef optout(req: OptOutRequest):\n148\t \"\"\"Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.\n149\t\n150\t La vie privée prime sur la complétude (§15) : on masque d'abord, la\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0cda1e9b-7180-4949-9c63-0d531e9fd68c","timestamp":"2026-08-21T00:52:46.727Z"}} |
|
41 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running find /Users/simon-pierreboucher/apps/crea-ka/fron…","usage":{"total_tokens":30627,"tool_uses":8,"duration_ms":6223},"last_tool_name":"Bash","uuid":"09d38e66-785d-4a36-b618-cd086f425f36","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
42 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyjsGaYDVbDu234nhTf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MXYFLGTd1jiA7cuMtFmueX","name":"Bash","input":{"command":"find /Users/simon-pierreboucher/apps/crea-ka/frontend -type f \\( -name \"*.html\" -o -name \"*.js\" -o -name \"*.ts\" -o -name \"*.vue\" -o -name \"*.svelte\" \\) | head -20"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":19610,"cache_read_input_tokens":10982,"cache_creation":{"ephemeral_5m_input_tokens":19610,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c6c05692-e1da-457c-94c5-647f6d25d70e"}} |
|
43 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MXYFLGTd1jiA7cuMtFmueX","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/frontend/dist/ka-agent.js\n/Users/simon-pierreboucher/apps/crea-ka/frontend/dist/index.html\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka-agent.js\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"258e1c74-6bb8-4fe1-b35b-66d7cf0d5317","timestamp":"2026-08-21T00:52:47.181Z"}} |
|
44 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading frontend/dist/index.html","usage":{"total_tokens":35248,"tool_uses":9,"duration_ms":8234},"last_tool_name":"Read","uuid":"007b9fcd-7b7b-4034-aada-ed869ee30e80","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
45 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyk4oNEH7t8Kue1NEfY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SFYCwvHkhJxi9R7LVh7Fok","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/dist/index.html"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":4603,"cache_read_input_tokens":30592,"cache_creation":{"ephemeral_5m_input_tokens":4603,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0fc69814-c7d2-4d11-b240-decc7ecc94d5"}} |
|
46 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File content (89496 tokens) exceeds maximum allowed tokens (25000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.","is_error":true,"tool_use_id":"toolu_01SFYCwvHkhJxi9R7LVh7Fok"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0abc29bf-07a2-4555-85b8-4a9fe7f435c4","timestamp":"2026-08-21T00:52:49.305Z","tool_use_result":"Error: File content (89496 tokens) exceeds maximum allowed tokens (25000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file."}} |
|
47 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running ls -la /Users/simon-pierreboucher/apps/crea-ka/fr…","usage":{"total_tokens":35266,"tool_uses":10,"duration_ms":8602},"last_tool_name":"Bash","uuid":"1f9cecb4-97d0-4f5c-8bb9-211eee86993f","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
48 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyk4oNEH7t8Kue1NEfY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DBEo6rVhqrNDdZu2qGy5fM","name":"Bash","input":{"command":"ls -la /Users/simon-pierreboucher/apps/crea-ka/frontend/ | head -20"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":4603,"cache_read_input_tokens":30592,"cache_creation":{"ephemeral_5m_input_tokens":4603,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a39de7a5-ae3d-4915-a068-8c5d9fa4316a"}} |
|
49 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DBEo6rVhqrNDdZu2qGy5fM","type":"tool_result","content":"total 0\ndrwxr-xr-x 4 simon-pierreboucher staff 128 Aug 17 16:13 .\ndrwxr-xr-x 19 simon-pierreboucher staff 608 Aug 18 16:58 ..\ndrwxr-xr-x 7 simon-pierreboucher staff 224 Aug 17 22:22 dist\ndrwxr-xr-x 6 simon-pierreboucher staff 192 Aug 17 22:22 src","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"21e0a149-da68-45ca-9b35-b9cacb90b85c","timestamp":"2026-08-21T00:52:49.563Z"}} |
|
50 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Searching for APIFY|apify|run-sync|dataset|actors","usage":{"total_tokens":35284,"tool_uses":11,"duration_ms":9006},"last_tool_name":"Grep","uuid":"e6d34793-b791-4cfd-9b11-23f348579395","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
51 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEyk4oNEH7t8Kue1NEfY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014dbUMDXMpUcgmZv5g2njgj","name":"Grep","input":{"pattern":"APIFY|apify|run-sync|dataset|actors","path":"/Users/simon-pierreboucher/apps/crea-ka","output_mode":"files_with_matches"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":4603,"cache_read_input_tokens":30592,"cache_creation":{"ephemeral_5m_input_tokens":4603,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"aea2993a-cbca-4bee-86bb-445a1ec2aa2a"}} |
|
52 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014dbUMDXMpUcgmZv5g2njgj","type":"tool_result","content":"Found 27 files\ncreaka/connectors/snowball.py\nfrontend/dist/index.html\nfrontend/src/index.template.html\ndocs/connecteurs/INDEX.md\ndocs/connecteurs/discord-apify.md\ndocs/connecteurs/patreon-apify.md\ndocs/connecteurs/snowball-ig.md\ndocs/connecteurs/tavily-seed.md\ndocs/connecteurs/fansly-apify.md\ndocs/connecteurs/onlyfans-apify.md\ndocs/connecteurs/kick-apify.md\ndocs/connecteurs/twitch-apify.md\ndocs/connecteurs/youtube-apify.md\ndocs/connecteurs/instagram-decouverte.md\ndocs/connecteurs/snapchat-apify.md\ndocs/connecteurs/threads-apify.md\ndocs/connecteurs/facebook-apify.md\ndocs/connecteurs/x-apify.md\ndocs/connecteurs/tiktok-apify.md\ndocs/connecteurs/instagram-apify.md\ndocs/connecteurs/listes-medias.md\ndata/sources.json\ncreaka/ingest.py\ncreaka/connectors/apify_social.py\nscripts/gen_connector_docs.py\nREADME.md\ncreaka/connectors/listes_medias.py"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"af7a3935-c74d-427c-9e14-431004a54da7","timestamp":"2026-08-21T00:52:49.962Z"}} |
|
53 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykGwsHXDFfWzbYxW5P","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Rroip2ZWpS6sjD79Nb9SLv","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","limit":100},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":816,"cache_read_input_tokens":35195,"cache_creation":{"ephemeral_5m_input_tokens":816,"ephemeral_1h_input_tokens":0},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fad3bd95-c8d6-41cb-8f12-77269c138d5d"}} |
|
54 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading frontend/src/index.template.html","usage":{"total_tokens":36124,"tool_uses":12,"duration_ms":10596},"last_tool_name":"Read","uuid":"1c35ca48-662a-4982-b538-dfb458285785","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
55 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Rroip2ZWpS6sjD79Nb9SLv","type":"tool_result","content":"1\t<!--\n2\t Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t File: frontend/src/index.template.html\n4\t Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n5\t scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n6\t __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n7\t importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n8\t sites, mentions légales du groupe : footer commun + page /contact).\n9\t Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n10\t encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n11\t / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n12\t (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n13\t 4 onglets, filtres défilants, anti-zoom iOS).\n14\t Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n15\t-->\n16\t<!DOCTYPE html>\n17\t<html lang=\"fr\">\n18\t<head>\n19\t<meta charset=\"utf-8\">\n20\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n21\t<title>Créa-Ka — Un service Groupe KA</title>\n22\t<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n23\t<meta name=\"theme-color\" content=\"#141814\">\n24\t<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n25\t<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n26\t<link rel=\"preconnect\" href=\"https://unavatar.io\">\n27\t<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n28\t<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n29\t<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n30\t<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n31\t<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n32\t<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n33\t<meta property=\"og:image:width\" content=\"1200\">\n34\t<meta property=\"og:image:height\" content=\"630\">\n35\t<meta name=\"twitter:card\" content=\"summary_large_image\">\n36\t<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n37\t<style>\n38\t/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n39\t (injecté au build par scripts/build_frontend.py) */\n40\t__KA_TOKENS__\n41\t</style>\n42\t<style>\n43\t/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n44\t NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n45\t changent — accent attribué à Créa-Ka : VIOLET. */\n46\t:root{\n47\t --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n48\t /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n49\t --lime:var(--accent); --lime-soft:var(--accent-soft);\n50\t --green:var(--accent-deep); /* primaire du site = violet profond */\n51\t --green-deep:#3d2178;\n52\t}\n53\t/* le layout du site est calibré sur des titres sans marge basse */\n54\th1,h2,h3,h4{margin:0}\n55\ta{text-decoration:none} button{font-family:inherit;cursor:pointer}\n56\t.icn{width:1em;height:1em;display:block;fill:currentColor}\n57\t.container{max-width:1240px;margin:0 auto;padding:0 24px}\n58\t@media(max-width:640px){.container{padding:0 16px}}\n59\t\n60\t/* ---------- cartes & boutons (langage du groupe) ---------- */\n61\t.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n62\t border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n63\t.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n64\t.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n65\t.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n66\t.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n67\t\n68\t/* ===== header ===== */\n69\t.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n70\t backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n71\t.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n72\t.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n73\t display:flex;align-items:center;line-height:1;color:var(--ink)}\n74\t.brand svg{margin-right:11px;flex:none}\n75\t@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n76\t.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n77\t.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n78\t line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n79\t@media(max-width:1040px){.brand-tag{display:none}}\n80\t.header-spacer{flex:1}\n81\t.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n82\t border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n83\t font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n84\t gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n85\t.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n86\t.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n87\t.hbtn.primary{background:var(--ink);color:var(--accent)}\n88\t.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n89\t@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n90\t/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n91\t.hbtn.acct{padding:7px 14px}\n92\t.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n93\t border:1.5px solid var(--ink);margin-left:-4px}\n94\t.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n95\t font-family:var(--font-mono);font-size:10px;font-weight:700}\n96\t.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n97\t/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n98\t.header .gk-badge{flex:none}\n99\t.hbtn .acct-short{display:none}\n100\t@media(max-width:560px){\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"dac6ff7e-2f52-4b8d-99d2-7021431f3f1f","timestamp":"2026-08-21T00:52:51.523Z"}} |
|
56 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running cat /Users/simon-pierreboucher/apps/crea-ka/creak…","usage":{"total_tokens":36148,"tool_uses":13,"duration_ms":10938},"last_tool_name":"Bash","uuid":"d178f388-9ffb-4a70-a92b-1087795d9518","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
57 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykGwsHXDFfWzbYxW5P","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NJgsHVYtXNtkkALY42ZJ4n","name":"Bash","input":{"command":"cat /Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py | head -100"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":816,"cache_read_input_tokens":35195,"cache_creation":{"ephemeral_5m_input_tokens":816,"ephemeral_1h_input_tokens":0},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c2307e45-e7e2-4759-bf07-b73195363df7"}} |
|
58 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NJgsHVYtXNtkkALY42ZJ4n","type":"tool_result","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/stats.py\n# Desc: Tableau de bord analytique /api/stats/dashboard (contrat ka-stats\n# SPEC.md v2) + rapport PDF Groupe-KA (/api/stats/report via kapdf.py,\n# 5 modes). AUCUNE stat inventée : tout est calculé depuis creators/\n# accounts/sync_log (first_seen converti en date locale, cache 5 min).\n# v2 : sparklines KPI, jauges de complétude, multi-courbes par\n# plateforme, ajouts empilés par source, distributions (audiences,\n# comptes/créateur, confiance), heatmap horaire 7×24, 5 tableaux,\n# records enrichis.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport time\nfrom datetime import date, datetime, timedelta, timezone\nfrom pathlib import Path\nfrom zoneinfo import ZoneInfo\n\nTZ = ZoneInfo(\"America/Toronto\")\nROOT = Path(__file__).resolve().parent.parent\nECO_PATH = ROOT / \"frontend\" / \"src\" / \"ka\" / \"ecosystem.json\"\n\nCACHE_TTL = 300 # ≥ 5 min par période (SPEC §2)\n_CACHE: dict[tuple, tuple[float, dict]] = {}\n\nPERIOD_DAYS = {\"auj\": 1, \"7j\": 7, \"30j\": 30, \"3m\": 91, \"6m\": 182, \"12m\": 365}\nPERIOD_LABELS = {\n \"auj\": \"aujourd'hui\", \"7j\": \"7 jours\", \"30j\": \"30 jours\", \"3m\": \"3 mois\",\n \"6m\": \"6 mois\", \"12m\": \"12 mois\", \"annee\": \"année en cours\",\n \"tout\": \"toute la période\",\n}\n\nPLAT_LBL = {\n \"instagram\": \"Instagram\", \"tiktok\": \"TikTok\", \"youtube\": \"YouTube\",\n \"twitch\": \"Twitch\", \"kick\": \"Kick\", \"x\": \"X (Twitter)\",\n \"facebook\": \"Facebook\", \"snapchat\": \"Snapchat\", \"substack\": \"Substack\",\n \"patreon\": \"Patreon\", \"onlyfans\": \"OnlyFans\", \"linkedin\": \"LinkedIn\",\n \"threads\": \"Threads\", \"podcast\": \"Balado\", \"site-web\": \"Site web\",\n \"spotify\": \"Spotify\", \"discord\": \"Discord\", \"fansly\": \"Fansly\",\n \"mym\": \"MYM\", \"autre\": \"Autre\",\n}\nNICHE_LBL = {\n \"humour\": \"Humour\", \"mode\": \"Mode\", \"beaute\": \"Beauté\",\n \"lifestyle\": \"Lifestyle\", \"famille-parentalite\": \"Famille & parentalité\",\n \"cuisine\": \"Cuisine\", \"gaming\": \"Gaming\", \"tech\": \"Tech\",\n \"sport-fitness\": \"Sport & fitness\", \"plein-air\": \"Plein air\",\n \"voyage\": \"Voyage\", \"musique\": \"Musique\", \"arts\": \"Arts\", \"danse\": \"Danse\",\n \"education\": \"Éducation\", \"finance-affaires\": \"Finance & affaires\",\n \"sante-mieux-etre\": \"Santé & mieux-être\", \"bouffe-resto\": \"Bouffe & resto\",\n \"actualite-opinion\": \"Actualité & opinion\", \"autre\": \"Autre\",\n}\nTYPE_LBL = {\n \"youtubeur\": \"Youtubeur\", \"influenceur\": \"Influenceur\",\n \"streamer\": \"Streamer\", \"podcasteur\": \"Podcasteur\",\n \"humoriste\": \"Humoriste\", \"createur-tiktok\": \"Créateur TikTok\",\n \"createur-ecrit\": \"Créateur écrit\", \"musicien\": \"Musicien\",\n \"artiste\": \"Artiste\", \"autre\": \"Autre\",\n}\n# bornes réelles de normalize.audience_tier (§6.3)\nTIER_LBL = [(\"nano\", \"Nano (< 10 k)\"), (\"micro\", \"Micro (10 k – 100 k)\"),\n (\"macro\", \"Macro (100 k – 1 M)\"), (\"mega\", \"Méga (1 M et +)\")]\n# tranches d'audience (distribution) — bornes en abonnés cumulés connus\nREACH_BINS = [(0, 1_000, \"< 1 k\"), (1_000, 10_000, \"1 k – 10 k\"),\n (10_000, 50_000, \"10 k – 50 k\"), (50_000, 100_000, \"50 k – 100 k\"),\n (100_000, 500_000, \"100 k – 500 k\"),\n (500_000, 1_000_000, \"500 k – 1 M\"),\n (1_000_000, None, \"1 M et +\")]\n\n\ndef site_info() -> dict:\n \"\"\"Identité Créa-Ka pour le PDF, lue dans ka/ecosystem.json (source commune).\"\"\"\n try:\n eco = json.loads(ECO_PATH.read_text(encoding=\"utf-8\"))\n s = next(x for x in eco[\"sites\"] if x[\"id\"] == \"crea-ka\")\n return {\"wordmark\": s[\"wordmark\"], \"accent\": s[\"accent\"],\n \"domain\": s[\"domain\"], \"tagline\": s.get(\"tagline\", \"\")}\n except Exception:\n return {\"wordmark\": \"Créa·Ka\", \"accent\": \"#7048e8\",\n \"domain\": \"www.crea-ka.com\",\n \"tagline\": \"Les créateurs d'ici, tous leurs liens\"}\n\n\n# --- utilitaires -----------------------------------------------------------------\n\ndef _local_dt(iso: str) -> datetime | None:\n \"\"\"ISO-8601 UTC (…Z) → datetime local (America/Toronto).\"\"\"\n if not iso:\n return None\n try:\n dt = datetime.strptime(iso[:19], \"%Y-%m-%dT%H:%M:%S\")\n return dt.replace(tzinfo=timezone.utc).astimezone(TZ)\n except ValueError:\n return None\n\n\ndef _local_date(iso: str) -> date | None:\n dt = _local_dt(iso)","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2fbc2e9b-fb55-43d4-8a4d-598e7e253208","timestamp":"2026-08-21T00:52:51.890Z"}} |
|
59 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -r \"metrics\\|snapshots\\|historical\" /Users/s…","usage":{"total_tokens":36172,"tool_uses":14,"duration_ms":11057},"last_tool_name":"Bash","uuid":"c38feb0d-8dd0-4186-9dfa-d1d71f00b1cd","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
60 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykGwsHXDFfWzbYxW5P","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AQN1iVrbNNPRXiSG9d3CNB","name":"Bash","input":{"command":"grep -r \"metrics\\|snapshots\\|historical\" /Users/simon-pierreboucher/apps/crea-ka/creaka --include=\"*.py\" | head -20"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":816,"cache_read_input_tokens":35195,"cache_creation":{"ephemeral_5m_input_tokens":816,"ephemeral_1h_input_tokens":0},"output_tokens":24,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c7a331dd-4b88-441d-b600-01d6a5fc87e0"}} |
|
61 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AQN1iVrbNNPRXiSG9d3CNB","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: def _needs_visit(self, metrics: dict) -> bool:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: raw = metrics.get(\"x_checked\")\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: if acc is not None and self._needs_visit(acc.metrics):\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: candidates.sort(key=lambda t: bool(t[1].metrics.get(\"x_checked\")))\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: acc.metrics[\"x_checked\"] = now_iso()\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: acc.metrics[\"following\"] = info[\"following\"]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: acc.metrics[\"x_checked\"] = now_iso()\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: → fraîcheur/dormance (metrics.last_video + dormant >12 mois).\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py:Cap 300 chaînes/passage avec ROTATION (metrics.yt_checked, re-visite 14 j,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: def _needs_visit(self, metrics: dict) -> bool:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: raw = metrics.get(\"yt_checked\")\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: yt.metrics.update({k: v for k, v in extra.items() if v is not None})\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: if yt is not None and self._needs_visit(yt.metrics):\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: candidates.sort(key=lambda t: bool(t[1].metrics.get(\"yt_checked\")))\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: yt.metrics[\"yt_checked\"] = now_iso()\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: yt.metrics.update({k: v for k, v in {\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/twitch_decouverte.py: acc.metrics = {k: v for k, v in {\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/palmares_apple.py: acc.metrics = {k: v for k, v in {\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/listes_medias.py: metrics = entry.get(\"metrics\") or {}\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/listes_medias.py: followers=metrics.get(metric_key)))","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2e162ed5-5815-4486-9eb2-731a0e586005","timestamp":"2026-08-21T00:52:52.018Z"}} |
|
62 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -r \"avatar\\|image\\|cover\\|profile\" /Users/si…","usage":{"total_tokens":41630,"tool_uses":15,"duration_ms":12733},"last_tool_name":"Bash","uuid":"515b317b-c67f-465c-ad3a-dc611f145f09","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
63 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykRMt6KCBuTP1Z4UZy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Rk779hztB1k34W65oQMDEY","name":"Bash","input":{"command":"grep -r \"avatar\\|image\\|cover\\|profile\" /Users/simon-pierreboucher/apps/crea-ka/frontend/src --include=\"*.js\" --include=\"*.html\" 2>/dev/null | head -15"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":5454,"cache_read_input_tokens":36011,"cache_creation":{"ephemeral_5m_input_tokens":5454,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4f5b045e-8b2c-4247-981b-f34c2ac59ffd"}} |
|
64 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Rk779hztB1k34W65oQMDEY","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<link rel=\"preconnect\" href=\"https://unavatar.io\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<meta property=\"og:image:width\" content=\"1200\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<meta property=\"og:image:height\" content=\"630\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<meta name=\"twitter:card\" content=\"summary_large_image\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:.mc-avatar img{width:100%;height:100%;object-fit:cover}\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html: background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html:@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9546f3db-5e16-43af-ae62-2e52cde0c144","timestamp":"2026-08-21T00:52:53.688Z"}} |
|
65 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -r \"avatar\\|followers\\|verified\\|metrics\" /U…","usage":{"total_tokens":41632,"tool_uses":16,"duration_ms":13043},"last_tool_name":"Bash","uuid":"089675ba-eeae-4ab3-b284-5cce46af99fa","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
66 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykRMt6KCBuTP1Z4UZy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DdKzcsouuX6gSedWb43wUr","name":"Bash","input":{"command":"grep -r \"avatar\\|followers\\|verified\\|metrics\" /Users/simon-pierreboucher/apps/crea-ka/creaka/web.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":5454,"cache_read_input_tokens":36011,"cache_creation":{"ephemeral_5m_input_tokens":5454,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"aa18bf39-3416-491d-a396-34f22002c4c9"}} |
|
67 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DdKzcsouuX6gSedWb43wUr","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b29b11e4-9ba7-4147-b5ff-5c7e91668d9d","timestamp":"2026-08-21T00:52:53.993Z"}} |
|
68 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading creaka/normalize.py","usage":{"total_tokens":41634,"tool_uses":17,"duration_ms":13383},"last_tool_name":"Read","uuid":"71a91ba5-5e10-448e-9e02-9f2ecda98471","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
69 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykRMt6KCBuTP1Z4UZy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XoCXPesWZR5sAJnTyGRi9A","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/normalize.py","limit":100},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":5454,"cache_read_input_tokens":36011,"cache_creation":{"ephemeral_5m_input_tokens":5454,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"225901f1-47cd-4eba-a136-6998c7258f2c"}} |
|
70 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XoCXPesWZR5sAJnTyGRi9A","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/normalize.py\n4\t# Desc: Normalisation commune — handles, URLs canoniques par plateforme,\n5\t# niches, régions, langue, taille d'audience (voir CLAUDE.md §6, §7, §11)\n6\t# ==============================================================================\n7\t\"\"\"Couche de normalisation partagée par tous les connecteurs.\n8\t\n9\tChaque connecteur remplit des champs bruts ; `schema.Creator.finalize()`\n10\tappelle ces fonctions pour produire une fiche canonique. Idempotent.\n11\t\"\"\"\n12\tfrom __future__ import annotations\n13\t\n14\timport re\n15\timport unicodedata\n16\t\n17\t# --- taxonomies (CLAUDE.md §6) ------------------------------------------------\n18\tNICHES = {\n19\t \"humour\", \"mode\", \"beaute\", \"lifestyle\", \"famille-parentalite\", \"cuisine\",\n20\t \"gaming\", \"tech\", \"sport-fitness\", \"plein-air\", \"voyage\", \"musique\", \"arts\",\n21\t \"danse\", \"education\", \"finance-affaires\", \"sante-mieux-etre\", \"bouffe-resto\",\n22\t \"actualite-opinion\", \"autre\",\n23\t}\n24\t\n25\tPLATFORMS = {\n26\t \"instagram\", \"tiktok\", \"youtube\", \"twitch\", \"kick\", \"x\", \"facebook\",\n27\t \"snapchat\", \"substack\", \"patreon\", \"onlyfans\", \"mym\", \"fansly\",\n28\t \"linkedin\", \"threads\", \"spotify\", \"discord\", \"podcast\", \"site-web\", \"autre\",\n29\t}\n30\t\n31\tCREATOR_TYPES = {\n32\t \"influenceur\", \"youtubeur\", \"streamer\", \"podcasteur\", \"createur-tiktok\",\n33\t \"createur-ecrit\", \"musicien\", \"humoriste\", \"autre\",\n34\t}\n35\t\n36\tLANGUAGES = {\"fr\", \"en\", \"bilingue\"}\n37\t\n38\tREGIONS = {\n39\t \"Bas-Saint-Laurent\", \"Saguenay–Lac-Saint-Jean\", \"Capitale-Nationale\",\n40\t \"Mauricie\", \"Estrie\", \"Montréal\", \"Outaouais\", \"Abitibi-Témiscamingue\",\n41\t \"Côte-Nord\", \"Nord-du-Québec\", \"Gaspésie–Îles-de-la-Madeleine\",\n42\t \"Chaudière-Appalaches\", \"Laval\", \"Lanaudière\", \"Laurentides\",\n43\t \"Montérégie\", \"Centre-du-Québec\",\n44\t}\n45\t\n46\t# --- URLs canoniques par plateforme (CLAUDE.md §11) ---------------------------\n47\t# gabarit : handle SANS @ ; None = pas de forme canonique (garder l'URL source)\n48\t_URL_TEMPLATES: dict[str, str | None] = {\n49\t \"instagram\": \"https://www.instagram.com/{h}/\",\n50\t \"tiktok\": \"https://www.tiktok.com/@{h}\",\n51\t \"youtube\": \"https://www.youtube.com/@{h}\",\n52\t \"twitch\": \"https://www.twitch.tv/{h}\",\n53\t \"kick\": \"https://kick.com/{h}\",\n54\t \"x\": \"https://x.com/{h}\",\n55\t \"facebook\": \"https://www.facebook.com/{h}\",\n56\t \"snapchat\": \"https://www.snapchat.com/add/{h}\",\n57\t \"substack\": \"https://{h}.substack.com\",\n58\t \"patreon\": \"https://www.patreon.com/{h}\",\n59\t \"onlyfans\": \"https://onlyfans.com/{h}\",\n60\t \"mym\": \"https://mym.fans/{h}\",\n61\t \"fansly\": \"https://fansly.com/{h}\",\n62\t \"linkedin\": \"https://www.linkedin.com/in/{h}\",\n63\t \"threads\": \"https://www.threads.net/@{h}\",\n64\t # IDs Spotify et codes d'invitation Discord : sensibles à la casse →\n65\t # pas de forme canonique reconstruite, on conserve l'URL source\n66\t \"spotify\": None,\n67\t \"discord\": None,\n68\t \"podcast\": None,\n69\t \"site-web\": None,\n70\t \"autre\": None,\n71\t}\n72\t\n73\t# détection de plateforme à partir d'une URL (pages link-in-bio, bios, etc.)\n74\t_URL_PLATFORM_RES: list[tuple[str, re.Pattern]] = [\n75\t (\"instagram\", re.compile(r\"instagram\\.com/([\\w.\\-]+)\", re.I)),\n76\t (\"tiktok\", re.compile(r\"tiktok\\.com/@([\\w.\\-]+)\", re.I)),\n77\t (\"youtube\", re.compile(r\"youtube\\.com/(?:@|c/|user/|channel/)([\\w.\\-]+)\", re.I)),\n78\t (\"youtube\", re.compile(r\"youtu\\.be/([\\w.\\-]+)\", re.I)),\n79\t (\"twitch\", re.compile(r\"twitch\\.tv/([\\w.\\-]+)\", re.I)),\n80\t (\"kick\", re.compile(r\"kick\\.com/([\\w.\\-]+)\", re.I)),\n81\t (\"x\", re.compile(r\"(?:twitter|x)\\.com/([\\w.\\-]+)\", re.I)),\n82\t (\"facebook\", re.compile(r\"facebook\\.com/([\\w.\\-]+)\", re.I)),\n83\t (\"snapchat\", re.compile(r\"snapchat\\.com/add/([\\w.\\-]+)\", re.I)),\n84\t (\"substack\", re.compile(r\"https?://([\\w\\-]+)\\.substack\\.com\", re.I)),\n85\t (\"patreon\", re.compile(r\"patreon\\.com/([\\w.\\-]+)\", re.I)),\n86\t (\"onlyfans\", re.compile(r\"onlyfans\\.com/([\\w.\\-]+)\", re.I)),\n87\t (\"mym\", re.compile(r\"mym\\.fans/([\\w.\\-]+)\", re.I)),\n88\t (\"fansly\", re.compile(r\"fansly\\.com/([\\w.\\-]+)\", re.I)),\n89\t (\"linkedin\", re.compile(r\"linkedin\\.com/in/([\\w.\\-]+)\", re.I)),\n90\t (\"threads\", re.compile(r\"threads\\.net/@?([\\w.\\-]+)\", re.I)),\n91\t (\"spotify\", re.compile(\n92\t r\"open\\.spotify\\.com/(?:intl-[\\w\\-]+/)?(?:artist|show|user)/([A-Za-z0-9]+)\",\n93\t re.I)),\n94\t (\"discord\", re.compile(r\"discord(?:\\.gg|(?:app)?\\.com/invite)/([\\w\\-]+)\", re.I)),\n95\t # une page Apple Podcasts pointe le MÊME balado que balados-itunes\n96\t # (handle = collectionId) → fusion naturelle des comptes podcast\n97\t (\"podcast\", re.compile(r\"podcasts\\.apple\\.com/\\S*?/id(\\d+)\", re.I)),\n98\t]\n99\t\n100\t# chemins qui ne sont PAS des handles (pages génériques des plateformes)\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8d92a512-19bc-497f-acb9-1352eb5631df","timestamp":"2026-08-21T00:52:54.308Z"}} |
|
71 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -A 20 \"cavatar\\|avatar_url\\|content_key\" /Us…","usage":{"total_tokens":44993,"tool_uses":18,"duration_ms":15388},"last_tool_name":"Bash","uuid":"bf333bd1-0d90-46bb-9fff-ba38e7ce0ecf","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
72 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykbU6HjTVjQQdLy8cq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HcPwiVYYEQc6PB7NFhgKhe","name":"Bash","input":{"command":"grep -A 20 \"cavatar\\|avatar_url\\|content_key\" /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html | head -30"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":3333,"cache_read_input_tokens":41465,"cache_creation":{"ephemeral_5m_input_tokens":3333,"ephemeral_1h_input_tokens":0},"output_tokens":26,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"81dabbd3-7648-4235-bdab-119c14567a00"}} |
|
73 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HcPwiVYYEQc6PB7NFhgKhe","type":"tool_result","content":".cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n--\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b742c091-47b3-4dad-8bd9-2e989cee3195","timestamp":"2026-08-21T00:52:56.343Z"}} |
|
74 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -B 3 -A 3 \"metrics\\|followers\\|verified\" /Us…","usage":{"total_tokens":45019,"tool_uses":19,"duration_ms":15861},"last_tool_name":"Bash","uuid":"bee54951-be50-4cc9-a3ca-7eec1d0793eb","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
75 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykbU6HjTVjQQdLy8cq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UBfMcdx1XY7H9DrDfBSq1F","name":"Bash","input":{"command":"grep -B 3 -A 3 \"metrics\\|followers\\|verified\" /Users/simon-pierreboucher/apps/crea-ka/creaka/web.py | head -40"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":3333,"cache_read_input_tokens":41465,"cache_creation":{"ephemeral_5m_input_tokens":3333,"ephemeral_1h_input_tokens":0},"output_tokens":26,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f00f7550-d37b-4304-84e9-12aa3af5272c"}} |
|
76 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running ls -la /Users/simon-pierreboucher/apps/crea-ka/ |…","usage":{"total_tokens":45045,"tool_uses":20,"duration_ms":15870},"last_tool_name":"Bash","uuid":"aef01c57-3256-41ee-98d6-64e157c6a9c3","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
77 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykbU6HjTVjQQdLy8cq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JxDTuD2jkysSV38qx5hG9T","name":"Bash","input":{"command":"ls -la /Users/simon-pierreboucher/apps/crea-ka/ | grep -E \"ecosystem|package.json|requirements|pyproject\""},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":3333,"cache_read_input_tokens":41465,"cache_creation":{"ephemeral_5m_input_tokens":3333,"ephemeral_1h_input_tokens":0},"output_tokens":26,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6bf30837-784e-404d-a479-9b089af7d394"}} |
|
78 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UBfMcdx1XY7H9DrDfBSq1F","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ae45eb2a-02bc-466a-923f-bd2d1db2bf9a","timestamp":"2026-08-21T00:52:56.817Z"}} |
|
79 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JxDTuD2jkysSV38qx5hG9T","type":"tool_result","content":"-rw-r--r-- 1 simon-pierreboucher staff 364 Aug 17 21:32 requirements.txt","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ff3dfe14-eedc-4c43-945a-60daf2bc8a34","timestamp":"2026-08-21T00:52:56.818Z"}} |
|
80 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Reading requirements.txt","usage":{"total_tokens":46332,"tool_uses":21,"duration_ms":17312},"last_tool_name":"Read","uuid":"9fc214ac-ad04-4999-9c45-4bf351bfc1b4","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
81 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykn6ZHCuBvSas4nhNq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014euF8i6T7bxuWroC2sheyM","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/requirements.txt"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1269,"cache_read_input_tokens":44798,"cache_creation":{"ephemeral_5m_input_tokens":1269,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"36495d5b-0b4c-4985-8e9b-ac7b3eeac90c"}} |
|
82 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014euF8i6T7bxuWroC2sheyM","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: requirements.txt\n4\t# Desc: Dépendances backend Créa-Ka\n5\t# ==============================================================================\n6\tfastapi>=0.110\n7\tuvicorn>=0.29\n8\trequests>=2.31\n9\tpydantic>=2.5\n10\tpytest>=8.0\n11\tfpdf2>=2.7\n12\t\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n"}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"86ba407b-a1a2-431d-af90-0af62b9d9173","timestamp":"2026-08-21T00:52:58.239Z"}} |
|
83 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running cat /Users/simon-pierreboucher/apps/crea-ka/packa…","usage":{"total_tokens":46350,"tool_uses":22,"duration_ms":17632},"last_tool_name":"Bash","uuid":"a0164948-1f2f-4427-905e-11329a656715","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
84 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykn6ZHCuBvSas4nhNq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0161um6NUGpchvHdBq92Ejpq","name":"Bash","input":{"command":"cat /Users/simon-pierreboucher/apps/crea-ka/package.json 2>/dev/null || echo \"No package.json\""},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1269,"cache_read_input_tokens":44798,"cache_creation":{"ephemeral_5m_input_tokens":1269,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"081c68ce-8b2c-4336-9ba9-1aca4341edc6"}} |
|
85 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0161um6NUGpchvHdBq92Ejpq","type":"tool_result","content":"No package.json","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b91ed02c-eb9e-460f-9594-c51ba1221d5d","timestamp":"2026-08-21T00:52:58.578Z"}} |
|
86 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -r \"PM2\\|ecosystem.config\\|docker\\|compose\" …","usage":{"total_tokens":46368,"tool_uses":23,"duration_ms":18124},"last_tool_name":"Bash","uuid":"4654a064-0d85-4de5-9e48-2e955410547a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
87 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEykn6ZHCuBvSas4nhNq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012R39ECNDddAnADTtD78Aa5","name":"Bash","input":{"command":"grep -r \"PM2\\|ecosystem.config\\|docker\\|compose\" /Users/simon-pierreboucher/apps/crea-ka --include=\"*.json\" --include=\"*.js\" --include=\"*.py\" 2>/dev/null | head -10"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1269,"cache_read_input_tokens":44798,"cache_creation":{"ephemeral_5m_input_tokens":1269,"ephemeral_1h_input_tokens":0},"output_tokens":18,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"29087c1c-24ee-45b1-b6af-66bc2960abfd"}} |
|
88 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012R39ECNDddAnADTtD78Aa5","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/packaging/version.py: Return a new version composed of the various parts.\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/packaging/ranges.py:#: it (see :func:`_decompose_dev0_gap` and :func:`_encode_gap`),\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/packaging/ranges.py:def _decompose_dev0_gap(\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/packaging/ranges.py: \"\"\"Decompose the gap ``[L.dev0, U.dev0)`` into wildcard prefixes.\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/packaging/ranges.py: tail = _decompose_dev0_gap(\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/packaging/ranges.py: prefixes = _decompose_dev0_gap(\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/h11/_events.py: data, because TCP connections are composed to two one-way channels which\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/pygments/lexers/rebol.py: r'new-line\\?|not|now|prin|print|reduce|compose|construct|repeat|'\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/pygments/lexers/rebol.py: r'case|reduce|compose|get|set|print|prin|equal\\?|'\n/Users/simon-pierreboucher/apps/crea-ka/.venv/lib/python3.14/site-packages/pygments/lexers/phix.py: 'mpz_re_compose', 'mpz_remove', 'mpz_scan0', 'mpz_scan1', 'mpz_set',","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fedbefdb-287c-4c8b-b728-f916f06a509c","timestamp":"2026-08-21T00:53:00.030Z"}} |
|
89 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running find /Users/simon-pierreboucher/apps/crea-ka -max…","usage":{"total_tokens":47515,"tool_uses":24,"duration_ms":20517},"last_tool_name":"Bash","uuid":"4575f8aa-bec9-4354-8c72-70beddc3ca9a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
90 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEym1deNhDBP6jsLahZd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0161unHSA7povPfpEkFqa7Pa","name":"Bash","input":{"command":"find /Users/simon-pierreboucher/apps/crea-ka -maxdepth 2 -name \"ecosystem*\" -o -name \"Procfile\" -o -name \"docker*\" 2>/dev/null"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1122,"cache_read_input_tokens":46067,"cache_creation":{"ephemeral_5m_input_tokens":1122,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4b081ceb-81fc-4e5e-a612-d371a84f65d4"}} |
|
91 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0161unHSA7povPfpEkFqa7Pa","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d5456e63-04c7-4d69-a388-61d4b8dfb7f0","timestamp":"2026-08-21T00:53:01.464Z"}} |
|
92 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running cat /Users/simon-pierreboucher/apps/crea-ka/READM…","usage":{"total_tokens":47540,"tool_uses":25,"duration_ms":20826},"last_tool_name":"Bash","uuid":"bccd3151-b2c1-4c33-b540-2b17ef367aea","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
93 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEym1deNhDBP6jsLahZd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XKvo6SyEuPFxyMtUFaupTW","name":"Bash","input":{"command":"cat /Users/simon-pierreboucher/apps/crea-ka/README.md | head -80"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1122,"cache_read_input_tokens":46067,"cache_creation":{"ephemeral_5m_input_tokens":1122,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ccf6f768-e346-4336-ad73-8707cead75f7"}} |
|
94 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XKvo6SyEuPFxyMtUFaupTW","type":"tool_result","content":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: README.md\n Desc: Créa·Ka — agrégateur des créateurs de contenu québécois\n-->\n<div align=\"center\">\n\n# Créa·Ka\n\n**Tous les créateurs québécois. Tous leurs liens.**\n\n\n\n[](https://www.crea-ka.com)\n[](https://www.crea-ka.com/stats)\n[](https://www.crea-ka.com)\n\n[](https://www.python.org)\n[](creaka/web.py)\n[](frontend/src/index.template.html)\n[](tests/)\n[](creaka/connectors/)\n[](https://www.crea-ka.com)\n\n**https://www.crea-ka.com**\n\n</div>\n\n---\n\n## Description\n\n**Créa·Ka** est l'**annuaire public, québécois et cross-plateforme des créateurs de contenu** — un croisement entre un **annuaire cherchable** et un **« Linktree collectif »**, membre de la famille **Groupe Ka**.\n\nChaque créateur québécois a **une fiche canonique** qui regroupe **tous ses comptes publics** — **Instagram, TikTok, YouTube, Twitch, X, Facebook, Snapchat, Kick, Substack, Patreon, OnlyFans, balado**… — chacun avec son **URL canonique**, ses **métriques** (abonnés, publications, j'aime, vues, épisodes) et son **score de confiance de rattachement**.\n\n## Fonctionnalités\n\n- **Annuaire filtrable** par **niche** (20), **région** (17 régions administratives), **langue**, **plateforme** et **taille d'audience** (**nano/micro/macro/méga**).\n- **Page dédiée par créateur** : `/createur/{id}` avec tous ses liens et métriques.\n- **Tableau de bord analytique** `/stats` + **rapport PDF** aux couleurs Groupe-KA.\n- **Connexion « Se connecter avec KA ID »** via le hub **groupe-ka.com** (SSO de l'écosystème).\n- **Widget KA Agent** (bulle de chat IA Groupe KA) câblé sur le site.\n- **Couverture cumulative** : un **watch quotidien** relance découverte + enrichissement — nouveaux créateurs, métriques rafraîchies, comptes croisés révélés via les **liens de bio** et les pages **link-in-bio**.\n- **Opt-out immédiat et persistant** (`/retrait`) et **éthique stricte** (voir plus bas).\n\n## Architecture (calquée sur Lou·Ka)\n\n```\nDÉCOUVERTE → EXTRACTION → RÉSOLUTION D'IDENTITÉ → NORMALISATION → DÉDUP. → SQLite → API/UI\n```\n\n| Module | Rôle |\n|---|---|\n| `creaka/schema.py` | fiche **`Creator`** + **`PlatformAccount`**, **validation stricte** (compte sans url/confidence = rejeté) |\n| `creaka/identity.py` | **signaux de rattachement** §12.1 : `profil_source` 0.98 · `link_in_bio` 0.95 · `cross_link` 0.90 · `listes_medias`/`base_publique` 0.85 · `handle_match` 0.75 |\n| `creaka/dedup.py` | fusion **uniquement** sur compte fort partagé — **jamais sur le nom** (homonymes) |\n| `creaka/ethics.py` | **purge des données privées**, opt-out persistant, **régime restreint des mineurs** |\n| `creaka/db.py` | **SQLite WAL** : fiches canoniques + index des comptes + journal des syncs |\n| `creaka/auth.py` | connexion **KA ID** (hub groupe-ka.com) |\n| `creaka/stats.py` + `creaka/kapdf.py` | statistiques `/stats` + **rapport PDF** |\n| `creaka/web.py` | **API FastAPI** + frontend statique |\n\n### Connecteurs (registre auto-découvrant — `creaka/connectors/`)\n\n| Connecteur | Famille | Accès | Signal |\n|---|---|---|---|\n| `listes-medias` | découverte | dataset local (palmarès médias publics croisés, **376 profils**) | 0.85 |\n| `wikidata-qc` | découverte | **SPARQL Wikidata** (nés/résidant au QC, handles curés) | 0.85 |\n| `onlyqueb` | découverte | sitemap + JSON-LD (annuaire public, liens auto-déclarés) | 0.90 |\n| `youtube-recherche` | découverte | recherche publique YouTube, ~120 requêtes × **marqueur QC obligatoire**, pagination par continuations | 0.98 |\n| `balados-itunes` | découverte | **API officielle** iTunes/Apple Podcasts | 0.98 |\n| `instagram-profil` | enrichissement | Scrapfly (profil public : abonnés, bio, **liens de bio** → cross-link) | — |\n| `tiktok-profil` | enrichissement | Scrapfly (JSON d'hydratation public) | — |\n| `youtube` | enrichissement | **API officielle v3** si clé, sinon page publique | — |\n| `twitch` | enrichissement | **API officielle Helix** (sauté sans clés) | — |\n| `link-in-bio` | enrichissement | Linktree/Beacons/Bio.link… — **le levier nº 1** : une page → tous les comptes | 0.95 |\n\n## Stack","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2c1ec098-7cf9-41d9-ab73-313ff1cb144a","timestamp":"2026-08-21T00:53:01.776Z"}} |
|
95 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -r \"avatar_url\\|avatars?\" /Users/simon-pierr…","usage":{"total_tokens":47565,"tool_uses":26,"duration_ms":20981},"last_tool_name":"Bash","uuid":"145307a9-720c-4731-a98e-cd8a28130a9f","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
96 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEym1deNhDBP6jsLahZd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YCckiheZ8suqNHDQsQ1nLr","name":"Bash","input":{"command":"grep -r \"avatar_url\\|avatars?\" /Users/simon-pierreboucher/apps/crea-ka/creaka --include=\"*.py\" | head -15"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1122,"cache_read_input_tokens":46067,"cache_creation":{"ephemeral_5m_input_tokens":1122,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"aa0dc36f-7e43-4c51-8179-32a750c158af"}} |
|
97 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YCckiheZ8suqNHDQsQ1nLr","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: if info.get(\"avatar\", \"\").startswith(\"http\") and not cr.avatar_url:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/x_profil.py: cr.avatar_url = info[\"avatar\"]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/wikipedia_qc.py: avatar_url=d[\"avatar\"],\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: if not cr.avatar_url and (info.get(\"avatar\") or \"\").startswith(\"http\"):\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/youtube.py: cr.avatar_url = info[\"avatar\"]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/kick_decouverte.py: avatar_url=avatar if avatar.startswith(\"http\") else None,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/twitch_decouverte.py: avatar_url=avatar if avatar.startswith(\"http\") else None,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/palmares_apple.py: avatar_url=info.get(\"artworkUrl600\")\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/onlyqueb.py: avatar_url=prof[\"image\"],\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/podcastindex.py: if image.startswith(\"http\") and not cr.avatar_url:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/podcastindex.py: cr.avatar_url = image\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/balados_rss.py: if feed[\"image\"].startswith(\"http\") and not cr.avatar_url:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/balados_rss.py: cr.avatar_url = feed[\"image\"]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/balados_itunes.py: avatar_url=r.get(\"artworkUrl600\") or r.get(\"artworkUrl100\"),\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/instagram.py: if not cr.avatar_url and user.get(\"profile_pic_url_hd\"):","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"43c78c92-1b3b-4c69-801e-482b856df219","timestamp":"2026-08-21T00:53:01.942Z"}} |
|
98 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -A 5 \"class ApifyInstagram\\|class ApifyTikTo…","usage":{"total_tokens":50538,"tool_uses":27,"duration_ms":23075},"last_tool_name":"Bash","uuid":"2f7d295c-b36e-4dcd-a174-6cdadb8d10a9","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
99 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEym9qWkv2vLbgNkoZSn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Src3TquL8nZwkMhp5sBMy5","name":"Bash","input":{"command":"grep -A 5 \"class ApifyInstagram\\|class ApifyTikTok\\|class ApifyX\\|class ApifyYouTube\" /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py | head -50"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":2969,"cache_read_input_tokens":47189,"cache_creation":{"ephemeral_5m_input_tokens":2969,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9244274a-1284-4a55-a998-e1f23d55bca6"}} |
|
100 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Src3TquL8nZwkMhp5sBMy5","type":"tool_result","content":"class ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n--\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n--\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n--\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fe4f0143-0bbe-4393-b149-a82f133e7f59","timestamp":"2026-08-21T00:53:04.027Z"}} |
|
101 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running cat /Users/simon-pierreboucher/apps/crea-ka/creak…","usage":{"total_tokens":50540,"tool_uses":28,"duration_ms":23373},"last_tool_name":"Bash","uuid":"7931eeb5-b529-4eec-8a77-12815cee20cf","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
102 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEym9qWkv2vLbgNkoZSn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011dYxJmsMjNYrsiVRdfyPUb","name":"Bash","input":{"command":"cat /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/__init__.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":2969,"cache_read_input_tokens":47189,"cache_creation":{"ephemeral_5m_input_tokens":2969,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9c90693c-8ac1-4479-80cf-97413f228143"}} |
|
103 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011dYxJmsMjNYrsiVRdfyPUb","type":"tool_result","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/__init__.py\n# Desc: Registre AUTO-DÉCOUVRANT des connecteurs (calqué sur Lou-Ka) —\n# tout module contenant une sous-classe de BaseConnector avec un\n# source_id non vide est enregistré automatiquement\n# ==============================================================================\nfrom __future__ import annotations\n\nimport importlib\nimport pkgutil\nimport sys\n\nfrom .base import BaseConnector\n\nCONNECTORS: dict[str, type[BaseConnector]] = {}\n\nfor _mod in pkgutil.iter_modules(__path__):\n if _mod.name in (\"base\", \"__init__\"):\n continue\n try:\n module = importlib.import_module(f\"{__name__}.{_mod.name}\")\n except Exception as exc: # un connecteur cassé ne bloque pas les autres\n print(f\"[crea-ka] connecteur '{_mod.name}' ignoré : {exc}\", file=sys.stderr)\n continue\n for obj in vars(module).values():\n if (isinstance(obj, type) and issubclass(obj, BaseConnector)\n and obj is not BaseConnector and getattr(obj, \"source_id\", \"\")):\n CONNECTORS[obj.source_id] = obj","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"64b6883d-6d8a-4053-a3ee-416ab388fbb5","timestamp":"2026-08-21T00:53:04.320Z"}} |
|
104 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running head -50 /Users/simon-pierreboucher/apps/crea-ka/…","usage":{"total_tokens":50542,"tool_uses":29,"duration_ms":23418},"last_tool_name":"Bash","uuid":"4865f53b-ca74-4d2f-a23f-67edf513e23f","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
105 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEym9qWkv2vLbgNkoZSn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017vbqQDK1sPvy1ahfvdtxyi","name":"Bash","input":{"command":"head -50 /Users/simon-pierreboucher/apps/crea-ka/creaka/identity.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":2969,"cache_read_input_tokens":47189,"cache_creation":{"ephemeral_5m_input_tokens":2969,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d4b9bbf9-0eb0-4723-b35e-2e76ad42114a"}} |
|
106 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017vbqQDK1sPvy1ahfvdtxyi","type":"tool_result","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/identity.py\n# Desc: Résolution d'identité — rattacher les bons comptes à la bonne personne\n# avec un score de confiance par signal (CLAUDE.md §12.1)\n# ==============================================================================\n\"\"\"Résolution d'identité : LE cœur du projet.\n\nChaque rattachement de compte porte un score `confidence` selon le signal qui\nl'a produit (du plus fort au plus faible, §12.1). Sous `REVIEW_THRESHOLD`, le\ncompte est marqué « à vérifier » et EXCLU de la fiche publique plutôt que\nrattaché à tort — mieux vaut un lien vérifié de moins qu'un lien erroné.\n\"\"\"\nfrom __future__ import annotations\n\nfrom .schema import PlatformAccount\n\n# signaux §12.1, du plus fort au plus faible\nSIGNAL_CONFIDENCE: dict[str, float] = {\n \"profil_source\": 0.98, # le compte DONT la fiche est issue (attribution inhérente)\n \"link_in_bio\": 0.95, # page Linktree/Beacons du créateur listant le compte\n \"api_officielle\": 0.95, # confirmé par l'API officielle de la plateforme\n \"cross_link\": 0.90, # un profil vérifié pointe vers l'autre (bio IG → TikTok)\n \"listes_medias\": 0.85, # listes/palmarès médias croisés (plusieurs sources concordantes)\n \"base_publique\": 0.85, # base de connaissances publique curée (Wikidata/Wikipédia)\n \"handle_match\": 0.75, # même handle + même nom d'affichage sur 2 plateformes\n \"mention\": 0.60, # mentions réciproques / mêmes liens externes\n \"inconnu\": 0.40,\n}\n\n# sous ce seuil : « à vérifier », jamais publié comme rattaché\nREVIEW_THRESHOLD = 0.50\n# à partir de ce seuil : compte considéré fiable pour la déduplication (§12.2)\nSTRONG_THRESHOLD = 0.75\n\n\ndef confidence_for(signal: str) -> float:\n return SIGNAL_CONFIDENCE.get(signal, SIGNAL_CONFIDENCE[\"inconnu\"])\n\n\ndef account(platform: str, handle: str, signal: str, *,\n followers: int | None = None, url: str = \"\",\n verified: bool | None = None) -> PlatformAccount:\n \"\"\"Fabrique un compte avec la confiance du signal fourni (helper connecteurs).\"\"\"\n return PlatformAccount(platform=platform, handle=handle, url=url,\n followers=followers, verified=verified,\n confidence=confidence_for(signal), signal=signal)\n\n\ndef needs_review(acc: PlatformAccount) -> bool:","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d7a75f5b-a876-4584-9a9a-ce8867ddaec4","timestamp":"2026-08-21T00:53:04.353Z"}} |
|
107 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -B 2 -A 8 \"def _public_dict\" /Users/simon-pi…","usage":{"total_tokens":52354,"tool_uses":30,"duration_ms":25494},"last_tool_name":"Bash","uuid":"64fdf18f-a056-475f-8f4a-824d7d8b6e8d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
108 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEymLCrKJ32UHdCT3qed","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01W8KwL8VUQcC6Uy3Yj5s6Gi","name":"Bash","input":{"command":"grep -B 2 -A 8 \"def _public_dict\" /Users/simon-pierreboucher/apps/crea-ka/creaka/db.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1806,"cache_read_input_tokens":50158,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"cdaeda36-993b-4b75-ac9a-e2e7fe53b91e"}} |
|
109 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01W8KwL8VUQcC6Uy3Yj5s6Gi","type":"tool_result","content":"def _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n doc = json.loads(row[\"doc\"])\n doc[\"id\"] = row[\"id\"]\n doc[\"first_seen\"] = row[\"first_seen\"]\n doc[\"last_seen\"] = row[\"last_seen\"]\n doc[\"updated_at\"] = row[\"updated_at\"]\n doc[\"platforms\"] = [\n {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bf4b46ce-879c-493d-bf95-71c9198a61c3","timestamp":"2026-08-21T00:53:06.450Z"}} |
|
110 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running grep -r \"recent_posts\\|recent_videos\\|recent_twee…","usage":{"total_tokens":52357,"tool_uses":31,"duration_ms":26105},"last_tool_name":"Bash","uuid":"99b49db6-3384-4123-80b1-ad2b347e1d2a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
111 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEymLCrKJ32UHdCT3qed","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EnkeK99rCYkT79xaHnHZw6","name":"Bash","input":{"command":"grep -r \"recent_posts\\|recent_videos\\|recent_tweets\" /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py | head -10"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1806,"cache_read_input_tokens":50158,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4b5f2c54-2025-4cfd-ab75-71caa5099035"}} |
|
112 |
+{"type":"claude","event":{"type":"system","subtype":"task_progress","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","description":"Running head -100 /Users/simon-pierreboucher/apps/crea-ka…","usage":{"total_tokens":52360,"tool_uses":32,"duration_ms":26128},"last_tool_name":"Bash","uuid":"1144e301-f4e4-4c5a-a4e7-61fab581c475","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
113 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011CeEymLCrKJ32UHdCT3qed","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011eXD2AZHcUjPFsdmZAC37A","name":"Bash","input":{"command":"head -100 /Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py | tail -50"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":7,"cache_creation_input_tokens":1806,"cache_read_input_tokens":50158,"cache_creation":{"ephemeral_5m_input_tokens":1806,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"not_available"},"context_management":null},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7049499b-d64c-4b2e-89c4-f1c99868734d"}} |
|
114 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EnkeK99rCYkT79xaHnHZw6","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py: content_key = \"recent_posts\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py: content_key = \"recent_videos\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py: content_key = \"recent_tweets\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py: content_key = \"recent_posts\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py: content_key = \"recent_videos\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py: content_key = \"recent_videos\"","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6738eb56-4ebc-4858-8f63-1780b581d126","timestamp":"2026-08-21T00:53:07.060Z"}} |
|
115 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011eXD2AZHcUjPFsdmZAC37A","type":"tool_result","content":" \"education\": \"Éducation\", \"finance-affaires\": \"Finance & affaires\",\n \"sante-mieux-etre\": \"Santé & mieux-être\", \"bouffe-resto\": \"Bouffe & resto\",\n \"actualite-opinion\": \"Actualité & opinion\", \"autre\": \"Autre\",\n}\nTYPE_LBL = {\n \"youtubeur\": \"Youtubeur\", \"influenceur\": \"Influenceur\",\n \"streamer\": \"Streamer\", \"podcasteur\": \"Podcasteur\",\n \"humoriste\": \"Humoriste\", \"createur-tiktok\": \"Créateur TikTok\",\n \"createur-ecrit\": \"Créateur écrit\", \"musicien\": \"Musicien\",\n \"artiste\": \"Artiste\", \"autre\": \"Autre\",\n}\n# bornes réelles de normalize.audience_tier (§6.3)\nTIER_LBL = [(\"nano\", \"Nano (< 10 k)\"), (\"micro\", \"Micro (10 k – 100 k)\"),\n (\"macro\", \"Macro (100 k – 1 M)\"), (\"mega\", \"Méga (1 M et +)\")]\n# tranches d'audience (distribution) — bornes en abonnés cumulés connus\nREACH_BINS = [(0, 1_000, \"< 1 k\"), (1_000, 10_000, \"1 k – 10 k\"),\n (10_000, 50_000, \"10 k – 50 k\"), (50_000, 100_000, \"50 k – 100 k\"),\n (100_000, 500_000, \"100 k – 500 k\"),\n (500_000, 1_000_000, \"500 k – 1 M\"),\n (1_000_000, None, \"1 M et +\")]\n\n\ndef site_info() -> dict:\n \"\"\"Identité Créa-Ka pour le PDF, lue dans ka/ecosystem.json (source commune).\"\"\"\n try:\n eco = json.loads(ECO_PATH.read_text(encoding=\"utf-8\"))\n s = next(x for x in eco[\"sites\"] if x[\"id\"] == \"crea-ka\")\n return {\"wordmark\": s[\"wordmark\"], \"accent\": s[\"accent\"],\n \"domain\": s[\"domain\"], \"tagline\": s.get(\"tagline\", \"\")}\n except Exception:\n return {\"wordmark\": \"Créa·Ka\", \"accent\": \"#7048e8\",\n \"domain\": \"www.crea-ka.com\",\n \"tagline\": \"Les créateurs d'ici, tous leurs liens\"}\n\n\n# --- utilitaires -----------------------------------------------------------------\n\ndef _local_dt(iso: str) -> datetime | None:\n \"\"\"ISO-8601 UTC (…Z) → datetime local (America/Toronto).\"\"\"\n if not iso:\n return None\n try:\n dt = datetime.strptime(iso[:19], \"%Y-%m-%dT%H:%M:%S\")\n return dt.replace(tzinfo=timezone.utc).astimezone(TZ)\n except ValueError:\n return None\n\n\ndef _local_date(iso: str) -> date | None:\n dt = _local_dt(iso)","is_error":false}]},"parent_tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b1f10115-6441-489f-9bb0-f74becd1a8a0","timestamp":"2026-08-21T00:53:07.067Z"}} |
|
116 |
+{"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"a3fe10a2a5a9a7eb9","tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","status":"completed","output_file":"","summary":"Explorer pipeline enrichissement crea-ka","usage":{"total_tokens":53921,"tool_uses":32,"duration_ms":62177},"uuid":"0be17dc3-dc16-4894-8da9-5014ef95584d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
117 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018tRUCG4ktEU4K2N14E6dmQ","type":"tool_result","content":[{"type":"text","text":"Excellent ! J'ai maintenant tous les éléments. Composons la cartographie finale.\n\n## Cartographie Créa-Ka — Pipeline Apify & Architecture\n\n### 1. **Comment le pipeline appelle les acteurs Apify**\n\n**Fichier clé :** `/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py`\n\n**Modèle d'appel :**\n- Fonctions `run_actor()` (ligne 124) et `run_actor_sharded()` (ligne 130) encapsulent les calls Apify\n- Endpoint : `https://api.apify.com/v2/acts/{ACTOR_OWNER}~{name}/runs` (ligne 76, ACTOR_OWNER = `gorgeous_thistle`)\n- Config parallèle : `SHARD_SIZE=60` handles/run, `MAX_SHARDS=32` runs simultanés, `RUN_MEMORY_MB=2048`, `RUN_CONCURRENCY=10` interne (ligne 53–56)\n- Proxy résidentiel forcé : `useApifyProxy: True` + `apifyProxyGroups: [\"RESIDENTIAL\"]` (ligne 45)\n\n**Acteurs déployés (classes avec `source_id`, `actor`, `cap`, `metric_map`) :**\n| Plateforme | Actor | Cap | Signal Apify | Champs consommés |\n|---|---|---|---|---|\n| Instagram | `ka-instagram` | 1800 | `instagram-apify` | `followers`, `following`, `posts_count`, `category`, `is_business`, `business_category`, `highlight_reels`, `avg_likes`, `avg_comments`, `avg_video_views`, `engagement_rate_pct`, `posts_per_week`, `last_post_at`, `video_share_pct`, `top_post`, `recent_posts` |\n| TikTok | `ka-tiktok` | 1800 | `tiktok-apify` | `following`, `total_likes`, `videos_count`, `region`, `friends`, `is_seller`, `is_live_now`, `commerce_category`, `account_created_at`, `avg_views`, `avg_likes`, `avg_comments`, `avg_shares`, `engagement_rate_pct`, `top_video`, `recent_videos` |\n| X/Twitter | `ka-x` | 1800 | `x-apify` | `following`, `tweets_count`, `location`, `created_at`, `listed_count`, `account_age_days`, `avg_likes`, `avg_retweets`, `avg_replies`, `engagement_rate_pct`, `tweets_per_week`, `last_tweet_at`, `media_share_pct`, `top_hashtags`, `top_tweet`, `recent_tweets` |\n| YouTube | `ka-youtube` | 1800 | `youtube-apify` | `videos_count`, `country`, `keywords`, `channel_id`, `avg_views`, `top_video`, `last_video_published`, `recent_videos` |\n| Twitch | `ka-twitch` | 1200 | `twitch-apify` | `is_partner`, `is_affiliate`, `team`, `created_at`, `is_live_now`, `live_viewers`, `live_game`, `last_broadcast_at`, `last_broadcast_game`, `avg_video_views`, `recent_games` |\n| Kick | `ka-kick` | 600 | `kick-apify` | `is_live_now`, `live_viewers`, `subscription_enabled`, `recent_categories` |\n| Facebook | `ka-facebook` | 400 | `facebook-apify` | `category` |\n| Threads | `ka-threads` | 60 | `threads-apify` | `avg_likes`, `top_post`, `recent_posts` |\n| Snapchat | `ka-snapchat` | 60 | `snapchat-apify` | `category`, `has_story`, `has_spotlight`, `address`, `subcategory`, `publisher_type`, `story_snaps_count`, `lenses_count`, `spotlight_highlights_count` |\n| OnlyFans | `ka-onlyfans` | 100 | `onlyfans-apify` | `posts_count`, `photos_count`, `videos_count`, `likes`, `streams_count`, `subscribe_price_usd`, `is_free`, `location` |\n| Fansly | `ka-fansly` | 600 | `fansly-apify` | `posts_count`, `images_count`, `videos_count`, `likes`, `following`, `location` |\n| Patreon | `ka-patreon` | 400 | `patreon-apify` | `patrons`, `posts_count`, `is_monthly` |\n| Discord | `ka-discord` | 400 | `discord-apify` | `members`, `online`, `boosts`, `partnered`, `guild_id` |\n\n**Tampon d'enrichissement :** Chaque connecteur Apify stocke un horodatage propre : `{source_id}_at` dans `account.metrics` (ligne 179, re-visite selon `REVISIT_DAYS` = 7 j défaut)\n\n### 2. **Schéma SQLite (creaka/db.py, lignes 38–77)**\n\n```\nTABLE creators (\n id TEXT PRIMARY KEY, -- slug du nom (ex. \"simon-pierre\")\n display_name TEXT NOT NULL, -- nom public/scène\n status TEXT (active|inactive|opted_out), -- défaut 'active'\n is_minor INTEGER, -- 0/1 (régime restreint §15)\n region TEXT, -- 17 régions QC\n city TEXT,\n niches TEXT, -- CSV (ex. \"humour,gaming\")\n languages TEXT, -- CSV (\"fr,en\" ou \"bilingue\")\n creator_type TEXT, -- inféré (influenceur|youtubeur|streamer…)\n primary_platform TEXT, -- plateforme dominante\n audience_tier TEXT, -- nano|micro|macro|mega\n total_reach INTEGER, -- cumul des followers connus\n bio TEXT,\n first_seen TEXT (ISO-8601), -- arrivée première source\n last_seen TEXT (ISO-8601), -- dernière ré-agrégation\n updated_at TEXT (ISO-8601),\n doc TEXT NOT NULL -- JSON sérialisé complet (Creator dataclass)\n);\n\nTABLE accounts (\n platform TEXT PRIMARY KEY, -- \"instagram\", \"tiktok\", \"x\"…\n handle TEXT PRIMARY KEY, -- sans \"@\", minuscules\n creator_id TEXT REFERENCES creators(id), -- FK\n url TEXT NOT NULL, -- form canonique\n followers INTEGER,\n verified INTEGER (0/1/NULL),\n confidence REAL, -- signal (0.98 → 0.40)\n signal TEXT, -- \"profil_source\"|\"link_in_bio\"|\"cross_link\"…\n needs_review INTEGER (0/1), -- confidence < 0.50\n last_checked TEXT (ISO-8601), -- dernier enrichissement\n metrics TEXT -- JSON : {\"following\": 1234, \"posts\": 56, \"recent_posts\": […], \"instagram-apify_at\": \"2026-08-20T…\"}\n);\n\nTABLE sync_log (\n id INTEGER PRIMARY KEY AUTO,\n ts TEXT (ISO-8601),\n source TEXT, -- source_id du connecteur\n creators INTEGER, -- créateurs extraits ce passage\n accounts INTEGER, -- comptes rajoutés/mis à jour\n avg_confidence REAL, -- moyenne des confiances\n added INTEGER, updated INTEGER, -- +ajouts/+mises-à-jour\n errors INTEGER,\n seconds REAL, -- durée d'exécution\n alert TEXT -- si 0 créateurs retourné (source bloquée)\n);\n```\n\n**Indexes :** `idx_accounts_creator`, `idx_creators_status`, `idx_creators_tier`\n\n### 3. **Frontend & Serveur (creaka/web.py + frontend/src/)**\n\n**API FastAPI (port 8160 défaut) — routes publiques :**\n- `GET /api/creators` — recherche filtrable (q, niche, region, langue, plateforme, tier, sort, limit, offset) — ligne 63\n- `GET /api/creators/{cid}` — fiche créateur complète (ligne 73)\n- `GET /api/stats` — comptes par plateforme, tiers, niches, régions (ligne 81)\n- `GET /api/stats/dashboard` — tableau de bord analytique (période, sparklines, distributions) — ligne 89\n- `GET /api/stats/report` — rapport PDF Groupe-KA (5 modes : complet, synthèse, tendances, répartitions, données) — ligne 101\n- `GET /api/taxonomies` — listes (niches, plateformes, régions, tiers, langues) — ligne 122\n- `GET /api/sources` — registre des connecteurs — ligne 130\n- `POST /api/optout` — masquage immédiat créateur — ligne 146\n\n**Affichage fiche créateur (/createur/{id}) — champs visibles :**\n- Avatar (`.cavatar` img, 104×104 border-radius 14px, fallback gradient violet) — fallback si absent\n- Nom affiché (h1 UPPERCASE) + badge vérifié (@vbadge icon si `verified=true`)\n- Bio (`.cpage-bio`, 15px)\n- Reach total (b affichée, accents soft violet) — cumul des followers connus\n- Niches (chips CSS `.mchip`)\n- Région, langue, créateur-type\n- Plateformes trièes décroissantes par abonnés (dans `account.metrics`)\n - Handle, followers, URL canonique\n - Métriques plateforme-spécifiques affichées selon dispo (posts, likes, engagement…)\n - `recent_posts/videos/tweets` limité à 12 items (ligne 248)\n\n**Images/avatars :**\n- Source : `avatar_url` field dans `Creator` (hotlinkée, URL source directe, ex. image Instagram/Wikipedia)\n- CDN fallback : `unavatar.io` (preconnect meta ligne 26 frontend HTML)\n- Sans avatar : gradient fallback (`repeating-linear-gradient` violet)\n- Aucun proxy/stockage local — **hotlinked directement depuis source**\n\n**Serveur :**\n- **Framework :** FastAPI + Uvicorn\n- **Commande :** `python run.py serve [port]` (défaut 8160) — ligne 54 run.py\n- **Frontend statique :** servi depuis `/frontend/dist` — ligne 24 web.py\n- **Frontend tech :** SPA zéro build (vanilla HTML/CSS/JS, routage client-side)\n- **Thread-local DB :** connexion SQLite par thread FastAPI (line 37–45 web.py)\n\n### 4. **Métriques & Historisation**\n\n**Statut actuel :** ✓ Métriques **enrichies par plateforme** stockées en JSON dans `account.metrics`\n- Pas d'**historisation/snapshots** de séries temporelles pour l'instant\n- **sync_log** enregistre agrégats passages (créateurs, comptes, confiance moy., durée, erreurs) mais **pas** des snapshots d'audience par créateur/compte\n- Champ `last_checked` par compte (ISO-8601) indique fraîcheur enrichissement\n\n**Pour major upgrade :** Ajouter table `metrics_snapshots` ou `metrics_timeline` (creator_id, platform, ts, followers, engagement_rate, …) — voir ligne 69–76 sync_log pour modèle temporel existant.\n\n### 5. **Commandes run.py**\n\n```bash\npython run.py sync [source ...] # synchro découverte + enrichissement\npython run.py watch [heures] # boucle infinie (défaut 24 h)\npython run.py serve [port] # API + frontend (défaut 8160)\npython run.py optout <nom|plateforme:handle> # retrait immédiat\n```\n\n### 6. **Gotchas visibles**\n\n- **Deduplication stricte :** ne fusionne **jamais** sur le nom (homonymes) — seulement sur clé account (`platform:handle`) partagée (db.py:185–204)\n- **Opt-out persistant & irréversible :** status `opted_out` jamais ré-agrégé (db.py:221–227)\n- **Confiance < 0.50 → à vérifier :** compte exclu de fiche publique (identity.py:REVIEW_THRESHOLD) — affichage JSON dans `needs_review:1` mais pas servi publiquement\n- **Signal confidence mappé :** profil_source 0.98 > link_in_bio 0.95 > cross_link 0.90 > listes_medias 0.85 > handle_match 0.75 > mention 0.60 (identity.py)\n- **Avatar hotlinké :** aucun cache/stockage — URL source directe, peut rompre si suppression source\n- **Métrique platform-specific** : clé `recent_posts` Instagram limitée à 12 items (apify_social.py:248) — tronquée intentionnellement\n- **Tampon Apify caché** : `instagram-apify_at`, `tiktok-apify_at`, etc. dans metrics permet re-visite intelligente (7 j par défaut, évite re-paiement proxy)\n\nTous les fichiers clés identifiés avec numéros de lignes exacts pour votre major upgrade. Les colonnes exactes `accounts.metrics` et `creators.doc` vous permettront de décider schéma de nouvel enrichissement Apify."}]}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9a2adf45-a5ea-4d92-b8c0-ded208d942e7","timestamp":"2026-08-21T00:53:43.100Z","tool_use_result":{"status":"completed","prompt":"Explore le repo /Users/simon-pierreboucher/apps/crea-ka (thoroughness: very thorough). C'est un agrégateur de créateurs québécois. Je prépare un « major upgrade » des acteurs Apify (ka-instagram, ka-tiktok, ka-youtube, ka-twitch, ka-x, ka-kick, etc.) pour capturer plus d'infos/images, et l'ajout de métriques/stats par créateur. J'ai besoin d'une cartographie précise :\n\n1. Comment le pipeline appelle les acteurs Apify : quels fichiers (cherche APIFY, apify, acts, run-sync, dataset dans creaka/), quels inputs envoyés, quels champs des résultats sont consommés/stockés.\n2. Le schéma de la base SQLite (data/creaka.db) : tables, colonnes — surtout accounts/creators/metrics s'il y en a. Lis le code de création de schéma (creaka/db.py ou similaire).\n3. Ce que le frontend affiche sur une fiche créateur (frontend/ — page créateur, champs affichés, images/avatars, stats). Quel serveur sert le frontend (API, port, PM2 name si trouvable dans les scripts ou ecosystem).\n4. Comment les images/avatars sont gérées actuellement (stockées ? hotlinkées ? proxy ?).\n5. Y a-t-il déjà une notion de métriques historisées / snapshots d'audience ?\n6. run.py : quelles commandes existent (sync, enrich, etc.).\n\nRapporte avec chemins de fichiers + numéros de lignes clés, noms exacts des colonnes DB, champs Apify consommés par plateforme, et tout gotcha visible. Réponse structurée, max ~800 mots.","agentId":"a3fe10a2a5a9a7eb9","agentType":"Explore","content":[{"type":"text","text":"Excellent ! J'ai maintenant tous les éléments. Composons la cartographie finale.\n\n## Cartographie Créa-Ka — Pipeline Apify & Architecture\n\n### 1. **Comment le pipeline appelle les acteurs Apify**\n\n**Fichier clé :** `/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py`\n\n**Modèle d'appel :**\n- Fonctions `run_actor()` (ligne 124) et `run_actor_sharded()` (ligne 130) encapsulent les calls Apify\n- Endpoint : `https://api.apify.com/v2/acts/{ACTOR_OWNER}~{name}/runs` (ligne 76, ACTOR_OWNER = `gorgeous_thistle`)\n- Config parallèle : `SHARD_SIZE=60` handles/run, `MAX_SHARDS=32` runs simultanés, `RUN_MEMORY_MB=2048`, `RUN_CONCURRENCY=10` interne (ligne 53–56)\n- Proxy résidentiel forcé : `useApifyProxy: True` + `apifyProxyGroups: [\"RESIDENTIAL\"]` (ligne 45)\n\n**Acteurs déployés (classes avec `source_id`, `actor`, `cap`, `metric_map`) :**\n| Plateforme | Actor | Cap | Signal Apify | Champs consommés |\n|---|---|---|---|---|\n| Instagram | `ka-instagram` | 1800 | `instagram-apify` | `followers`, `following`, `posts_count`, `category`, `is_business`, `business_category`, `highlight_reels`, `avg_likes`, `avg_comments`, `avg_video_views`, `engagement_rate_pct`, `posts_per_week`, `last_post_at`, `video_share_pct`, `top_post`, `recent_posts` |\n| TikTok | `ka-tiktok` | 1800 | `tiktok-apify` | `following`, `total_likes`, `videos_count`, `region`, `friends`, `is_seller`, `is_live_now`, `commerce_category`, `account_created_at`, `avg_views`, `avg_likes`, `avg_comments`, `avg_shares`, `engagement_rate_pct`, `top_video`, `recent_videos` |\n| X/Twitter | `ka-x` | 1800 | `x-apify` | `following`, `tweets_count`, `location`, `created_at`, `listed_count`, `account_age_days`, `avg_likes`, `avg_retweets`, `avg_replies`, `engagement_rate_pct`, `tweets_per_week`, `last_tweet_at`, `media_share_pct`, `top_hashtags`, `top_tweet`, `recent_tweets` |\n| YouTube | `ka-youtube` | 1800 | `youtube-apify` | `videos_count`, `country`, `keywords`, `channel_id`, `avg_views`, `top_video`, `last_video_published`, `recent_videos` |\n| Twitch | `ka-twitch` | 1200 | `twitch-apify` | `is_partner`, `is_affiliate`, `team`, `created_at`, `is_live_now`, `live_viewers`, `live_game`, `last_broadcast_at`, `last_broadcast_game`, `avg_video_views`, `recent_games` |\n| Kick | `ka-kick` | 600 | `kick-apify` | `is_live_now`, `live_viewers`, `subscription_enabled`, `recent_categories` |\n| Facebook | `ka-facebook` | 400 | `facebook-apify` | `category` |\n| Threads | `ka-threads` | 60 | `threads-apify` | `avg_likes`, `top_post`, `recent_posts` |\n| Snapchat | `ka-snapchat` | 60 | `snapchat-apify` | `category`, `has_story`, `has_spotlight`, `address`, `subcategory`, `publisher_type`, `story_snaps_count`, `lenses_count`, `spotlight_highlights_count` |\n| OnlyFans | `ka-onlyfans` | 100 | `onlyfans-apify` | `posts_count`, `photos_count`, `videos_count`, `likes`, `streams_count`, `subscribe_price_usd`, `is_free`, `location` |\n| Fansly | `ka-fansly` | 600 | `fansly-apify` | `posts_count`, `images_count`, `videos_count`, `likes`, `following`, `location` |\n| Patreon | `ka-patreon` | 400 | `patreon-apify` | `patrons`, `posts_count`, `is_monthly` |\n| Discord | `ka-discord` | 400 | `discord-apify` | `members`, `online`, `boosts`, `partnered`, `guild_id` |\n\n**Tampon d'enrichissement :** Chaque connecteur Apify stocke un horodatage propre : `{source_id}_at` dans `account.metrics` (ligne 179, re-visite selon `REVISIT_DAYS` = 7 j défaut)\n\n### 2. **Schéma SQLite (creaka/db.py, lignes 38–77)**\n\n```\nTABLE creators (\n id TEXT PRIMARY KEY, -- slug du nom (ex. \"simon-pierre\")\n display_name TEXT NOT NULL, -- nom public/scène\n status TEXT (active|inactive|opted_out), -- défaut 'active'\n is_minor INTEGER, -- 0/1 (régime restreint §15)\n region TEXT, -- 17 régions QC\n city TEXT,\n niches TEXT, -- CSV (ex. \"humour,gaming\")\n languages TEXT, -- CSV (\"fr,en\" ou \"bilingue\")\n creator_type TEXT, -- inféré (influenceur|youtubeur|streamer…)\n primary_platform TEXT, -- plateforme dominante\n audience_tier TEXT, -- nano|micro|macro|mega\n total_reach INTEGER, -- cumul des followers connus\n bio TEXT,\n first_seen TEXT (ISO-8601), -- arrivée première source\n last_seen TEXT (ISO-8601), -- dernière ré-agrégation\n updated_at TEXT (ISO-8601),\n doc TEXT NOT NULL -- JSON sérialisé complet (Creator dataclass)\n);\n\nTABLE accounts (\n platform TEXT PRIMARY KEY, -- \"instagram\", \"tiktok\", \"x\"…\n handle TEXT PRIMARY KEY, -- sans \"@\", minuscules\n creator_id TEXT REFERENCES creators(id), -- FK\n url TEXT NOT NULL, -- form canonique\n followers INTEGER,\n verified INTEGER (0/1/NULL),\n confidence REAL, -- signal (0.98 → 0.40)\n signal TEXT, -- \"profil_source\"|\"link_in_bio\"|\"cross_link\"…\n needs_review INTEGER (0/1), -- confidence < 0.50\n last_checked TEXT (ISO-8601), -- dernier enrichissement\n metrics TEXT -- JSON : {\"following\": 1234, \"posts\": 56, \"recent_posts\": […], \"instagram-apify_at\": \"2026-08-20T…\"}\n);\n\nTABLE sync_log (\n id INTEGER PRIMARY KEY AUTO,\n ts TEXT (ISO-8601),\n source TEXT, -- source_id du connecteur\n creators INTEGER, -- créateurs extraits ce passage\n accounts INTEGER, -- comptes rajoutés/mis à jour\n avg_confidence REAL, -- moyenne des confiances\n added INTEGER, updated INTEGER, -- +ajouts/+mises-à-jour\n errors INTEGER,\n seconds REAL, -- durée d'exécution\n alert TEXT -- si 0 créateurs retourné (source bloquée)\n);\n```\n\n**Indexes :** `idx_accounts_creator`, `idx_creators_status`, `idx_creators_tier`\n\n### 3. **Frontend & Serveur (creaka/web.py + frontend/src/)**\n\n**API FastAPI (port 8160 défaut) — routes publiques :**\n- `GET /api/creators` — recherche filtrable (q, niche, region, langue, plateforme, tier, sort, limit, offset) — ligne 63\n- `GET /api/creators/{cid}` — fiche créateur complète (ligne 73)\n- `GET /api/stats` — comptes par plateforme, tiers, niches, régions (ligne 81)\n- `GET /api/stats/dashboard` — tableau de bord analytique (période, sparklines, distributions) — ligne 89\n- `GET /api/stats/report` — rapport PDF Groupe-KA (5 modes : complet, synthèse, tendances, répartitions, données) — ligne 101\n- `GET /api/taxonomies` — listes (niches, plateformes, régions, tiers, langues) — ligne 122\n- `GET /api/sources` — registre des connecteurs — ligne 130\n- `POST /api/optout` — masquage immédiat créateur — ligne 146\n\n**Affichage fiche créateur (/createur/{id}) — champs visibles :**\n- Avatar (`.cavatar` img, 104×104 border-radius 14px, fallback gradient violet) — fallback si absent\n- Nom affiché (h1 UPPERCASE) + badge vérifié (@vbadge icon si `verified=true`)\n- Bio (`.cpage-bio`, 15px)\n- Reach total (b affichée, accents soft violet) — cumul des followers connus\n- Niches (chips CSS `.mchip`)\n- Région, langue, créateur-type\n- Plateformes trièes décroissantes par abonnés (dans `account.metrics`)\n - Handle, followers, URL canonique\n - Métriques plateforme-spécifiques affichées selon dispo (posts, likes, engagement…)\n - `recent_posts/videos/tweets` limité à 12 items (ligne 248)\n\n**Images/avatars :**\n- Source : `avatar_url` field dans `Creator` (hotlinkée, URL source directe, ex. image Instagram/Wikipedia)\n- CDN fallback : `unavatar.io` (preconnect meta ligne 26 frontend HTML)\n- Sans avatar : gradient fallback (`repeating-linear-gradient` violet)\n- Aucun proxy/stockage local — **hotlinked directement depuis source**\n\n**Serveur :**\n- **Framework :** FastAPI + Uvicorn\n- **Commande :** `python run.py serve [port]` (défaut 8160) — ligne 54 run.py\n- **Frontend statique :** servi depuis `/frontend/dist` — ligne 24 web.py\n- **Frontend tech :** SPA zéro build (vanilla HTML/CSS/JS, routage client-side)\n- **Thread-local DB :** connexion SQLite par thread FastAPI (line 37–45 web.py)\n\n### 4. **Métriques & Historisation**\n\n**Statut actuel :** ✓ Métriques **enrichies par plateforme** stockées en JSON dans `account.metrics`\n- Pas d'**historisation/snapshots** de séries temporelles pour l'instant\n- **sync_log** enregistre agrégats passages (créateurs, comptes, confiance moy., durée, erreurs) mais **pas** des snapshots d'audience par créateur/compte\n- Champ `last_checked` par compte (ISO-8601) indique fraîcheur enrichissement\n\n**Pour major upgrade :** Ajouter table `metrics_snapshots` ou `metrics_timeline` (creator_id, platform, ts, followers, engagement_rate, …) — voir ligne 69–76 sync_log pour modèle temporel existant.\n\n### 5. **Commandes run.py**\n\n```bash\npython run.py sync [source ...] # synchro découverte + enrichissement\npython run.py watch [heures] # boucle infinie (défaut 24 h)\npython run.py serve [port] # API + frontend (défaut 8160)\npython run.py optout <nom|plateforme:handle> # retrait immédiat\n```\n\n### 6. **Gotchas visibles**\n\n- **Deduplication stricte :** ne fusionne **jamais** sur le nom (homonymes) — seulement sur clé account (`platform:handle`) partagée (db.py:185–204)\n- **Opt-out persistant & irréversible :** status `opted_out` jamais ré-agrégé (db.py:221–227)\n- **Confiance < 0.50 → à vérifier :** compte exclu de fiche publique (identity.py:REVIEW_THRESHOLD) — affichage JSON dans `needs_review:1` mais pas servi publiquement\n- **Signal confidence mappé :** profil_source 0.98 > link_in_bio 0.95 > cross_link 0.90 > listes_medias 0.85 > handle_match 0.75 > mention 0.60 (identity.py)\n- **Avatar hotlinké :** aucun cache/stockage — URL source directe, peut rompre si suppression source\n- **Métrique platform-specific** : clé `recent_posts` Instagram limitée à 12 items (apify_social.py:248) — tronquée intentionnellement\n- **Tampon Apify caché** : `instagram-apify_at`, `tiktok-apify_at`, etc. dans metrics permet re-visite intelligente (7 j par défaut, évite re-paiement proxy)\n\nTous les fichiers clés identifiés avec numéros de lignes exacts pour votre major upgrade. Les colonnes exactes `accounts.metrics` et `creators.doc` vous permettront de décider schéma de nouvel enrichissement Apify."}],"totalDurationMs":62179,"totalTokens":57061,"totalToolUseCount":32,"usage":{"input_tokens":7,"cache_creation_input_tokens":1558,"cache_read_input_tokens":51964,"output_tokens":3532,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":1558},"inference_geo":"","iterations":[{"input_tokens":7,"output_tokens":3532,"cache_read_input_tokens":51964,"cache_creation_input_tokens":1558,"cache_creation":{"ephemeral_5m_input_tokens":1558,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"toolStats":{"readCount":10,"searchCount":1,"bashCount":21,"editFileCount":0,"linesAdded":0,"linesRemoved":0,"otherToolCount":0}}}} |
|
118 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ff00d2d0-424c-4d5a-9dd7-87a6018a5a7e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
119 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEypC4Hw24H1TMkTdU3Y","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzAcKpAEIERgCKkADlzXdiYHT4dM79tkoDnRoDhNDVdJzxTI4BM4CvAQWdmGUzjFaTuGHHQPuWQHWqOpvJnr+GgpC30ftYRQsyGQ8Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGiw57UBhIMsJz9ehx/tx2eM6TbGgzzdw4AMmJBv/ASMt0iMKaJdsH3/V86fdIOv+T7P9AkRHhvB08teCbcXNx0dV7ukENOnkjKh2B8KgM9OF4FvirUBSJ0y1eQTo5P6rQTxdrOJENxyFGiX6n/vvhERogiobnbEpY9VYV3rLaf+hT5rk4qq5dyxL7pjdm5bj2ruioZJERsYDDlQTWMn2L4nVnAPryLYeJOr5luswFFORMFjDWZoxR/YzD50TeWFxVyzT/SLxI/7keWacIlOPtwMpzYhWM7jTOHh/k03j5tI3rrVdGdCmLhPvSBM8IXt2vFtGhoY01oCjPLRZTaP24DD01L5yyZXxO937NqienKf7VxNzJWnmH15SlEmnO5N9UHRHu1htVbQT/FhJqyk9jJaJhu/NWPBQllcwtHiaAyPgx3a9zTCX6KsEtEXTxxlvjQYVDqpwYxTweTaYZe8jfv8ydn3IOz4+t37b71LIHNRZrD0PFL56fSNeImWeSdRFPBDAgzjN6uIcHKlv589KlkWK0bSz95NolyviM/qXe5kndYrTeI6KqpCgVVzJ1ajtiqhM5tcHnU41R01lXog45pfiOkfJZRpwM1V4KOaBwKUrSVwB7R832W1PMZ1OPBcvvvWg54hVUVCx40FV4kep08hluJtVrWCudkVcDsP3/qR8y/sGO20p4HjL/BAs1ymTbRE4/n6td0XHsGssCOeWtC0L4vDEkbq8myFpe+rSV2mNhBMNaGFFD5IGwLgqPG44I8/ko/XitCvdT407QHXN2NM8n+5sWOKbwOUFtGPgp9uX7u3Ze89zH0F63nwTWDb9gF59aaozHjDS1JmC8RY411LUJw1dj6lVfPi4+7CilOXf6OpKNyYT9T5L90cTZESw5SGG5LL0VJlpY4KwwuuSVTg7GJoLkvP/FfVKLOUrB5Gmj3LUht8FHw69W+VMJQCFu+ZIBFdbn4aXDuou6lUXFnXK0Z/7Xbrgt9I7ct5qw1C0hKqOaB3+0fL0l2u3O/BxBdzplxlrbU/w9EF/g9bi+orcERTWyFZGGjg+iwbpY1bIx7EZxDNQrU1hQYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6007,"cache_read_input_tokens":46782,"cache_creation":{"ephemeral_5m_input_tokens":6007,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"cf041a07-adae-4b7e-9047-a15dca7f34e6"}} |
|
120 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEypC4Hw24H1TMkTdU3Y","type":"message","role":"assistant","content":[{"type":"text","text":"La base est claire. Maintenant je dois localiser les sources des 13 acteurs Apify (elles ne semblent pas sur ce nœud — je vais les tirer de la plateforme avec `apify pull`)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6007,"cache_read_input_tokens":46782,"cache_creation":{"ephemeral_5m_input_tokens":6007,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a5862bab-a570-4d9a-84dd-cfc50d38f585"}} |
|
121 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEypC4Hw24H1TMkTdU3Y","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0166CJijW5c97sRhsTsu7qQH","name":"Bash","input":{"command":"source ~/.claude/.env 2>/dev/null; curl -s \"https://api.apify.com/v2/acts?my=1&limit=50\" -H \"Authorization: Bearer $APIFY_TOKEN\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)['data']['items']\nfor a in d: print(a['id'], a['name'], a.get('stats',{}).get('totalRuns'))\"","description":"List Apify actors on the account"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6007,"cache_read_input_tokens":46782,"cache_creation":{"ephemeral_5m_input_tokens":6007,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"41e17c7d-dfaa-4e57-833a-f128b85a8dd3"}} |
|
122 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0166CJijW5c97sRhsTsu7qQH","type":"tool_result","content":"UMjMYceVVxkE2ravb ka-x 35\n6gcVyRGKdutQvKehc ka-threads 4\nKBZOgVi5pojB0p3U7 ka-snapchat 8\nzkoaq24DyOao4diJh ka-facebook 4\ngdvZToUte8fxa972h ka-tiktok 14\nrXXfTakUVVDbLQZoA ka-instagram 90\nA4w6VWbgRkgfLPVkJ ka-onlyfans 7\n84UzNtZzgEwJEHy0u ka-fansly 3\nqlwAB98hOIsv3984s ka-kick 6\naj5CiXs4OKOTzszQP ka-youtube 243\ngifOKDQiEdtq4d8c8 ka-twitch 24\n5uRKpcBDnoumIPe20 ka-discord 7\noWhOMabwWiVXKiljn ka-patreon 7","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5b7968f9-67c1-4ab1-9022-7ef131c54910","timestamp":"2026-08-21T00:53:55.784Z","tool_use_result":{"stdout":"UMjMYceVVxkE2ravb ka-x 35\n6gcVyRGKdutQvKehc ka-threads 4\nKBZOgVi5pojB0p3U7 ka-snapchat 8\nzkoaq24DyOao4diJh ka-facebook 4\ngdvZToUte8fxa972h ka-tiktok 14\nrXXfTakUVVDbLQZoA ka-instagram 90\nA4w6VWbgRkgfLPVkJ ka-onlyfans 7\n84UzNtZzgEwJEHy0u ka-fansly 3\nqlwAB98hOIsv3984s ka-kick 6\naj5CiXs4OKOTzszQP ka-youtube 243\ngifOKDQiEdtq4d8c8 ka-twitch 24\n5uRKpcBDnoumIPe20 ka-discord 7\noWhOMabwWiVXKiljn ka-patreon 7","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
123 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEypC4Hw24H1TMkTdU3Y","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Jvc8Qz3s4QPSgevmWxXEMq","name":"Grep","input":{"pattern":"actor|apify","path":"/Users/simon-pierreboucher/apps/crea-ka/scripts","output_mode":"files_with_matches","-i":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6007,"cache_read_input_tokens":46782,"cache_creation":{"ephemeral_5m_input_tokens":6007,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d5863eba-2c21-442b-9725-b8e46fad3651"}} |
|
124 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Jvc8Qz3s4QPSgevmWxXEMq","type":"tool_result","content":"Found 1 file\nscripts/gen_connector_docs.py"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"30f8cef7-5d72-4288-bb1b-323c8c9af70e","timestamp":"2026-08-21T00:53:57.020Z","tool_use_result":{"mode":"files_with_matches","filenames":["scripts/gen_connector_docs.py"],"numFiles":1}}} |
|
125 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"1700b3d6-4c0d-46dd-907e-1f916b648627","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
126 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyqDTA7TTHVSG9m5B6m","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzxsKpAEIERgCKkBF7qxzsMg6/nq4fnowNSibZVtAQ+oQnLP/kG6/h2PkN2RtpzQkJ3MP1G0GXIyOn/egQTUGGEUysPgTMDflLVqLMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG9w57UBhIMLq+n3nZpExK+IymJGgxiQid6IsVsRO9JnfIiMGbWWGqzPbxERFFRo8Hw+CwsgvF7/6FDzI0k6d/Za5PPyr0Z7cSag4Rrg21hsnM7ByrXGQJ8m/wvqTNZMmxnJwOdh3fwL/wvY/7liemTI9sZqGOZLLwAmbsGbuTwJnwaX+7gM0syi+00rRvEl1AaIGP1bLBWog8oqwg8O0LXwK//PSqBMOgu6CEt5aE3jRVJdwwy4+t6OIl1QhqMhKxYMPZkEoqqhDmcYOskA2VlI99WvlIeBlkQvAdoHee3419PVBMGYA5CTgoZveFMhXWdGB3tTSZgiaR+dImWiBZlBgYNaOAPFtyxme84wbkBfwpcyWo0S0NySy7N+5EoPHQeiohYTjZJxauPiK5bwnHVMKP6eOtUPVh6fakBdlcZOoJpevpJRVipiLnQ5L5tvnDJP2RVlRNlwCc3PqiT/hBMDLKwmsQ+NLZcAS9xka7zdKXhWF4D3EXLiXugN5nY7WE2Dh0LZ7Nxv3RW2hiBS053KuvfVhJbBi9mNO/W1lKdJVFFn/mO5+auABhoz9qshUkhIZ5NadY4EqXGQJsJXVK0utrJsSshiZ/+qX4H6N7k/8o7MuQQqO3Dtezi/StMhP7G8miMMlkQIZZc//HWu6fDyrFolChXE4uuCYjycnbUyi3C8uTRtgc6cn8cR+rkHRy9pBIvwG/73Sob4Af06ljDX4qSxSWmWuFK1pYiUCB2whG2ohG9NWQV2jxVB2eXxKRnh1qWoVU9HXFwqb7tphqeVfp+l1bHACPrBJl8yIzknfIMCWBCnI0HevpdjBRIJ4LGJsRk1oC60blp1c3WoNEIrbmjrJr/N8MUyr+GGLJdyJtXG1JmFhQT4nW2/KTUBzsfqjV7te5g6+5QeholFAY4WYoEn3gfLpXpusJR+Pm3QdhlN7zAO9z5R3VkdasPr0FzfKRL9T9m4RypfUbooAYBgEE40REkisv7udQXq2Xj9RIViKM5l0yUSxcYl5u5mkyayLsFvKPoiLwdBgWecDYG+w3iPmqAxLzxsqB8jprpyiHF99a7TbYV2EMdg7UJ/spuJ3/1mjFadbEyyhfwKBnDbIj7v4w0uFUKdMSVIMK6NVSXnIGN3LjS6EXvtNFP/mwn6dRq+c3k7HCBg11niGZTB5EMsoiARgALvVqxmnYTLTQlLrQecA+wTdTtAp5+sMJrtuHt39PkPdQGQj1nPtCJO/WH4z1yPeMGWRCnNAAmaWELJGjibLsc2iW0XKN7L7SA5POSQwiLireQQLH/C2gVEUvsNXK9/5ocQwYIUWbjexdr4Qk+p5BjkRnYiiP6aFoUQaR42UZ8XZkH/SLmjifAaAPLu3Yx6GNt5ZfbapuQuOfVS05G23i8Ji9SqPzkODdbZFLxYgSzRiQ9di3alP9/bdBvxyG/274wthWI1hXZHVZyCiEILc1yZdPX4muZs8maD5A/pVJGXM3k813KO1Q/mFjtYiTYrM2kCCcz/gLT4J6bwvk/I1B1szruGoZ6Qh0+afk/rC/MBV4Cg13/A8OCnqkSKs9V6XqNdQTJv82Gk45sl73OWs5qX2yTLTJq56YILK/+PZw3hXKYz9vajTEw8U4ZDaiG6qc4mv5Mrk4w5XkkVzPLUKSli+Lv1elhecSiaFy0WM07oZL3B749aXW/m3IbyeEWrBG2azAWq6DTA10fvCdAtwQzwZdmz1A7F1kKqj/Wbf5YmBtot3YJeFf2BKXlwlsK763QXq07Z7h9Gy56+3+aJ6vwdUOSKp+vVuIpjYNLQ00LwK7eI0t1jKzRAnXCwqFHIBmvIu4x5d35e9R//CAa3Kmz9HNl/GbZo4+kQcpYsSSpTbbH/KE+2tTwpi6Lu+MOKupAOBYYicx7ZaO2d0joNG6Z260Elw3diCiElzbsnojI67hD/VnRdjGKunSsDUlSkUitx9ou5b65dZ41HER2gNgggSyiHOOibA2VXsGCTxMFfl42rnAM5sCDvWZkOx8V5FG3QbyaZxzSoUgsUmlGA89P93XJ9QtGPrnjo5ssYqBjQh49pkT6+oBoR4TCqbtaurLk9dSCSy0sc9DksLOW/4V2z17YK27cAUTmd5wRIXbXJhur3dqlf+K+Iv7aq7vdIB0G1HPARZbVLBoQt87t8vHk91LUMHtuIN6NJBTfCaPr9Tm7XNcV5q4oEhb8ogNNXd/ws4L8VF2a4bdXwvJEzEHpAd+Lz9TMtU/5bcvKBGmXP4059A/xHePLTgAjDDv34JxWotlBH+WwG6Rm3IZhmGXw6letrIMqiH2LfklE8dHXYQLq6RpQnZF+khR6Z6+A+5aXO5jy+uFuD5ajyj1O6UjTISdz2aDbvYz6iXXozZsRFSr+svf5cBnzeoBFW04wgDNRUpvAPG0EptvXhJ3cwAgCs3dD6SL0RxJEq6gtNK3emwK/Fu4jeh5bhvfum55hMfy8KBwEAHBoaGdcbSRQmylWvdSYydzCeWVehi3zVSV/bZ1gTMvhiTmaysgmlhgM8n4pl4amfp4Aotu5dJywKPbI6HQEgXXNld1nOfcc2BJq3oGhEDsWl6atCYIz1I4E8Vc1LCs9zn0WEewXeKSUGXvIlpXKqZpgDv3yGpe0CAoAPVN/rEgpq0iSpViK5kOr1w3VrnYQYXB+OZa0gOW7FFDQqRuRRE8l9BOPBIVJKocOys+a9CzGPG77N96mynxasALoh0Cs5g7rIQxmfXGpFLUEQin+ipO4Fg6G1wK3T9we6O7Zl4L1PprXWTzxpJmyJGrm1V1tDdWBFGw85ssjFozLSdBaL2MS4gSHDeuiYFeCtLsrVShG+/O9uYOlYqeqwRGHxJvnbHTwUkeVOfUVQ1gqCmpegpcKuP5QC/QDh7bKfiq4wE3YzO+OOpUqsSOPNvwsFhKXRQPd99dfaFhr1ySsv1d01E7pL+8pLPhCcwmV+ScGkxJvV84eoNQyRobFx58MbYjwlkUe11KYHeH9dmreXjiQyHjTTXwo1iIVMY0LuaKjq9X9kxwzR3K8k/5lYvBmSkRc4DWNxcAx7ASZ827VENgJ54a9k6/2vJeqvDioo8xADGd3RRQmRlQobnanNOWbME5jJLRns7PhPTvUAu9F04bSyWpFum9h9E27oTpF+21JQVUXGg53q44FkI6keUrBoisBkwl68Pl1HMhVWPRwdhf/orw0ipjWwqSUBCu0uwid4hdtqip8fEkU3STFByXA7SZpGY/NFyvTAqld/NGwF5Wymsy0VO4K9leeS524tidOsxQEJVGUAXXjfuJsbhN4M0blQg8zD03bpu81CCOGg1smD0wXkKcUW+NNvBvG5U+EBMYoDHuDnuZPCo5Y6TGWLxFG3xI6CU7GfoR7fl2iPSRHuA/Rmu+DgBRZO4sdLWrSW4ZSKm/WSqNwpap8OEAw2v5Qg+Hw4RZVYK+IAnSLInVDuv4NL0o2u7lAQZwrDFAOHuwxVzWGenvNFpJjeMkRYtCci4DXBBdRp19h9xFIBz3FUEcvAiYPER0rvA7WAd1iDzFTBbxUtWZ3kABfIORNu7L72iUBxpM2Pyswy+jj2JahUb8Hs5C3oUrDufxKRZBlGyokTY0Eee7Foc0jfD00C/Np/SIuD0giNN0iHqlSFtMogZbv86vmV6ykB4+WQUJUFXaTds1iwzf7NjIAdvcmbq9VpSMm6VrSUCEQEPQR9kFLQ9u1iEDydcm3l5W7Mbq4TkYJhCIdGYqgXBcZfNgxusagEbG/l9F3uPdGcNhkOY3TckMNqKF9dHBzzv7hNBKZWiuOR3XaFcAid6T8jJdzBsvu+ghdhKSJDzjvkA/4MeCjd1v3wOKUAP50XnuaFecd0PIQCEIdVZTwaaB4KehzIK18My6WoUJcgtDhK+JtAn0j61WpjVhzBJa6b0y2/k+Uy466qsIfVkNkyOvw7UfclEHRrFho9gnUXH541nbTIhip3EJdtwkbsBIO7mrnMXPu3Di2D4J6nbPJiqS42WFRsJiqIqzL4WkVdirYeoIXq74FkiCXn1d30Ofyx5MkABwyU0Lc1if2nYNn3Hx0i0UFkt9cK1yjKajjANtltJmouxDaYWiqItO8eAhsM1C6q+APO7jYk0uRdBKB4joM+lzZaIXhL6SrQertuIMhEBuyKPDzg3Cpx4fAp7YTVgjWWUqpu+N8fVffk8IQCVQhHaAGQxsL4Vqa4uhA+zkyCk4E3rIXf3vkY4Y6YPiGtqOS5febBBhIQyVeEmRcx+7qQsMfKan/0pGU884iT06BkUkcK2caMbrvKEBoUWnBCK5sDNKoAVE3/6VKUnfr1/GL8R/iznWLgIHd7+93yMX4yrmL30HwKMhs2Gm634KnRU7iwqC8MzyGYc6Exv6BhMtbNDVuMQsUxyhcEE1WNfUy48ljFssCbAAdHWeELaW09aDLs+A8PTagTwq2MkV91qXyKRTZDsUARZ5sY5jzOWwoDZjYs85H9e0UADUBj63c+68aESwg+CJ3GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1088,"cache_read_input_tokens":52789,"cache_creation":{"ephemeral_5m_input_tokens":1088,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fb493522-7341-44ea-b9d7-d4b6e2fcfc59"}} |
|
127 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyqDTA7TTHVSG9m5B6m","type":"message","role":"assistant","content":[{"type":"text","text":"Les 13 acteurs sont sur la plateforme mais leurs sources ne sont pas sur ce nœud. Je vais les tirer avec `apify pull`, puis lire le pipeline. Je monte un plan de travail."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1088,"cache_read_input_tokens":52789,"cache_creation":{"ephemeral_5m_input_tokens":1088,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0b15fc40-bc51-4584-b7fe-cbabf32b25f3"}} |
|
128 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyqDTA7TTHVSG9m5B6m","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01U8EzZ3JrwSQzDQk8VUrLm3","name":"ToolSearch","input":{"query":"select:TodoWrite","max_results":1},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1088,"cache_read_input_tokens":52789,"cache_creation":{"ephemeral_5m_input_tokens":1088,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fe1389d1-9349-4df8-8c6c-663e3bc7b3ab"}} |
|
129 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01U8EzZ3JrwSQzDQk8VUrLm3","content":[{"type":"tool_reference","tool_name":"TodoWrite"}]}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1012f895-01c1-4732-a75c-2f8524aadf74","timestamp":"2026-08-21T00:54:22.694Z","tool_use_result":{"matches":["TodoWrite"],"query":"select:TodoWrite","total_deferred_tools":17}}} |
|
130 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyqDTA7TTHVSG9m5B6m","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E3Lfvr2vhRvsxR18B7M3kA","name":"Bash","input":{"command":"find /tmp ~/Desktop -maxdepth 4 -name \"actor.json\" 2>/dev/null | head; ls /tmp | head -30","description":"Check /tmp for existing actor sources"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1088,"cache_read_input_tokens":52789,"cache_creation":{"ephemeral_5m_input_tokens":1088,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"494c29ee-db95-4622-b68e-e9ec7e7485d9"}} |
|
131 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E3Lfvr2vhRvsxR18B7M3kA","type":"tool_result","content":"adisq.html\nadminka-mcp-0f1cc8c8757cceaaa11482607400919a.json\nadminka-mcp-34646ab52748ed5c65ea4bdd112e4936.json\nadminka-mcp-51ac5eb9c9fb659b97582823c8e93917.json\nadminka-mcp-57b101109e089b5cee6b2f9b6c03c82c.json\nadminka-mcp-5ade769f568640eede95574b963cf8de.json\nadminka-mcp-97c8fd26b2428511cf3573904e8f03a9.json\nadminka-mcp-a732683127c614963bac1baf2dac4a7d.json\nadminka-mcp-d39077d8cc2752b3c003a7d57da4acfe.json\nadminka-mcp-e2cf54deb2738be315d7ccfdc8a0bd56.json\nadminka-mcp-e9c20de72df973f344d015e3549541bd.json\nadminka-pid-0f1cc8c8757cceaaa11482607400919a\nadminka-pid-2db050c59ad25cf42e6816aca02a16d7\nadminka-pid-34646ab52748ed5c65ea4bdd112e4936\nadminka-pid-51ac5eb9c9fb659b97582823c8e93917\nadminka-pid-57b101109e089b5cee6b2f9b6c03c82c\nadminka-pid-5ade769f568640eede95574b963cf8de\nadminka-pid-5ba9cee9ebc1ef17f9e1cb98dbef7932\nadminka-pid-5e36bc8cf88bd75272d37bd1b609046d\nadminka-pid-6d25014a4fe7cbeb942cf33331069389\nadminka-pid-770786ea6436a5abfd2c397c1818a967\nadminka-pid-914ea250abed1acfccecf4a85f83eb26\nadminka-pid-9515a92d42185a1c300c5e968e99a380\nadminka-pid-97c8fd26b2428511cf3573904e8f03a9\nadminka-pid-a732683127c614963bac1baf2dac4a7d\nadminka-pid-bc0a2cd19003d65367d66a8d0a004340\nadminka-pid-cdcaeb1444c6ee37c74b05c6ec1d9c7b\nadminka-pid-d39077d8cc2752b3c003a7d57da4acfe\nadminka-pid-e2cf54deb2738be315d7ccfdc8a0bd56\nadminka-pid-e9c20de72df973f344d015e3549541bd","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2715ebcf-4667-4128-a490-122971fceb94","timestamp":"2026-08-21T00:54:23.322Z","tool_use_result":{"stdout":"adisq.html\nadminka-mcp-0f1cc8c8757cceaaa11482607400919a.json\nadminka-mcp-34646ab52748ed5c65ea4bdd112e4936.json\nadminka-mcp-51ac5eb9c9fb659b97582823c8e93917.json\nadminka-mcp-57b101109e089b5cee6b2f9b6c03c82c.json\nadminka-mcp-5ade769f568640eede95574b963cf8de.json\nadminka-mcp-97c8fd26b2428511cf3573904e8f03a9.json\nadminka-mcp-a732683127c614963bac1baf2dac4a7d.json\nadminka-mcp-d39077d8cc2752b3c003a7d57da4acfe.json\nadminka-mcp-e2cf54deb2738be315d7ccfdc8a0bd56.json\nadminka-mcp-e9c20de72df973f344d015e3549541bd.json\nadminka-pid-0f1cc8c8757cceaaa11482607400919a\nadminka-pid-2db050c59ad25cf42e6816aca02a16d7\nadminka-pid-34646ab52748ed5c65ea4bdd112e4936\nadminka-pid-51ac5eb9c9fb659b97582823c8e93917\nadminka-pid-57b101109e089b5cee6b2f9b6c03c82c\nadminka-pid-5ade769f568640eede95574b963cf8de\nadminka-pid-5ba9cee9ebc1ef17f9e1cb98dbef7932\nadminka-pid-5e36bc8cf88bd75272d37bd1b609046d\nadminka-pid-6d25014a4fe7cbeb942cf33331069389\nadminka-pid-770786ea6436a5abfd2c397c1818a967\nadminka-pid-914ea250abed1acfccecf4a85f83eb26\nadminka-pid-9515a92d42185a1c300c5e968e99a380\nadminka-pid-97c8fd26b2428511cf3573904e8f03a9\nadminka-pid-a732683127c614963bac1baf2dac4a7d\nadminka-pid-bc0a2cd19003d65367d66a8d0a004340\nadminka-pid-cdcaeb1444c6ee37c74b05c6ec1d9c7b\nadminka-pid-d39077d8cc2752b3c003a7d57da4acfe\nadminka-pid-e2cf54deb2738be315d7ccfdc8a0bd56\nadminka-pid-e9c20de72df973f344d015e3549541bd","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
132 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2f442d38-c0eb-48f1-87ce-ad218e3c0233","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
133 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEysA6ZQpj7iSoKEzrRT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NcuqJDyoNYBfWtFCxgSrEE","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"in_progress","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"pending","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (instagram, tiktok, youtube, twitch, x) : + d'infos et d'images","status":"pending","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, facebook, threads, snapchat, patreon, discord, fansly, onlyfans)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) pour consommer les nouveaux champs + avatars/bannières","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter métriques/stats par créateur (snapshots historisés + stats calculées)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (galerie d'images, stats, bannières)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":59958,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":59958,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bec9e003-c366-480b-b662-4e0d240d309f"}} |
|
134 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NcuqJDyoNYBfWtFCxgSrEE","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ecaf82e9-8699-47a3-9162-660a223dfa88","timestamp":"2026-08-21T00:54:34.378Z","tool_use_result":{"oldTodos":[],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"in_progress","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"pending","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (instagram, tiktok, youtube, twitch, x) : + d'infos et d'images","status":"pending","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, facebook, threads, snapchat, patreon, discord, fansly, onlyfans)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) pour consommer les nouveaux champs + avatars/bannières","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter métriques/stats par créateur (snapshots historisés + stats calculées)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (galerie d'images, stats, bannières)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
135 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"45a90801-18ea-4487-9bd2-670703173d64","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
136 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEysyLBgVut4FmCdsZs7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DCArgnmm8frr9p42cu2Egi","name":"Bash","input":{"command":"mkdir -p ~/apps/crea-ka/actors && cd ~/apps/crea-ka/actors && source ~/.claude/.env 2>/dev/null; for a in ka-instagram ka-tiktok ka-youtube ka-twitch ka-x ka-kick ka-facebook ka-threads ka-snapchat ka-patreon ka-discord ka-fansly ka-onlyfans; do apify pull gorgeous_thistle/$a >/dev/null 2>&1 & done; wait; ls","description":"Pull all 13 Apify actor sources in parallel","timeout":180000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":816,"cache_read_input_tokens":59958,"cache_creation":{"ephemeral_5m_input_tokens":816,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"34c928be-b0a7-4c18-b01c-e977379ce2d4"}} |
|
137 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DCArgnmm8frr9p42cu2Egi","type":"tool_result","content":"ka-discord\nka-facebook\nka-fansly\nka-instagram\nka-kick\nka-onlyfans\nka-patreon\nka-snapchat\nka-threads\nka-tiktok\nka-twitch\nka-x\nka-youtube","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7493e369-9dfb-4335-b561-f7c58f7ab5e4","timestamp":"2026-08-21T00:54:41.851Z","tool_use_result":{"stdout":"ka-discord\nka-facebook\nka-fansly\nka-instagram\nka-kick\nka-onlyfans\nka-patreon\nka-snapchat\nka-threads\nka-tiktok\nka-twitch\nka-x\nka-youtube","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
138 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"07d3e7a6-1434-4a8a-a948-9ef25863851e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
139 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEytX9NAXYYEjJhXc7NB","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISkwMKpAEIERgCKkCcJybzQxrcazXBB2u1sNpsWPiR0IkYQ+vtrizKW2JEHoqWSjbFSVME3kHtoq2zOkva3DQiCzF4EoLK2DP3LZ+eMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHWw57UBhIMK+3aQ2HXkzPpcLgaGgwhsUPlv8RjlnuzuOMiMA0GXllai/4g0PGv5uGQ/qWq8x7sJm15tU3FcS6bGlrCvFRKm4AglhzLnPjg3tukYiqbASnn57oqk/WI4UMnN3NWEzRFzxpjHwpwbgiiFscTkPDcjxgXEyx3yFuhJHE90ZbmNYd1hAx9XEaJRiaZ/DSJb2vbN5VqH7PtmTvW+qhMiDpCliXzUQq5R8TIFFwfjgcwA0Uo7w7ZYm8YhvSIMFJ+CF/qyCHm6HjiBdFTJXZZJH9YZCWo21QIrNYXIwB7EEak2RNmai9+3s8jS6TQGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":60774,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"86cb90ab-67fb-4dc0-98ed-566c4ffffd00"}} |
|
140 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEytX9NAXYYEjJhXc7NB","type":"message","role":"assistant","content":[{"type":"text","text":"Les 13 acteurs sont tirés dans `actors/`. Je lis maintenant le pipeline et les acteurs pour cerner exactement ce qui manque."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":60774,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"cfa6b3b9-f408-48f9-83bf-c78cb3ea52ec"}} |
|
141 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEytX9NAXYYEjJhXc7NB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XYb9Sumt5HL5VgvBZFSr3T","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && for d in */; do echo \"== $d\"; find $d -type f -not -path \"*/.git/*\" | head -12; done; echo; wc -l */src/main.py 2>/dev/null || true","description":"Show structure and sizes of actor sources"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":377,"cache_read_input_tokens":60774,"cache_creation":{"ephemeral_5m_input_tokens":377,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"acab9b8d-0a7c-469b-a7e6-ece465afb28e"}} |
|
142 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XYb9Sumt5HL5VgvBZFSr3T","type":"tool_result","content":"== ka-discord/\nka-discord/.actor/input_schema.json\nka-discord/.actor/Dockerfile\nka-discord/.actor/actor.json\nka-discord/requirements.txt\nka-discord/src/net.py\nka-discord/src/main.py\nka-discord/src/__main__.py\n== ka-facebook/\nka-facebook/.actor/input_schema.json\nka-facebook/.actor/Dockerfile\nka-facebook/.actor/actor.json\nka-facebook/requirements.txt\nka-facebook/src/net.py\nka-facebook/src/__pycache__/main.cpython-314.pyc\nka-facebook/src/__pycache__/net.cpython-314.pyc\nka-facebook/src/__pycache__/__main__.cpython-314.pyc\nka-facebook/src/main.py\nka-facebook/src/__main__.py\n== ka-fansly/\nka-fansly/.actor/input_schema.json\nka-fansly/.actor/Dockerfile\nka-fansly/.actor/actor.json\nka-fansly/requirements.txt\nka-fansly/src/net.py\nka-fansly/src/__pycache__/main.cpython-314.pyc\nka-fansly/src/__pycache__/net.cpython-314.pyc\nka-fansly/src/main.py\nka-fansly/src/__main__.py\n== ka-instagram/\nka-instagram/.actor/input_schema.json\nka-instagram/.actor/Dockerfile\nka-instagram/.actor/actor.json\nka-instagram/requirements.txt\nka-instagram/src/net.py\nka-instagram/src/__pycache__/main.cpython-314.pyc\nka-instagram/src/__pycache__/net.cpython-314.pyc\nka-instagram/src/__pycache__/__main__.cpython-314.pyc\nka-instagram/src/main.py\nka-instagram/src/__main__.py\n== ka-kick/\nka-kick/.actor/input_schema.json\nka-kick/.actor/Dockerfile\nka-kick/.actor/actor.json\nka-kick/requirements.txt\nka-kick/src/net.py\nka-kick/src/__pycache__/main.cpython-314.pyc\nka-kick/src/__pycache__/net.cpython-314.pyc\nka-kick/src/main.py\nka-kick/src/__main__.py\n== ka-onlyfans/\nka-onlyfans/.actor/input_schema.json\nka-onlyfans/.actor/Dockerfile\nka-onlyfans/.actor/actor.json\nka-onlyfans/requirements.txt\nka-onlyfans/src/net.py\nka-onlyfans/src/__pycache__/main.cpython-314.pyc\nka-onlyfans/src/__pycache__/net.cpython-314.pyc\nka-onlyfans/src/main.py\nka-onlyfans/src/__main__.py\n== ka-patreon/\nka-patreon/.actor/input_schema.json\nka-patreon/.actor/Dockerfile\nka-patreon/.actor/actor.json\nka-patreon/requirements.txt\nka-patreon/src/net.py\nka-patreon/src/main.py\nka-patreon/src/__main__.py\n== ka-snapchat/\nka-snapchat/.actor/input_schema.json\nka-snapchat/.actor/Dockerfile\nka-snapchat/.actor/actor.json\nka-snapchat/requirements.txt\nka-snapchat/src/net.py\nka-snapchat/src/__pycache__/main.cpython-314.pyc\nka-snapchat/src/__pycache__/net.cpython-314.pyc\nka-snapchat/src/__pycache__/__main__.cpython-314.pyc\nka-snapchat/src/main.py\nka-snapchat/src/__main__.py\n== ka-threads/\nka-threads/.actor/input_schema.json\nka-threads/.actor/Dockerfile\nka-threads/.actor/actor.json\nka-threads/requirements.txt\nka-threads/src/net.py\nka-threads/src/__pycache__/main.cpython-314.pyc\nka-threads/src/__pycache__/net.cpython-314.pyc\nka-threads/src/__pycache__/__main__.cpython-314.pyc\nka-threads/src/main.py\nka-threads/src/__main__.py\n== ka-tiktok/\nka-tiktok/.actor/input_schema.json\nka-tiktok/.actor/Dockerfile\nka-tiktok/.actor/actor.json\nka-tiktok/requirements.txt\nka-tiktok/src/net.py\nka-tiktok/src/__pycache__/main.cpython-314.pyc\nka-tiktok/src/__pycache__/net.cpython-314.pyc\nka-tiktok/src/__pycache__/__main__.cpython-314.pyc\nka-tiktok/src/main.py\nka-tiktok/src/__main__.py\n== ka-twitch/\nka-twitch/.actor/input_schema.json\nka-twitch/.actor/Dockerfile\nka-twitch/.actor/actor.json\nka-twitch/requirements.txt\nka-twitch/src/net.py\nka-twitch/src/__pycache__/main.cpython-314.pyc\nka-twitch/src/__pycache__/net.cpython-314.pyc\nka-twitch/src/main.py\nka-twitch/src/__main__.py\n== ka-x/\nka-x/.actor/input_schema.json\nka-x/.actor/Dockerfile\nka-x/.actor/actor.json\nka-x/requirements.txt\nka-x/src/net.py\nka-x/src/__pycache__/main.cpython-314.pyc\nka-x/src/__pycache__/net.cpython-314.pyc\nka-x/src/__pycache__/__main__.cpython-314.pyc\nka-x/src/main.py\nka-x/src/__main__.py\n== ka-youtube/\nka-youtube/.actor/input_schema.json\nka-youtube/.actor/Dockerfile\nka-youtube/.actor/actor.json\nka-youtube/requirements.txt\nka-youtube/src/net.py\nka-youtube/src/__pycache__/main.cpython-314.pyc\nka-youtube/src/__pycache__/net.cpython-314.pyc\nka-youtube/src/main.py\nka-youtube/src/__main__.py\n\n 78 ka-discord/src/main.py\n 121 ka-facebook/src/main.py\n 93 ka-fansly/src/main.py\n 243 ka-instagram/src/main.py\n 96 ka-kick/src/main.py\n 112 ka-onlyfans/src/main.py\n 90 ka-patreon/src/main.py\n 104 ka-snapchat/src/main.py\n 99 ka-threads/src/main.py\n 189 ka-tiktok/src/main.py\n 141 ka-twitch/src/main.py\n 215 ka-x/src/main.py\n 239 ka-youtube/src/main.py\n 1820 total","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ad6ef92f-fc75-44ca-81b9-7b35b8edeb67","timestamp":"2026-08-21T00:54:47.959Z","tool_use_result":{"stdout":"== ka-discord/\nka-discord/.actor/input_schema.json\nka-discord/.actor/Dockerfile\nka-discord/.actor/actor.json\nka-discord/requirements.txt\nka-discord/src/net.py\nka-discord/src/main.py\nka-discord/src/__main__.py\n== ka-facebook/\nka-facebook/.actor/input_schema.json\nka-facebook/.actor/Dockerfile\nka-facebook/.actor/actor.json\nka-facebook/requirements.txt\nka-facebook/src/net.py\nka-facebook/src/__pycache__/main.cpython-314.pyc\nka-facebook/src/__pycache__/net.cpython-314.pyc\nka-facebook/src/__pycache__/__main__.cpython-314.pyc\nka-facebook/src/main.py\nka-facebook/src/__main__.py\n== ka-fansly/\nka-fansly/.actor/input_schema.json\nka-fansly/.actor/Dockerfile\nka-fansly/.actor/actor.json\nka-fansly/requirements.txt\nka-fansly/src/net.py\nka-fansly/src/__pycache__/main.cpython-314.pyc\nka-fansly/src/__pycache__/net.cpython-314.pyc\nka-fansly/src/main.py\nka-fansly/src/__main__.py\n== ka-instagram/\nka-instagram/.actor/input_schema.json\nka-instagram/.actor/Dockerfile\nka-instagram/.actor/actor.json\nka-instagram/requirements.txt\nka-instagram/src/net.py\nka-instagram/src/__pycache__/main.cpython-314.pyc\nka-instagram/src/__pycache__/net.cpython-314.pyc\nka-instagram/src/__pycache__/__main__.cpython-314.pyc\nka-instagram/src/main.py\nka-instagram/src/__main__.py\n== ka-kick/\nka-kick/.actor/input_schema.json\nka-kick/.actor/Dockerfile\nka-kick/.actor/actor.json\nka-kick/requirements.txt\nka-kick/src/net.py\nka-kick/src/__pycache__/main.cpython-314.pyc\nka-kick/src/__pycache__/net.cpython-314.pyc\nka-kick/src/main.py\nka-kick/src/__main__.py\n== ka-onlyfans/\nka-onlyfans/.actor/input_schema.json\nka-onlyfans/.actor/Dockerfile\nka-onlyfans/.actor/actor.json\nka-onlyfans/requirements.txt\nka-onlyfans/src/net.py\nka-onlyfans/src/__pycache__/main.cpython-314.pyc\nka-onlyfans/src/__pycache__/net.cpython-314.pyc\nka-onlyfans/src/main.py\nka-onlyfans/src/__main__.py\n== ka-patreon/\nka-patreon/.actor/input_schema.json\nka-patreon/.actor/Dockerfile\nka-patreon/.actor/actor.json\nka-patreon/requirements.txt\nka-patreon/src/net.py\nka-patreon/src/main.py\nka-patreon/src/__main__.py\n== ka-snapchat/\nka-snapchat/.actor/input_schema.json\nka-snapchat/.actor/Dockerfile\nka-snapchat/.actor/actor.json\nka-snapchat/requirements.txt\nka-snapchat/src/net.py\nka-snapchat/src/__pycache__/main.cpython-314.pyc\nka-snapchat/src/__pycache__/net.cpython-314.pyc\nka-snapchat/src/__pycache__/__main__.cpython-314.pyc\nka-snapchat/src/main.py\nka-snapchat/src/__main__.py\n== ka-threads/\nka-threads/.actor/input_schema.json\nka-threads/.actor/Dockerfile\nka-threads/.actor/actor.json\nka-threads/requirements.txt\nka-threads/src/net.py\nka-threads/src/__pycache__/main.cpython-314.pyc\nka-threads/src/__pycache__/net.cpython-314.pyc\nka-threads/src/__pycache__/__main__.cpython-314.pyc\nka-threads/src/main.py\nka-threads/src/__main__.py\n== ka-tiktok/\nka-tiktok/.actor/input_schema.json\nka-tiktok/.actor/Dockerfile\nka-tiktok/.actor/actor.json\nka-tiktok/requirements.txt\nka-tiktok/src/net.py\nka-tiktok/src/__pycache__/main.cpython-314.pyc\nka-tiktok/src/__pycache__/net.cpython-314.pyc\nka-tiktok/src/__pycache__/__main__.cpython-314.pyc\nka-tiktok/src/main.py\nka-tiktok/src/__main__.py\n== ka-twitch/\nka-twitch/.actor/input_schema.json\nka-twitch/.actor/Dockerfile\nka-twitch/.actor/actor.json\nka-twitch/requirements.txt\nka-twitch/src/net.py\nka-twitch/src/__pycache__/main.cpython-314.pyc\nka-twitch/src/__pycache__/net.cpython-314.pyc\nka-twitch/src/main.py\nka-twitch/src/__main__.py\n== ka-x/\nka-x/.actor/input_schema.json\nka-x/.actor/Dockerfile\nka-x/.actor/actor.json\nka-x/requirements.txt\nka-x/src/net.py\nka-x/src/__pycache__/main.cpython-314.pyc\nka-x/src/__pycache__/net.cpython-314.pyc\nka-x/src/__pycache__/__main__.cpython-314.pyc\nka-x/src/main.py\nka-x/src/__main__.py\n== ka-youtube/\nka-youtube/.actor/input_schema.json\nka-youtube/.actor/Dockerfile\nka-youtube/.actor/actor.json\nka-youtube/requirements.txt\nka-youtube/src/net.py\nka-youtube/src/__pycache__/main.cpython-314.pyc\nka-youtube/src/__pycache__/net.cpython-314.pyc\nka-youtube/src/main.py\nka-youtube/src/__main__.py\n\n 78 ka-discord/src/main.py\n 121 ka-facebook/src/main.py\n 93 ka-fansly/src/main.py\n 243 ka-instagram/src/main.py\n 96 ka-kick/src/main.py\n 112 ka-onlyfans/src/main.py\n 90 ka-patreon/src/main.py\n 104 ka-snapchat/src/main.py\n 99 ka-threads/src/main.py\n 189 ka-tiktok/src/main.py\n 141 ka-twitch/src/main.py\n 215 ka-x/src/main.py\n 239 ka-youtube/src/main.py\n 1820 total","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
143 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a56b717e-d79b-47cd-b292-b0e8a69a742e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
144 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEytyDWmfBPf6MLCmftP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LCj3jT6XCtU5jqraFHeWSV","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2748,"cache_read_input_tokens":61151,"cache_creation":{"ephemeral_5m_input_tokens":2748,"ephemeral_1h_input_tokens":0},"output_tokens":14,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"34e53112-e511-4b27-8c5c-ad31060dcc95"}} |
|
145 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LCj3jT6XCtU5jqraFHeWSV","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-instagram)\n4\t# Desc: Profils Instagram publics ULTRA DÉTAILLÉS via l'endpoint web officiel\n5\t# web_profile_info (JSON du web public) : identité complète, compteurs,\n6\t# 12 derniers posts (likes/commentaires/vues/légende/musique/lieu/\n7\t# co-auteurs/épinglés), agrégats d'engagement et de cadence, profils\n8\t# reliés — + recherche d'utilisateurs (topsearch) en découverte.\n9\t# ==============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport asyncio\n13\timport json\n14\timport urllib.parse\n15\tfrom datetime import datetime, timezone\n16\t\n17\tfrom apify import Actor\n18\t\n19\tfrom .net import Fetcher\n20\t\n21\tPROFILE_API = (\"https://i.instagram.com/api/v1/users/web_profile_info/\"\n22\t \"?username={u}\")\n23\tSEARCH_API = (\"https://www.instagram.com/web/search/topsearch/\"\n24\t \"?context=blended&query={q}&count=30\")\n25\tSEARCH_API_ALT = (\"https://i.instagram.com/api/v1/web/search/topsearch/\"\n26\t \"?context=blended&query={q}&count=30\")\n27\tHDRS = {\"x-ig-app-id\": \"936619743392459\", \"Accept\": \"application/json\"}\n28\t\n29\t\n30\tdef _iso(ts) -> str | None:\n31\t try:\n32\t return datetime.fromtimestamp(int(ts), tz=timezone.utc) \\\n33\t .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n34\t except (TypeError, ValueError, OSError):\n35\t return None\n36\t\n37\t\n38\tdef _avg(vals: list) -> int | None:\n39\t vals = [v for v in vals if isinstance(v, (int, float))]\n40\t return round(sum(vals) / len(vals)) if vals else None\n41\t\n42\t\n43\tdef parse_post(node: dict) -> dict:\n44\t \"\"\"Nœud média du fil → post détaillé (métriques, musique, lieu, épinglé).\"\"\"\n45\t cap = (node.get(\"edge_media_to_caption\") or {}).get(\"edges\") or []\n46\t music = node.get(\"clips_music_attribution_info\") or {}\n47\t dims = node.get(\"dimensions\") or {}\n48\t return {\n49\t \"id\": node.get(\"id\"),\n50\t \"shortcode\": node.get(\"shortcode\"),\n51\t \"url\": f\"https://www.instagram.com/p/{node.get('shortcode')}/\",\n52\t \"type\": (\"reel\" if node.get(\"product_type\") == \"clips\"\n53\t else \"video\" if node.get(\"is_video\") else\n54\t \"carousel\" if node.get(\"edge_sidecar_to_children\")\n55\t else \"image\"),\n56\t \"caption\": ((cap[0].get(\"node\") or {}).get(\"text\", \"\")\n57\t if cap else \"\")[:500],\n58\t \"likes\": (node.get(\"edge_liked_by\") or {}).get(\"count\"),\n59\t \"comments\": (node.get(\"edge_media_to_comment\") or {}).get(\"count\"),\n60\t \"video_views\": node.get(\"video_view_count\"),\n61\t \"video_duration_s\": node.get(\"video_duration\"),\n62\t \"timestamp\": node.get(\"taken_at_timestamp\"),\n63\t \"posted_at\": _iso(node.get(\"taken_at_timestamp\")),\n64\t \"display_url\": node.get(\"display_url\"),\n65\t \"thumbnail_url\": node.get(\"thumbnail_src\"),\n66\t \"width\": dims.get(\"width\"),\n67\t \"height\": dims.get(\"height\"),\n68\t \"is_pinned\": bool(node.get(\"pinned_for_users\")),\n69\t \"comments_disabled\": node.get(\"comments_disabled\"),\n70\t \"location\": (node.get(\"location\") or {}).get(\"name\"),\n71\t \"coauthors\": [c.get(\"username\")\n72\t for c in (node.get(\"coauthor_producers\") or [])\n73\t if c.get(\"username\")],\n74\t \"music\": ({\"artist\": music.get(\"artist_name\"),\n75\t \"song\": music.get(\"song_name\"),\n76\t \"uses_original_audio\": music.get(\"uses_original_audio\")}\n77\t if music.get(\"song_name\") or music.get(\"artist_name\")\n78\t else None),\n79\t \"accessibility_caption\": (node.get(\"accessibility_caption\")\n80\t or \"\")[:200] or None,\n81\t \"tagged_users\": [((t.get(\"node\") or {}).get(\"user\") or {})\n82\t .get(\"username\")\n83\t for t in ((node.get(\"edge_media_to_tagged_user\")\n84\t or {}).get(\"edges\") or [])][:10],\n85\t }\n86\t\n87\t\n88\tdef parse_user(user: dict) -> dict:\n89\t \"\"\"Objet user web_profile_info → enregistrement riche standardisé.\"\"\"\n90\t posts = [parse_post((e.get(\"node\") or {}))\n91\t for e in ((user.get(\"edge_owner_to_timeline_media\") or {})\n92\t .get(\"edges\") or [])]\n93\t followers = (user.get(\"edge_followed_by\") or {}).get(\"count\")\n94\t avg_likes = _avg([p[\"likes\"] for p in posts])\n95\t avg_comments = _avg([p[\"comments\"] for p in posts])\n96\t videos = [p for p in posts if p[\"type\"] in (\"video\", \"reel\")]\n97\t avg_video_views = _avg([p[\"video_views\"] for p in videos])\n98\t engagement = None\n99\t if followers and avg_likes is not None:\n100\t engagement = round(\n101\t (avg_likes + (avg_comments or 0)) / followers * 100, 3)\n102\t timestamps = sorted(p[\"timestamp\"] for p in posts\n103\t if isinstance(p[\"timestamp\"], int))\n104\t posts_per_week = None\n105\t if len(timestamps) >= 3 and timestamps[-1] > timestamps[0]:\n106\t span_weeks = (timestamps[-1] - timestamps[0]) / 604_800\n107\t posts_per_week = round((len(timestamps) - 1) / span_weeks, 2)\n108\t top_post = max((p for p in posts if isinstance(p[\"likes\"], int)),\n109\t key=lambda p: p[\"likes\"], default=None)\n110\t related = []\n111\t for edge in ((user.get(\"edge_related_profiles\") or {}).get(\"edges\") or []):\n112\t n = edge.get(\"node\") or {}\n113\t if n.get(\"username\"):\n114\t related.append({\"username\": n[\"username\"],\n115\t \"full_name\": n.get(\"full_name\"),\n116\t \"is_verified\": n.get(\"is_verified\"),\n117\t \"is_private\": n.get(\"is_private\")})\n118\t return {\n119\t \"kind\": \"profile\",\n120\t \"platform\": \"instagram\",\n121\t \"found\": True,\n122\t \"id\": user.get(\"id\"),\n123\t \"username\": user.get(\"username\"),\n124\t \"full_name\": user.get(\"full_name\"),\n125\t \"biography\": user.get(\"biography\"),\n126\t \"pronouns\": user.get(\"pronouns\") or None,\n127\t \"external_url\": user.get(\"external_url\"),\n128\t \"bio_links\": [b.get(\"url\") for b in (user.get(\"bio_links\") or [])\n129\t if b.get(\"url\")],\n130\t \"followers\": followers,\n131\t \"following\": (user.get(\"edge_follow\") or {}).get(\"count\"),\n132\t \"posts_count\": (user.get(\"edge_owner_to_timeline_media\")\n133\t or {}).get(\"count\"),\n134\t \"highlight_reels\": user.get(\"highlight_reel_count\"),\n135\t \"is_verified\": user.get(\"is_verified\"),\n136\t \"is_private\": user.get(\"is_private\"),\n137\t \"is_business\": user.get(\"is_business_account\"),\n138\t \"is_professional\": user.get(\"is_professional_account\"),\n139\t \"category\": user.get(\"category_name\"),\n140\t \"business_category\": user.get(\"business_category_name\"),\n141\t \"business_address\": user.get(\"business_address_json\"),\n142\t \"has_clips\": user.get(\"has_clips\"),\n143\t \"has_channel\": user.get(\"has_channel\"),\n144\t \"has_ar_effects\": user.get(\"has_ar_effects\"),\n145\t \"recently_joined\": user.get(\"is_joined_recently\"),\n146\t \"avatar\": user.get(\"profile_pic_url_hd\")\n147\t or user.get(\"profile_pic_url\"),\n148\t # agrégats calculés sur les 12 derniers posts\n149\t \"avg_likes\": avg_likes,\n150\t \"avg_comments\": avg_comments,\n151\t \"avg_video_views\": avg_video_views,\n152\t \"engagement_rate_pct\": engagement,\n153\t \"posts_per_week\": posts_per_week,\n154\t \"last_post_at\": _iso(timestamps[-1]) if timestamps else None,\n155\t \"video_share_pct\": (round(len(videos) / len(posts) * 100)\n156\t if posts else None),\n157\t \"top_post\": ({\"url\": top_post[\"url\"], \"likes\": top_post[\"likes\"],\n158\t \"comments\": top_post[\"comments\"],\n159\t \"type\": top_post[\"type\"]} if top_post else None),\n160\t \"recent_posts\": posts,\n161\t \"related_profiles\": related,\n162\t }\n163\t\n164\t\n165\tasync def main() -> None:\n166\t async with Actor:\n167\t inp = await Actor.get_input() or {}\n168\t usernames = [u.strip().lstrip(\"@\").lower()\n169\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n170\t queries = [q.strip() for q in (inp.get(\"queries\") or [])\n171\t if q and q.strip()]\n172\t proxy = await Actor.create_proxy_configuration(\n173\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n174\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n175\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n176\t\n177\t async def one_profile(u: str) -> None:\n178\t async with sem:\n179\t miss = {\"kind\": \"profile\", \"platform\": \"instagram\",\n180\t \"username\": u, \"found\": False}\n181\t try:\n182\t resp = await fetcher.get(PROFILE_API.format(u=u),\n183\t headers=HDRS, session_id=u)\n184\t except Exception as exc:\n185\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n186\t return\n187\t if resp.status_code == 404:\n188\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n189\t return\n190\t user = None\n191\t if resp.status_code == 200:\n192\t try:\n193\t user = ((json.loads(resp.text).get(\"data\") or {})\n194\t .get(\"user\"))\n195\t except Exception:\n196\t user = None\n197\t if not user:\n198\t await Actor.push_data(\n199\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n200\t return\n201\t await Actor.push_data(parse_user(user))\n202\t\n203\t async def one_search(q: str) -> None:\n204\t async with sem:\n205\t data = {}\n206\t for endpoint in (SEARCH_API, SEARCH_API_ALT):\n207\t u2 = endpoint.format(q=urllib.parse.quote(q))\n208\t try:\n209\t resp = await fetcher.get(u2, headers=HDRS,\n210\t session_id=f\"srch{q[:4]}\")\n211\t except Exception as exc:\n212\t Actor.log.warning(f\"recherche '{q}' : {exc}\")\n213\t continue\n214\t if resp.status_code == 200:\n215\t try:\n216\t data = json.loads(resp.text)\n217\t except Exception:\n218\t data = {}\n219\t if data.get(\"users\"):\n220\t break\n221\t if not data.get(\"users\"):\n222\t Actor.log.warning(\n223\t f\"recherche '{q}' : 0 user \"\n224\t f\"(http {resp.status_code}) {resp.text[:200]!r}\")\n225\t for item in data.get(\"users\") or []:\n226\t u = item.get(\"user\") or {}\n227\t if not u.get(\"username\") or u.get(\"is_private\"):\n228\t continue\n229\t await Actor.push_data({\n230\t \"kind\": \"search_user\",\n231\t \"platform\": \"instagram\",\n232\t \"query\": q,\n233\t \"username\": u.get(\"username\"),\n234\t \"full_name\": u.get(\"full_name\"),\n235\t \"is_verified\": u.get(\"is_verified\"),\n236\t \"followers\": u.get(\"follower_count\"),\n237\t \"avatar\": u.get(\"profile_pic_url\"),\n238\t })\n239\t\n240\t await asyncio.gather(*[one_profile(u) for u in usernames],\n241\t *[one_search(q) for q in queries])\n242\t Actor.log.info(f\"terminé : {len(usernames)} profils, \"\n243\t f\"{len(queries)} recherches\")\n244\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6a1f8bed-4ee3-4570-b5db-164580e92b1a","timestamp":"2026-08-21T00:54:51.811Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-instagram)\n# Desc: Profils Instagram publics ULTRA DÉTAILLÉS via l'endpoint web officiel\n# web_profile_info (JSON du web public) : identité complète, compteurs,\n# 12 derniers posts (likes/commentaires/vues/légende/musique/lieu/\n# co-auteurs/épinglés), agrégats d'engagement et de cadence, profils\n# reliés — + recherche d'utilisateurs (topsearch) en découverte.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport urllib.parse\nfrom datetime import datetime, timezone\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_API = (\"https://i.instagram.com/api/v1/users/web_profile_info/\"\n \"?username={u}\")\nSEARCH_API = (\"https://www.instagram.com/web/search/topsearch/\"\n \"?context=blended&query={q}&count=30\")\nSEARCH_API_ALT = (\"https://i.instagram.com/api/v1/web/search/topsearch/\"\n \"?context=blended&query={q}&count=30\")\nHDRS = {\"x-ig-app-id\": \"936619743392459\", \"Accept\": \"application/json\"}\n\n\ndef _iso(ts) -> str | None:\n try:\n return datetime.fromtimestamp(int(ts), tz=timezone.utc) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n except (TypeError, ValueError, OSError):\n return None\n\n\ndef _avg(vals: list) -> int | None:\n vals = [v for v in vals if isinstance(v, (int, float))]\n return round(sum(vals) / len(vals)) if vals else None\n\n\ndef parse_post(node: dict) -> dict:\n \"\"\"Nœud média du fil → post détaillé (métriques, musique, lieu, épinglé).\"\"\"\n cap = (node.get(\"edge_media_to_caption\") or {}).get(\"edges\") or []\n music = node.get(\"clips_music_attribution_info\") or {}\n dims = node.get(\"dimensions\") or {}\n return {\n \"id\": node.get(\"id\"),\n \"shortcode\": node.get(\"shortcode\"),\n \"url\": f\"https://www.instagram.com/p/{node.get('shortcode')}/\",\n \"type\": (\"reel\" if node.get(\"product_type\") == \"clips\"\n else \"video\" if node.get(\"is_video\") else\n \"carousel\" if node.get(\"edge_sidecar_to_children\")\n else \"image\"),\n \"caption\": ((cap[0].get(\"node\") or {}).get(\"text\", \"\")\n if cap else \"\")[:500],\n \"likes\": (node.get(\"edge_liked_by\") or {}).get(\"count\"),\n \"comments\": (node.get(\"edge_media_to_comment\") or {}).get(\"count\"),\n \"video_views\": node.get(\"video_view_count\"),\n \"video_duration_s\": node.get(\"video_duration\"),\n \"timestamp\": node.get(\"taken_at_timestamp\"),\n \"posted_at\": _iso(node.get(\"taken_at_timestamp\")),\n \"display_url\": node.get(\"display_url\"),\n \"thumbnail_url\": node.get(\"thumbnail_src\"),\n \"width\": dims.get(\"width\"),\n \"height\": dims.get(\"height\"),\n \"is_pinned\": bool(node.get(\"pinned_for_users\")),\n \"comments_disabled\": node.get(\"comments_disabled\"),\n \"location\": (node.get(\"location\") or {}).get(\"name\"),\n \"coauthors\": [c.get(\"username\")\n for c in (node.get(\"coauthor_producers\") or [])\n if c.get(\"username\")],\n \"music\": ({\"artist\": music.get(\"artist_name\"),\n \"song\": music.get(\"song_name\"),\n \"uses_original_audio\": music.get(\"uses_original_audio\")}\n if music.get(\"song_name\") or music.get(\"artist_name\")\n else None),\n \"accessibility_caption\": (node.get(\"accessibility_caption\")\n or \"\")[:200] or None,\n \"tagged_users\": [((t.get(\"node\") or {}).get(\"user\") or {})\n .get(\"username\")\n for t in ((node.get(\"edge_media_to_tagged_user\")\n or {}).get(\"edges\") or [])][:10],\n }\n\n\ndef parse_user(user: dict) -> dict:\n \"\"\"Objet user web_profile_info → enregistrement riche standardisé.\"\"\"\n posts = [parse_post((e.get(\"node\") or {}))\n for e in ((user.get(\"edge_owner_to_timeline_media\") or {})\n .get(\"edges\") or [])]\n followers = (user.get(\"edge_followed_by\") or {}).get(\"count\")\n avg_likes = _avg([p[\"likes\"] for p in posts])\n avg_comments = _avg([p[\"comments\"] for p in posts])\n videos = [p for p in posts if p[\"type\"] in (\"video\", \"reel\")]\n avg_video_views = _avg([p[\"video_views\"] for p in videos])\n engagement = None\n if followers and avg_likes is not None:\n engagement = round(\n (avg_likes + (avg_comments or 0)) / followers * 100, 3)\n timestamps = sorted(p[\"timestamp\"] for p in posts\n if isinstance(p[\"timestamp\"], int))\n posts_per_week = None\n if len(timestamps) >= 3 and timestamps[-1] > timestamps[0]:\n span_weeks = (timestamps[-1] - timestamps[0]) / 604_800\n posts_per_week = round((len(timestamps) - 1) / span_weeks, 2)\n top_post = max((p for p in posts if isinstance(p[\"likes\"], int)),\n key=lambda p: p[\"likes\"], default=None)\n related = []\n for edge in ((user.get(\"edge_related_profiles\") or {}).get(\"edges\") or []):\n n = edge.get(\"node\") or {}\n if n.get(\"username\"):\n related.append({\"username\": n[\"username\"],\n \"full_name\": n.get(\"full_name\"),\n \"is_verified\": n.get(\"is_verified\"),\n \"is_private\": n.get(\"is_private\")})\n return {\n \"kind\": \"profile\",\n \"platform\": \"instagram\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"username\"),\n \"full_name\": user.get(\"full_name\"),\n \"biography\": user.get(\"biography\"),\n \"pronouns\": user.get(\"pronouns\") or None,\n \"external_url\": user.get(\"external_url\"),\n \"bio_links\": [b.get(\"url\") for b in (user.get(\"bio_links\") or [])\n if b.get(\"url\")],\n \"followers\": followers,\n \"following\": (user.get(\"edge_follow\") or {}).get(\"count\"),\n \"posts_count\": (user.get(\"edge_owner_to_timeline_media\")\n or {}).get(\"count\"),\n \"highlight_reels\": user.get(\"highlight_reel_count\"),\n \"is_verified\": user.get(\"is_verified\"),\n \"is_private\": user.get(\"is_private\"),\n \"is_business\": user.get(\"is_business_account\"),\n \"is_professional\": user.get(\"is_professional_account\"),\n \"category\": user.get(\"category_name\"),\n \"business_category\": user.get(\"business_category_name\"),\n \"business_address\": user.get(\"business_address_json\"),\n \"has_clips\": user.get(\"has_clips\"),\n \"has_channel\": user.get(\"has_channel\"),\n \"has_ar_effects\": user.get(\"has_ar_effects\"),\n \"recently_joined\": user.get(\"is_joined_recently\"),\n \"avatar\": user.get(\"profile_pic_url_hd\")\n or user.get(\"profile_pic_url\"),\n # agrégats calculés sur les 12 derniers posts\n \"avg_likes\": avg_likes,\n \"avg_comments\": avg_comments,\n \"avg_video_views\": avg_video_views,\n \"engagement_rate_pct\": engagement,\n \"posts_per_week\": posts_per_week,\n \"last_post_at\": _iso(timestamps[-1]) if timestamps else None,\n \"video_share_pct\": (round(len(videos) / len(posts) * 100)\n if posts else None),\n \"top_post\": ({\"url\": top_post[\"url\"], \"likes\": top_post[\"likes\"],\n \"comments\": top_post[\"comments\"],\n \"type\": top_post[\"type\"]} if top_post else None),\n \"recent_posts\": posts,\n \"related_profiles\": related,\n }\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n queries = [q.strip() for q in (inp.get(\"queries\") or [])\n if q and q.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one_profile(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"instagram\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PROFILE_API.format(u=u),\n headers=HDRS, session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n user = None\n if resp.status_code == 200:\n try:\n user = ((json.loads(resp.text).get(\"data\") or {})\n .get(\"user\"))\n except Exception:\n user = None\n if not user:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n await Actor.push_data(parse_user(user))\n\n async def one_search(q: str) -> None:\n async with sem:\n data = {}\n for endpoint in (SEARCH_API, SEARCH_API_ALT):\n u2 = endpoint.format(q=urllib.parse.quote(q))\n try:\n resp = await fetcher.get(u2, headers=HDRS,\n session_id=f\"srch{q[:4]}\")\n except Exception as exc:\n Actor.log.warning(f\"recherche '{q}' : {exc}\")\n continue\n if resp.status_code == 200:\n try:\n data = json.loads(resp.text)\n except Exception:\n data = {}\n if data.get(\"users\"):\n break\n if not data.get(\"users\"):\n Actor.log.warning(\n f\"recherche '{q}' : 0 user \"\n f\"(http {resp.status_code}) {resp.text[:200]!r}\")\n for item in data.get(\"users\") or []:\n u = item.get(\"user\") or {}\n if not u.get(\"username\") or u.get(\"is_private\"):\n continue\n await Actor.push_data({\n \"kind\": \"search_user\",\n \"platform\": \"instagram\",\n \"query\": q,\n \"username\": u.get(\"username\"),\n \"full_name\": u.get(\"full_name\"),\n \"is_verified\": u.get(\"is_verified\"),\n \"followers\": u.get(\"follower_count\"),\n \"avatar\": u.get(\"profile_pic_url\"),\n })\n\n await asyncio.gather(*[one_profile(u) for u in usernames],\n *[one_search(q) for q in queries])\n Actor.log.info(f\"terminé : {len(usernames)} profils, \"\n f\"{len(queries)} recherches\")\n","numLines":244,"startLine":1,"totalLines":244}}}} |
|
146 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEytyDWmfBPf6MLCmftP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VBQqAMAro4EDAdAxoE9nWK","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/net.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2748,"cache_read_input_tokens":61151,"cache_creation":{"ephemeral_5m_input_tokens":2748,"ephemeral_1h_input_tokens":0},"output_tokens":14,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2dabec10-6de2-4ae9-8076-f75b788119b0"}} |
|
147 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VBQqAMAro4EDAdAxoE9nWK","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/net.py\n4\t# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +\n5\t# proxy Apify (résidentiel par défaut), throttling poli + retries\n6\t# ==============================================================================\n7\tfrom __future__ import annotations\n8\t\n9\timport asyncio\n10\timport random\n11\timport re\n12\t\n13\tfrom apify import Actor\n14\tfrom curl_cffi import requests as cffi\n15\t\n16\tUA = (\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \"\n17\t \"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36\")\n18\t\n19\t\n20\tclass Fetcher:\n21\t \"\"\"GET poli avec proxy Apify + impersonation Chrome + retries 403/429.\"\"\"\n22\t\n23\t def __init__(self, proxy_configuration, delay: float = 1.0) -> None:\n24\t self.proxy_configuration = proxy_configuration\n25\t self.delay = delay\n26\t self._lock = asyncio.Lock()\n27\t self._last = 0.0\n28\t\n29\t async def _throttle(self) -> None:\n30\t async with self._lock:\n31\t now = asyncio.get_event_loop().time()\n32\t wait = self.delay - (now - self._last)\n33\t if wait > 0:\n34\t await asyncio.sleep(wait)\n35\t self._last = asyncio.get_event_loop().time()\n36\t\n37\t async def get(self, url: str, headers: dict | None = None,\n38\t retries: int = 3, session_id: str | None = None,\n39\t impersonate: str | None = \"chrome\"):\n40\t hdrs = {\"User-Agent\": UA,\n41\t \"Accept-Language\": \"fr-CA,fr;q=0.9,en;q=0.8\"}\n42\t if headers:\n43\t hdrs.update(headers)\n44\t last_exc: Exception | None = None\n45\t for attempt in range(retries):\n46\t await self._throttle()\n47\t proxy = None\n48\t if self.proxy_configuration:\n49\t sid = session_id or f\"s{random.randint(1, 999_999)}\"\n50\t sid = re.sub(r\"[^\\w._~]\", \"\", sid)[:40] or \"s\"\n51\t proxy = await self.proxy_configuration.new_url(\n52\t session_id=f\"{sid}r{attempt}\")\n53\t try:\n54\t resp = await asyncio.to_thread(\n55\t cffi.get, url, headers=hdrs, impersonate=impersonate,\n56\t timeout=60, allow_redirects=True,\n57\t proxies={\"http\": proxy, \"https\": proxy} if proxy else None)\n58\t if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:\n59\t Actor.log.warning(\n60\t f\"HTTP {resp.status_code} {url} — retry {attempt + 1}\")\n61\t await asyncio.sleep(1.5 * (attempt + 1))\n62\t continue\n63\t return resp\n64\t except Exception as exc: # réseau/proxy : on retente\n65\t last_exc = exc\n66\t await asyncio.sleep(1.5 * (attempt + 1))\n67\t if last_exc:\n68\t raise last_exc\n69\t raise RuntimeError(f\"échec après {retries} tentatives : {url}\")\n70\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"357018c5-8651-4b92-96f6-e28ca60f432b","timestamp":"2026-08-21T00:54:51.898Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/net.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/net.py\n# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +\n# proxy Apify (résidentiel par défaut), throttling poli + retries\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport re\n\nfrom apify import Actor\nfrom curl_cffi import requests as cffi\n\nUA = (\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \"\n \"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36\")\n\n\nclass Fetcher:\n \"\"\"GET poli avec proxy Apify + impersonation Chrome + retries 403/429.\"\"\"\n\n def __init__(self, proxy_configuration, delay: float = 1.0) -> None:\n self.proxy_configuration = proxy_configuration\n self.delay = delay\n self._lock = asyncio.Lock()\n self._last = 0.0\n\n async def _throttle(self) -> None:\n async with self._lock:\n now = asyncio.get_event_loop().time()\n wait = self.delay - (now - self._last)\n if wait > 0:\n await asyncio.sleep(wait)\n self._last = asyncio.get_event_loop().time()\n\n async def get(self, url: str, headers: dict | None = None,\n retries: int = 3, session_id: str | None = None,\n impersonate: str | None = \"chrome\"):\n hdrs = {\"User-Agent\": UA,\n \"Accept-Language\": \"fr-CA,fr;q=0.9,en;q=0.8\"}\n if headers:\n hdrs.update(headers)\n last_exc: Exception | None = None\n for attempt in range(retries):\n await self._throttle()\n proxy = None\n if self.proxy_configuration:\n sid = session_id or f\"s{random.randint(1, 999_999)}\"\n sid = re.sub(r\"[^\\w._~]\", \"\", sid)[:40] or \"s\"\n proxy = await self.proxy_configuration.new_url(\n session_id=f\"{sid}r{attempt}\")\n try:\n resp = await asyncio.to_thread(\n cffi.get, url, headers=hdrs, impersonate=impersonate,\n timeout=60, allow_redirects=True,\n proxies={\"http\": proxy, \"https\": proxy} if proxy else None)\n if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:\n Actor.log.warning(\n f\"HTTP {resp.status_code} {url} — retry {attempt + 1}\")\n await asyncio.sleep(1.5 * (attempt + 1))\n continue\n return resp\n except Exception as exc: # réseau/proxy : on retente\n last_exc = exc\n await asyncio.sleep(1.5 * (attempt + 1))\n if last_exc:\n raise last_exc\n raise RuntimeError(f\"échec après {retries} tentatives : {url}\")\n","numLines":70,"startLine":1,"totalLines":70}}}} |
|
148 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9ec3f253-7729-4b38-9e7d-fca7e3d3560a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
149 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuG9k4xorV8VZgFYvk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2QYKpAEIERgCKkDcIh30kYSYA6FBW8Wgwep2iI2wTcotE4PEOZSvA981sbQbqXNVz2j3+1hHOIb1s+a8zFrFU0sl2ezIuCSJMOZdMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHiw57UBhIMRsihFdgCza/mzD44GgxoxQB9Wb307hUG90siMBTse+KKzkvfEC8dXWjpbkjHPaIjl2IZTnpCfTXmG4iaRE0sNS0UcnAb28C3Tdk7AyrhBHcaa/2mT9dLi9/qbq7Ktp74C5N6YEjdKGnNCWQXgGoXrWIm7XlT2/OgOVs99guL4xGJ6+5eMmtkgY9iv1vNEAkTeHouxVXWDjV72vrhB/TOXv4nibySEJcLXtwgVIWPS9HrQuAakOqJlFIOr5kASujOqE4edLwlwMDWL8E5SP/rFBJB3Cs+NDx0Xv1fk/4KkO003SM7iiFyxBHIMi1eu01xDjaFFUTESMbVGROk/1e+ShNf3DGpUgWAALYVYPoX4RKdJ5gYenXFtIL4QU7BlnmDELkCNEBWkgC2ua0D/BZy0JyXMHak50CaKDfxBgyuJnCar1UMvJgzo+wTZsf+za2znsktR5faqNVBjcHsDUmcyZw7G3/IkoEi+HB5ay2dLZaVJcEO9owWMBxQliYeRsqLvdtcCCcIqWNhpUDnVUNHb1rXxh3EZjniQvv5IHyojSKCN7nNiNes22Y2TRwpeezIvJx1xH2thC7My5yJ778qiqabxepgkEH20VRjZzpKRZE7uz8Fzbv9xL1evsYYkVNZITX72XvdUCNr2xnca4wLupwuPtX7UiToKaYPVTWsY1g7RzWy9z5NFZkHN5O/3xSb09CoA+8w31O6DZ0YA2DYiYQymh4CSVsuw9WoVcg2K5e5QWpcsU41uBPOBkpr+k40MF7qR5k+S/YFv9/qaov4Ri9+9/3cUzBPapiLiFU/5oaws+/rAC3yfhf2YL3PC89AID46bDcBgl+Y0hNxWG9is/bZx0dTncHnOMvqqa5uPWeeWaZprVxB+OLg2fe83l/SlkZuVvYUaN+RUL2bLM/d5xgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5991,"cache_read_input_tokens":63899,"cache_creation":{"ephemeral_5m_input_tokens":5991,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a663fdcf-3bee-4e5f-8c4b-e672c3e5482c"}} |
|
150 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuG9k4xorV8VZgFYvk","type":"message","role":"assistant","content":[{"type":"text","text":"L'acteur Instagram est déjà riche. Le vrai enjeu est probablement double : (1) les autres acteurs sont plus minces, (2) le pipeline ne consomme qu'une partie des champs. Je lis le reste."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5991,"cache_read_input_tokens":63899,"cache_creation":{"ephemeral_5m_input_tokens":5991,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fd4664e8-086e-4d63-96e2-6262ebb7c6cb"}} |
|
151 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuG9k4xorV8VZgFYvk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013HmzyFvL5iHDM7xRB72Md9","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5991,"cache_read_input_tokens":63899,"cache_creation":{"ephemeral_5m_input_tokens":5991,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b61b50be-6f89-43a9-bf38-6b65363dcbe3"}} |
|
152 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013HmzyFvL5iHDM7xRB72Md9","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/connectors/apify_social.py\n4\t# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n5\t# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n6\t# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n7\t# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n8\t# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n9\t# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n10\t# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n11\t# résidentielles + empreinte TLS Chrome côté acteur).\n12\t# ==============================================================================\n13\t\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n14\t\n15\tChaque connecteur d'enrichissement :\n16\t1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n17\t que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n18\t2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n19\t (proxy résidentiel Apify forcé) et attend le dataset ;\n20\t3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n21\t récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n22\t `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n23\t\n24\tNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\n25\tUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n26\t\"\"\"\n27\tfrom __future__ import annotations\n28\t\n29\timport os\n30\timport random\n31\timport time\n32\tfrom concurrent.futures import ThreadPoolExecutor, as_completed\n33\tfrom datetime import datetime, timedelta, timezone\n34\t\n35\timport requests\n36\t\n37\tfrom ..identity import account, merge_accounts\n38\tfrom ..normalize import parse_count, platform_from_url\n39\tfrom ..schema import Creator, now_iso\n40\tfrom .base import BaseConnector, SkipSource\n41\tfrom .linkinbio import is_supported as is_linkinbio\n42\t\n43\tAPIFY_API = \"https://api.apify.com/v2\"\n44\tACTOR_OWNER = \"gorgeous_thistle\"\n45\tRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n46\t\n47\t# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n48\t# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n49\t# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n50\t# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n51\t# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n52\t# (large sous les 256 Go / 128 runs concurrents du plan).\n53\tSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\n54\tMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\n55\tRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\n56\tRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n57\t# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n58\t# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\n59\tREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n60\t\n61\t\n62\tdef _token() -> str:\n63\t token = os.environ.get(\"APIFY_TOKEN\")\n64\t if not token:\n65\t raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n66\t return token\n67\t\n68\t\n69\tdef _launch(name: str, run_input: dict, memory_mb: int,\n70\t retries: int = 6) -> str:\n71\t \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n72\t elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n73\t headers = {\"Authorization\": f\"Bearer {_token()}\"}\n74\t for attempt in range(retries):\n75\t resp = requests.post(\n76\t f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n77\t json=run_input, headers=headers,\n78\t params={\"memory\": memory_mb}, timeout=60)\n79\t if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n80\t if attempt < retries - 1:\n81\t # backoff + jitter : désynchronise les tranches (évite le\n82\t # lockstep où toutes redemandent la RAM au même instant)\n83\t time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n84\t continue\n85\t resp.raise_for_status()\n86\t return resp.json()[\"data\"][\"id\"]\n87\t resp.raise_for_status()\n88\t return resp.json()[\"data\"][\"id\"]\n89\t\n90\t\n91\tdef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n92\t headers = {\"Authorization\": f\"Bearer {_token()}\"}\n93\t t0 = time.time()\n94\t while True:\n95\t time.sleep(15)\n96\t r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n97\t headers=headers, timeout=60)\n98\t r.raise_for_status()\n99\t status = r.json()[\"data\"]\n100\t if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n101\t break\n102\t if time.time() - t0 > timeout_s:\n103\t requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n104\t headers=headers, timeout=60)\n105\t raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n106\t if status[\"status\"] != \"SUCCEEDED\":\n107\t raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n108\t dataset = status[\"defaultDatasetId\"]\n109\t items: list[dict] = []\n110\t offset = 0\n111\t while True:\n112\t r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n113\t params={\"offset\": offset, \"limit\": 1000,\n114\t \"clean\": \"true\"},\n115\t headers=headers, timeout=120)\n116\t r.raise_for_status()\n117\t chunk = r.json()\n118\t items.extend(chunk)\n119\t if len(chunk) < 1000:\n120\t return items\n121\t offset += 1000\n122\t\n123\t\n124\tdef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n125\t memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n126\t \"\"\"Un seul run (recherche, petit lot).\"\"\"\n127\t return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n128\t\n129\t\n130\tdef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n131\t timeout_s: int = 2400,\n132\t shard_size: int = SHARD_SIZE,\n133\t max_shards: int = MAX_SHARDS,\n134\t memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n135\t \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n136\t\n137\t Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n138\t plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n139\t 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n140\t n'annule pas les autres.\n141\t \"\"\"\n142\t if not usernames:\n143\t return []\n144\t n = max(1, min(max_shards,\n145\t (len(usernames) + shard_size - 1) // shard_size))\n146\t shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n147\t items: list[dict] = []\n148\t with ThreadPoolExecutor(max_workers=n) as pool:\n149\t futs = {pool.submit(run_actor, name,\n150\t {**base_input, \"usernames\": shard},\n151\t timeout_s=timeout_s, memory_mb=memory_mb): i\n152\t for i, shard in enumerate(shards) if shard}\n153\t for fut in as_completed(futs):\n154\t try:\n155\t items.extend(fut.result())\n156\t except Exception as exc: # une tranche morte ne bloque pas le reste\n157\t print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n158\t return items\n159\t\n160\t\n161\tclass _ApifySocialEnrich(BaseConnector):\n162\t \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n163\t\n164\t kind = \"enrichment\"\n165\t platform = \"\" # plateforme canonique (§6.2)\n166\t actor = \"\" # nom court de l'acteur (ka-instagram…)\n167\t cap = 3000 # comptes max par passage (shardé en parallèle)\n168\t revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n169\t content_key = \"\" # clé du contenu récent dans l'item acteur\n170\t # clés d'item copiées telles quelles dans account.metrics si non nulles\n171\t metric_map: dict[str, str] = {}\n172\t\n173\t @property\n174\t def _stamp_key(self) -> str:\n175\t # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n176\t # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n177\t # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n178\t return f\"{self.source_id}_at\"\n179\t\n180\t def _stale(self, acc) -> bool:\n181\t stamp = (acc.metrics or {}).get(self._stamp_key)\n182\t if not stamp:\n183\t return True\n184\t try:\n185\t seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n186\t except ValueError:\n187\t return True\n188\t return (datetime.now(timezone.utc) - seen\n189\t > timedelta(days=self.revisit_days))\n190\t\n191\t def extra_input(self) -> dict:\n192\t return {}\n193\t\n194\t def target_of(self, acc) -> str:\n195\t \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n196\t\n197\t Certaines plateformes exigent autre chose que le handle minusculé —\n198\t ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n199\t être repris depuis l'URL. Le rapprochement se fait ensuite en\n200\t minuscules des deux côtés.\n201\t \"\"\"\n202\t return acc.handle\n203\t\n204\t def enrich(self, creators: list[Creator]) -> list[Creator]:\n205\t targets: dict[str, list] = {}\n206\t for cr in creators:\n207\t acc = next((a for a in cr.platforms\n208\t if a.platform == self.platform and a.handle), None)\n209\t if acc is None or not self._stale(acc):\n210\t continue\n211\t targets.setdefault(self.target_of(acc), []).append((cr, acc))\n212\t if len(targets) >= self.cap:\n213\t break\n214\t if not targets:\n215\t return []\n216\t items = run_actor_sharded(\n217\t self.actor,\n218\t {\"proxyConfiguration\": RESIDENTIAL,\n219\t \"concurrency\": RUN_CONCURRENCY,\n220\t **self.extra_input()},\n221\t sorted(targets))\n222\t by_handle = {str(it.get(\"username\") or \"\").lower(): it\n223\t for it in items if it.get(\"kind\") == \"profile\"}\n224\t enriched: list[Creator] = []\n225\t self.errors = 0\n226\t for handle, pairs in targets.items():\n227\t it = by_handle.get(handle.lower())\n228\t if it is None:\n229\t continue\n230\t if not it.get(\"found\"):\n231\t if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n232\t self.errors += 1\n233\t continue\n234\t for cr, acc in pairs:\n235\t self.apply(cr, acc, it)\n236\t enriched.append(cr)\n237\t return enriched\n238\t\n239\t # -- application aux fiches ------------------------------------------------\n240\t def apply(self, cr: Creator, acc, it: dict) -> None:\n241\t acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n242\t if it.get(\"is_verified\") is not None:\n243\t acc.verified = bool(it[\"is_verified\"])\n244\t acc.last_checked = now_iso()\n245\t metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n246\t if it.get(src) is not None}\n247\t if self.content_key and it.get(self.content_key):\n248\t metrics[self.content_key] = it[self.content_key][:12]\n249\t metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n250\t acc.metrics.update(metrics)\n251\t if not cr.avatar_url and it.get(\"avatar\"):\n252\t cr.avatar_url = it[\"avatar\"]\n253\t if not cr.bio and it.get(\"biography\"):\n254\t cr.bio = it[\"biography\"]\n255\t self.cross_links(cr, it)\n256\t\n257\t def cross_links(self, cr: Creator, it: dict) -> None:\n258\t \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n259\t for url in self.bio_urls(it):\n260\t url = (url or \"\").strip()\n261\t if url and not url.startswith(\"http\"):\n262\t url = \"https://\" + url\n263\t if not url:\n264\t continue\n265\t if is_linkinbio(url) and not cr.link_in_bio_url:\n266\t cr.link_in_bio_url = url\n267\t continue\n268\t hit = platform_from_url(url)\n269\t if hit and hit[0] != self.platform:\n270\t cr.platforms = merge_accounts(cr.platforms, [\n271\t account(hit[0], hit[1], \"cross_link\",\n272\t url=url).finalize()])\n273\t\n274\t def bio_urls(self, it: dict) -> list[str]:\n275\t return []\n276\t\n277\t\n278\tclass ApifyInstagram(_ApifySocialEnrich):\n279\t source_id = \"instagram-apify\"\n280\t platform = \"instagram\"\n281\t actor = \"ka-instagram\"\n282\t cap = 1800\n283\t content_key = \"recent_posts\"\n284\t metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n285\t \"category\": \"category\", \"is_business\": \"is_business\",\n286\t \"business_category\": \"business_category\",\n287\t \"highlight_reels\": \"highlight_reels\",\n288\t \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n289\t \"avg_video_views\": \"avg_video_views\",\n290\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n291\t \"posts_per_week\": \"posts_per_week\",\n292\t \"last_post_at\": \"last_post_at\",\n293\t \"video_share_pct\": \"video_share_pct\",\n294\t \"top_post\": \"top_post\"}\n295\t\n296\t def bio_urls(self, it: dict) -> list[str]:\n297\t return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n298\t\n299\t def apply(self, cr, acc, it):\n300\t super().apply(cr, acc, it)\n301\t related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n302\t or []) if r.get(\"username\")]\n303\t if related: # matière première de découvertes futures (§12.1 mention)\n304\t acc.metrics[\"related_profiles\"] = related[:10]\n305\t\n306\t\n307\tclass ApifyTikTok(_ApifySocialEnrich):\n308\t source_id = \"tiktok-apify\"\n309\t platform = \"tiktok\"\n310\t actor = \"ka-tiktok\"\n311\t cap = 1800\n312\t content_key = \"recent_videos\"\n313\t metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n314\t \"videos_count\": \"videos\", \"region\": \"region\",\n315\t \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n316\t \"is_live_now\": \"is_live_now\",\n317\t \"commerce_category\": \"commerce_category\",\n318\t \"account_created_at\": \"account_created_at\",\n319\t \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n320\t \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n321\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n322\t \"top_video\": \"top_video\"}\n323\t\n324\t def bio_urls(self, it: dict) -> list[str]:\n325\t return [it.get(\"bio_link\") or \"\"]\n326\t\n327\t\n328\tclass ApifyX(_ApifySocialEnrich):\n329\t source_id = \"x-apify\"\n330\t platform = \"x\"\n331\t actor = \"ka-x\"\n332\t cap = 1800\n333\t content_key = \"recent_tweets\"\n334\t metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n335\t \"location\": \"location\", \"created_at\": \"created_at\",\n336\t \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n337\t \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n338\t \"avg_replies\": \"avg_replies\",\n339\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n340\t \"tweets_per_week\": \"tweets_per_week\",\n341\t \"last_tweet_at\": \"last_tweet_at\",\n342\t \"media_share_pct\": \"media_share_pct\",\n343\t \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n344\t\n345\t def extra_input(self) -> dict:\n346\t return {\"maxTweets\": 20}\n347\t\n348\t def bio_urls(self, it: dict) -> list[str]:\n349\t return [it.get(\"website\") or \"\"]\n350\t\n351\t\n352\tclass ApifyFacebook(_ApifySocialEnrich):\n353\t source_id = \"facebook-apify\"\n354\t platform = \"facebook\"\n355\t actor = \"ka-facebook\"\n356\t cap = 400\n357\t revisit_days = 7\n358\t metric_map = {\"category\": \"category\"}\n359\t\n360\t\n361\tclass ApifyThreads(_ApifySocialEnrich):\n362\t source_id = \"threads-apify\"\n363\t platform = \"threads\"\n364\t actor = \"ka-threads\"\n365\t cap = 60\n366\t revisit_days = 7\n367\t content_key = \"recent_posts\"\n368\t metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n369\t\n370\t\n371\tclass ApifySnapchat(_ApifySocialEnrich):\n372\t source_id = \"snapchat-apify\"\n373\t platform = \"snapchat\"\n374\t actor = \"ka-snapchat\"\n375\t cap = 60\n376\t revisit_days = 7\n377\t metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n378\t \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n379\t \"subcategory\": \"subcategory\",\n380\t \"publisher_type\": \"publisher_type\",\n381\t \"story_snaps_count\": \"story_snaps\",\n382\t \"lenses_count\": \"lenses\",\n383\t \"spotlight_highlights_count\": \"spotlight_highlights\"}\n384\t\n385\t def bio_urls(self, it: dict) -> list[str]:\n386\t return [it.get(\"website\") or \"\"]\n387\t\n388\t\n389\tclass ApifyYouTube(_ApifySocialEnrich):\n390\t source_id = \"youtube-apify\"\n391\t platform = \"youtube\"\n392\t actor = \"ka-youtube\"\n393\t cap = 1800\n394\t content_key = \"recent_videos\"\n395\t metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n396\t \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n397\t \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n398\t \"last_video_published\": \"last_video_published\"}\n399\t\n400\t\n401\tclass ApifyTwitch(_ApifySocialEnrich):\n402\t source_id = \"twitch-apify\"\n403\t platform = \"twitch\"\n404\t actor = \"ka-twitch\"\n405\t cap = 1200\n406\t content_key = \"recent_videos\"\n407\t metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n408\t \"team\": \"team\", \"created_at\": \"created_at\",\n409\t \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n410\t \"live_game\": \"live_game\",\n411\t \"last_broadcast_at\": \"last_broadcast_at\",\n412\t \"last_broadcast_game\": \"last_broadcast_game\",\n413\t \"avg_video_views\": \"avg_video_views\",\n414\t \"recent_games\": \"recent_games\"}\n415\t\n416\t\n417\tclass ApifyKick(_ApifySocialEnrich):\n418\t source_id = \"kick-apify\"\n419\t platform = \"kick\"\n420\t actor = \"ka-kick\"\n421\t cap = 600\n422\t metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n423\t \"subscription_enabled\": \"subscription_enabled\",\n424\t \"recent_categories\": \"recent_categories\"}\n425\t\n426\t def cross_links(self, cr: Creator, it: dict) -> None:\n427\t \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n428\t socials = it.get(\"social_links\") or {}\n429\t tmpl = {\"instagram\": \"https://instagram.com/{}\",\n430\t \"twitter\": \"https://x.com/{}\",\n431\t \"youtube\": \"https://youtube.com/{}\",\n432\t \"tiktok\": \"https://www.tiktok.com/@{}\",\n433\t \"facebook\": \"https://facebook.com/{}\",\n434\t \"discord\": \"https://discord.gg/{}\"}\n435\t for key, val in socials.items():\n436\t val = (val or \"\").strip()\n437\t if not val:\n438\t continue\n439\t url = val if val.startswith(\"http\") else \\\n440\t tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n441\t hit = platform_from_url(url)\n442\t if hit and hit[0] != self.platform:\n443\t cr.platforms = merge_accounts(cr.platforms, [\n444\t account(hit[0], hit[1], \"cross_link\",\n445\t url=url).finalize()])\n446\t\n447\t\n448\tclass ApifyOnlyFans(_ApifySocialEnrich):\n449\t source_id = \"onlyfans-apify\"\n450\t platform = \"onlyfans\"\n451\t actor = \"ka-onlyfans\"\n452\t cap = 100\n453\t revisit_days = 7\n454\t metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n455\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n456\t \"streams_count\": \"streams\",\n457\t \"subscribe_price_usd\": \"subscribe_price_usd\",\n458\t \"is_free\": \"is_free\", \"location\": \"location\"}\n459\t\n460\t def bio_urls(self, it: dict) -> list[str]:\n461\t return [it.get(\"website\") or \"\"]\n462\t\n463\t\n464\tclass ApifyFansly(_ApifySocialEnrich):\n465\t source_id = \"fansly-apify\"\n466\t platform = \"fansly\"\n467\t actor = \"ka-fansly\"\n468\t cap = 600\n469\t revisit_days = 7\n470\t metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n471\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n472\t \"following\": \"following\", \"location\": \"location\"}\n473\t\n474\t\n475\tclass ApifyPatreon(_ApifySocialEnrich):\n476\t source_id = \"patreon-apify\"\n477\t platform = \"patreon\"\n478\t actor = \"ka-patreon\"\n479\t cap = 400\n480\t revisit_days = 7\n481\t metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n482\t \"is_monthly\": \"is_monthly\"}\n483\t\n484\t\n485\tclass ApifyDiscord(_ApifySocialEnrich):\n486\t source_id = \"discord-apify\"\n487\t platform = \"discord\"\n488\t actor = \"ka-discord\"\n489\t cap = 400\n490\t revisit_days = 7\n491\t metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n492\t \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n493\t\n494\t def target_of(self, acc) -> str:\n495\t \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n496\t handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n497\t url = acc.url or \"\"\n498\t for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n499\t if sep in url:\n500\t return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n501\t return acc.handle\n502\t\n503\t\n504\t# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n505\t\n506\t# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n507\t# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n508\t_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n509\t \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n510\t \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n511\t \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n512\t \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n513\t \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n514\t \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n515\t \"créateur de contenu\", \"créatrice de contenu\"]\n516\t_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n517\t\n518\t\n519\tclass InstagramDecouverteApify(BaseConnector):\n520\t \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n521\t\n522\t L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n523\t mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n524\t (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n525\t résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n526\t EST la source de la fiche.\n527\t \"\"\"\n528\t\n529\t source_id = \"instagram-decouverte\"\n530\t kind = \"discovery\"\n531\t queries_per_pass = 40\n532\t\n533\t def build_queries(self) -> list[str]:\n534\t combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n535\t day = datetime.now(timezone.utc).timetuple().tm_yday\n536\t start = (day * self.queries_per_pass) % len(combos)\n537\t rotated = combos[start:] + combos[:start]\n538\t return rotated[:self.queries_per_pass]\n539\t\n540\t def fetch(self) -> list[Creator]:\n541\t # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n542\t # une session authentifiée (401 « Server Error » depuis toute IP non\n543\t # connectée). La découverte par mots-clés est donc suspendue proprement\n544\t # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n545\t # (instagram-apify) alimente `related_profiles` comme graine future.\n546\t raise SkipSource(\"topsearch Instagram exige une session connectée \"\n547\t \"(voie publique fermée le 2026-08-18)\")\n548\t items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n549\t \"usernames\": [],\n550\t \"queries\": self.build_queries(),\n551\t \"proxyConfiguration\": RESIDENTIAL,\n552\t })\n553\t creators: list[Creator] = []\n554\t seen: set[str] = set()\n555\t for it in items:\n556\t if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n557\t continue\n558\t handle = str(it[\"username\"]).lower()\n559\t if handle in seen:\n560\t continue\n561\t seen.add(handle)\n562\t acc = account(\"instagram\", handle, \"profil_source\",\n563\t followers=parse_count(it.get(\"followers\")),\n564\t verified=it.get(\"is_verified\"),\n565\t url=f\"https://www.instagram.com/{handle}/\")\n566\t creators.append(Creator(\n567\t source=self.source_id,\n568\t external_id=handle,\n569\t display_name=it.get(\"full_name\") or handle,\n570\t avatar_url=it.get(\"avatar\") or None,\n571\t platforms=[acc],\n572\t notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n573\t ))\n574\t return creators\n575\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"174db013-39f4-4951-a38f-c4d32d8a7c7f","timestamp":"2026-08-21T00:54:59.524Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n if not cr.avatar_url and it.get(\"avatar\"):\n cr.avatar_url = it[\"avatar\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","numLines":575,"startLine":1,"totalLines":575}}}} |
|
153 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5c98d98f-5141-4e02-839e-a96dc85700d2","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
154 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuprFn6s15BsQvBfnJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS1AYKpAEIERgCKkCtNyRO0I2XtvqgbLNHmMcQcQyHr7QX8X/0OhTJmZoa5Yh/ZTF3IyigW3yKsfIUCL46MXwgBclZ6nk8A4D5Z5nnMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHrw57UBhIMgEKozvDH3V/9RospGgzJEk5EaM4Fu0X88wciMMXkU4GnKJBgvWs1TO5COcowW7Xam+UojJLI3hJjhJ6rSOmq+J8FQJu43nClSl+TpircBOL2VuJ+1VMHA5FxKVEhGRCAAsuxmsUlirr36Ogdwpkn5GUklQMccWZUQvJyzR8LmThRGDleXh1Cf2KKWdrmZAMCnZ/pXe5x3dNLUKhlhm5e51CQkRIxQI6emLzOzK+1PJ8ziDk5ouaN7lb0WhEUAbEl5UKOgu8q/Q48qS9isQQgRiw471TuxsrznccHTZwdxecAxEOSaIjImSsXTQZ45C4uVYnKKfJmztm9O7KNc/kdxGPqZnORPeXyHcFRlDZ9T0ksRLBG0ZKrWBsjHP11i1cxa0a6aqnUQLHv6WZf1kMWHqLJ8mhJblD1OLxrOsiBXk8inwQtGpnibKTmovluHE8fp5q4eh04HJAV7dFdJfFOA7M+ELKfbfaFsvIHbomk6fLMYNS1I5uJpuQGsvinR0TN8JUDCSQDOnAgHjo+p0WmosnGqFK137cUIBdSqxa99rrb3wsDFzey/tP6QYXgo+PFEgRsnA653UUcelhG3qL+zuF5QZlotkMV6C9syvQM9va71L/HIrC/GsiAVXTZC3eCSMzaTNd0JVeiUbkCFeXvd09PGLZNJNXQbqGSRbxs3ncKTNTJthzCvO9+rcFg+7gsdFBgBIhVz6xEIMcPiCZMyKstjuqlK10aMtvzinT/XVepoJuqZLKFqoZzPyGhbGMQrgqJzTnCIwPn8bn8JYwPNJWc74mFAV6C+/1JBgEGvjSIRHvaerUmRawaZVpYW/EraJKDJajXrGI7vKjEOOEoOpXuc4YCvqcIZ2/bCdRtZSGLAyU1PFLl4ara0TiLAClOuxZa8TSaCacQe/cYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11373,"cache_read_input_tokens":69890,"cache_creation":{"ephemeral_5m_input_tokens":11373,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7e6ee419-84f8-4d55-bfb5-3ee39be1a5aa"}} |
|
155 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuprFn6s15BsQvBfnJ","type":"message","role":"assistant","content":[{"type":"text","text":"Le pipeline est clair. Je lis maintenant les 12 autres acteurs pour dresser la liste précise des lacunes par plateforme."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11373,"cache_read_input_tokens":69890,"cache_creation":{"ephemeral_5m_input_tokens":11373,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4fe72582-766e-4494-bfe1-bcb7d61b94af"}} |
|
156 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuprFn6s15BsQvBfnJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RNoGSwogrr9BbGaL58nWNq","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11373,"cache_read_input_tokens":69890,"cache_creation":{"ephemeral_5m_input_tokens":11373,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"05cdbedc-7541-4e3d-9cfc-f5761f6af3fa"}} |
|
157 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RNoGSwogrr9BbGaL58nWNq","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-tiktok)\n4\t# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n5\t# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n6\t# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n7\t# TikTok les inclut dans le SSR (ItemModule / itemList).\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport json\n13\timport re\n14\t\n15\tfrom apify import Actor\n16\t\n17\tfrom .net import Fetcher\n18\t\n19\tPROFILE_URL = \"https://www.tiktok.com/@{u}\"\n20\t_STATE_RE = re.compile(\n21\t r'<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"[^>]*>(.*?)</script>',\n22\t re.S)\n23\t_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n24\t\n25\t\n26\tdef parse_videos(html: str, data: dict) -> list[dict]:\n27\t \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n28\t items: list[dict] = []\n29\t raw: dict = {}\n30\t scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n31\t detail = scope.get(\"webapp.user-detail\") or {}\n32\t for it in (detail.get(\"itemList\") or []):\n33\t raw[it.get(\"id\", str(len(raw)))] = it\n34\t if not raw:\n35\t m = _ITEM_RE.search(html)\n36\t if m:\n37\t try:\n38\t raw = json.loads(m.group(1))\n39\t except Exception:\n40\t raw = {}\n41\t for vid in list(raw.values())[:20]:\n42\t stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n43\t author = vid.get(\"author\")\n44\t handle = author if isinstance(author, str) else \\\n45\t (author or {}).get(\"uniqueId\", \"\")\n46\t items.append({\n47\t \"id\": vid.get(\"id\"),\n48\t \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n49\t \"caption\": (vid.get(\"desc\") or \"\")[:500],\n50\t \"views\": _num(stats.get(\"playCount\")),\n51\t \"likes\": _num(stats.get(\"diggCount\")),\n52\t \"comments\": _num(stats.get(\"commentCount\")),\n53\t \"shares\": _num(stats.get(\"shareCount\")),\n54\t \"timestamp\": vid.get(\"createTime\"),\n55\t })\n56\t return items\n57\t\n58\t\n59\tdef _num(v):\n60\t try:\n61\t return int(v)\n62\t except (TypeError, ValueError):\n63\t return None\n64\t\n65\t\n66\tasync def main() -> None:\n67\t async with Actor:\n68\t inp = await Actor.get_input() or {}\n69\t usernames = [u.strip().lstrip(\"@\").lower()\n70\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n71\t proxy = await Actor.create_proxy_configuration(\n72\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n73\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n74\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n75\t\n76\t async def one(u: str) -> None:\n77\t async with sem:\n78\t miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n79\t \"username\": u, \"found\": False}\n80\t resp = None\n81\t m = None\n82\t for attempt in range(3): # coquille sans SSR → nouvelle IP\n83\t try:\n84\t resp = await fetcher.get(PROFILE_URL.format(u=u),\n85\t session_id=f\"{u}t{attempt}\")\n86\t except Exception as exc:\n87\t await Actor.push_data({**miss,\n88\t \"error\": str(exc)[:200]})\n89\t return\n90\t if resp.status_code == 404:\n91\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n92\t return\n93\t m = _STATE_RE.search(resp.text or \"\")\n94\t if m and \"webapp.user-detail\" in (resp.text or \"\"):\n95\t break\n96\t info = None\n97\t data: dict = {}\n98\t if m:\n99\t try:\n100\t data = json.loads(m.group(1))\n101\t info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n102\t .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n103\t except Exception:\n104\t info = None\n105\t if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n106\t await Actor.push_data(\n107\t {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n108\t return\n109\t user = info.get(\"user\") or {}\n110\t stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n111\t if user.get(\"privateAccount\"):\n112\t await Actor.push_data({**miss, \"error\": \"private\"})\n113\t return\n114\t followers = _num(stats.get(\"followerCount\"))\n115\t videos = parse_videos(resp.text, data)\n116\t views = [v[\"views\"] for v in videos\n117\t if isinstance(v[\"views\"], int)]\n118\t likes = [v[\"likes\"] for v in videos\n119\t if isinstance(v[\"likes\"], int)]\n120\t comments = [v[\"comments\"] for v in videos\n121\t if isinstance(v[\"comments\"], int)]\n122\t shares = [v[\"shares\"] for v in videos\n123\t if isinstance(v[\"shares\"], int)]\n124\t avg_views = round(sum(views) / len(views)) if views else None\n125\t avg_likes = round(sum(likes) / len(likes)) if likes else None\n126\t engagement = None\n127\t if followers and avg_likes is not None:\n128\t engagement = round(\n129\t (avg_likes\n130\t + (round(sum(comments) / len(comments))\n131\t if comments else 0)\n132\t + (round(sum(shares) / len(shares))\n133\t if shares else 0)) / followers * 100, 3)\n134\t top_video = max(\n135\t (v for v in videos if isinstance(v[\"views\"], int)),\n136\t key=lambda v: v[\"views\"], default=None)\n137\t commerce = user.get(\"commerceUserInfo\") or {}\n138\t bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n139\t or \"\").strip()\n140\t total_likes = _num(stats.get(\"heartCount\")\n141\t or stats.get(\"heart\"))\n142\t videos_count = _num(stats.get(\"videoCount\"))\n143\t await Actor.push_data({\n144\t \"kind\": \"profile\",\n145\t \"platform\": \"tiktok\",\n146\t \"found\": True,\n147\t \"id\": user.get(\"id\"),\n148\t \"sec_uid\": user.get(\"secUid\"),\n149\t \"username\": user.get(\"uniqueId\"),\n150\t \"full_name\": user.get(\"nickname\"),\n151\t \"biography\": user.get(\"signature\"),\n152\t \"bio_link\": bio_link,\n153\t \"followers\": followers,\n154\t \"following\": _num(stats.get(\"followingCount\")),\n155\t \"friends\": _num(stats.get(\"friendCount\")),\n156\t \"total_likes\": total_likes,\n157\t \"videos_count\": videos_count,\n158\t \"avg_likes_per_video_lifetime\": (\n159\t round(total_likes / videos_count)\n160\t if total_likes and videos_count else None),\n161\t \"is_verified\": bool(user.get(\"verified\")),\n162\t \"is_organization\": bool(user.get(\"isOrganization\")),\n163\t \"is_seller\": bool(user.get(\"ttSeller\")),\n164\t \"is_live_now\": bool(user.get(\"roomId\")),\n165\t \"commerce_category\": (commerce.get(\"category\") or None\n166\t if commerce.get(\"commerceUser\")\n167\t else None),\n168\t \"account_created_at\": user.get(\"createTime\"),\n169\t \"region\": user.get(\"region\"),\n170\t \"language\": user.get(\"language\"),\n171\t \"avatar\": user.get(\"avatarLarger\")\n172\t or user.get(\"avatarMedium\"),\n173\t # agrégats sur les vidéos présentes dans le SSR\n174\t \"avg_views\": avg_views,\n175\t \"avg_likes\": avg_likes,\n176\t \"avg_comments\": (round(sum(comments) / len(comments))\n177\t if comments else None),\n178\t \"avg_shares\": (round(sum(shares) / len(shares))\n179\t if shares else None),\n180\t \"engagement_rate_pct\": engagement,\n181\t \"top_video\": ({\"url\": top_video[\"url\"],\n182\t \"views\": top_video[\"views\"],\n183\t \"likes\": top_video[\"likes\"]}\n184\t if top_video else None),\n185\t \"recent_videos\": videos,\n186\t })\n187\t\n188\t await asyncio.gather(*[one(u) for u in usernames])\n189\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n190\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"45d66d6c-2bbe-455d-bddf-b932b12cae55","timestamp":"2026-08-21T00:55:07.814Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-tiktok)\n# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.tiktok.com/@{u}\"\n_STATE_RE = re.compile(\n r'<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"[^>]*>(.*?)</script>',\n re.S)\n_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n\n\ndef parse_videos(html: str, data: dict) -> list[dict]:\n \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n items: list[dict] = []\n raw: dict = {}\n scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n detail = scope.get(\"webapp.user-detail\") or {}\n for it in (detail.get(\"itemList\") or []):\n raw[it.get(\"id\", str(len(raw)))] = it\n if not raw:\n m = _ITEM_RE.search(html)\n if m:\n try:\n raw = json.loads(m.group(1))\n except Exception:\n raw = {}\n for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n })\n return items\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n \"username\": u, \"found\": False}\n resp = None\n m = None\n for attempt in range(3): # coquille sans SSR → nouvelle IP\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=f\"{u}t{attempt}\")\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _STATE_RE.search(resp.text or \"\")\n if m and \"webapp.user-detail\" in (resp.text or \"\"):\n break\n info = None\n data: dict = {}\n if m:\n try:\n data = json.loads(m.group(1))\n info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n except Exception:\n info = None\n if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n await Actor.push_data(\n {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n return\n user = info.get(\"user\") or {}\n stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n if user.get(\"privateAccount\"):\n await Actor.push_data({**miss, \"error\": \"private\"})\n return\n followers = _num(stats.get(\"followerCount\"))\n videos = parse_videos(resp.text, data)\n views = [v[\"views\"] for v in videos\n if isinstance(v[\"views\"], int)]\n likes = [v[\"likes\"] for v in videos\n if isinstance(v[\"likes\"], int)]\n comments = [v[\"comments\"] for v in videos\n if isinstance(v[\"comments\"], int)]\n shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n avg_views = round(sum(views) / len(views)) if views else None\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round(\n (avg_likes\n + (round(sum(comments) / len(comments))\n if comments else 0)\n + (round(sum(shares) / len(shares))\n if shares else 0)) / followers * 100, 3)\n top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n commerce = user.get(\"commerceUserInfo\") or {}\n bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n or \"\").strip()\n total_likes = _num(stats.get(\"heartCount\")\n or stats.get(\"heart\"))\n videos_count = _num(stats.get(\"videoCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"tiktok\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"sec_uid\": user.get(\"secUid\"),\n \"username\": user.get(\"uniqueId\"),\n \"full_name\": user.get(\"nickname\"),\n \"biography\": user.get(\"signature\"),\n \"bio_link\": bio_link,\n \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,\n \"avg_likes_per_video_lifetime\": (\n round(total_likes / videos_count)\n if total_likes and videos_count else None),\n \"is_verified\": bool(user.get(\"verified\")),\n \"is_organization\": bool(user.get(\"isOrganization\")),\n \"is_seller\": bool(user.get(\"ttSeller\")),\n \"is_live_now\": bool(user.get(\"roomId\")),\n \"commerce_category\": (commerce.get(\"category\") or None\n if commerce.get(\"commerceUser\")\n else None),\n \"account_created_at\": user.get(\"createTime\"),\n \"region\": user.get(\"region\"),\n \"language\": user.get(\"language\"),\n \"avatar\": user.get(\"avatarLarger\")\n or user.get(\"avatarMedium\"),\n # agrégats sur les vidéos présentes dans le SSR\n \"avg_views\": avg_views,\n \"avg_likes\": avg_likes,\n \"avg_comments\": (round(sum(comments) / len(comments))\n if comments else None),\n \"avg_shares\": (round(sum(shares) / len(shares))\n if shares else None),\n \"engagement_rate_pct\": engagement,\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"]}\n if top_video else None),\n \"recent_videos\": videos,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","numLines":190,"startLine":1,"totalLines":190}}}} |
|
158 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyuprFn6s15BsQvBfnJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Sxmn68GPXZQsjfTfU5WWcD","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11373,"cache_read_input_tokens":69890,"cache_creation":{"ephemeral_5m_input_tokens":11373,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f71e5620-3093-4d26-921d-58e8ac601278"}} |
|
159 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Sxmn68GPXZQsjfTfU5WWcD","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-youtube)\n4\t# Desc: Chaînes YouTube détaillées SANS clé API via le JSON ytInitialData de\n5\t# la page /videos : abonnés, nb de vidéos, description, avatar, liens\n6\t# externes + vidéos récentes (vues, durée, date relative) et agrégats.\n7\t# Accepte @handle, handle nu, ou id de chaîne (UC…).\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport json\n13\timport re\n14\timport urllib.parse\n15\t\n16\tfrom apify import Actor\n17\t\n18\tfrom .net import Fetcher\n19\t\n20\t_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n21\t_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n22\t\n23\t\n24\tdef _url_for(handle: str) -> str:\n25\t h = urllib.parse.unquote(handle).strip()\n26\t if h.startswith(\"UC\") and len(h) == 24:\n27\t return f\"https://www.youtube.com/channel/{h}/videos\"\n28\t if not h.startswith(\"@\"):\n29\t h = \"@\" + h\n30\t return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n31\t\n32\t\n33\tdef parse_text_count(text: str) -> int | None:\n34\t \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n35\t if not text:\n36\t return None\n37\t m = _COUNT_RE.search(str(text))\n38\t if not m:\n39\t return None\n40\t num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n41\t unit = (m.group(2) or \"\").upper()\n42\t if unit:\n43\t num = num.replace(\",\", \".\")\n44\t if num.count(\".\") > 1:\n45\t num = num.replace(\".\", \"\", num.count(\".\") - 1)\n46\t try:\n47\t val = float(num)\n48\t except ValueError:\n49\t return None\n50\t return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n51\t num = re.sub(r\"[.,]\", \"\", num)\n52\t return int(num) if num.isdigit() else None\n53\t\n54\t\n55\tdef _walk(node, key: str):\n56\t \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n57\t if isinstance(node, dict):\n58\t if key in node:\n59\t return node[key]\n60\t for v in node.values():\n61\t hit = _walk(v, key)\n62\t if hit is not None:\n63\t return hit\n64\t elif isinstance(node, list):\n65\t for v in node:\n66\t hit = _walk(v, key)\n67\t if hit is not None:\n68\t return hit\n69\t return None\n70\t\n71\t\n72\tdef _texts(node) -> list[str]:\n73\t \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n74\t out: list[str] = []\n75\t\n76\t def rec(n):\n77\t if isinstance(n, dict):\n78\t for k in (\"content\", \"simpleText\", \"text\"):\n79\t if isinstance(n.get(k), str):\n80\t out.append(n[k])\n81\t for v in n.values():\n82\t rec(v)\n83\t elif isinstance(n, list):\n84\t for v in n:\n85\t rec(v)\n86\t rec(node)\n87\t return out\n88\t\n89\t\n90\tdef _lockup_video(lv: dict) -> dict | None:\n91\t \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n92\t if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n93\t return None\n94\t vid = lv.get(\"contentId\")\n95\t if not vid:\n96\t return None\n97\t md = lv.get(\"metadata\") or {}\n98\t title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n99\t .get(\"content\")\n100\t strings = _texts(md)\n101\t views = next((parse_text_count(s) for s in strings\n102\t if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n103\t published = next((s for s in strings\n104\t if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n105\t overlays = _texts(lv.get(\"contentImage\") or {})\n106\t duration = next((s for s in overlays\n107\t if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n108\t return {\n109\t \"id\": vid,\n110\t \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n111\t \"title\": (title or \"\")[:200],\n112\t \"views\": views,\n113\t \"published\": published,\n114\t \"duration\": duration,\n115\t }\n116\t\n117\t\n118\tdef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n119\t out: list[dict] = []\n120\t\n121\t def rec(node):\n122\t if len(out) >= cap:\n123\t return\n124\t if isinstance(node, dict):\n125\t lv = node.get(\"lockupViewModel\")\n126\t if isinstance(lv, dict):\n127\t v = _lockup_video(lv)\n128\t if v:\n129\t out.append(v)\n130\t return\n131\t vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n132\t if vr and vr.get(\"videoId\"):\n133\t out.append({\n134\t \"id\": vr[\"videoId\"],\n135\t \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n136\t \"title\": \"\".join(\n137\t r.get(\"text\", \"\") for r in\n138\t (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n139\t \"views\": parse_text_count(\n140\t (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n141\t \"published\": ((vr.get(\"publishedTimeText\") or {})\n142\t .get(\"simpleText\")),\n143\t \"duration\": ((vr.get(\"lengthText\") or {})\n144\t .get(\"simpleText\")),\n145\t })\n146\t return\n147\t for v in node.values():\n148\t rec(v)\n149\t elif isinstance(node, list):\n150\t for v in node:\n151\t rec(v)\n152\t\n153\t rec(data)\n154\t return out\n155\t\n156\t\n157\tasync def main() -> None:\n158\t async with Actor:\n159\t inp = await Actor.get_input() or {}\n160\t usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n161\t if u and u.strip()]\n162\t proxy = await Actor.create_proxy_configuration(\n163\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n164\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n165\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n166\t\n167\t async def one(u: str) -> None:\n168\t async with sem:\n169\t miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n170\t \"username\": u, \"found\": False}\n171\t try:\n172\t resp = await fetcher.get(\n173\t _url_for(u), session_id=u,\n174\t headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n175\t except Exception as exc:\n176\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n177\t return\n178\t m = _INITIAL_RE.search(resp.text or \"\")\n179\t if resp.status_code != 200 or not m:\n180\t await Actor.push_data(\n181\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n182\t return\n183\t try:\n184\t data = json.loads(m.group(1))\n185\t except Exception:\n186\t await Actor.push_data({**miss, \"error\": \"bad_json\"})\n187\t return\n188\t meta = _walk(data, \"channelMetadataRenderer\") or {}\n189\t if not meta.get(\"externalId\"):\n190\t await Actor.push_data({**miss, \"error\": \"no_channel\"})\n191\t return\n192\t # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n193\t # on lit les chaînes « N subscribers » / « N videos » du header\n194\t header = _walk(data, \"pageHeaderViewModel\") or {}\n195\t header_txt = _texts(header) or _texts(\n196\t _walk(data, \"c4TabbedHeaderRenderer\") or {})\n197\t sub_text = next((t for t in header_txt\n198\t if \"subscriber\" in t.lower()\n199\t or \"abonné\" in t.lower()), \"\")\n200\t vid_text = next((t for t in header_txt\n201\t if (\"video\" in t.lower()\n202\t or \"vidéo\" in t.lower())\n203\t and any(c.isdigit() for c in t)), \"\")\n204\t if not sub_text: # ancien en-tête\n205\t sub_text = ((_walk(data, \"subscriberCountText\") or {})\n206\t .get(\"simpleText\") or \"\")\n207\t subs = parse_text_count(sub_text)\n208\t videos_count = parse_text_count(vid_text)\n209\t vids = _collect_videos(data)\n210\t views = [v[\"views\"] for v in vids\n211\t if isinstance(v[\"views\"], int)]\n212\t await Actor.push_data({\n213\t \"kind\": \"profile\",\n214\t \"platform\": \"youtube\",\n215\t \"found\": True,\n216\t \"username\": u,\n217\t \"channel_id\": meta.get(\"externalId\"),\n218\t \"full_name\": meta.get(\"title\"),\n219\t \"biography\": (meta.get(\"description\") or \"\")[:1000],\n220\t \"followers\": subs,\n221\t \"videos_count\": videos_count,\n222\t \"is_family_safe\": meta.get(\"isFamilySafe\"),\n223\t \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n224\t \"country\": _walk(data, \"country\"),\n225\t \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n226\t \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n227\t or [{}])[-1].get(\"url\")),\n228\t \"avg_views\": (round(sum(views) / len(views))\n229\t if views else None),\n230\t \"top_video\": (max(\n231\t (v for v in vids if isinstance(v[\"views\"], int)),\n232\t key=lambda v: v[\"views\"], default=None)),\n233\t \"last_video_published\": (vids[0][\"published\"]\n234\t if vids else None),\n235\t \"recent_videos\": vids,\n236\t })\n237\t\n238\t await asyncio.gather(*[one(u) for u in usernames])\n239\t Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n240\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"74c0647d-17dc-4672-85f2-c1d897e6f41d","timestamp":"2026-08-21T00:55:08.161Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube détaillées SANS clé API via le JSON ytInitialData de\n# la page /videos : abonnés, nb de vidéos, description, avatar, liens\n# externes + vidéos récentes (vues, durée, date relative) et agrégats.\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","numLines":240,"startLine":1,"totalLines":240}}}} |
|
160 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"10eb53d1-751b-4444-9a1a-c6a2d7c9bd67","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
161 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyvVXvsSBX5cJENYHxD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XD7jiqyWVC2jRKAkPKHT9h","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"in_progress","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (instagram, tiktok, youtube, twitch, x) : + d'infos et d'images","status":"pending","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, facebook, threads, snapchat, patreon, discord, fansly, onlyfans)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) pour consommer les nouveaux champs + avatars/bannières","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter métriques/stats par créateur (snapshots historisés + stats calculées)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (galerie d'images, stats, bannières)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":8282,"cache_read_input_tokens":81263,"cache_creation":{"ephemeral_5m_input_tokens":8282,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"aa1893d3-cb1a-4951-a834-b29889207938"}} |
|
162 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XD7jiqyWVC2jRKAkPKHT9h","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b24bfed5-a163-4a58-b646-da5322efb527","timestamp":"2026-08-21T00:55:18.958Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"in_progress","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"pending","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (instagram, tiktok, youtube, twitch, x) : + d'infos et d'images","status":"pending","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, facebook, threads, snapchat, patreon, discord, fansly, onlyfans)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) pour consommer les nouveaux champs + avatars/bannières","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter métriques/stats par créateur (snapshots historisés + stats calculées)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (galerie d'images, stats, bannières)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"in_progress","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (instagram, tiktok, youtube, twitch, x) : + d'infos et d'images","status":"pending","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, facebook, threads, snapchat, patreon, discord, fansly, onlyfans)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) pour consommer les nouveaux champs + avatars/bannières","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter métriques/stats par créateur (snapshots historisés + stats calculées)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (galerie d'images, stats, bannières)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
163 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"db3079d3-3817-4189-bb73-d6247f85e922","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
164 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEywFrwRES6ELzVR2XzH","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0135tg3aHyFUBehRgcZu3KC4","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":816,"cache_read_input_tokens":89545,"cache_creation":{"ephemeral_5m_input_tokens":816,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7a04aba9-2136-42f6-b2a8-8bc75296d79e"}} |
|
165 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEywFrwRES6ELzVR2XzH","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Bpw94JCBvDs9tiy6MSo15X","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":816,"cache_read_input_tokens":89545,"cache_creation":{"ephemeral_5m_input_tokens":816,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"60df6156-d912-4a50-bb62-58cd6c99c704"}} |
|
166 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Bpw94JCBvDs9tiy6MSo15X","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-twitch)\n4\t# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n5\t# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n6\t# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n7\t# vidéos récentes avec vues. Pas de clé requise.\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport json\n13\t\n14\tfrom apify import Actor\n15\t\n16\tfrom .net import Fetcher, UA\n17\t\n18\tGQL_URL = \"https://gql.twitch.tv/gql\"\n19\tCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n20\t\n21\tQUERY = \"\"\"\n22\tquery($login: String!) {\n23\t user(login: $login) {\n24\t id\n25\t login\n26\t displayName\n27\t description\n28\t createdAt\n29\t profileImageURL(width: 300)\n30\t bannerImageURL\n31\t followers { totalCount }\n32\t roles { isPartner isAffiliate }\n33\t primaryTeam { displayName }\n34\t channel { chanlets { id } }\n35\t lastBroadcast { startedAt title game { displayName } }\n36\t stream { viewersCount createdAt game { displayName } }\n37\t videos(first: 10, sort: TIME) {\n38\t edges { node {\n39\t id title viewCount lengthSeconds publishedAt game { displayName }\n40\t } }\n41\t }\n42\t }\n43\t}\n44\t\"\"\"\n45\t\n46\t\n47\tasync def main() -> None:\n48\t async with Actor:\n49\t inp = await Actor.get_input() or {}\n50\t usernames = [u.strip().lstrip(\"@\").lower()\n51\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n52\t proxy = await Actor.create_proxy_configuration(\n53\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n54\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n55\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n56\t\n57\t import random\n58\t from curl_cffi import requests as cffi\n59\t\n60\t async def gql(login: str) -> dict | None:\n61\t body = json.dumps({\"query\": QUERY,\n62\t \"variables\": {\"login\": login}})\n63\t p = None\n64\t if fetcher.proxy_configuration:\n65\t p = await fetcher.proxy_configuration.new_url(\n66\t session_id=f\"tw{random.randint(1, 999_999)}\")\n67\t await fetcher._throttle()\n68\t resp = await asyncio.to_thread(\n69\t cffi.post, GQL_URL, data=body,\n70\t headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n71\t \"Content-Type\": \"application/json\"},\n72\t impersonate=\"chrome\", timeout=45,\n73\t proxies={\"http\": p, \"https\": p} if p else None)\n74\t if resp.status_code != 200:\n75\t raise RuntimeError(f\"gql http_{resp.status_code}\")\n76\t return (resp.json().get(\"data\") or {}).get(\"user\")\n77\t\n78\t async def one(u: str) -> None:\n79\t async with sem:\n80\t miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n81\t \"username\": u, \"found\": False}\n82\t try:\n83\t user = await gql(u)\n84\t except Exception as exc:\n85\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n86\t return\n87\t if not user:\n88\t await Actor.push_data({**miss, \"error\": \"not_found\"})\n89\t return\n90\t vids = []\n91\t for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n92\t n = e.get(\"node\") or {}\n93\t vids.append({\n94\t \"id\": n.get(\"id\"),\n95\t \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n96\t \"title\": (n.get(\"title\") or \"\")[:200],\n97\t \"views\": n.get(\"viewCount\"),\n98\t \"duration_s\": n.get(\"lengthSeconds\"),\n99\t \"published_at\": n.get(\"publishedAt\"),\n100\t \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n101\t })\n102\t views = [v[\"views\"] for v in vids\n103\t if isinstance(v[\"views\"], int)]\n104\t games = [v[\"game\"] for v in vids if v[\"game\"]]\n105\t stream = user.get(\"stream\") or {}\n106\t last = user.get(\"lastBroadcast\") or {}\n107\t roles = user.get(\"roles\") or {}\n108\t await Actor.push_data({\n109\t \"kind\": \"profile\",\n110\t \"platform\": \"twitch\",\n111\t \"found\": True,\n112\t \"id\": user.get(\"id\"),\n113\t \"username\": user.get(\"login\"),\n114\t \"full_name\": user.get(\"displayName\"),\n115\t \"biography\": user.get(\"description\"),\n116\t \"followers\": ((user.get(\"followers\") or {})\n117\t .get(\"totalCount\")),\n118\t \"is_partner\": roles.get(\"isPartner\"),\n119\t \"is_affiliate\": roles.get(\"isAffiliate\"),\n120\t \"is_verified\": bool(roles.get(\"isPartner\")),\n121\t \"team\": ((user.get(\"primaryTeam\") or {})\n122\t .get(\"displayName\")),\n123\t \"created_at\": user.get(\"createdAt\"),\n124\t \"avatar\": user.get(\"profileImageURL\"),\n125\t \"banner\": user.get(\"bannerImageURL\"),\n126\t \"is_live_now\": bool(stream),\n127\t \"live_viewers\": stream.get(\"viewersCount\"),\n128\t \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n129\t \"last_broadcast_at\": last.get(\"startedAt\"),\n130\t \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n131\t or None,\n132\t \"last_broadcast_game\": ((last.get(\"game\") or {})\n133\t .get(\"displayName\")),\n134\t \"avg_video_views\": (round(sum(views) / len(views))\n135\t if views else None),\n136\t \"recent_games\": list(dict.fromkeys(games))[:5],\n137\t \"recent_videos\": vids,\n138\t })\n139\t\n140\t await asyncio.gather(*[one(u) for u in usernames])\n141\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n142\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"478a2cd0-e716-4afd-8d90-a1495676b6dd","timestamp":"2026-08-21T00:55:22.986Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-twitch)\n# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher, UA\n\nGQL_URL = \"https://gql.twitch.tv/gql\"\nCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n\nQUERY = \"\"\"\nquery($login: String!) {\n user(login: $login) {\n id\n login\n displayName\n description\n createdAt\n profileImageURL(width: 300)\n bannerImageURL\n followers { totalCount }\n roles { isPartner isAffiliate }\n primaryTeam { displayName }\n channel { chanlets { id } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt game { displayName }\n } }\n }\n }\n}\n\"\"\"\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n import random\n from curl_cffi import requests as cffi\n\n async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n p = None\n if fetcher.proxy_configuration:\n p = await fetcher.proxy_configuration.new_url(\n session_id=f\"tw{random.randint(1, 999_999)}\")\n await fetcher._throttle()\n resp = await asyncio.to_thread(\n cffi.post, GQL_URL, data=body,\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"},\n impersonate=\"chrome\", timeout=45,\n proxies={\"http\": p, \"https\": p} if p else None)\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n \"username\": u, \"found\": False}\n try:\n user = await gql(u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if not user:\n await Actor.push_data({**miss, \"error\": \"not_found\"})\n return\n vids = []\n for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n n = e.get(\"node\") or {}\n vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n games = [v[\"game\"] for v in vids if v[\"game\"]]\n stream = user.get(\"stream\") or {}\n last = user.get(\"lastBroadcast\") or {}\n roles = user.get(\"roles\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"twitch\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"login\"),\n \"full_name\": user.get(\"displayName\"),\n \"biography\": user.get(\"description\"),\n \"followers\": ((user.get(\"followers\") or {})\n .get(\"totalCount\")),\n \"is_partner\": roles.get(\"isPartner\"),\n \"is_affiliate\": roles.get(\"isAffiliate\"),\n \"is_verified\": bool(roles.get(\"isPartner\")),\n \"team\": ((user.get(\"primaryTeam\") or {})\n .get(\"displayName\")),\n \"created_at\": user.get(\"createdAt\"),\n \"avatar\": user.get(\"profileImageURL\"),\n \"banner\": user.get(\"bannerImageURL\"),\n \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n \"last_broadcast_at\": last.get(\"startedAt\"),\n \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n or None,\n \"last_broadcast_game\": ((last.get(\"game\") or {})\n .get(\"displayName\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","numLines":142,"startLine":1,"totalLines":142}}}} |
|
167 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0135tg3aHyFUBehRgcZu3KC4","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-x)\n4\t# Desc: Profils X (Twitter) SANS clé via le service OFFICIEL de syndication\n5\t# des widgets (syndication.twitter.com/srv/timeline-profile) : objet\n6\t# user complet + timeline récente avec métriques par tweet. Conçu pour\n7\t# être appelé sans authentification ; l'empreinte TLS Chrome de\n8\t# curl_cffi évite les 429 réservés aux clients python-requests.\n9\t# ==============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport asyncio\n13\timport json\n14\timport re\n15\timport time\n16\t\n17\tfrom apify import Actor\n18\t\n19\tfrom .net import Fetcher\n20\t\n21\tSYNDICATION_URL = (\"https://syndication.twitter.com/srv/timeline-profile/\"\n22\t \"screen-name/{u}\")\n23\t_NEXT_DATA_RE = re.compile(\n24\t r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n25\t\n26\t\n27\tdef _find_user(node, handle: str):\n28\t \"\"\"Parcourt le JSON Next.js : l'objet user dont screen_name == handle.\"\"\"\n29\t if isinstance(node, dict):\n30\t if str(node.get(\"screen_name\", \"\")).lower() == handle and \\\n31\t \"followers_count\" in node:\n32\t return node\n33\t for v in node.values():\n34\t hit = _find_user(v, handle)\n35\t if hit is not None:\n36\t return hit\n37\t elif isinstance(node, list):\n38\t for v in node:\n39\t hit = _find_user(v, handle)\n40\t if hit is not None:\n41\t return hit\n42\t return None\n43\t\n44\t\n45\tdef parse_tweets(data: dict, handle: str, cap: int) -> list[dict]:\n46\t entries = (((data.get(\"props\") or {}).get(\"pageProps\") or {})\n47\t .get(\"timeline\") or {}).get(\"entries\") or []\n48\t tweets: list[dict] = []\n49\t for entry in entries:\n50\t t = (entry.get(\"content\") or {}).get(\"tweet\") or {}\n51\t if not t.get(\"id_str\"):\n52\t continue\n53\t u = (t.get(\"user\") or {})\n54\t ent = t.get(\"entities\") or {}\n55\t media = (t.get(\"extended_entities\") or ent).get(\"media\") or []\n56\t tweets.append({\n57\t \"id\": t.get(\"id_str\"),\n58\t \"url\": f\"https://x.com/{u.get('screen_name', handle)}\"\n59\t f\"/status/{t.get('id_str')}\",\n60\t \"text\": (t.get(\"full_text\") or t.get(\"text\") or \"\")[:500],\n61\t \"likes\": t.get(\"favorite_count\"),\n62\t \"retweets\": t.get(\"retweet_count\"),\n63\t \"replies\": t.get(\"reply_count\"),\n64\t \"quotes\": t.get(\"quote_count\"),\n65\t \"created_at\": t.get(\"created_at\"),\n66\t \"is_retweet\": bool(t.get(\"retweeted_status\")),\n67\t \"is_reply\": bool(t.get(\"in_reply_to_status_id_str\")),\n68\t \"is_quote\": bool(t.get(\"is_quote_status\")),\n69\t \"lang\": t.get(\"lang\"),\n70\t \"hashtags\": [h.get(\"text\") for h in (ent.get(\"hashtags\") or [])\n71\t if h.get(\"text\")][:8],\n72\t \"mentions\": [m.get(\"screen_name\")\n73\t for m in (ent.get(\"user_mentions\") or [])\n74\t if m.get(\"screen_name\")][:8],\n75\t \"media_type\": (media[0].get(\"type\") if media else None),\n76\t \"media_count\": len(media) or None,\n77\t \"links\": [x.get(\"expanded_url\") for x in (ent.get(\"urls\") or [])\n78\t if x.get(\"expanded_url\")][:4],\n79\t })\n80\t if len(tweets) >= cap:\n81\t break\n82\t return tweets\n83\t\n84\t\n85\tdef _ts(created_at: str) -> float | None:\n86\t \"\"\"« Tue Sep 12 23:56:24 +0000 2023 » → epoch (None si illisible).\"\"\"\n87\t try:\n88\t from datetime import datetime\n89\t return datetime.strptime(created_at,\n90\t \"%a %b %d %H:%M:%S %z %Y\").timestamp()\n91\t except (TypeError, ValueError):\n92\t return None\n93\t\n94\t\n95\tasync def main() -> None:\n96\t async with Actor:\n97\t inp = await Actor.get_input() or {}\n98\t usernames = [u.strip().lstrip(\"@\").lower()\n99\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n100\t max_tweets = int(inp.get(\"maxTweets\", 20))\n101\t proxy = await Actor.create_proxy_configuration(\n102\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n103\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n104\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n105\t\n106\t async def one(u: str) -> None:\n107\t async with sem:\n108\t miss = {\"kind\": \"profile\", \"platform\": \"x\",\n109\t \"username\": u, \"found\": False}\n110\t try:\n111\t resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n112\t session_id=u, impersonate=None)\n113\t if resp.status_code == 429:\n114\t resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n115\t session_id=f\"{u}b\",\n116\t impersonate=\"chrome\")\n117\t except Exception as exc:\n118\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n119\t return\n120\t m = _NEXT_DATA_RE.search(resp.text or \"\")\n121\t if resp.status_code != 200 or not m:\n122\t await Actor.push_data(\n123\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n124\t return\n125\t try:\n126\t data = json.loads(m.group(1))\n127\t except Exception:\n128\t await Actor.push_data({**miss, \"error\": \"bad_json\"})\n129\t return\n130\t user = _find_user(data, u)\n131\t if not user:\n132\t await Actor.push_data({**miss, \"error\": \"user_not_found\"})\n133\t return\n134\t tweets = parse_tweets(data, u, max_tweets)\n135\t own = [t for t in tweets if not t[\"is_retweet\"]]\n136\t likes = [t[\"likes\"] for t in own if isinstance(t[\"likes\"], int)]\n137\t rts = [t[\"retweets\"] for t in own\n138\t if isinstance(t[\"retweets\"], int)]\n139\t reps = [t[\"replies\"] for t in own\n140\t if isinstance(t[\"replies\"], int)]\n141\t followers = user.get(\"followers_count\")\n142\t avg_likes = round(sum(likes) / len(likes)) if likes else None\n143\t avg_rts = round(sum(rts) / len(rts)) if rts else None\n144\t engagement = None\n145\t if followers and avg_likes is not None:\n146\t engagement = round((avg_likes + (avg_rts or 0))\n147\t / followers * 100, 3)\n148\t stamps = sorted(s for s in (_ts(t[\"created_at\"])\n149\t for t in own) if s)\n150\t tweets_per_week = None\n151\t if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n152\t tweets_per_week = round(\n153\t (len(stamps) - 1) / ((stamps[-1] - stamps[0])\n154\t / 604_800), 2)\n155\t top = max((t for t in own if isinstance(t[\"likes\"], int)),\n156\t key=lambda t: t[\"likes\"], default=None)\n157\t hashtags: dict[str, int] = {}\n158\t for t in own:\n159\t for h in t[\"hashtags\"]:\n160\t hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1\n161\t created_ts = _ts(user.get(\"created_at\"))\n162\t await Actor.push_data({\n163\t \"kind\": \"profile\",\n164\t \"platform\": \"x\",\n165\t \"found\": True,\n166\t \"id\": user.get(\"id_str\"),\n167\t \"username\": user.get(\"screen_name\"),\n168\t \"full_name\": user.get(\"name\"),\n169\t \"biography\": user.get(\"description\"),\n170\t \"location\": user.get(\"location\"),\n171\t \"website\": ((user.get(\"entities\") or {}).get(\"url\") or {})\n172\t .get(\"urls\", [{}])[0].get(\"expanded_url\")\n173\t if (user.get(\"entities\") or {}).get(\"url\") else None,\n174\t \"followers\": followers,\n175\t \"following\": user.get(\"friends_count\"),\n176\t \"tweets_count\": user.get(\"statuses_count\"),\n177\t \"listed_count\": user.get(\"listed_count\"),\n178\t \"likes_given\": user.get(\"favourites_count\"),\n179\t \"is_verified\": bool(user.get(\"verified\")\n180\t or user.get(\"is_blue_verified\")),\n181\t \"is_blue_verified\": user.get(\"is_blue_verified\"),\n182\t \"is_protected\": user.get(\"protected\"),\n183\t \"created_at\": user.get(\"created_at\"),\n184\t \"account_age_days\": (round((time.time() - created_ts)\n185\t / 86_400)\n186\t if created_ts else None),\n187\t \"avatar\": (user.get(\"profile_image_url_https\") or \"\")\n188\t .replace(\"_normal.\", \"_400x400.\"),\n189\t \"banner\": user.get(\"profile_banner_url\"),\n190\t # agrégats sur les tweets récents (hors retweets)\n191\t \"avg_likes\": avg_likes,\n192\t \"avg_retweets\": avg_rts,\n193\t \"avg_replies\": (round(sum(reps) / len(reps))\n194\t if reps else None),\n195\t \"engagement_rate_pct\": engagement,\n196\t \"tweets_per_week\": tweets_per_week,\n197\t \"last_tweet_at\": own[0][\"created_at\"] if own else None,\n198\t \"reply_share_pct\": (round(sum(t[\"is_reply\"]\n199\t for t in own)\n200\t / len(own) * 100)\n201\t if own else None),\n202\t \"media_share_pct\": (round(sum(bool(t[\"media_count\"])\n203\t for t in own)\n204\t / len(own) * 100)\n205\t if own else None),\n206\t \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n207\t reverse=True)[:8],\n208\t \"top_tweet\": ({\"url\": top[\"url\"], \"likes\": top[\"likes\"],\n209\t \"retweets\": top[\"retweets\"]}\n210\t if top else None),\n211\t \"recent_tweets\": tweets,\n212\t })\n213\t\n214\t await asyncio.gather(*[one(u) for u in usernames])\n215\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n216\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0ace5982-6d45-4310-8869-b11c18740482","timestamp":"2026-08-21T00:55:22.988Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-x)\n# Desc: Profils X (Twitter) SANS clé via le service OFFICIEL de syndication\n# des widgets (syndication.twitter.com/srv/timeline-profile) : objet\n# user complet + timeline récente avec métriques par tweet. Conçu pour\n# être appelé sans authentification ; l'empreinte TLS Chrome de\n# curl_cffi évite les 429 réservés aux clients python-requests.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport time\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nSYNDICATION_URL = (\"https://syndication.twitter.com/srv/timeline-profile/\"\n \"screen-name/{u}\")\n_NEXT_DATA_RE = re.compile(\n r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n\n\ndef _find_user(node, handle: str):\n \"\"\"Parcourt le JSON Next.js : l'objet user dont screen_name == handle.\"\"\"\n if isinstance(node, dict):\n if str(node.get(\"screen_name\", \"\")).lower() == handle and \\\n \"followers_count\" in node:\n return node\n for v in node.values():\n hit = _find_user(v, handle)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _find_user(v, handle)\n if hit is not None:\n return hit\n return None\n\n\ndef parse_tweets(data: dict, handle: str, cap: int) -> list[dict]:\n entries = (((data.get(\"props\") or {}).get(\"pageProps\") or {})\n .get(\"timeline\") or {}).get(\"entries\") or []\n tweets: list[dict] = []\n for entry in entries:\n t = (entry.get(\"content\") or {}).get(\"tweet\") or {}\n if not t.get(\"id_str\"):\n continue\n u = (t.get(\"user\") or {})\n ent = t.get(\"entities\") or {}\n media = (t.get(\"extended_entities\") or ent).get(\"media\") or []\n tweets.append({\n \"id\": t.get(\"id_str\"),\n \"url\": f\"https://x.com/{u.get('screen_name', handle)}\"\n f\"/status/{t.get('id_str')}\",\n \"text\": (t.get(\"full_text\") or t.get(\"text\") or \"\")[:500],\n \"likes\": t.get(\"favorite_count\"),\n \"retweets\": t.get(\"retweet_count\"),\n \"replies\": t.get(\"reply_count\"),\n \"quotes\": t.get(\"quote_count\"),\n \"created_at\": t.get(\"created_at\"),\n \"is_retweet\": bool(t.get(\"retweeted_status\")),\n \"is_reply\": bool(t.get(\"in_reply_to_status_id_str\")),\n \"is_quote\": bool(t.get(\"is_quote_status\")),\n \"lang\": t.get(\"lang\"),\n \"hashtags\": [h.get(\"text\") for h in (ent.get(\"hashtags\") or [])\n if h.get(\"text\")][:8],\n \"mentions\": [m.get(\"screen_name\")\n for m in (ent.get(\"user_mentions\") or [])\n if m.get(\"screen_name\")][:8],\n \"media_type\": (media[0].get(\"type\") if media else None),\n \"media_count\": len(media) or None,\n \"links\": [x.get(\"expanded_url\") for x in (ent.get(\"urls\") or [])\n if x.get(\"expanded_url\")][:4],\n })\n if len(tweets) >= cap:\n break\n return tweets\n\n\ndef _ts(created_at: str) -> float | None:\n \"\"\"« Tue Sep 12 23:56:24 +0000 2023 » → epoch (None si illisible).\"\"\"\n try:\n from datetime import datetime\n return datetime.strptime(created_at,\n \"%a %b %d %H:%M:%S %z %Y\").timestamp()\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n max_tweets = int(inp.get(\"maxTweets\", 20))\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"x\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=u, impersonate=None)\n if resp.status_code == 429:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}b\",\n impersonate=\"chrome\")\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _NEXT_DATA_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n user = _find_user(data, u)\n if not user:\n await Actor.push_data({**miss, \"error\": \"user_not_found\"})\n return\n tweets = parse_tweets(data, u, max_tweets)\n own = [t for t in tweets if not t[\"is_retweet\"]]\n likes = [t[\"likes\"] for t in own if isinstance(t[\"likes\"], int)]\n rts = [t[\"retweets\"] for t in own\n if isinstance(t[\"retweets\"], int)]\n reps = [t[\"replies\"] for t in own\n if isinstance(t[\"replies\"], int)]\n followers = user.get(\"followers_count\")\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n avg_rts = round(sum(rts) / len(rts)) if rts else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round((avg_likes + (avg_rts or 0))\n / followers * 100, 3)\n stamps = sorted(s for s in (_ts(t[\"created_at\"])\n for t in own) if s)\n tweets_per_week = None\n if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n tweets_per_week = round(\n (len(stamps) - 1) / ((stamps[-1] - stamps[0])\n / 604_800), 2)\n top = max((t for t in own if isinstance(t[\"likes\"], int)),\n key=lambda t: t[\"likes\"], default=None)\n hashtags: dict[str, int] = {}\n for t in own:\n for h in t[\"hashtags\"]:\n hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1\n created_ts = _ts(user.get(\"created_at\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"x\",\n \"found\": True,\n \"id\": user.get(\"id_str\"),\n \"username\": user.get(\"screen_name\"),\n \"full_name\": user.get(\"name\"),\n \"biography\": user.get(\"description\"),\n \"location\": user.get(\"location\"),\n \"website\": ((user.get(\"entities\") or {}).get(\"url\") or {})\n .get(\"urls\", [{}])[0].get(\"expanded_url\")\n if (user.get(\"entities\") or {}).get(\"url\") else None,\n \"followers\": followers,\n \"following\": user.get(\"friends_count\"),\n \"tweets_count\": user.get(\"statuses_count\"),\n \"listed_count\": user.get(\"listed_count\"),\n \"likes_given\": user.get(\"favourites_count\"),\n \"is_verified\": bool(user.get(\"verified\")\n or user.get(\"is_blue_verified\")),\n \"is_blue_verified\": user.get(\"is_blue_verified\"),\n \"is_protected\": user.get(\"protected\"),\n \"created_at\": user.get(\"created_at\"),\n \"account_age_days\": (round((time.time() - created_ts)\n / 86_400)\n if created_ts else None),\n \"avatar\": (user.get(\"profile_image_url_https\") or \"\")\n .replace(\"_normal.\", \"_400x400.\"),\n \"banner\": user.get(\"profile_banner_url\"),\n # agrégats sur les tweets récents (hors retweets)\n \"avg_likes\": avg_likes,\n \"avg_retweets\": avg_rts,\n \"avg_replies\": (round(sum(reps) / len(reps))\n if reps else None),\n \"engagement_rate_pct\": engagement,\n \"tweets_per_week\": tweets_per_week,\n \"last_tweet_at\": own[0][\"created_at\"] if own else None,\n \"reply_share_pct\": (round(sum(t[\"is_reply\"]\n for t in own)\n / len(own) * 100)\n if own else None),\n \"media_share_pct\": (round(sum(bool(t[\"media_count\"])\n for t in own)\n / len(own) * 100)\n if own else None),\n \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n reverse=True)[:8],\n \"top_tweet\": ({\"url\": top[\"url\"], \"likes\": top[\"likes\"],\n \"retweets\": top[\"retweets\"]}\n if top else None),\n \"recent_tweets\": tweets,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","numLines":216,"startLine":1,"totalLines":216}}}} |
|
168 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3c2c7e98-09d8-47c4-b3ed-138b6b0165f5","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
169 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEywZ7GesBH19bSqtwWT","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS6gwKpAEIERgCKkChMWkY2CQzaAjJkFDRQeisEJI0xwiA+WVWpgEYrteQZjw2MzbYN0OnRroMdsUR9YrIbU46jIelGjkNslakLj0BMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGIxJ7UBhIMWemZfYKSNLDg1WleGgzR+mEyWp52Zmky+HUiMBtf+CP+XltILghTtgt43M/iFJR7/3Xl4KRa2WNpAM3a/P0Jx6ECrRBU6qTZBPtFgSryCjC0BZ1qnKn9aFwSW6fTXtOeRd8jnOUSnRxjJ2CvjXMOlPxXcYZKxWOYKDtUTIz7s4CMT7QS4CtwMW0NheexL3i9ztDKTlYt+xbc0GRkw8AhN8dJ8tIxulca2McCtgEWHpUCD1Ts12hlr13M3m2s80yMarZgVn5P80cJhKMRXFtCo0eRwutJpF04wYEtajx3PByTIGd5DZHMCeZdNSODsl8ZCvRlXLq9HJK6eE1o2jzIv44d+Ob89DYTFoXgS2YtGaOoyhu5VJjXumUQFwTAnqe6Es+iUEShpormutUexoz/M27s0n8hY902tGDeHgj/0lLjYb7CQrc7fw/fRoC1liPaWu8TFFuXo4c4a6CaQat0aU9DUGBK51FD0R9AjQU3F7PhC2KBfu8tLx/cGyspahlqQtRPaifOmJiDLv4eLv8Ts9+VvaQCL4uks4jV5Y0wGBiPCpR7mH3GWeDIY64HMgu59MISzPxFS+okUJKJnUjaHjJ5/s3ssm/7oldH3JxHd2PuHM8lhAnmGwBS8HwRwG6aeot0fIExYaWbyGOJuFuZ9dncCPix+5nIXaW+1I7SshrMJUaoIztqVV/BTUjgwjUxlpgAkEvkjbswSrUG8EFBE8GJdYEglqGa8ez1QDZwxDoCBb6myvO9toKBlk+bilT5U2SnPY2wX4Y39Gkn5rl8fgbZFyG5ZfMn7EQ2YLg6qLcg6sILd7bGMHRr+gOuVJTjJhydy8YeiHvUei1qP0bVfG1aEr+bCvgh8vcR663KjHSHATLYAQ1W78TEYvyPpn+OMFCgBAmv397YHpA8TJgNfJgFfIGjAkOtlIjBtsDEL715tZJOKXAEjzzC0hA2/6ZJlzyXYOxom3PNkbn6tmNPNgVRAUuQbkzScXH0xR3NWMv0epH9P4RVGWrOmr1y/Szev+KxAN7sUUxRWXnxyhibs8JE/AYk26YzWNfBQj52cZ2hMGXO+gOe+9iWmzvOq5/qPBWE0rcdJKhtjg4sEfgxxkm8wBOy8nzXmM1PWWQ9OAP+JptXKkrpSbksvA32X/XEETtvO90lo3asvbuOzBKvMyYHtnpiECJxWJRVEft8Qz9tiOeAk6f9M/jKUB/eYB8ieRPcFAnlv0si+aQ1Lp7zvpOUq5SRKurOVD1of7pEUPxNBD+azUD8JRdhutitsucTECR4rznQpGWsRrcCZdtj4he+66snFQ3coCBvgLtWRN943FXBemYhg1dLgkPQ8caFBSVtrntsGDdM/5GeWhn6HswGjsm7rk7GBErPiBZuj1NeSRJeXlNUr103jUxct46vR5mDeoH2XgzxDjAxwBmPwk0WCrAtGPRQEASFhLnUiKFFhUK+b/Tq/l1sE5BuikCPKxhT+ADoMkbRndOmBllE2Cl72R6NBbCv6zbbm5ZqV73qN6h77cLHPPHDqBJKl6Umxo107c9E95PGO9C+IyPbndbnsKhQSZNkkVtUkmfbU75v+ZS7GXAMdsk26QP8P4l9cL5GZouAc/juXchnEA/tQqiroS/2bkJNL/M9qzNUfi1CRo/1rEw8caUPVHoB7XEYTuc8zYWxKl+k5oZCKYxJuX/PHvdg6f/1BWVWRXBjE1hZeIZipeWYPhltaag+2J/1vN8fjgMLLim1P2GAvuyyloglb99lnNzFsMfMooHNj6Ma/5jzZ8oc8a8SxcjP69Hg28fYZrD80qTCTNKSnfGrzDrbMfqcmsXGxQnUtCavxJBShoN1IT1AOWntt/11UDL4b2OPTi9u1cZhCxld3J0kxDxTQAw9qnx0FNr9lL+ffudTLpP5Ok0ij7WAhil//7i/ReCzTYMTxTzFMF75FgzudXb/9revEMVJ2iQcaY+vt1eWGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2418,"cache_creation_input_tokens":4119,"cache_read_input_tokens":90361,"cache_creation":{"ephemeral_5m_input_tokens":4119,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"179ba350-4fa8-42c5-8828-077b97ccdeb0"}} |
|
170 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEywZ7GesBH19bSqtwWT","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmgQKpQEIERgCKkBxXVC6NgOf87gkvd53cw+rba6AiSgW1TDjO2JeEP6IkkKHzRYYloRx1JZfGooq+TWxIac84EHarz75s19vCkHAMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBiMSe1AYSDEpJSNj39H9J3AWbxBoMqS2XCsLMvIclRcnfIjAfYNzZvsHQ5EL1iuJ1Qq/s56QD4ak3/cqz6KLk2eFS5skPA0DxOqxZus9pAEtN7+gqoQLRIPbbHEREEsGnV+Qb/kxlzgd5bClBzntB9xjZ5VZChRMScSHYOQsB2Rs1GKftYNHW/awp1rJKywHbKCfz7zFmTJiU+MeEwcYX3t+e++JewDZS1T+ZRhfKDQwEg5SKRZHn3M1iTDPGMo8yPhbxDRn/yIrz9YW675mIaZRHYbC5KE4ejG0N/MUp2UqyqEpk/SH1UCfpYMuWu2IqwQ8m4qZ+jHjZdgxoQDhklG2iMeDgunLCA6W9wZNxRHB8wlGEHoFB1qU0nv+hQXQt/MRJb7ErcPrqvxaaN7srii+hbIoGkNX+guUyxJS3HMj9PNaxxFgDsWwQquJ/7++zx4CiIhm8b8eOjtwV5cCFv28b6Hqi0NpyN6JXuVXvvetijLlrLmEJGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2418,"cache_creation_input_tokens":4119,"cache_read_input_tokens":90361,"cache_creation":{"ephemeral_5m_input_tokens":4119,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fcfcbdbe-4939-4c81-b5d7-0818f860637b"}} |
|
171 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEywZ7GesBH19bSqtwWT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019pCP8FJiRmEsk3SVGYqCrY","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2418,"cache_creation_input_tokens":4119,"cache_read_input_tokens":90361,"cache_creation":{"ephemeral_5m_input_tokens":4119,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"cf213303-2177-439b-9f21-fbad0e2a74bc"}} |
|
172 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019pCP8FJiRmEsk3SVGYqCrY","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-kick)\n4\t# Desc: Chaînes Kick détaillées via l'API publique /api/v2/channels/{slug}\n5\t# (Cloudflare : proxy résidentiel + empreinte Chrome) : abonnés, badge,\n6\t# live en cours, bio + LIENS SOCIAUX AUTO-DÉCLARÉS (instagram/twitter/\n7\t# youtube/tiktok/facebook/discord → cross_link 0.90 côté crea-ka).\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport json\n13\t\n14\tfrom apify import Actor\n15\t\n16\tfrom .net import Fetcher\n17\t\n18\tAPI_URL = \"https://kick.com/api/v2/channels/{u}\"\n19\tSOCIAL_KEYS = (\"instagram\", \"twitter\", \"youtube\", \"tiktok\", \"facebook\",\n20\t \"discord\")\n21\t\n22\t\n23\tasync def main() -> None:\n24\t async with Actor:\n25\t inp = await Actor.get_input() or {}\n26\t usernames = [u.strip().lstrip(\"@\").lower()\n27\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n28\t proxy = await Actor.create_proxy_configuration(\n29\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n30\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n31\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n32\t\n33\t async def one(u: str) -> None:\n34\t async with sem:\n35\t miss = {\"kind\": \"profile\", \"platform\": \"kick\",\n36\t \"username\": u, \"found\": False}\n37\t resp = None\n38\t for attempt in range(3): # mur Cloudflare → nouvelle IP\n39\t try:\n40\t resp = await fetcher.get(\n41\t API_URL.format(u=u), session_id=f\"{u}k{attempt}\",\n42\t headers={\"Accept\": \"application/json\"})\n43\t except Exception as exc:\n44\t await Actor.push_data({**miss,\n45\t \"error\": str(exc)[:200]})\n46\t return\n47\t if resp.status_code == 404:\n48\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n49\t return\n50\t if resp.status_code == 200 and \\\n51\t (resp.text or \"\").startswith(\"{\"):\n52\t break\n53\t if resp.status_code != 200 or \\\n54\t not (resp.text or \"\").startswith(\"{\"):\n55\t await Actor.push_data(\n56\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n57\t return\n58\t try:\n59\t ch = json.loads(resp.text)\n60\t except Exception:\n61\t await Actor.push_data({**miss, \"error\": \"bad_json\"})\n62\t return\n63\t user = ch.get(\"user\") or {}\n64\t live = ch.get(\"livestream\") or {}\n65\t cats = [((c.get(\"category\") or {}).get(\"name\"))\n66\t for c in (ch.get(\"recent_categories\") or [])\n67\t if isinstance(c, dict)]\n68\t socials = {k: (user.get(k) or \"\").strip()\n69\t for k in SOCIAL_KEYS if (user.get(k) or \"\").strip()}\n70\t await Actor.push_data({\n71\t \"kind\": \"profile\",\n72\t \"platform\": \"kick\",\n73\t \"found\": True,\n74\t \"id\": ch.get(\"id\"),\n75\t \"username\": ch.get(\"slug\"),\n76\t \"full_name\": user.get(\"username\"),\n77\t \"biography\": user.get(\"bio\"),\n78\t \"followers\": ch.get(\"followers_count\")\n79\t or ch.get(\"followersCount\"),\n80\t \"is_verified\": bool(ch.get(\"verified\")),\n81\t \"is_banned\": bool(ch.get(\"is_banned\")),\n82\t \"subscription_enabled\": ch.get(\"subscription_enabled\"),\n83\t \"is_live_now\": bool(live),\n84\t \"live_viewers\": live.get(\"viewer_count\"),\n85\t \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n86\t or None,\n87\t \"avatar\": user.get(\"profile_pic\"),\n88\t \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")\n89\t if isinstance(ch.get(\"banner_image\"), dict)\n90\t else None),\n91\t \"recent_categories\": [c for c in cats if c][:5],\n92\t \"social_links\": socials,\n93\t })\n94\t\n95\t await asyncio.gather(*[one(u) for u in usernames])\n96\t Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n97\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"40c5c3c7-3eb5-4154-8576-9ef145ad6d20","timestamp":"2026-08-21T00:55:36.889Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-kick)\n# Desc: Chaînes Kick détaillées via l'API publique /api/v2/channels/{slug}\n# (Cloudflare : proxy résidentiel + empreinte Chrome) : abonnés, badge,\n# live en cours, bio + LIENS SOCIAUX AUTO-DÉCLARÉS (instagram/twitter/\n# youtube/tiktok/facebook/discord → cross_link 0.90 côté crea-ka).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nAPI_URL = \"https://kick.com/api/v2/channels/{u}\"\nSOCIAL_KEYS = (\"instagram\", \"twitter\", \"youtube\", \"tiktok\", \"facebook\",\n \"discord\")\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"kick\",\n \"username\": u, \"found\": False}\n resp = None\n for attempt in range(3): # mur Cloudflare → nouvelle IP\n try:\n resp = await fetcher.get(\n API_URL.format(u=u), session_id=f\"{u}k{attempt}\",\n headers={\"Accept\": \"application/json\"})\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n if resp.status_code == 200 and \\\n (resp.text or \"\").startswith(\"{\"):\n break\n if resp.status_code != 200 or \\\n not (resp.text or \"\").startswith(\"{\"):\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n ch = json.loads(resp.text)\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n user = ch.get(\"user\") or {}\n live = ch.get(\"livestream\") or {}\n cats = [((c.get(\"category\") or {}).get(\"name\"))\n for c in (ch.get(\"recent_categories\") or [])\n if isinstance(c, dict)]\n socials = {k: (user.get(k) or \"\").strip()\n for k in SOCIAL_KEYS if (user.get(k) or \"\").strip()}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"kick\",\n \"found\": True,\n \"id\": ch.get(\"id\"),\n \"username\": ch.get(\"slug\"),\n \"full_name\": user.get(\"username\"),\n \"biography\": user.get(\"bio\"),\n \"followers\": ch.get(\"followers_count\")\n or ch.get(\"followersCount\"),\n \"is_verified\": bool(ch.get(\"verified\")),\n \"is_banned\": bool(ch.get(\"is_banned\")),\n \"subscription_enabled\": ch.get(\"subscription_enabled\"),\n \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,\n \"avatar\": user.get(\"profile_pic\"),\n \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")\n if isinstance(ch.get(\"banner_image\"), dict)\n else None),\n \"recent_categories\": [c for c in cats if c][:5],\n \"social_links\": socials,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","numLines":97,"startLine":1,"totalLines":97}}}} |
|
173 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEywZ7GesBH19bSqtwWT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AZAa2CspZdzUPo6iLhGKkN","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2418,"cache_creation_input_tokens":4119,"cache_read_input_tokens":90361,"cache_creation":{"ephemeral_5m_input_tokens":4119,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"024113ba-9c0a-4475-b319-2ed39a08d5ed"}} |
|
174 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AZAa2CspZdzUPo6iLhGKkN","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-facebook)\n4\t# Desc: Pages Facebook publiques — extraction best-effort du HTML servi aux\n5\t# navigateurs non connectés : compteur d'abonnés (JSON embarqué\n6\t# follower_count / texte « N followers/abonnés »), nom (og:title),\n7\t# avatar (og:image), catégorie. EXPÉRIMENTAL : FB mure agressivement ;\n8\t# un résultat found=False n'est pas une erreur du pipeline.\n9\t# ==============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport asyncio\n13\timport html as htmllib\n14\timport re\n15\t\n16\tfrom apify import Actor\n17\t\n18\tfrom .net import Fetcher\n19\t\n20\tPAGE_URL = \"https://www.facebook.com/{u}\"\n21\t\n22\t_OG_RE = {\n23\t \"title\": re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"'),\n24\t \"image\": re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"'),\n25\t \"description\": re.compile(\n26\t r'<meta (?:property=\"og:description\"|name=\"description\") '\n27\t r'content=\"([^\"]*)\"'),\n28\t}\n29\t_FOLLOWER_RES = (\n30\t re.compile(r'\"follower_count\"\\s*:\\s*(\\d+)'),\n31\t re.compile(r'\"global_likers_count\"\\s*:\\s*(\\d+)'),\n32\t)\n33\t# texte « 1,2 M followers » / « 12 k abonnés » / « 4 016 971 mentions J’aime »\n34\t_TEXT_COUNT_RE = re.compile(\n35\t r'([\\d][\\d\\s .,]*)\\s*([KkMm]?)\\s*'\n36\t r'(?:followers|abonnés|mentions\\s+J’aime|J’aime|likes)',\n37\t re.I)\n38\t_CATEGORY_RE = re.compile(r'\"category_name\"\\s*:\\s*\"([^\"]+)\"')\n39\t_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n40\t\n41\t\n42\tdef parse_text_count(text: str) -> int | None:\n43\t m = _TEXT_COUNT_RE.search(text or \"\")\n44\t if not m:\n45\t return None\n46\t num = m.group(1).replace(\" \", \"\").replace(\" \", \"\").replace(\",\", \".\")\n47\t try:\n48\t val = float(num)\n49\t except ValueError:\n50\t return None\n51\t unit = (m.group(2) or \"\").lower()\n52\t if unit == \"k\":\n53\t val *= 1_000\n54\t elif unit == \"m\":\n55\t val *= 1_000_000\n56\t return int(val)\n57\t\n58\t\n59\tasync def main() -> None:\n60\t async with Actor:\n61\t inp = await Actor.get_input() or {}\n62\t usernames = []\n63\t for raw in (inp.get(\"usernames\") or []):\n64\t raw = (raw or \"\").strip()\n65\t if not raw:\n66\t continue\n67\t raw = re.sub(r\"^https?://(www\\.|m\\.)?facebook\\.com/\", \"\", raw)\n68\t usernames.append(raw.strip(\"/\").split(\"?\")[0])\n69\t proxy = await Actor.create_proxy_configuration(\n70\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n71\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 2))\n72\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 2))\n73\t\n74\t async def one(u: str) -> None:\n75\t async with sem:\n76\t miss = {\"kind\": \"profile\", \"platform\": \"facebook\",\n77\t \"username\": u, \"found\": False}\n78\t try:\n79\t resp = await fetcher.get(PAGE_URL.format(u=u),\n80\t session_id=u)\n81\t except Exception as exc:\n82\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n83\t return\n84\t text = resp.text or \"\"\n85\t if resp.status_code != 200 or len(text) < 5000:\n86\t await Actor.push_data(\n87\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n88\t return\n89\t followers = None\n90\t for rx in _FOLLOWER_RES:\n91\t m = rx.search(text)\n92\t if m:\n93\t followers = int(m.group(1))\n94\t break\n95\t og = {k: htmllib.unescape(m.group(1)) if (m := rx.search(text))\n96\t else None for k, rx in _OG_RE.items()}\n97\t if followers is None:\n98\t followers = parse_text_count(og.get(\"description\") or \"\")\n99\t if followers is None: # texte « N followers/abonnés » du corps\n100\t followers = parse_text_count(text)\n101\t if followers is None and not og.get(\"title\"):\n102\t await Actor.push_data({**miss, \"error\": \"login_wall\"})\n103\t return\n104\t cat = _CATEGORY_RE.search(text)\n105\t ver = _VERIFIED_RE.search(text)\n106\t await Actor.push_data({\n107\t \"kind\": \"profile\",\n108\t \"platform\": \"facebook\",\n109\t \"found\": True,\n110\t \"username\": u,\n111\t \"full_name\": og.get(\"title\"),\n112\t \"biography\": og.get(\"description\"),\n113\t \"followers\": followers,\n114\t \"category\": cat.group(1) if cat else None,\n115\t \"is_verified\": (ver.group(1) == \"true\") if ver else None,\n116\t \"avatar\": og.get(\"image\"),\n117\t \"og_description\": og.get(\"description\"),\n118\t })\n119\t\n120\t await asyncio.gather(*[one(u) for u in usernames])\n121\t Actor.log.info(f\"terminé : {len(usernames)} pages\")\n122\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"199abb3e-f7d3-46ca-8885-72e3de91aa3a","timestamp":"2026-08-21T00:55:37.134Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-facebook)\n# Desc: Pages Facebook publiques — extraction best-effort du HTML servi aux\n# navigateurs non connectés : compteur d'abonnés (JSON embarqué\n# follower_count / texte « N followers/abonnés »), nom (og:title),\n# avatar (og:image), catégorie. EXPÉRIMENTAL : FB mure agressivement ;\n# un résultat found=False n'est pas une erreur du pipeline.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPAGE_URL = \"https://www.facebook.com/{u}\"\n\n_OG_RE = {\n \"title\": re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"'),\n \"image\": re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"'),\n \"description\": re.compile(\n r'<meta (?:property=\"og:description\"|name=\"description\") '\n r'content=\"([^\"]*)\"'),\n}\n_FOLLOWER_RES = (\n re.compile(r'\"follower_count\"\\s*:\\s*(\\d+)'),\n re.compile(r'\"global_likers_count\"\\s*:\\s*(\\d+)'),\n)\n# texte « 1,2 M followers » / « 12 k abonnés » / « 4 016 971 mentions J’aime »\n_TEXT_COUNT_RE = re.compile(\n r'([\\d][\\d\\s .,]*)\\s*([KkMm]?)\\s*'\n r'(?:followers|abonnés|mentions\\s+J’aime|J’aime|likes)',\n re.I)\n_CATEGORY_RE = re.compile(r'\"category_name\"\\s*:\\s*\"([^\"]+)\"')\n_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n\n\ndef parse_text_count(text: str) -> int | None:\n m = _TEXT_COUNT_RE.search(text or \"\")\n if not m:\n return None\n num = m.group(1).replace(\" \", \"\").replace(\" \", \"\").replace(\",\", \".\")\n try:\n val = float(num)\n except ValueError:\n return None\n unit = (m.group(2) or \"\").lower()\n if unit == \"k\":\n val *= 1_000\n elif unit == \"m\":\n val *= 1_000_000\n return int(val)\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = []\n for raw in (inp.get(\"usernames\") or []):\n raw = (raw or \"\").strip()\n if not raw:\n continue\n raw = re.sub(r\"^https?://(www\\.|m\\.)?facebook\\.com/\", \"\", raw)\n usernames.append(raw.strip(\"/\").split(\"?\")[0])\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 2))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 2))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"facebook\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PAGE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n text = resp.text or \"\"\n if resp.status_code != 200 or len(text) < 5000:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n followers = None\n for rx in _FOLLOWER_RES:\n m = rx.search(text)\n if m:\n followers = int(m.group(1))\n break\n og = {k: htmllib.unescape(m.group(1)) if (m := rx.search(text))\n else None for k, rx in _OG_RE.items()}\n if followers is None:\n followers = parse_text_count(og.get(\"description\") or \"\")\n if followers is None: # texte « N followers/abonnés » du corps\n followers = parse_text_count(text)\n if followers is None and not og.get(\"title\"):\n await Actor.push_data({**miss, \"error\": \"login_wall\"})\n return\n cat = _CATEGORY_RE.search(text)\n ver = _VERIFIED_RE.search(text)\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"facebook\",\n \"found\": True,\n \"username\": u,\n \"full_name\": og.get(\"title\"),\n \"biography\": og.get(\"description\"),\n \"followers\": followers,\n \"category\": cat.group(1) if cat else None,\n \"is_verified\": (ver.group(1) == \"true\") if ver else None,\n \"avatar\": og.get(\"image\"),\n \"og_description\": og.get(\"description\"),\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","numLines":122,"startLine":1,"totalLines":122}}}} |
|
175 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"95df7334-de4e-401b-8085-8fadd474ea1d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
176 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyxbgpbuwM3PEJ9t4wQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014Dk6CVPy5i935DthdNxXZa","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7107,"cache_read_input_tokens":94480,"cache_creation":{"ephemeral_5m_input_tokens":7107,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e02904ca-8e9c-440d-99c5-7e37d74c6d19"}} |
|
177 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014Dk6CVPy5i935DthdNxXZa","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-threads)\n4\t# Desc: Profils Meta Threads publics : la page @handle embarque le profil\n5\t# dans des scripts JSON Relay (follower_count, biography, badge,\n6\t# avatar) + les posts récents (caption/like_count) quand servis.\n7\t# Extraction par regex ciblées — robuste aux réorganisations du JSON.\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport html as htmllib\n13\timport json\n14\timport re\n15\t\n16\tfrom apify import Actor\n17\t\n18\tfrom .net import Fetcher\n19\t\n20\tPROFILE_URL = \"https://www.threads.com/@{u}\"\n21\t\n22\t_FOLLOWERS_RE = re.compile(r'\"follower_count\"\\s*:\\s*(\\d+)')\n23\t_BIO_RE = re.compile(r'\"biography\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n24\t_NAME_RE = re.compile(r'\"full_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n25\t_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n26\t_PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n27\t# posts : paires texte + like_count dans les payloads thread_items\n28\t_POST_RE = re.compile(\n29\t r'\"caption\"\\s*:\\s*\\{\\s*\"text\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"[^{}]*?\\}'\n30\t r'.{0,600}?\"like_count\"\\s*:\\s*(\\d+)', re.S)\n31\t\n32\t\n33\tasync def main() -> None:\n34\t async with Actor:\n35\t inp = await Actor.get_input() or {}\n36\t usernames = [u.strip().lstrip(\"@\").lower()\n37\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n38\t proxy = await Actor.create_proxy_configuration(\n39\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n40\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 2))\n41\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 2))\n42\t\n43\t async def one(u: str) -> None:\n44\t async with sem:\n45\t miss = {\"kind\": \"profile\", \"platform\": \"threads\",\n46\t \"username\": u, \"found\": False}\n47\t try:\n48\t resp = await fetcher.get(PROFILE_URL.format(u=u),\n49\t session_id=u)\n50\t except Exception as exc:\n51\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n52\t return\n53\t text = resp.text or \"\"\n54\t fol = _FOLLOWERS_RE.search(text)\n55\t if resp.status_code != 200 or not fol:\n56\t await Actor.push_data(\n57\t {**miss,\n58\t \"error\": f\"shell_or_{resp.status_code}\"})\n59\t return\n60\t posts = []\n61\t for m in _POST_RE.finditer(text):\n62\t cap = _dec(m.group(1))[:500]\n63\t if cap and all(p[\"caption\"] != cap for p in posts):\n64\t posts.append({\"caption\": cap,\n65\t \"likes\": int(m.group(2))})\n66\t if len(posts) >= 15:\n67\t break\n68\t bio = _BIO_RE.search(text)\n69\t name = _NAME_RE.search(text)\n70\t ver = _VERIFIED_RE.search(text)\n71\t pic = _PIC_RE.search(text)\n72\t likes = [p[\"likes\"] for p in posts]\n73\t top = max(posts, key=lambda p: p[\"likes\"], default=None)\n74\t await Actor.push_data({\n75\t \"kind\": \"profile\",\n76\t \"platform\": \"threads\",\n77\t \"found\": True,\n78\t \"username\": u,\n79\t \"full_name\": _dec(name.group(1)) if name else None,\n80\t \"biography\": _dec(bio.group(1)) if bio else None,\n81\t \"followers\": int(fol.group(1)),\n82\t \"is_verified\": (ver.group(1) == \"true\") if ver else None,\n83\t \"avatar\": _dec(pic.group(1)) if pic else None,\n84\t \"avg_likes\": round(sum(likes) / len(likes))\n85\t if likes else None,\n86\t \"top_post\": top,\n87\t \"recent_posts\": posts,\n88\t })\n89\t\n90\t await asyncio.gather(*[one(u) for u in usernames])\n91\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n92\t\n93\t\n94\tdef _dec(raw: str) -> str:\n95\t \"\"\"Décode une chaîne échappée JSON (\\\\uXXXX, \\\\/ …).\"\"\"\n96\t try:\n97\t return htmllib.unescape(json.loads(f'\"{raw}\"'))\n98\t except Exception:\n99\t return raw\n100\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ee9dc901-964a-48c2-a55a-f93260b67adb","timestamp":"2026-08-21T00:55:41.403Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-threads)\n# Desc: Profils Meta Threads publics : la page @handle embarque le profil\n# dans des scripts JSON Relay (follower_count, biography, badge,\n# avatar) + les posts récents (caption/like_count) quand servis.\n# Extraction par regex ciblées — robuste aux réorganisations du JSON.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.threads.com/@{u}\"\n\n_FOLLOWERS_RE = re.compile(r'\"follower_count\"\\s*:\\s*(\\d+)')\n_BIO_RE = re.compile(r'\"biography\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_NAME_RE = re.compile(r'\"full_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n_PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# posts : paires texte + like_count dans les payloads thread_items\n_POST_RE = re.compile(\n r'\"caption\"\\s*:\\s*\\{\\s*\"text\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"[^{}]*?\\}'\n r'.{0,600}?\"like_count\"\\s*:\\s*(\\d+)', re.S)\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 2))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 2))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"threads\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n text = resp.text or \"\"\n fol = _FOLLOWERS_RE.search(text)\n if resp.status_code != 200 or not fol:\n await Actor.push_data(\n {**miss,\n \"error\": f\"shell_or_{resp.status_code}\"})\n return\n posts = []\n for m in _POST_RE.finditer(text):\n cap = _dec(m.group(1))[:500]\n if cap and all(p[\"caption\"] != cap for p in posts):\n posts.append({\"caption\": cap,\n \"likes\": int(m.group(2))})\n if len(posts) >= 15:\n break\n bio = _BIO_RE.search(text)\n name = _NAME_RE.search(text)\n ver = _VERIFIED_RE.search(text)\n pic = _PIC_RE.search(text)\n likes = [p[\"likes\"] for p in posts]\n top = max(posts, key=lambda p: p[\"likes\"], default=None)\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"threads\",\n \"found\": True,\n \"username\": u,\n \"full_name\": _dec(name.group(1)) if name else None,\n \"biography\": _dec(bio.group(1)) if bio else None,\n \"followers\": int(fol.group(1)),\n \"is_verified\": (ver.group(1) == \"true\") if ver else None,\n \"avatar\": _dec(pic.group(1)) if pic else None,\n \"avg_likes\": round(sum(likes) / len(likes))\n if likes else None,\n \"top_post\": top,\n \"recent_posts\": posts,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n\n\ndef _dec(raw: str) -> str:\n \"\"\"Décode une chaîne échappée JSON (\\\\uXXXX, \\\\/ …).\"\"\"\n try:\n return htmllib.unescape(json.loads(f'\"{raw}\"'))\n except Exception:\n return raw\n","numLines":100,"startLine":1,"totalLines":100}}}} |
|
178 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyxbgpbuwM3PEJ9t4wQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0195pFFttcjVpVFLyr1QaTiP","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7107,"cache_read_input_tokens":94480,"cache_creation":{"ephemeral_5m_input_tokens":7107,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3750e085-4971-4faa-a7e8-731e7e679041"}} |
|
179 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0195pFFttcjVpVFLyr1QaTiP","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-snapchat)\n4\t# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page\n5\t# snapchat.com/add/<user> : publicProfileInfo (abonnés, bio, badge,\n6\t# catégorie, avatar) + présence story/spotlight + comptes reliés.\n7\t# Structure validée le 2026-08-18 (pageProps.userProfile.$case).\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport json\n13\timport re\n14\t\n15\tfrom apify import Actor\n16\t\n17\tfrom .net import Fetcher\n18\t\n19\tPROFILE_URL = \"https://www.snapchat.com/add/{u}\"\n20\t_NEXT_DATA_RE = re.compile(\n21\t r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n22\t\n23\t\n24\tdef _num(v):\n25\t try:\n26\t return int(v)\n27\t except (TypeError, ValueError):\n28\t return None\n29\t\n30\t\n31\tasync def main() -> None:\n32\t async with Actor:\n33\t inp = await Actor.get_input() or {}\n34\t usernames = [u.strip().lstrip(\"@\").lower()\n35\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n36\t proxy = await Actor.create_proxy_configuration(\n37\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n38\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n39\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n40\t\n41\t async def one(u: str) -> None:\n42\t async with sem:\n43\t miss = {\"kind\": \"profile\", \"platform\": \"snapchat\",\n44\t \"username\": u, \"found\": False}\n45\t try:\n46\t resp = await fetcher.get(PROFILE_URL.format(u=u),\n47\t session_id=u)\n48\t except Exception as exc:\n49\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n50\t return\n51\t m = _NEXT_DATA_RE.search(resp.text or \"\")\n52\t if resp.status_code != 200 or not m:\n53\t await Actor.push_data(\n54\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n55\t return\n56\t try:\n57\t props = (json.loads(m.group(1)).get(\"props\") or {}) \\\n58\t .get(\"pageProps\") or {}\n59\t except Exception:\n60\t await Actor.push_data({**miss, \"error\": \"bad_json\"})\n61\t return\n62\t up = props.get(\"userProfile\") or {}\n63\t info = up.get(\"publicProfileInfo\") or {}\n64\t story = (props.get(\"story\") or {})\n65\t snaps = story.get(\"snapList\") or []\n66\t lenses = props.get(\"lenses\") or []\n67\t spotlight = props.get(\"spotlightHighlights\") or []\n68\t if not info.get(\"username\"):\n69\t await Actor.push_data({**miss, \"error\": \"no_profile\"})\n70\t return\n71\t related = [\n72\t {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n73\t for r in ((info.get(\"relatedAccountsInfo\") or [])\n74\t if isinstance(info.get(\"relatedAccountsInfo\"),\n75\t list) else [])\n76\t if isinstance(r, dict) and r.get(\"username\")]\n77\t subs = _num(info.get(\"subscriberCount\"))\n78\t await Actor.push_data({\n79\t \"kind\": \"profile\",\n80\t \"platform\": \"snapchat\",\n81\t \"found\": True,\n82\t \"username\": info.get(\"username\"),\n83\t \"full_name\": info.get(\"title\"),\n84\t \"biography\": info.get(\"bio\"),\n85\t \"followers\": subs if subs else None, # 0 = masqué\n86\t \"is_verified\": str(info.get(\"badge\")) == \"1\",\n87\t \"category\": info.get(\"categoryStringId\"),\n88\t \"website\": info.get(\"websiteUrl\"),\n89\t \"address\": info.get(\"address\"),\n90\t \"avatar\": info.get(\"profilePictureUrl\"),\n91\t \"has_story\": bool(info.get(\"hasStory\")),\n92\t \"has_spotlight\": bool(info.get(\"hasSpotlightHighlights\")),\n93\t \"has_curated_highlights\": bool(\n94\t info.get(\"hasCuratedHighlights\")),\n95\t \"story_snaps_count\": len(snaps) or None,\n96\t \"lenses_count\": len(lenses) or None,\n97\t \"spotlight_highlights_count\": len(spotlight) or None,\n98\t \"publisher_type\": info.get(\"publisherType\") or None,\n99\t \"subcategory\": info.get(\"subcategoryStringId\") or None,\n100\t \"related_accounts\": related,\n101\t })\n102\t\n103\t await asyncio.gather(*[one(u) for u in usernames])\n104\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n105\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e12c424d-1015-4237-b471-b84dcaeb7b4a","timestamp":"2026-08-21T00:55:41.587Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-snapchat)\n# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page\n# snapchat.com/add/<user> : publicProfileInfo (abonnés, bio, badge,\n# catégorie, avatar) + présence story/spotlight + comptes reliés.\n# Structure validée le 2026-08-18 (pageProps.userProfile.$case).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.snapchat.com/add/{u}\"\n_NEXT_DATA_RE = re.compile(\n r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"snapchat\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _NEXT_DATA_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n props = (json.loads(m.group(1)).get(\"props\") or {}) \\\n .get(\"pageProps\") or {}\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n up = props.get(\"userProfile\") or {}\n info = up.get(\"publicProfileInfo\") or {}\n story = (props.get(\"story\") or {})\n snaps = story.get(\"snapList\") or []\n lenses = props.get(\"lenses\") or []\n spotlight = props.get(\"spotlightHighlights\") or []\n if not info.get(\"username\"):\n await Actor.push_data({**miss, \"error\": \"no_profile\"})\n return\n related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]\n subs = _num(info.get(\"subscriberCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"snapchat\",\n \"found\": True,\n \"username\": info.get(\"username\"),\n \"full_name\": info.get(\"title\"),\n \"biography\": info.get(\"bio\"),\n \"followers\": subs if subs else None, # 0 = masqué\n \"is_verified\": str(info.get(\"badge\")) == \"1\",\n \"category\": info.get(\"categoryStringId\"),\n \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),\n \"has_story\": bool(info.get(\"hasStory\")),\n \"has_spotlight\": bool(info.get(\"hasSpotlightHighlights\")),\n \"has_curated_highlights\": bool(\n info.get(\"hasCuratedHighlights\")),\n \"story_snaps_count\": len(snaps) or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,\n \"publisher_type\": info.get(\"publisherType\") or None,\n \"subcategory\": info.get(\"subcategoryStringId\") or None,\n \"related_accounts\": related,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","numLines":105,"startLine":1,"totalLines":105}}}} |
|
180 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"bf9ffdb0-9ab5-4111-b4d3-f30d464cde45","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
181 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyxyE7zpsV2P4qXdzxf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01F6sGdbqofVEBxwm4sSW5yi","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":101587,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5e05174e-4cc1-469c-880e-7cecfa292f11"}} |
|
182 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01F6sGdbqofVEBxwm4sSW5yi","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-patreon)\n4\t# Desc: Pages Patreon publiques — la page /<vanity> embarque l'objet campagne\n5\t# (patron_count, creation_count, is_monthly, pay_per_name, résumé) +\n6\t# og:title/og:image. Aucune clé. Proxy résidentiel recommandé.\n7\t# ==============================================================================\n8\tfrom __future__ import annotations\n9\t\n10\timport asyncio\n11\timport html as htmllib\n12\timport json\n13\timport re\n14\t\n15\tfrom apify import Actor\n16\t\n17\tfrom .net import Fetcher\n18\t\n19\tPAGE_URL = \"https://www.patreon.com/{u}\"\n20\t_NUM = {\n21\t \"patrons\": re.compile(r'\"patron_count\"\\s*:\\s*(\\d+)'),\n22\t \"posts\": re.compile(r'\"creation_count\"\\s*:\\s*(\\d+)'),\n23\t \"paid_posts\": re.compile(r'\"paid_member_count\"\\s*:\\s*(\\d+)'),\n24\t}\n25\t_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n26\t_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n27\t_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n28\t_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n29\t_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n30\t\n31\t\n32\tasync def main() -> None:\n33\t async with Actor:\n34\t inp = await Actor.get_input() or {}\n35\t usernames = [u.strip().lstrip(\"@\").lower()\n36\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n37\t proxy = await Actor.create_proxy_configuration(\n38\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n39\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n40\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n41\t\n42\t async def one(u: str) -> None:\n43\t async with sem:\n44\t miss = {\"kind\": \"profile\", \"platform\": \"patreon\",\n45\t \"username\": u, \"found\": False}\n46\t try:\n47\t resp = await fetcher.get(PAGE_URL.format(u=u),\n48\t session_id=u)\n49\t except Exception as exc:\n50\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n51\t return\n52\t text = resp.text or \"\"\n53\t if resp.status_code == 404:\n54\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n55\t return\n56\t m = _NUM[\"patrons\"].search(text)\n57\t if resp.status_code != 200 or not m:\n58\t await Actor.push_data(\n59\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n60\t return\n61\t\n62\t def num(key):\n63\t mm = _NUM[key].search(text)\n64\t return int(mm.group(1)) if mm else None\n65\t title = _OG_TITLE.search(text)\n66\t img = _OG_IMG.search(text)\n67\t desc = _OG_DESC.search(text)\n68\t monthly = _MONTHLY.search(text)\n69\t nsfw = _NSFW.search(text)\n70\t name = htmllib.unescape(title.group(1)) if title else u\n71\t name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()\n72\t await Actor.push_data({\n73\t \"kind\": \"profile\",\n74\t \"platform\": \"patreon\",\n75\t \"found\": True,\n76\t \"username\": u,\n77\t \"full_name\": name,\n78\t \"biography\": (htmllib.unescape(desc.group(1))\n79\t if desc else None),\n80\t \"followers\": num(\"patrons\"), # patrons = « abonnés » KA\n81\t \"patrons\": num(\"patrons\"),\n82\t \"posts_count\": num(\"posts\"),\n83\t \"is_monthly\": (monthly.group(1) == \"true\"\n84\t if monthly else None),\n85\t \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n86\t \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n87\t })\n88\t\n89\t await asyncio.gather(*[one(u) for u in usernames])\n90\t Actor.log.info(f\"terminé : {len(usernames)} pages\")\n91\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c2b91ee3-6b00-4d09-beac-e800733ff0fa","timestamp":"2026-08-21T00:55:46.321Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page /<vanity> embarque l'objet campagne\n# (patron_count, creation_count, is_monthly, pay_per_name, résumé) +\n# og:title/og:image. Aucune clé. Proxy résidentiel recommandé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPAGE_URL = \"https://www.patreon.com/{u}\"\n_NUM = {\n \"patrons\": re.compile(r'\"patron_count\"\\s*:\\s*(\\d+)'),\n \"posts\": re.compile(r'\"creation_count\"\\s*:\\s*(\\d+)'),\n \"paid_posts\": re.compile(r'\"paid_member_count\"\\s*:\\s*(\\d+)'),\n}\n_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"patreon\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PAGE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n text = resp.text or \"\"\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _NUM[\"patrons\"].search(text)\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n\n def num(key):\n mm = _NUM[key].search(text)\n return int(mm.group(1)) if mm else None\n title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"patreon\",\n \"found\": True,\n \"username\": u,\n \"full_name\": name,\n \"biography\": (htmllib.unescape(desc.group(1))\n if desc else None),\n \"followers\": num(\"patrons\"), # patrons = « abonnés » KA\n \"patrons\": num(\"patrons\"),\n \"posts_count\": num(\"posts\"),\n \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","numLines":91,"startLine":1,"totalLines":91}}}} |
|
183 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyxyE7zpsV2P4qXdzxf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CvVzBWqzB6hfSX4umAHH6W","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3908,"cache_read_input_tokens":101587,"cache_creation":{"ephemeral_5m_input_tokens":3908,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1332f59b-02b5-4425-a18a-e6ed8191ac5c"}} |
|
184 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CvVzBWqzB6hfSX4umAHH6W","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-discord)\n4\t# Desc: Serveurs Discord via l'API PUBLIQUE d'invitation\n5\t# /api/v9/invites/{code}?with_counts=true : membres approximatifs,\n6\t# présents en ligne, nom/description/boosts du serveur. Le CODE d'invit\n7\t# est SENSIBLE À LA CASSE → il vient de l'URL, pas du handle minusculé.\n8\t# ==============================================================================\n9\tfrom __future__ import annotations\n10\t\n11\timport asyncio\n12\timport json\n13\t\n14\tfrom apify import Actor\n15\t\n16\tfrom .net import Fetcher\n17\t\n18\tINVITE_API = (\"https://discord.com/api/v9/invites/{code}\"\n19\t \"?with_counts=true&with_expiration=true\")\n20\t\n21\t\n22\tasync def main() -> None:\n23\t async with Actor:\n24\t inp = await Actor.get_input() or {}\n25\t # ici « usernames » = codes d'invitation (casse préservée par l'appelant)\n26\t codes = [c.strip() for c in (inp.get(\"usernames\") or []) if c.strip()]\n27\t proxy = await Actor.create_proxy_configuration(\n28\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n29\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n30\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 6))\n31\t\n32\t async def one(code: str) -> None:\n33\t async with sem:\n34\t miss = {\"kind\": \"profile\", \"platform\": \"discord\",\n35\t \"username\": code, \"found\": False}\n36\t try:\n37\t resp = await fetcher.get(\n38\t INVITE_API.format(code=code), session_id=code[:12],\n39\t headers={\"Accept\": \"application/json\"})\n40\t except Exception as exc:\n41\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n42\t return\n43\t if resp.status_code == 404:\n44\t await Actor.push_data({**miss, \"error\": \"invite_invalide\"})\n45\t return\n46\t if resp.status_code != 200:\n47\t await Actor.push_data(\n48\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n49\t return\n50\t try:\n51\t d = json.loads(resp.text)\n52\t except Exception:\n53\t await Actor.push_data({**miss, \"error\": \"bad_json\"})\n54\t return\n55\t guild = d.get(\"guild\") or {}\n56\t icon = guild.get(\"icon\")\n57\t gid = guild.get(\"id\")\n58\t await Actor.push_data({\n59\t \"kind\": \"profile\",\n60\t \"platform\": \"discord\",\n61\t \"found\": True,\n62\t \"username\": code,\n63\t \"guild_id\": gid,\n64\t \"full_name\": guild.get(\"name\"),\n65\t \"biography\": guild.get(\"description\"),\n66\t \"followers\": d.get(\"approximate_member_count\"), # membres\n67\t \"members\": d.get(\"approximate_member_count\"),\n68\t \"online\": d.get(\"approximate_presence_count\"),\n69\t \"boosts\": guild.get(\"premium_subscription_count\"),\n70\t \"verified\": (\"VERIFIED\" in (guild.get(\"features\") or [])),\n71\t \"partnered\": (\"PARTNERED\" in (guild.get(\"features\") or [])),\n72\t \"channel\": (d.get(\"channel\") or {}).get(\"name\"),\n73\t \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n74\t if gid and icon else None),\n75\t })\n76\t\n77\t await asyncio.gather(*[one(c) for c in codes])\n78\t Actor.log.info(f\"terminé : {len(codes)} invitations\")\n79\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6c9de338-891b-4abd-b93e-53ed67c1c04c","timestamp":"2026-08-21T00:55:46.361Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-discord)\n# Desc: Serveurs Discord via l'API PUBLIQUE d'invitation\n# /api/v9/invites/{code}?with_counts=true : membres approximatifs,\n# présents en ligne, nom/description/boosts du serveur. Le CODE d'invit\n# est SENSIBLE À LA CASSE → il vient de l'URL, pas du handle minusculé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nINVITE_API = (\"https://discord.com/api/v9/invites/{code}\"\n \"?with_counts=true&with_expiration=true\")\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n # ici « usernames » = codes d'invitation (casse préservée par l'appelant)\n codes = [c.strip() for c in (inp.get(\"usernames\") or []) if c.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 6))\n\n async def one(code: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"discord\",\n \"username\": code, \"found\": False}\n try:\n resp = await fetcher.get(\n INVITE_API.format(code=code), session_id=code[:12],\n headers={\"Accept\": \"application/json\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"invite_invalide\"})\n return\n if resp.status_code != 200:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n d = json.loads(resp.text)\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"discord\",\n \"found\": True,\n \"username\": code,\n \"guild_id\": gid,\n \"full_name\": guild.get(\"name\"),\n \"biography\": guild.get(\"description\"),\n \"followers\": d.get(\"approximate_member_count\"), # membres\n \"members\": d.get(\"approximate_member_count\"),\n \"online\": d.get(\"approximate_presence_count\"),\n \"boosts\": guild.get(\"premium_subscription_count\"),\n \"verified\": (\"VERIFIED\" in (guild.get(\"features\") or [])),\n \"partnered\": (\"PARTNERED\" in (guild.get(\"features\") or [])),\n \"channel\": (d.get(\"channel\") or {}).get(\"name\"),\n \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n })\n\n await asyncio.gather(*[one(c) for c in codes])\n Actor.log.info(f\"terminé : {len(codes)} invitations\")\n","numLines":79,"startLine":1,"totalLines":79}}}} |
|
185 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"00c1e71c-75b1-46c0-87a5-e39c004d353e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
186 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyyH2wMpivyPv3k72ZW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018R5i7fwJyXv6ZwVMLFDBjS","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3280,"cache_read_input_tokens":105495,"cache_creation":{"ephemeral_5m_input_tokens":3280,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"cd84f26a-dc46-4399-ae0f-03bff8acc12d"}} |
|
187 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018R5i7fwJyXv6ZwVMLFDBjS","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-fansly)\n4\t# Desc: Profils Fansly via l'API publique apiv3.fansly.com/api/v1/account\n5\t# (?usernames=) : abonnés (followCount), bio, badge, compteurs de\n6\t# médias (images/vidéos), avatar/bannière. Lots de 20 handles/requête.\n7\t# ==============================================================================\n8\tfrom __future__ import annotations\n9\t\n10\timport asyncio\n11\timport json\n12\t\n13\tfrom apify import Actor\n14\t\n15\tfrom .net import Fetcher\n16\t\n17\tAPI_URL = (\"https://apiv3.fansly.com/api/v1/account\"\n18\t \"?usernames={us}&ngsw-bypass=true\")\n19\t\n20\t\n21\tdef _loc(media: dict | None) -> str | None:\n22\t \"\"\"Objet média Fansly → URL de la première variante disponible.\"\"\"\n23\t if not isinstance(media, dict):\n24\t return None\n25\t for variant in ([media] + (media.get(\"variants\") or [])):\n26\t for loc in (variant.get(\"locations\") or []):\n27\t if loc.get(\"location\"):\n28\t return loc[\"location\"]\n29\t return None\n30\t\n31\t\n32\tasync def main() -> None:\n33\t async with Actor:\n34\t inp = await Actor.get_input() or {}\n35\t usernames = [u.strip().lstrip(\"@\").lower()\n36\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n37\t proxy = await Actor.create_proxy_configuration(\n38\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n39\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n40\t\n41\t found: set[str] = set()\n42\t for i in range(0, len(usernames), 20):\n43\t batch = usernames[i:i + 20]\n44\t try:\n45\t resp = await fetcher.get(API_URL.format(us=\",\".join(batch)),\n46\t session_id=f\"b{i}\",\n47\t headers={\"Accept\":\n48\t \"application/json\"})\n49\t data = (json.loads(resp.text)\n50\t if resp.status_code == 200 else {})\n51\t except Exception as exc:\n52\t for u in batch:\n53\t await Actor.push_data(\n54\t {\"kind\": \"profile\", \"platform\": \"fansly\",\n55\t \"username\": u, \"found\": False,\n56\t \"error\": str(exc)[:200]})\n57\t continue\n58\t for acc in (data.get(\"response\") or []):\n59\t u = str(acc.get(\"username\") or \"\").lower()\n60\t if not u:\n61\t continue\n62\t found.add(u)\n63\t stats = acc.get(\"timelineStats\") or {}\n64\t await Actor.push_data({\n65\t \"kind\": \"profile\",\n66\t \"platform\": \"fansly\",\n67\t \"found\": True,\n68\t \"id\": acc.get(\"id\"),\n69\t \"username\": u,\n70\t \"full_name\": acc.get(\"displayName\") or u,\n71\t \"biography\": (acc.get(\"about\") or \"\")[:1000],\n72\t \"followers\": acc.get(\"followCount\"),\n73\t \"following\": acc.get(\"followingCount\"),\n74\t \"is_verified\": bool((acc.get(\"statusInfo\") or {})\n75\t .get(\"verified\")\n76\t or acc.get(\"verified\")),\n77\t \"posts_count\": acc.get(\"postCount\")\n78\t or stats.get(\"postCount\"),\n79\t \"images_count\": stats.get(\"imageCount\"),\n80\t \"videos_count\": stats.get(\"videoCount\"),\n81\t \"likes\": acc.get(\"accountMediaLikes\"),\n82\t \"avatar\": _loc(acc.get(\"avatar\")),\n83\t \"banner\": _loc(acc.get(\"banner\")),\n84\t \"location\": acc.get(\"location\"),\n85\t })\n86\t for u in batch:\n87\t if u not in found:\n88\t await Actor.push_data(\n89\t {\"kind\": \"profile\", \"platform\": \"fansly\",\n90\t \"username\": u, \"found\": False,\n91\t \"error\": f\"not_found_{resp.status_code}\"})\n92\t Actor.log.info(f\"terminé : {len(usernames)} profils, \"\n93\t f\"{len(found)} trouvés\")\n94\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3d4b472b-7072-43ac-9b5b-bc81c7ad32dc","timestamp":"2026-08-21T00:55:50.484Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-fansly)\n# Desc: Profils Fansly via l'API publique apiv3.fansly.com/api/v1/account\n# (?usernames=) : abonnés (followCount), bio, badge, compteurs de\n# médias (images/vidéos), avatar/bannière. Lots de 20 handles/requête.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nAPI_URL = (\"https://apiv3.fansly.com/api/v1/account\"\n \"?usernames={us}&ngsw-bypass=true\")\n\n\ndef _loc(media: dict | None) -> str | None:\n \"\"\"Objet média Fansly → URL de la première variante disponible.\"\"\"\n if not isinstance(media, dict):\n return None\n for variant in ([media] + (media.get(\"variants\") or [])):\n for loc in (variant.get(\"locations\") or []):\n if loc.get(\"location\"):\n return loc[\"location\"]\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n\n found: set[str] = set()\n for i in range(0, len(usernames), 20):\n batch = usernames[i:i + 20]\n try:\n resp = await fetcher.get(API_URL.format(us=\",\".join(batch)),\n session_id=f\"b{i}\",\n headers={\"Accept\":\n \"application/json\"})\n data = (json.loads(resp.text)\n if resp.status_code == 200 else {})\n except Exception as exc:\n for u in batch:\n await Actor.push_data(\n {\"kind\": \"profile\", \"platform\": \"fansly\",\n \"username\": u, \"found\": False,\n \"error\": str(exc)[:200]})\n continue\n for acc in (data.get(\"response\") or []):\n u = str(acc.get(\"username\") or \"\").lower()\n if not u:\n continue\n found.add(u)\n stats = acc.get(\"timelineStats\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"fansly\",\n \"found\": True,\n \"id\": acc.get(\"id\"),\n \"username\": u,\n \"full_name\": acc.get(\"displayName\") or u,\n \"biography\": (acc.get(\"about\") or \"\")[:1000],\n \"followers\": acc.get(\"followCount\"),\n \"following\": acc.get(\"followingCount\"),\n \"is_verified\": bool((acc.get(\"statusInfo\") or {})\n .get(\"verified\")\n or acc.get(\"verified\")),\n \"posts_count\": acc.get(\"postCount\")\n or stats.get(\"postCount\"),\n \"images_count\": stats.get(\"imageCount\"),\n \"videos_count\": stats.get(\"videoCount\"),\n \"likes\": acc.get(\"accountMediaLikes\"),\n \"avatar\": _loc(acc.get(\"avatar\")),\n \"banner\": _loc(acc.get(\"banner\")),\n \"location\": acc.get(\"location\"),\n })\n for u in batch:\n if u not in found:\n await Actor.push_data(\n {\"kind\": \"profile\", \"platform\": \"fansly\",\n \"username\": u, \"found\": False,\n \"error\": f\"not_found_{resp.status_code}\"})\n Actor.log.info(f\"terminé : {len(usernames)} profils, \"\n f\"{len(found)} trouvés\")\n","numLines":94,"startLine":1,"totalLines":94}}}} |
|
188 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyyH2wMpivyPv3k72ZW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UhSkHi9NMrqebVfw9yCvLS","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3280,"cache_read_input_tokens":105495,"cache_creation":{"ephemeral_5m_input_tokens":3280,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ec83410f-d825-4bbd-9671-d5c1e963ae0a"}} |
|
189 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UhSkHi9NMrqebVfw9yCvLS","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: src/main.py (ka-onlyfans)\n4\t# Desc: Profils OnlyFans publics (page de profil hors connexion) — extraction\n5\t# best-effort des compteurs embarqués : posts, photos, vidéos, J'aime\n6\t# (favoritedCount), prix d'abonnement, badge, bio, avatar/bannière.\n7\t# EXPÉRIMENTAL : OnlyFans change souvent son rendu ; found=False n'est\n8\t# pas une erreur du pipeline.\n9\t# ==============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport asyncio\n13\timport html as htmllib\n14\timport json\n15\timport re\n16\t\n17\tfrom apify import Actor\n18\t\n19\tfrom .net import Fetcher\n20\t\n21\tPROFILE_URL = \"https://onlyfans.com/{u}\"\n22\t\n23\t_NUM_RES = {\n24\t \"posts_count\": re.compile(r'\"postsCount\"\\s*:\\s*(\\d+)'),\n25\t \"photos_count\": re.compile(r'\"photosCount\"\\s*:\\s*(\\d+)'),\n26\t \"videos_count\": re.compile(r'\"videosCount\"\\s*:\\s*(\\d+)'),\n27\t \"likes\": re.compile(r'\"favoritedCount\"\\s*:\\s*(\\d+)'),\n28\t \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),\n29\t}\n30\t_PRICE_RE = re.compile(r'\"subscribePrice\"\\s*:\\s*([\\d.]+)')\n31\t_STR_RES = {\n32\t \"full_name\": re.compile(r'\"name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n33\t \"biography\": re.compile(r'\"rawAbout\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n34\t \"location\": re.compile(r'\"location\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n35\t \"website\": re.compile(r'\"website\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n36\t \"avatar\": re.compile(r'\"avatar\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n37\t \"banner\": re.compile(r'\"header\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n38\t}\n39\t_VERIFIED_RE = re.compile(r'\"isVerified\"\\s*:\\s*(true|false)')\n40\t_OG_TITLE_RE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n41\t\n42\t\n43\tdef _dec(raw: str) -> str:\n44\t try:\n45\t return htmllib.unescape(json.loads(f'\"{raw}\"'))\n46\t except Exception:\n47\t return raw\n48\t\n49\t\n50\tasync def main() -> None:\n51\t async with Actor:\n52\t inp = await Actor.get_input() or {}\n53\t usernames = [u.strip().lstrip(\"@\").lower()\n54\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n55\t proxy = await Actor.create_proxy_configuration(\n56\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n57\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 2))\n58\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 2))\n59\t\n60\t async def one(u: str) -> None:\n61\t async with sem:\n62\t miss = {\"kind\": \"profile\", \"platform\": \"onlyfans\",\n63\t \"username\": u, \"found\": False}\n64\t resp = None\n65\t for attempt in range(3): # mur Cloudflare → nouvelle IP\n66\t try:\n67\t resp = await fetcher.get(PROFILE_URL.format(u=u),\n68\t session_id=f\"{u}o{attempt}\")\n69\t except Exception as exc:\n70\t await Actor.push_data({**miss,\n71\t \"error\": str(exc)[:200]})\n72\t return\n73\t if resp.status_code == 404:\n74\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n75\t return\n76\t if resp.status_code == 200 and \\\n77\t \"postsCount\" in (resp.text or \"\"):\n78\t break\n79\t text = resp.text or \"\"\n80\t if resp.status_code != 200 or \"postsCount\" not in text:\n81\t await Actor.push_data(\n82\t {**miss, \"error\": f\"wall_{resp.status_code}\"})\n83\t return\n84\t rec: dict = {\"kind\": \"profile\", \"platform\": \"onlyfans\",\n85\t \"found\": True, \"username\": u}\n86\t for key, rx in _NUM_RES.items():\n87\t m = rx.search(text)\n88\t rec[key] = int(m.group(1)) if m else None\n89\t for key, rx in _STR_RES.items():\n90\t m = rx.search(text)\n91\t rec[key] = _dec(m.group(1)) if m else None\n92\t if rec.get(\"biography\"):\n93\t rec[\"biography\"] = re.sub(r\"<[^>]+>\", \" \",\n94\t rec[\"biography\"])[:1000].strip()\n95\t m = _PRICE_RE.search(text)\n96\t rec[\"subscribe_price_usd\"] = float(m.group(1)) if m else None\n97\t rec[\"is_free\"] = (rec[\"subscribe_price_usd\"] == 0.0\n98\t if rec[\"subscribe_price_usd\"] is not None\n99\t else None)\n100\t m = _VERIFIED_RE.search(text)\n101\t rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None\n102\t if not rec.get(\"full_name\"):\n103\t m = _OG_TITLE_RE.search(text)\n104\t if m:\n105\t rec[\"full_name\"] = htmllib.unescape(\n106\t m.group(1)).split(\" OnlyFans\")[0].strip()\n107\t # « followers » au sens crea-ka : le compteur public = J'aime\n108\t rec[\"followers\"] = None\n109\t await Actor.push_data(rec)\n110\t\n111\t await asyncio.gather(*[one(u) for u in usernames])\n112\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n113\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"32ef0e4f-7646-41ec-9a47-df57afaba297","timestamp":"2026-08-21T00:55:50.613Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-onlyfans)\n# Desc: Profils OnlyFans publics (page de profil hors connexion) — extraction\n# best-effort des compteurs embarqués : posts, photos, vidéos, J'aime\n# (favoritedCount), prix d'abonnement, badge, bio, avatar/bannière.\n# EXPÉRIMENTAL : OnlyFans change souvent son rendu ; found=False n'est\n# pas une erreur du pipeline.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://onlyfans.com/{u}\"\n\n_NUM_RES = {\n \"posts_count\": re.compile(r'\"postsCount\"\\s*:\\s*(\\d+)'),\n \"photos_count\": re.compile(r'\"photosCount\"\\s*:\\s*(\\d+)'),\n \"videos_count\": re.compile(r'\"videosCount\"\\s*:\\s*(\\d+)'),\n \"likes\": re.compile(r'\"favoritedCount\"\\s*:\\s*(\\d+)'),\n \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),\n}\n_PRICE_RE = re.compile(r'\"subscribePrice\"\\s*:\\s*([\\d.]+)')\n_STR_RES = {\n \"full_name\": re.compile(r'\"name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n \"biography\": re.compile(r'\"rawAbout\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n \"location\": re.compile(r'\"location\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n \"website\": re.compile(r'\"website\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n \"avatar\": re.compile(r'\"avatar\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n \"banner\": re.compile(r'\"header\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),\n}\n_VERIFIED_RE = re.compile(r'\"isVerified\"\\s*:\\s*(true|false)')\n_OG_TITLE_RE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n\n\ndef _dec(raw: str) -> str:\n try:\n return htmllib.unescape(json.loads(f'\"{raw}\"'))\n except Exception:\n return raw\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 2))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 2))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"onlyfans\",\n \"username\": u, \"found\": False}\n resp = None\n for attempt in range(3): # mur Cloudflare → nouvelle IP\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=f\"{u}o{attempt}\")\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n if resp.status_code == 200 and \\\n \"postsCount\" in (resp.text or \"\"):\n break\n text = resp.text or \"\"\n if resp.status_code != 200 or \"postsCount\" not in text:\n await Actor.push_data(\n {**miss, \"error\": f\"wall_{resp.status_code}\"})\n return\n rec: dict = {\"kind\": \"profile\", \"platform\": \"onlyfans\",\n \"found\": True, \"username\": u}\n for key, rx in _NUM_RES.items():\n m = rx.search(text)\n rec[key] = int(m.group(1)) if m else None\n for key, rx in _STR_RES.items():\n m = rx.search(text)\n rec[key] = _dec(m.group(1)) if m else None\n if rec.get(\"biography\"):\n rec[\"biography\"] = re.sub(r\"<[^>]+>\", \" \",\n rec[\"biography\"])[:1000].strip()\n m = _PRICE_RE.search(text)\n rec[\"subscribe_price_usd\"] = float(m.group(1)) if m else None\n rec[\"is_free\"] = (rec[\"subscribe_price_usd\"] == 0.0\n if rec[\"subscribe_price_usd\"] is not None\n else None)\n m = _VERIFIED_RE.search(text)\n rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None\n if not rec.get(\"full_name\"):\n m = _OG_TITLE_RE.search(text)\n if m:\n rec[\"full_name\"] = htmllib.unescape(\n m.group(1)).split(\" OnlyFans\")[0].strip()\n # « followers » au sens crea-ka : le compteur public = J'aime\n rec[\"followers\"] = None\n await Actor.push_data(rec)\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","numLines":113,"startLine":1,"totalLines":113}}}} |
|
190 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2f89132f-9bc0-4a5e-8233-45d5c711f51d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
191 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyybCpz6N8pjGaphey9","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiCgKpAEIERgCKkCc7/4k0mn7FI1yZ+BdFIRmkzD/a0Dtno3OJSyKOzsQCUz82Mr9+lLXEpVirh4KuyI4B2RaWFROEFRGCSvJlViJMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG3xJ7UBhIM4vAWTUcBRM2BYqN2Ggxe7TF48tT/VO+tS6QiMO7HwrPBq3lJEvoLiVE4q/GBHrpZ1Bkz2AweW4DexqXQt1MhC+RZtgpaWXy8cfG67CqQJrJwed1LiqltCjnULIa5Uoh9bF7UIDTiwYAk2OrDJmRhgYLgz028E5UhyxFan08cQqNpuxpNZ1m7CFdHPfE3/2jlN5UUHWHGGxetkePvwOKEPVFlV22pScA8KA8avrLUODNOF0DOKBK2dK4d6rjZlH66NLzO/cZG/0xrJ793X1NAW7BUW+GedTLlcMC2GPfqcHA6dkRRhpDp458rqKbFBBTiQyrOB/jRSvrnTPpbQH+0j0x/z/pVDHcNHsOwhNJOLqBGjgNQFKhmYCyjXoMX2YWML0ExV2BiFoO801j1GRiZNvNecKl8QnXG+9k5KFRiDghfpky5XcpeIoc0AXWhSa+sLlp9V4aI0kxVBtruDoS3jR0tl+3LUx5veWxAo+QOj+JeSWwTL3r/npSy8sG/YgGe03BQFC35hkwaK+v10Ilqd6jczt2Zo1a2AIJS2ZGreqMp9CjEmSYzWie87wuOhbjIN2rjt+ySVVILd48Q9SmkNN1Qx42zXkPw8Z8QnztsKykR3gHx3BWY6e7xST8GdMchdY8A8R4m8oVnFSbEKeyi/dXvcEt+INJZOWp08HkuTi0Spunbhowsvdhs2GdBvNIIrCeV+8ruw5CTFp8fRKMNSB0Wu6i0ixK1rruQcOOjuUMwffJ9ctwFvozZ4qq3dG9AJlFrjfewXAll7/RXUx+sCoeNfcxv7FqWLu7w8Mb/Jb5/A6Y1f5gNGXYOd3A5QB4ALUW1oo6JJm27OA3fW6XbwqMjhyV20hCtd78WDdLiy/VMJWLZM3HyGtIFk1kxtVIYvjeKwrAVrToJEeOtuRwK2e+ing89fxHmff5mfAFbmQ5Zzb7S/Vsnnl4i8U2iu+TlTCojcJNvW7ZaGDFdcHOIexQBAfoVI0UhZEv1o3d8dZNROPmtkAR2DuFTdphIrQ51CUuSFKhSIhRnt5YHYU+5Nw1zWZr+frmjSXNHE+nTnTLlDv16N1rtaCUGW317PM+MpfT4eyUtMucgUfHhqJSKQOXMYFg6J3XiRschB6shpGQbATUc1SGT6MK39Sg/1aX8tEaEzmCd8bh4F9BALh5Ntjsp4lyjqUNmZZc4gLsnKdMG2EElyZQBINRphZ5HfwDmhZAl4kEnM1eMHc0ql9/SxQNBr2Kq5riplmogsDQxPQXcOh1jx40HE0c95MbXHRzvPzqt2e975SoQp1LNJ5beZLArDjK4yaubp9qSax26auUl3R4FQuN/JoIXnu6uKUze1OzXbMxvw2wD2H+L+0EN/7R6EAL42IQy+YGSxTye7G1e9Cj96VHhoobeKDTMtH1MEmjHCcd4xRmN8Wbyv/16pmMa49zoSwapm0b6y3QwCdzOyKtRR3Y23+Z47lxAXuuBVUS6rQLnspRgaunmp3cNOYAKmTh2acmfXZz5Kuhc58Wns1L5JSlSH1e0582yyvEVaAOuB/IkpeOBygP9WCPypHlpOrqAZ/cW4hEV/cEpNbFXd/JtM1MEpOCP7qRDuGV6YrOlWAnivFQLKi3WSp+/+vZJPN2SWpDoffrOHsHtkFffD87wRZ7d4l9qbsjgpbh7xUKkBEXsrsWOn5n6njCpuZNG0Gun8eRXKrvAvh9ByRIPCzoxETPm9blG33d69+zuaIsSqOJFDknHXanzJFt2AZln43fYdwxjFGZbdUJV9O0d8yGc6q1YbPcOZJCd6S+hTcDBqAnEzmrzuEHKw0mVnwgfPCbeQWpxUJ2zLJEdNs4PAFs67Cc+S3z2aY0qlJBrjmp4kc0IfLTwdJUgryFmFyf/DGdfi6hRLQ/BiIeWe+DubNFcgG0V8HfILXuSL+qxoVCIDZuAdbDbtLK0UpFY731Wu8tROiLekBFIHOO4wtNyuPLI6HHtr/SGABMisZGhGmEYdV7P7Fe2MhhPax69PrpZ6rIA9pIl7fqAajceQ90ooa0+2eX93CP7hfdrJGWk+4v3GOcZG+i/XTJK/RPoNXo5AUnBRpY2X5ijtj8c/YJglkca1fb/ZQLvExXiPilX+Bvaz4J/qc+AE4pocvadptgB6tQmTsIabrVL6tdKDykRdCG8Aofy5C7Zaro4ffRapt8fSzDLj2vSDDIALqV9mMLoDOKxs0jvCeSNaeCnJGucKzl88U02sMqf7FlJy7erYQyJDGFKxnOGc7t8fWEWXZECbi8RFY0fTmr3Opb/ahUTm4m08hBJzGRrnUwJIF9Om2BVcIb31auLB/nrt6ETCnvu6PaPHRN5m1lJzLssUU9hUMnffEkB2k7oEjujf8w22GuX6C8fAR5WQ6E86wxsmeJGA4MeTjf/8OJubBZih3pdkFPvCStubjpjjDt5mRHrF+7bvFapC3FOSX3xKDqRrBO9hzj96yxevZI5xnCYNjp6yuWHKs5aUFzX7Gvtn4OpDMp903OrsSPnoyTAtPMxAYgbgmTNKgTOA01cHj1A1uHSam3SxDxGNMacCgNZ9Qjc2h+VtJ7B2pwaO69/FYEbHyD2O1pXHAWeJwczTdr6Ju8BahJviDiou3391hQYxYYgMhI1i8AOA7YLFCUqRECauey7xlhmUAX1gBBnaNx7W8ZPOSOsB1YNRF/v4wwCJaz7EHQkCox+cNu0ASYNiDIw8WA5XWDKq3+2a/gQXvUAc3I3LEhYb797lHcs50HIHVdiUhxMJzkpERNN6wFx/cG2kL3ZO0n9Axd/wTd4LhS4J4lx882LHluhoCEbOYl5MvQ45mndovnoN0B9i+8FCn3rxpEwyFjc8h0JEF8UpLtmo+KoHyohmJRHJW+QR22iwJEANIvnRR2ZG0T+wD3kfc79ZV2pBxa5U9rVCVpnBUygXnXKA9I+jBvWhcwi/tbhIC+yevK3FyaPi+NTyjs5MZM0ho2UdPeYOTuDQ5w4inUWSDiEDnUsNmEklGlJqa0eDtnJ/kkBF8hVxmLytgZZoovlb8CvK6f3XEBPSjoTPGCEsCA2JUyRTm4NbPOJgAwWHUBDcFx9WKIN9AkEpq8S0kThfqmDqF48qA3kNQrn1p+oq7UoFTGjbZEYyFil53/IN8j7AfP536R2hQ0TIUpnIdM4Ea37MnnXpT15iSrR55bMUJB1RUE1k0LmNu1mdd21/AHq2Y9td3JaAoJbahoekDLNDLXCzjHGIq/KdC7jkDYJK1StwYNAHDCzSSi6BS4UUZjZMp0IyNn4qvuO4HoP5whcHwnAUumQpzomVpza9qDuVf/qO8vrACxEzXxPBHdyjm/vrH8nkoIj5q1itRZils48Y6umYKFJHRntAP1WH5W/7OrjGCnCUsswgYWJJeQEmHyRpRfe+sSns3BHjM/oA3h8nQdT4UW2FUPyUjm8/jARVzLuec4Gn6SFVqHVoMB5cGUG9g30lpvvVHLcvqN12tjF1DW5XPBDq+EkUMMR1RMDeV0Lc2W2ZNk7izI/VWp0DVpc2V5hw9ONojo2VwP3o/MfbvO/wF+q16VVEimfHyaUjXx9Xww8zG+OAs7PC/G/LwywDrHtcXBuN5CjqIWepNvszPwBpE2QZ+I0HrQE8vPnGDinMasI/xEuAfwgaj5gEYV9R1jFUcirXhem8F4BDbs+7ZRdY2lAhUSn6qNcGsQmc2v5UgZcOttD2NGuvu4fERArkpuaLgZKFfuom8WHtnH7SArLSGgi5s0tdbSzF3u10oGijw47EChRHv9FGhVLI3XBhXG1rvy2/eu52VauMkjSyW6JBbnxNHoXeV/nH3haytDij6QKrz1PhyrmomypLNv42/zC1f76bblvHrQkT+zZiExs9h/nt8BvCRp3jSBR5nuasSOOHkezvqm9DqcJNQXPwKmlAo0EUegOUcpwI07kQ2hNU9yJ7OgtYQ7/3DEScxXypA/ruwyqorl3DyFiDFDxKzr6GLiZp8VuJOHaXE190TxeXVa5HsOv6le0IytPrdRho5oa2sFmFrcqBP8IWQYLxRsWic/JKncdlIu0RE6SF4+sxpTlrMjz4y6OagVR5k8JaXV9WZMYS7xQixu5yrzvSA6INegWci1/L08D6AGdLHc0jWJW0ofgCJcpHv2QbVScLoHi2HghtTI8RG/vX7dx55c548lF/jsk+7i7Q5X4pBdWOAFiJxenjLM0dc2Rhm+5jsC/PQZig2qk9167UH4R5hhQHX6ff8MCwxV6Szhdg/GBI9TCoyZ3GWxCOjpCs8+DU8gXRIYryCCqUXhZk1rOMXlUWyFFpGOvk/w37MxIJ12kJu+0f58z23Plb5GTbUUbY0suoYbHb0FPPUNJI4oE8GNdz9B44PaAG9TdMORURNIzMBj7jsTqCw7W/KIWN1if9HQ/9siOEJZdwW21USOi2iF6HZmG5zNqQexU5u4KO4gky/dMWRXBe2E/pXB8jUXBg3auqBtD6IHjr9PIn4n0HvoyseL0pdlxK1UiNFRS2bpEMqJw0KqNNpNInubbmw+++iO2i5DA3mWEsGSKNdvtnBdUahWZ2RnnkaRoz3AGnAKIO5WUh1VMNQTVGK0KXnYWOx8VgVzHxb6diFmw5OoBJ76BIScBZkze+kV7dNpjiYKZTesC7FY+5+BJ5O4RqQxlUt7i0MzkhEc1c9Rqfzo75ZTjfr3t5Sga1zZMfjUJrGMUUC2XxM/43qOXWJVIEJfSwVc6S7CwB+BXBQcNYRn27JK0a6L7+Unq031Gn7i7Ow9Y2+sjraDfCaUhLmJGkSisln9bRJy6wcFFK/gWYyYNFHSmQQqMohqdNENRtO98yNiOXsyM6ah5QYegFDYh9oEAKJZf7n46uprj7jb5o6OEeaRzADMq7/sBBOQ41wARbeGd0+M1VyEkF0ViB5F59bFqaJDFp7dX2ZleEGeM5qxLPzmWuoy/yC2FnmfAGNPAieELHCRau6/hKM95ql5LBTkOmQ4+hyh1bQDDeKXqhOIeeRYP2EBkCwclKWMgm5JbaXng5QvG9opmAkdI7agYCkY5sZLTwKrO3v9qdApPbGGPHhExP53qvzLf9Z9YMjorSQSY1HC00NjMgD0tstqd+4QGdCkh7ceiJMJ6gZ0k/u7/yigYK/qSLLzsORZn8EcvVICyLPFvK0jNG7rkvBvRmnJIVFufdzK8VUR6PNpQcqNPVkWBvmFMuWpX8PgXCl/1qDOF0dWiqwyjHQlgk63u3fDLkh5DkUSB995PN/56uhUjyLRKjHc/4zvO+ybf5Rmdp5pUsBcuFqiK8pBOGqk+srobtObBUUI/iSKvF12n6T0YJEmUupPTDQBhjNzXtcn3tnpqHKpy6NO+OBLh9w7C/hSe1nSeyJowinhg+lEkizNr4E6HD3/FVJngu3mWrWNFdsOUtYGYbU92G1vR1Ub9s3cphQD05RhLb1X6ODb70G22OfB5UpIIqdXkYnwMxpP9UYgDo7pmKJ42RGwmLEEtHVtsNpCDUVXZtSojxQHoRdV9n6+vl30oJw+vfVwtR1nQYyGHc7eIwOwU1LqA9R3WL4qhem/neo3PiCTl4EQ/ZIrz0hF01tISSflkPwKLh5z86wv+UGJADtV8GZf7bDIzK6GRR/BhJq9BMdWnQ/tB9CJW7MO5ImKiCgaM7VCS53v+/4U/h39LQAM8c3M1X2vnU8hldjI2PIXtVfQuGsz1Hv5od9xKFcW+hBRYFIfvR479ZxPz1C3OudJOgk/ZURiURhrHlLZ25V+S3yB6mrk4ZbwrdxlUf0spddeFIa+TQQac8ASUGoATMrLDAeqqgvzQxxzIlo+Z08ylThS7rNXKyYeSu81fL8Xy2NpAb9SGxGf7zuuGBOX0ncjScKAIH0t04xvKj6wXnHp13LdYnd8Vd5J1LVwx4F+dWrGysev0x7diufIFGwXTUDz+ibSbDI7NX8J659L1UmSpTAXs7w6vH04dd2cPaCNSUz70cYe/REDa3LmbhIlxTjIJ5W/6nUoF9Dj1V+hoRZuCIYAPnvklYPTHqDJKWzHItr8QypraL5+81guPuEGrYNERfQ+egYxm7IgecsMvPi+0u6aJ11CSddJKaXvUonehj1LOZFTawpqxjQNmm6SHb0dEkZ35TRHBQbTqU+QYzup1wf/8wOv4IKOhKvwVB3QYbdvWlJRwiDJ9HYezYiJe/b1XJh1G3SHPbv9+rBTUOELpdJrCqz1pgYb9oY2F31MCgUI7M5TtnH20rx0V0WTofuiPCKFIsDmgZ1hmxZJ9ndG+kpPQjM7rl2GY6eA+lSshp4i6ZQcJRYOqW9CPwg3LavfcOWpEu7XNA5dQWXqO7g5lJ5gaXRqwlqZbf9A8yinkLR+GvjbvViLc5DpCGsqo6Cc+GdW/JpzEXDH75mo++QChd8p1/6Frgbz2ZkvW4xTqrRRylxG2HCzbLryLVYzOGink3hktni/km1w8sVwNjivFrwIpMyaitOH/WjS6KIetEm4SXh9FYY/KnTBwwIjTPodA/XHjYMHfmo9j53GQo9guakKPVMhQ+oEFC2VeTFg3Fn+NLdzaCXEclMnT+FbFHGC2Hma9IbXAqnMt6jyoUoSP4ujwVT1IO8BmfptetIWg7JNh/Z/GoaWXGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4563,"cache_read_input_tokens":108775,"cache_creation":{"ephemeral_5m_input_tokens":4563,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f73507f8-a620-4520-8d73-dcb814873e24"}} |
|
192 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyybCpz6N8pjGaphey9","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISgQUKpQEIERgCKkB1VumUPUMXEECEi2F8cHuhdp+NkiJawiRV+xC37s7vwfnoo/LXOr0i8cX43cmbDYFc8n5hFTsuz8RyVS83I0nHMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBt8Se1AYSDBJjS5KVrljzBDRMcBoM/RAkRnZxkRYsoWDvIjAs75nBxrOISnNbmfhOtZlI5HZOWlBIJnDjHUirmizRJg1b6N4O4Zvd05HTR+9yBlQqiAMycjuKI3Yn7YmwIPytQfCoOj3AhuWRWETvtoCR68JQAeNt1Qdnb89Po/MaglL6iDPnQJun+lyAl3Ea2zlm0UIxvnpgrcVaACf8L73mXK+3JkLj+W0VdB5AqlRr+fsnpWHN+w7aw8iTtcogrZjDDlHdj3Ov8knPKLJ4M8Y2BYyj83j2KxViDvVDmTQsmUi/YldY1gaMkN+jmmLflDjwLvNup7hAPpJ+N1vl1DvuK+dp8n7/qKNM5Wxkf9pqivzh9q4DtB7M7xFpzBJ69kvWR2AVXzJkEFnZFNVc6PzunWyI80Nksj8TG6G0nwLp7bg+ADdJOM+WrptfDaSWZCVUWFvcgtl3oD6AomS5UgOoVOVxahfgxRcbdMITT8qNqITAfBuCwkKycY0O/bVfnqHU9U2aGaXF9IR1ttQDYLhNVHo098kMMh7900kDqwU0t7lRS8SXvTm8cWRnRV9EZYsqJySQVTiZWBEhE/CKqv5C21uKUAow/PukzXXtdVS6NOW9lrAXuy1Zac0UWRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4563,"cache_read_input_tokens":108775,"cache_creation":{"ephemeral_5m_input_tokens":4563,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d72378c2-7848-4293-900d-7a81b9c40cc6"}} |
|
193 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyybCpz6N8pjGaphey9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PGgspvGtKYekmFiR9HaPVB","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4563,"cache_read_input_tokens":108775,"cache_creation":{"ephemeral_5m_input_tokens":4563,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"58db8735-1af4-4b2e-a92a-eed7fdcebc3e"}} |
|
194 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PGgspvGtKYekmFiR9HaPVB","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/schema.py\n4\t# Desc: Modèle de données standardisé — Creator + PlatformAccount (CLAUDE.md §5)\n5\t# + normalisation centrale via finalize()\n6\t# ==============================================================================\n7\t\"\"\"Schéma standard d'une fiche créateur et de ses comptes de plateformes.\n8\t\n9\tChaque connecteur, peu importe la source, doit produire des objets `Creator`\n10\tconformes. `finalize()` applique la couche commune (creaka/normalize.py) :\n11\thandles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs\n12\trestent simples et remplissent les champs bruts.\n13\t\n14\tValidation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence`\n15\test REJETÉ à la finalisation.\n16\t\"\"\"\n17\tfrom __future__ import annotations\n18\t\n19\timport hashlib\n20\timport json\n21\tfrom dataclasses import dataclass, field, asdict\n22\tfrom datetime import datetime, timezone\n23\t\n24\tfrom .normalize import (\n25\t LANGUAGES,\n26\t NICHES,\n27\t PLATFORMS,\n28\t audience_tier,\n29\t canonical_url,\n30\t clean_bio,\n31\t clean_handle,\n32\t clean_name,\n33\t guess_language,\n34\t infer_creator_type,\n35\t map_niche,\n36\t normalize_platform,\n37\t slugify,\n38\t)\n39\t\n40\t__all__ = [\"Creator\", \"PlatformAccount\", \"now_iso\"]\n41\t\n42\t\n43\tdef now_iso() -> str:\n44\t return datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n45\t\n46\t\n47\t@dataclass\n48\tclass PlatformAccount:\n49\t \"\"\"Un compte public d'un créateur sur UNE plateforme (§5).\"\"\"\n50\t\n51\t platform: str # * canonique §6.2\n52\t handle: str # * sans @, minuscules\n53\t url: str = \"\" # * forme canonique\n54\t followers: int | None = None # abonnés (None = inconnu)\n55\t verified: bool | None = None # badge vérifié (None = inconnu)\n56\t confidence: float | None = None # * confiance du rattachement (§12)\n57\t last_checked: str = \"\" # * ISO-8601\n58\t signal: str = \"\" # signal d'origine du rattachement (§12.1)\n59\t metrics: dict = field(default_factory=dict) # métriques étendues publiques :\n60\t # following, posts, likes, views, videos, category… (selon la plateforme)\n61\t\n62\t def finalize(self) -> \"PlatformAccount\":\n63\t self.platform = normalize_platform(self.platform)\n64\t self.handle = clean_handle(self.handle)\n65\t self.url = canonical_url(self.platform, self.handle, fallback=self.url)\n66\t if not self.handle:\n67\t raise ValueError(\"compte sans handle\")\n68\t if not self.url:\n69\t raise ValueError(f\"compte {self.platform}:{self.handle} sans url\")\n70\t if self.confidence is None:\n71\t raise ValueError(f\"compte {self.platform}:{self.handle} sans confidence\")\n72\t self.confidence = max(0.0, min(1.0, float(self.confidence)))\n73\t if self.followers is not None:\n74\t self.followers = max(0, int(self.followers))\n75\t if not self.last_checked:\n76\t self.last_checked = now_iso()\n77\t return self\n78\t\n79\t @property\n80\t def key(self) -> str:\n81\t \"\"\"Clé d'unicité d'un compte : plateforme + handle normalisé.\"\"\"\n82\t return f\"{self.platform}:{self.handle}\"\n83\t\n84\t\n85\t@dataclass\n86\tclass Creator:\n87\t \"\"\"Fiche créateur canonique Créa-Ka (CLAUDE.md §5).\"\"\"\n88\t\n89\t source: str # id de la source (registre data/sources.json)\n90\t external_id: str # identifiant chez la source\n91\t display_name: str # * nom public / nom de scène\n92\t legal_name: str | None = None # SEULEMENT si rendu public par le créateur\n93\t bio: str = \"\"\n94\t niches: list[str] = field(default_factory=list)\n95\t creator_type: str = \"\"\n96\t region: str | None = None # une des 17 régions §7, sinon None\n97\t city: str | None = None\n98\t languages: list[str] = field(default_factory=list)\n99\t primary_platform: str = \"\"\n100\t platforms: list[PlatformAccount] = field(default_factory=list)\n101\t link_in_bio_url: str | None = None\n102\t avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n103\t total_reach: int | None = None\n104\t audience_tier: str = \"\"\n105\t business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n106\t agency: str | None = None\n107\t is_minor: bool = False\n108\t status: str = \"active\" # active | inactive | opted_out\n109\t notes: str = \"\" # contexte public (listes médias), affichable\n110\t source_ids: list[str] = field(default_factory=list)\n111\t\n112\t @property\n113\t def uid(self) -> str:\n114\t return f\"{self.source}:{self.external_id}\"\n115\t\n116\t def content_hash(self) -> str:\n117\t \"\"\"Hash du contenu pour la détection de changements.\"\"\"\n118\t payload = asdict(self)\n119\t for acc in payload[\"platforms\"]:\n120\t acc.pop(\"last_checked\", None) # horodatage ≠ changement de contenu\n121\t blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n122\t return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n123\t\n124\t def finalize(self) -> \"Creator\":\n125\t \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n126\t\n127\t Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n128\t Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas\n129\t sa place dans un annuaire de liens).\n130\t \"\"\"\n131\t self.display_name = clean_name(self.display_name)\n132\t if not self.display_name:\n133\t raise ValueError(\"créateur sans nom d'affichage\")\n134\t if not self.external_id:\n135\t self.external_id = slugify(self.display_name)\n136\t self.bio = clean_bio(self.bio)\n137\t\n138\t # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne)\n139\t seen: dict[str, PlatformAccount] = {}\n140\t for acc in self.platforms:\n141\t try:\n142\t acc = acc.finalize()\n143\t except ValueError:\n144\t continue # un compte invalide ne bloque pas la fiche (§18 : rejeté)\n145\t prev = seen.get(acc.key)\n146\t if prev is None or (acc.confidence or 0) > (prev.confidence or 0):\n147\t if prev is not None and prev.followers and not acc.followers:\n148\t acc.followers = prev.followers\n149\t if prev is not None:\n150\t acc.metrics = {**prev.metrics, **acc.metrics}\n151\t seen[acc.key] = acc\n152\t # tri : abonnés décroissants (None en dernier), pour primary_platform\n153\t self.platforms = sorted(\n154\t seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0)))\n155\t if not self.platforms:\n156\t raise ValueError(f\"{self.display_name}: aucun compte valide\")\n157\t\n158\t self.niches = [map_niche(n) for n in (self.niches or [\"autre\"])]\n159\t self.niches = sorted({n for n in self.niches if n in NICHES}) or [\"autre\"]\n160\t\n161\t if not self.primary_platform:\n162\t self.primary_platform = self.platforms[0].platform\n163\t self.primary_platform = normalize_platform(self.primary_platform)\n164\t if self.primary_platform not in PLATFORMS:\n165\t self.primary_platform = self.platforms[0].platform\n166\t\n167\t if not self.languages:\n168\t self.languages = [guess_language(self.bio, self.notes)]\n169\t self.languages = [l for l in self.languages if l in LANGUAGES] or [\"fr\"]\n170\t\n171\t if not self.creator_type:\n172\t self.creator_type = infer_creator_type(\n173\t [a.platform for a in self.platforms], self.niches[0])\n174\t\n175\t # portée totale : somme indicative des audiences connues\n176\t known = [a.followers for a in self.platforms if a.followers]\n177\t self.total_reach = sum(known) if known else None\n178\t primary = next((a.followers for a in self.platforms\n179\t if a.platform == self.primary_platform and a.followers), None)\n180\t if not self.audience_tier:\n181\t self.audience_tier = audience_tier(primary or (max(known) if known else None))\n182\t\n183\t if not self.source_ids:\n184\t self.source_ids = [self.uid]\n185\t\n186\t # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py\n187\t if self.is_minor:\n188\t self.business_contact = None\n189\t self.city = None\n190\t self.region = None\n191\t return self\n192\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4d59d2d6-a644-4e6c-ad14-752a29e15ef5","timestamp":"2026-08-21T00:56:24.152Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/schema.py\n# Desc: Modèle de données standardisé — Creator + PlatformAccount (CLAUDE.md §5)\n# + normalisation centrale via finalize()\n# ==============================================================================\n\"\"\"Schéma standard d'une fiche créateur et de ses comptes de plateformes.\n\nChaque connecteur, peu importe la source, doit produire des objets `Creator`\nconformes. `finalize()` applique la couche commune (creaka/normalize.py) :\nhandles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs\nrestent simples et remplissent les champs bruts.\n\nValidation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence`\nest REJETÉ à la finalisation.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\nfrom datetime import datetime, timezone\n\nfrom .normalize import (\n LANGUAGES,\n NICHES,\n PLATFORMS,\n audience_tier,\n canonical_url,\n clean_bio,\n clean_handle,\n clean_name,\n guess_language,\n infer_creator_type,\n map_niche,\n normalize_platform,\n slugify,\n)\n\n__all__ = [\"Creator\", \"PlatformAccount\", \"now_iso\"]\n\n\ndef now_iso() -> str:\n return datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n\n\n@dataclass\nclass PlatformAccount:\n \"\"\"Un compte public d'un créateur sur UNE plateforme (§5).\"\"\"\n\n platform: str # * canonique §6.2\n handle: str # * sans @, minuscules\n url: str = \"\" # * forme canonique\n followers: int | None = None # abonnés (None = inconnu)\n verified: bool | None = None # badge vérifié (None = inconnu)\n confidence: float | None = None # * confiance du rattachement (§12)\n last_checked: str = \"\" # * ISO-8601\n signal: str = \"\" # signal d'origine du rattachement (§12.1)\n metrics: dict = field(default_factory=dict) # métriques étendues publiques :\n # following, posts, likes, views, videos, category… (selon la plateforme)\n\n def finalize(self) -> \"PlatformAccount\":\n self.platform = normalize_platform(self.platform)\n self.handle = clean_handle(self.handle)\n self.url = canonical_url(self.platform, self.handle, fallback=self.url)\n if not self.handle:\n raise ValueError(\"compte sans handle\")\n if not self.url:\n raise ValueError(f\"compte {self.platform}:{self.handle} sans url\")\n if self.confidence is None:\n raise ValueError(f\"compte {self.platform}:{self.handle} sans confidence\")\n self.confidence = max(0.0, min(1.0, float(self.confidence)))\n if self.followers is not None:\n self.followers = max(0, int(self.followers))\n if not self.last_checked:\n self.last_checked = now_iso()\n return self\n\n @property\n def key(self) -> str:\n \"\"\"Clé d'unicité d'un compte : plateforme + handle normalisé.\"\"\"\n return f\"{self.platform}:{self.handle}\"\n\n\n@dataclass\nclass Creator:\n \"\"\"Fiche créateur canonique Créa-Ka (CLAUDE.md §5).\"\"\"\n\n source: str # id de la source (registre data/sources.json)\n external_id: str # identifiant chez la source\n display_name: str # * nom public / nom de scène\n legal_name: str | None = None # SEULEMENT si rendu public par le créateur\n bio: str = \"\"\n niches: list[str] = field(default_factory=list)\n creator_type: str = \"\"\n region: str | None = None # une des 17 régions §7, sinon None\n city: str | None = None\n languages: list[str] = field(default_factory=list)\n primary_platform: str = \"\"\n platforms: list[PlatformAccount] = field(default_factory=list)\n link_in_bio_url: str | None = None\n avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n total_reach: int | None = None\n audience_tier: str = \"\"\n business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n agency: str | None = None\n is_minor: bool = False\n status: str = \"active\" # active | inactive | opted_out\n notes: str = \"\" # contexte public (listes médias), affichable\n source_ids: list[str] = field(default_factory=list)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements.\"\"\"\n payload = asdict(self)\n for acc in payload[\"platforms\"]:\n acc.pop(\"last_checked\", None) # horodatage ≠ changement de contenu\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n def finalize(self) -> \"Creator\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas\n sa place dans un annuaire de liens).\n \"\"\"\n self.display_name = clean_name(self.display_name)\n if not self.display_name:\n raise ValueError(\"créateur sans nom d'affichage\")\n if not self.external_id:\n self.external_id = slugify(self.display_name)\n self.bio = clean_bio(self.bio)\n\n # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne)\n seen: dict[str, PlatformAccount] = {}\n for acc in self.platforms:\n try:\n acc = acc.finalize()\n except ValueError:\n continue # un compte invalide ne bloque pas la fiche (§18 : rejeté)\n prev = seen.get(acc.key)\n if prev is None or (acc.confidence or 0) > (prev.confidence or 0):\n if prev is not None and prev.followers and not acc.followers:\n acc.followers = prev.followers\n if prev is not None:\n acc.metrics = {**prev.metrics, **acc.metrics}\n seen[acc.key] = acc\n # tri : abonnés décroissants (None en dernier), pour primary_platform\n self.platforms = sorted(\n seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0)))\n if not self.platforms:\n raise ValueError(f\"{self.display_name}: aucun compte valide\")\n\n self.niches = [map_niche(n) for n in (self.niches or [\"autre\"])]\n self.niches = sorted({n for n in self.niches if n in NICHES}) or [\"autre\"]\n\n if not self.primary_platform:\n self.primary_platform = self.platforms[0].platform\n self.primary_platform = normalize_platform(self.primary_platform)\n if self.primary_platform not in PLATFORMS:\n self.primary_platform = self.platforms[0].platform\n\n if not self.languages:\n self.languages = [guess_language(self.bio, self.notes)]\n self.languages = [l for l in self.languages if l in LANGUAGES] or [\"fr\"]\n\n if not self.creator_type:\n self.creator_type = infer_creator_type(\n [a.platform for a in self.platforms], self.niches[0])\n\n # portée totale : somme indicative des audiences connues\n known = [a.followers for a in self.platforms if a.followers]\n self.total_reach = sum(known) if known else None\n primary = next((a.followers for a in self.platforms\n if a.platform == self.primary_platform and a.followers), None)\n if not self.audience_tier:\n self.audience_tier = audience_tier(primary or (max(known) if known else None))\n\n if not self.source_ids:\n self.source_ids = [self.uid]\n\n # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py\n if self.is_minor:\n self.business_contact = None\n self.city = None\n self.region = None\n return self\n","numLines":192,"startLine":1,"totalLines":192}}}} |
|
195 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEyybCpz6N8pjGaphey9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Fq6jD4xvC1mQj53E6458Ln","name":"Bash","input":{"command":"ls -R ~/apps/crea-ka/frontend | head -40; wc -l ~/apps/crea-ka/creaka/db.py ~/apps/crea-ka/creaka/web.py","description":"List frontend structure and file sizes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4563,"cache_read_input_tokens":108775,"cache_creation":{"ephemeral_5m_input_tokens":4563,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fec7bd75-aad8-4cd2-abfe-ba29935a50ba"}} |
|
196 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Fq6jD4xvC1mQj53E6458Ln","type":"tool_result","content":"dist\nsrc\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/dist:\napple-touch-icon.png\nfavicon.svg\nindex.html\nka-agent.js\nog.png\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src:\nicons.json\nindex.template.html\nka\nka-agent.js\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n 416 /Users/simon-pierreboucher/apps/crea-ka/creaka/db.py\n 182 /Users/simon-pierreboucher/apps/crea-ka/creaka/web.py\n 598 total","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8d0978fd-37a2-46ed-a8cd-6a92bde9e725","timestamp":"2026-08-21T00:56:24.936Z","tool_use_result":{"stdout":"dist\nsrc\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/dist:\napple-touch-icon.png\nfavicon.svg\nindex.html\nka-agent.js\nog.png\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src:\nicons.json\nindex.template.html\nka\nka-agent.js\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka:\nGroupeKaBadge.tsx\nKaFooter.tsx\necosystem.json\nstats\ntokens.css\n\n/Users/simon-pierreboucher/apps/crea-ka/frontend/src/ka/stats:\nSPEC.md\nkacharts.tsx\nkapdf.py\n 416 /Users/simon-pierreboucher/apps/crea-ka/creaka/db.py\n 182 /Users/simon-pierreboucher/apps/crea-ka/creaka/web.py\n 598 total","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
197 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"43c3bdfa-9431-4c96-95e6-64df14b49eff","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
198 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz28FBiweWrRFTw1ztx","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISigQKpAEIERgCKkAaybZqUddtD7EUN1j43Gfuz5Y/k2wY9wEgVkn/pN+UAX3m6rXRQM5cFfdihKxHmuvJ2VB6CmB7daizF9CTaNE3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG/xJ7UBhIMFLe/wXtI46DeAnruGgxey7AjtvRoqgrlX3giMCfcRs2fRHPTOIMxnMX2V8fYjtdgj1w+jB0ywlwSIZFG2D7eH5yK15W9wuOn91TEFCqSAn+w0miHnzjD76gT9JsQTT9pUWLJ71MIhPR2/ThutZZOYouoepGOIv/OMAV9AWxuuoahf8YY0pkkzQoHlTi6X4I3g/gBReO4a2sA0boN1WHOIBDVrvpJOGV4tZWQLHDU3Ts05CvvaDv+l3XFLu4tTzkeITF+/02fawTSVh8BCRNz2zFEbDtL0vDEXiTAtS9Lxp2NfaoHELQq5Hs5OYgfxgEpuaqDdi3Veb8XVWT3xjl8tMEUQZny/8q4jYxx6qUNs8cZwrzzGErc9Moeu7ZZBcS2cDkVUy8olkxNz0Y1KtiG5XPmDKZfKvc/crAa1/TwDD8FN7UtCL5CpFTI5RjBxrtHBeQDfeDZC4jo02fY8tXPxq0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6024,"cache_read_input_tokens":113338,"cache_creation":{"ephemeral_5m_input_tokens":6024,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"92670398-e145-4e65-a101-18d22e21c4d1"}} |
|
199 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz28FBiweWrRFTw1ztx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01D8y3m1QmqDSViBN486H7yg","name":"Bash","input":{"command":"wc -l ~/apps/crea-ka/frontend/dist/index.html ~/apps/crea-ka/frontend/src/index.template.html; ls ~/apps/crea-ka/scripts; grep -rn \"template\" ~/apps/crea-ka/scripts/*.py 2>/dev/null | head -5","description":"Check frontend build mechanism"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6024,"cache_read_input_tokens":113338,"cache_creation":{"ephemeral_5m_input_tokens":6024,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9ed56e6f-9f2c-4d59-a1c8-90a65565a8a8"}} |
|
200 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01D8y3m1QmqDSViBN486H7yg","type":"tool_result","content":" 2257 /Users/simon-pierreboucher/apps/crea-ka/frontend/dist/index.html\n 1619 /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html\n 3876 total\nbuild_frontend.py\ndeploy-m3u96b.sh\ngen_connector_docs.py\nscreenshots.sh\n/Users/simon-pierreboucher/apps/crea-ka/scripts/build_frontend.py:13:Lit frontend/src/index.template.html + frontend/src/icons.json +\n/Users/simon-pierreboucher/apps/crea-ka/scripts/build_frontend.py:31: html = (SRC / \"index.template.html\").read_text(encoding=\"utf-8\")","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9ebad3a7-ae15-4fd6-845f-f6ddb94766bb","timestamp":"2026-08-21T00:56:31.720Z","tool_use_result":{"stdout":" 2257 /Users/simon-pierreboucher/apps/crea-ka/frontend/dist/index.html\n 1619 /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html\n 3876 total\nbuild_frontend.py\ndeploy-m3u96b.sh\ngen_connector_docs.py\nscreenshots.sh\n/Users/simon-pierreboucher/apps/crea-ka/scripts/build_frontend.py:13:Lit frontend/src/index.template.html + frontend/src/icons.json +\n/Users/simon-pierreboucher/apps/crea-ka/scripts/build_frontend.py:31: html = (SRC / \"index.template.html\").read_text(encoding=\"utf-8\")","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
201 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz28FBiweWrRFTw1ztx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Xn6znDZBftpyeWD5246PVz","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6024,"cache_read_input_tokens":113338,"cache_creation":{"ephemeral_5m_input_tokens":6024,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"79dfcf95-c357-408e-9b8f-6933df8dbb48"}} |
|
202 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Xn6znDZBftpyeWD5246PVz","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/web.py\n4\t# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —\n5\t# annuaire cherchable par niche, région, langue, plateforme, taille\n6\t# ==============================================================================\n7\tfrom __future__ import annotations\n8\t\n9\timport json\n10\timport threading\n11\tfrom pathlib import Path\n12\t\n13\tfrom fastapi import FastAPI, HTTPException, Query\n14\tfrom fastapi.middleware.cors import CORSMiddleware\n15\tfrom fastapi.middleware.gzip import GZipMiddleware\n16\tfrom fastapi.responses import FileResponse, Response\n17\tfrom pydantic import BaseModel, Field\n18\t\n19\tfrom . import auth, db, ethics, kapdf\n20\tfrom . import stats as stats_mod\n21\tfrom .normalize import NICHES, PLATFORMS, REGIONS\n22\t\n23\tROOT = Path(__file__).resolve().parent.parent\n24\tFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n25\tSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n26\t\n27\tapp = FastAPI(title=\"Créa-Ka API\", version=\"1.0\",\n28\t description=\"Annuaire public des créateurs de contenu québécois \"\n29\t \"— tous leurs comptes, toutes leurs plateformes.\")\n30\tapp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n31\t allow_methods=[\"*\"], allow_headers=[\"*\"])\n32\tapp.add_middleware(GZipMiddleware, minimum_size=1000)\n33\t\n34\t# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans\n35\t# un pool de threads — une connexion partagée (check_same_thread=False) fait\n36\t# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'\n37\t# object is not subscriptable »). threading.local = une connexion par thread.\n38\t_local = threading.local()\n39\t\n40\t\n41\tdef _db():\n42\t con = getattr(_local, \"con\", None)\n43\t if con is None:\n44\t con = _local.con = db.connect()\n45\t return con\n46\t\n47\t# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py\n48\tapp.include_router(auth.router)\n49\t\n50\t\n51\t@app.get(\"/healthz\")\n52\tdef healthz():\n53\t return {\"ok\": True}\n54\t\n55\t\n56\t@app.get(\"/api/health\")\n57\tdef health():\n58\t s = db.stats(_db())\n59\t return {\"ok\": True, \"creators\": s[\"creators\"], \"accounts\": s[\"accounts\"],\n60\t \"last_sync\": s[\"last_sync\"]}\n61\t\n62\t\n63\t@app.get(\"/api/creators\")\n64\tdef list_creators(q: str = \"\", niche: str = \"\", region: str = \"\",\n65\t langue: str = \"\", plateforme: str = \"\", tier: str = \"\",\n66\t sort: str = \"reach\",\n67\t limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):\n68\t return db.search(_db(), q=q.strip(), niche=niche, region=region,\n69\t langue=langue, plateforme=plateforme, tier=tier,\n70\t sort=sort, limit=limit, offset=offset)\n71\t\n72\t\n73\t@app.get(\"/api/creators/{cid}\")\n74\tdef get_creator(cid: str):\n75\t creator = db.get_creator(_db(), cid)\n76\t if creator is None:\n77\t raise HTTPException(404, \"créateur introuvable\")\n78\t return creator\n79\t\n80\t\n81\t@app.get(\"/api/stats\")\n82\tdef get_stats():\n83\t return db.stats(_db())\n84\t\n85\t\n86\t_PERIODS_OK = {\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"}\n87\t\n88\t\n89\t@app.get(\"/api/stats/dashboard\")\n90\tdef stats_dashboard(period: str = \"30j\",\n91\t date_from: str = Query(\"\", alias=\"from\"),\n92\t date_to: str = Query(\"\", alias=\"to\")):\n93\t \"\"\"Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min.\"\"\"\n94\t if period not in _PERIODS_OK and not (date_from and date_to):\n95\t raise HTTPException(400, \"période inconnue \"\n96\t \"(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)\")\n97\t return stats_mod.dashboard(_db(), period=period,\n98\t date_from=date_from, date_to=date_to)\n99\t\n100\t\n101\t@app.get(\"/api/stats/report\")\n102\tdef stats_report(period: str = \"30j\", mode: str = \"complet\",\n103\t date_from: str = Query(\"\", alias=\"from\"),\n104\t date_to: str = Query(\"\", alias=\"to\")):\n105\t \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) :\n106\t complet | synthese | tendances | repartitions | donnees.\n107\t Un mode inconnu retombe sur `complet` (rétrocompatible v1).\"\"\"\n108\t if mode not in kapdf.REPORT_MODES:\n109\t mode = \"complet\"\n110\t if period not in _PERIODS_OK and not (date_from and date_to):\n111\t raise HTTPException(400, \"période inconnue\")\n112\t dash = stats_mod.dashboard(_db(), period=period,\n113\t date_from=date_from, date_to=date_to)\n114\t pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),\n115\t dashboard=dash, mode=mode).build()\n116\t fname = kapdf.filename(\"crea-ka\", period, mode)\n117\t return Response(content=pdf, media_type=\"application/pdf\",\n118\t headers={\"Content-Disposition\":\n119\t f'attachment; filename=\"{fname}\"'})\n120\t\n121\t\n122\t@app.get(\"/api/taxonomies\")\n123\tdef taxonomies():\n124\t return {\"niches\": sorted(NICHES), \"plateformes\": sorted(PLATFORMS),\n125\t \"regions\": sorted(REGIONS),\n126\t \"tiers\": [\"nano\", \"micro\", \"macro\", \"mega\"],\n127\t \"langues\": [\"fr\", \"en\", \"bilingue\"]}\n128\t\n129\t\n130\t@app.get(\"/api/sources\")\n131\tdef sources():\n132\t if SOURCES_PATH.exists():\n133\t return json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))\n134\t return {\"sources\": []}\n135\t\n136\t\n137\tclass OptOutRequest(BaseModel):\n138\t \"\"\"Demande de retrait (§15 — opt-out obligatoire et respecté).\"\"\"\n139\t name: str = Field(\"\", max_length=200)\n140\t account: str = Field(\"\", max_length=200,\n141\t description=\"plateforme:handle, ex. instagram:monnom\")\n142\t reason: str = Field(\"\", max_length=500)\n143\t contact: str = Field(\"\", max_length=200)\n144\t\n145\t\n146\t@app.post(\"/api/optout\")\n147\tdef optout(req: OptOutRequest):\n148\t \"\"\"Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.\n149\t\n150\t La vie privée prime sur la complétude (§15) : on masque d'abord, la\n151\t demande est journalisée pour vérification humaine ensuite.\n152\t \"\"\"\n153\t name = req.name.strip() or None\n154\t acc = req.account.strip().lower() or None\n155\t if not name and not acc:\n156\t raise HTTPException(400, \"préciser un nom ou un compte (plateforme:handle)\")\n157\t ethics.add_optout(name=name, account=acc, reason=req.reason,\n158\t contact=req.contact)\n159\t masked = db.apply_optout(_db(), name=name, account=acc)\n160\t return {\"ok\": True, \"masked\": masked,\n161\t \"message\": \"Fiche masquée. La demande sera vérifiée ; merci.\"}\n162\t\n163\t\n164\t# --- frontend statique -----------------------------------------------------------\n165\t\n166\t@app.get(\"/\")\n167\tdef index():\n168\t idx = FRONTEND_DIST / \"index.html\"\n169\t if idx.exists():\n170\t return FileResponse(idx)\n171\t raise HTTPException(404, \"frontend non construit\")\n172\t\n173\t\n174\t@app.get(\"/{path:path}\")\n175\tdef static_files(path: str):\n176\t target = (FRONTEND_DIST / path).resolve()\n177\t if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())):\n178\t return FileResponse(target)\n179\t idx = FRONTEND_DIST / \"index.html\"\n180\t if idx.exists():\n181\t return FileResponse(idx) # SPA fallback\n182\t raise HTTPException(404, \"introuvable\")\n183\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"85632310-9cb4-49b1-b510-c8da64b35f38","timestamp":"2026-08-21T00:56:31.724Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/web.py\n# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —\n# annuaire cherchable par niche, région, langue, plateforme, taille\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, ethics, kapdf\nfrom . import stats as stats_mod\nfrom .normalize import NICHES, PLATFORMS, REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n\napp = FastAPI(title=\"Créa-Ka API\", version=\"1.0\",\n description=\"Annuaire public des créateurs de contenu québécois \"\n \"— tous leurs comptes, toutes leurs plateformes.\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans\n# un pool de threads — une connexion partagée (check_same_thread=False) fait\n# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'\n# object is not subscriptable »). threading.local = une connexion par thread.\n_local = threading.local()\n\n\ndef _db():\n con = getattr(_local, \"con\", None)\n if con is None:\n con = _local.con = db.connect()\n return con\n\n# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py\napp.include_router(auth.router)\n\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"ok\": True}\n\n\n@app.get(\"/api/health\")\ndef health():\n s = db.stats(_db())\n return {\"ok\": True, \"creators\": s[\"creators\"], \"accounts\": s[\"accounts\"],\n \"last_sync\": s[\"last_sync\"]}\n\n\n@app.get(\"/api/creators\")\ndef list_creators(q: str = \"\", niche: str = \"\", region: str = \"\",\n langue: str = \"\", plateforme: str = \"\", tier: str = \"\",\n sort: str = \"reach\",\n limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):\n return db.search(_db(), q=q.strip(), niche=niche, region=region,\n langue=langue, plateforme=plateforme, tier=tier,\n sort=sort, limit=limit, offset=offset)\n\n\n@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n return creator\n\n\n@app.get(\"/api/stats\")\ndef get_stats():\n return db.stats(_db())\n\n\n_PERIODS_OK = {\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n date_from: str = Query(\"\", alias=\"from\"),\n date_to: str = Query(\"\", alias=\"to\")):\n \"\"\"Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min.\"\"\"\n if period not in _PERIODS_OK and not (date_from and date_to):\n raise HTTPException(400, \"période inconnue \"\n \"(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)\")\n return stats_mod.dashboard(_db(), period=period,\n date_from=date_from, date_to=date_to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\", mode: str = \"complet\",\n date_from: str = Query(\"\", alias=\"from\"),\n date_to: str = Query(\"\", alias=\"to\")):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) :\n complet | synthese | tendances | repartitions | donnees.\n Un mode inconnu retombe sur `complet` (rétrocompatible v1).\"\"\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n if period not in _PERIODS_OK and not (date_from and date_to):\n raise HTTPException(400, \"période inconnue\")\n dash = stats_mod.dashboard(_db(), period=period,\n date_from=date_from, date_to=date_to)\n pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),\n dashboard=dash, mode=mode).build()\n fname = kapdf.filename(\"crea-ka\", period, mode)\n return Response(content=pdf, media_type=\"application/pdf\",\n headers={\"Content-Disposition\":\n f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/taxonomies\")\ndef taxonomies():\n return {\"niches\": sorted(NICHES), \"plateformes\": sorted(PLATFORMS),\n \"regions\": sorted(REGIONS),\n \"tiers\": [\"nano\", \"micro\", \"macro\", \"mega\"],\n \"langues\": [\"fr\", \"en\", \"bilingue\"]}\n\n\n@app.get(\"/api/sources\")\ndef sources():\n if SOURCES_PATH.exists():\n return json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))\n return {\"sources\": []}\n\n\nclass OptOutRequest(BaseModel):\n \"\"\"Demande de retrait (§15 — opt-out obligatoire et respecté).\"\"\"\n name: str = Field(\"\", max_length=200)\n account: str = Field(\"\", max_length=200,\n description=\"plateforme:handle, ex. instagram:monnom\")\n reason: str = Field(\"\", max_length=500)\n contact: str = Field(\"\", max_length=200)\n\n\n@app.post(\"/api/optout\")\ndef optout(req: OptOutRequest):\n \"\"\"Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.\n\n La vie privée prime sur la complétude (§15) : on masque d'abord, la\n demande est journalisée pour vérification humaine ensuite.\n \"\"\"\n name = req.name.strip() or None\n acc = req.account.strip().lower() or None\n if not name and not acc:\n raise HTTPException(400, \"préciser un nom ou un compte (plateforme:handle)\")\n ethics.add_optout(name=name, account=acc, reason=req.reason,\n contact=req.contact)\n masked = db.apply_optout(_db(), name=name, account=acc)\n return {\"ok\": True, \"masked\": masked,\n \"message\": \"Fiche masquée. La demande sera vérifiée ; merci.\"}\n\n\n# --- frontend statique -----------------------------------------------------------\n\n@app.get(\"/\")\ndef index():\n idx = FRONTEND_DIST / \"index.html\"\n if idx.exists():\n return FileResponse(idx)\n raise HTTPException(404, \"frontend non construit\")\n\n\n@app.get(\"/{path:path}\")\ndef static_files(path: str):\n target = (FRONTEND_DIST / path).resolve()\n if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())):\n return FileResponse(target)\n idx = FRONTEND_DIST / \"index.html\"\n if idx.exists():\n return FileResponse(idx) # SPA fallback\n raise HTTPException(404, \"introuvable\")\n","numLines":183,"startLine":1,"totalLines":183}}}} |
|
203 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"fa7a93e8-458c-47de-9c16-b7a3a38f8c59","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
204 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz2cyaJTMsirCwt783o","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS6TEKpAEIERgCKkC/1PHz8tgjQJfexQtYkR8D5yw5Td+mmFvg4PzHOcZaLLcvmzuEFlho5BbrIaMyLv/XvlcN+9cNmiRjQ04ProScMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHoxJ7UBhIMj0b9umE6u379/jusGgyGX+x48WPFs6i6Kt4iMCQZejG+pmNPLIdQsI6z84MQo4LQiJua6rnyuhQvJBx8nM7lFq7djOhZjAsQJoyIsCrxLwAAJJJ1o/nb1uCSJEf4+E1z4GdYRlkH8hRVgDweSEvxGejkJwZER5FIY+QhKCOj9FUWxwDgdnFmUHbNYSPD411Jl2r3zf7onw79NUkfzlKQoZ9l9XIf3dEWcTABDvQOV3AOGlqeyyoQz138G49Xj5sNfkwpp8oxf9cfZa8T2hbIxQ/DPA+j4jAYfHdR6kk9CXUtC3jvX9tcL265CFiY8E2kVt8ofUYdi1FojJvKbMDgTub0s8Hfyj09VY/FD/xUu+9AV/lE9T9IGB0/6FZ59Pw+b/HQyg7DhA1NTg66cYtKZnX0/lWmSTlTWv3afWo53/XbZiwS6OvyE040bkPfhosFA3r0a4pfeixXWYs9t+n0Qvh4HYgBHawsuFmmyWijH9dzGSFwxCI1+WlpbzVjKlX6LNJdUHyHXrji89dDtRl1frYgsaDrx2P0DHPi2esCyass+NACCFiIcCns9ccUur+Ew+fMCXLMGKBhacsm+WCJNbpBlNlxVzN/FJgnGyuPX5N9r3LVOSjylGjlE/GSdFbVwdyON791DpsanXw4P315R5iL6mLdWyAQ9QbT3o3nXHTqF8Id5eTDUv3SrDxENrFSEvs5mt8qjyVtTBP4kwymQnKsGANmXOP/p+D09sjv3rP+5Qap1Rr4/JkAcRab5LsN3qF1mapzsW4DuV8J6ZKdn3SHa7+EvlK3O1hwcwjeOY1zEQqTfOFduWX9dqmvGoCHH+S0j9DEFzV83gVZUtKqu/5Qfk7zfDWDv58sgDEjs/ZSuFI7zvCsDU+hdyzmliul2m4ergCFw2E2+4cSUNDflGq+yasgqVMACeZJDzxVy4JVBK+Veg+pr4kjbeYLqltKn2/sw+wa8AWxsDmlvNfoGNrx0VsrDCIn/tWLxV0qrue3tl39OFwOOnbzjrDATDfdm+vxcBoyMsBsmpPH9m/ln0nYikGHFQY9Gn1QGixpoBj+CLbPIPLX6Z2Ow7aWgYnM0Sg/0MgtB8WuzLazHS1inxKmlCoz+mpxVN180rHHYv/rpwofh+wfdIQ8usyHuwRd84nsus5EnCP170e9cksNT2D1RuLTOc8TmEybdTvhSigIb6HNHxH5gQj1dO3dt2U1Q69PTZzqS0gIGjaNog1Ex2/CllCVBrmSCMo2oDZip99KZBMgSV109Xp+HtwNOFQXje2wxtrMgzU1CCN7smwf7UgD2xSoyUgfW12bTPiRB8ybBJvtMCSb9vmqYemh6zFf5CbgL1hwNenFG9HZg3xzUj4rpNTzA+lTjTkPqFbH6oHrDepE5369+nHyuN5ETUQRDStpu6AOf/D0tKgEMhHFQy9aEmwR08LadbHd46hSQ+5SWoXXtj5htkdSibA1Onsoa/5WYxOV3xstInUQ9j6HwRnWVxzcTKOZv7Yw9XL0XmxnKwsrD58V7idLHtGb5fMGRgcZoit81fPfgCz7JlpXrh+3I1uhp2uJXszhqNm0vSoggPMfKGP9P8qPHADdoezsvyFZPyjFkHzIolVAuYwNVVpUgCcHotMGJyYa3Yha7g/OwFhPGwBgzX7T9wRLmXmmraymT/wyrC6fYnbCrE8ZPAmIKnxYEy6fVaiHCEDzgqh6etpNTiN2NYOxnkEK7NRvE0eMZxlp3KLhEYyrCMNRIPkzWFrNfjMwODZR0ykjDPfb4A80auY/xaoJpmP/YoY2Wztjh2WOL7/D8zPcPZYwni89xEIb7Hr+vVFDenv2t4VXzVhUkq4/kBG+sE8eZ/DWSmXIlDxDm2eRuOF6I/IP/rJUoZONnh+U+Mehkqo5IriPM7VPTLRMd79ZP3PzvWd4Zes8IzwRH1med5Ss+s9Qd7BHomPBdw1DtPJmGCY8pPgaTOAlRwwCMSxG8NLB1mzLekqGRF3k+rirGfxrCAgYdIcVfqU4+eR65wuvpmbkVXMLa7D0fgJm3bI4zcS8+4MWp4qZ83GO5BOX7tNDbI3K9N0T4MoFd3LkgnWUvK9YdRnXPWJAZlEAS9ZoWI/Ef08gOx/FVwA7QTJqiA50QuPwoxUHgGwUiKH+vNQvauAO18Ij37FdL7NuUIry7h2PE+NTJ7jIlyJ+gsyMuzDOABkzyFlUoKUhuJQ6oXlzr6J7UvfFnFns7dKSewsjAEXT+x8K7q301XPosfk0JbVE92F7pMP84QHELxZeHw5C7A4C3nK23p3wat1I68Xzwyu2JPzOb0qwFNdWp3LlzqL16vbOb4Xd6Dh3Kth3RkqXFYerExHX/DSJvdw0Nw15NfH5aBQglVu9k5+Mmjf0/ImspFAyLOguo25augtBqO0/NSZ0H3/pg+zAL0Q126TA3UOPNlVxTfiraauVjigqzZoN3SRkFZhXer+01ZOXd0gsYy5TVbqohMhxA0G80vvcA9Ano6j/Z1yqLcQbnHJmqm0i5n8f65R3FOUFU+7iWprDYJe4r32UHxB2nUVqFpbpyGpTfQlh5SAH/MYSkcXrdz4gUwtymPAw4GC02BwtRIIul+0Tzfx2F2w4RhBBnD+K52hKeZouPr8sBCXYaGy8MkOC4KAJuwym9FC+7PUZMUqc8UqG4WSUPhORGUg4UmLGHhqGERP2DNJ7DAQjBpFrh2PfN1uIJVnqfNKtJ2hGNPrz4AbortbTmPvGLcQu3Pt+qHH+jrzgQfWrRdakZsC7agvkeiYSbkUUl2uzHTN/1EM/BtAP6VNuunLMZLZayXjwj6yiph1Nt+EmyF+boVuosvvrivnKPdCvJYOpQx4Dgzhr+zWeNVDq6efPidRAVMQHOZwZQh2X3SfuZQXQr0C4y9+D3//+G8Eqlfu9qsFl7RzHWPgqi0nWmORiFDTqU3XWunRrc1Z84VHFYEXLhwmdXIAs2bRCMawR4rKLfOKSORvDTrGSAlOXnpDgaQRyhDynJBaLOqz128/gHQXWeM4rf2KE24tTEeweqpClAUGOX14yJROanLNzZCwEIxehfSa3mEpE7uajyIDpNd4NQ3zy4fQzbIvcS8iV3pSSfQjVo+WrOTfcFCazBQlPbu53FNPW+xHr/pat9PBw/Euv3woSsPorB+vqUn8ZK3u3Y5P6PcE0KJVdmvH7iYDYNqbSLe20CgCA73t4ehWb9BM9VKaPH8QjWVuR7Zld7w3zF/Fyh2GRoKZ4m84uMYZxNzrRRCiE94aNIDBig2h208GhORYvbYCXbdO48T+7hUkhLsp8JdBa+1Fql9s1o98HJuiU7UkrqIjzHyihdc6TIoBN8H54NOA0vJdeFgN+et2xk20V/cGekkpAoY76Guw5GhB1id2isZLNbFrYz4L62RpWDeZbOVSA9XLsoik4f8M5QBkSBR5jUeieuBNFZZ0hE5e6C8Xz6MaKc5lPcaQuWUSpW7L00QMTsAfNpZcbkmUDtEnHxzTMA0in20tMH8rMW+pF3vkJYbGzTjr28Pe8bBr1o1KjleZXMb5gWeGV+f8AkevcMVHuIkyG1pi+n2vN8VmbKLQEVuVz7L+zZYzRCuxTkniejx5c0nLMU+qQaZ8eLrcDIReBYNBALYaENneJpWLnFXe1bKKKDjqbmjKd4k8wHXqQkB/AeVnZxhXQJpag8byj539+FDDEmJ6AS9yVlNrw6VgrcWuEjzp+iwVwhkx1kGfzzOJjXkpJfTV2xFv90FGDkLWDflRnj0xxghp3pER5WOWrixSMIoS77RuikMnmIeDGfGTSQdRbdU7bb+p5FSb4K0+8jKaIsdm9Xg73sH8aws0E0BIH1dwTkERFz/e7BzGjzYpCbI2Ph/mbf/2/oDZJg3PZKCUPE3Uv6bd6G93FMfaWPvAddH+gVlOF4cr/jurWgjQ7qxmXh5FSIKuEOlHPz5/f7nMAlzQanu6otfPXpNqe0QEAMApV8MGRkFVSlW38afr1GepXrYJfqPJGNpTUYIvKZUTD0cIn/aRX/+/sOpk7Cnst7HnS5E86nebTDbh69zElKDVymR9ELDu9uzG+zpiI3EzQslkd5L7T0gbl037Ruip9SkOKvCTuG19DyIEBVeYTsPAKHKjl3SDiiMeFYI8zhXrkWz7V8uJ8d1MYsTYadTJgfKPxAcL7H8ckrD+NBNrB9Uhs3Vw8S3mNuiE3XiIqzg13yEPRICuUR5BjGmNNoInpvLW6l6K0vU6qkuJrn0Fo8OewAyamYHKdI9L+hFKVVuCKmyrf76fyl4XRfOo7RPG24xTZUDRoPLojfpZ8UdbxuETg5NGahD/xxX7sGfBGRtvVMA5QBN9Ik8FeQ8YldbtB/fCUl9TXtX1HwhZcsGL3toDo4MyAP8/z8eqXMJKLRT1W7cVn5/9KqOsRMj82rR2wPYYbgLsXbKFzbvO1eU44CgFp79lJ2k7kZj9WYIBcwXK0+sy7skbGbJH9uCgSleSnmuPOUdXPL0i8t/4EeH4f90tvL2lxVPHaOHm5KcyOekg8gNqd2tqbIeGL58Vefo+WTvyPiDQL3SfAXMqiUYo45gWCo9h0oiDqUaxgAPxr6sVD1gKzVQuuxaC2X4qT0ja3bNagI1qXZvC9lde84kEauxeUqF+utssUtajhZQZGBJ3stWUkQQTs6lnPy8elptabZQoXcxHOe/FU+89KmSC10nRw8d4cMCX7FH132nUAwKQ7ZPrfsmW4g20S1ebBr+to6knp+5Hbmz/KEjLNYNthfqyKOPj/cSPu1MOd5hWckR5ANslbm7BZdSAVppaBqHS6P+5IiOQrcb3JalYNXRBkABUZNZtHPnr8h/RAjlxNKwTvgk3YKTv0PmpFerW54GHS2I/TvY/7v3XS9pqgJgnsir0waXzHFNqhB8K/oFOsaLARjG26PywlDdU8URCxa3zszFlc1VUaYai8ri73s0x9N+t2mirXZyaik+RUfNMBW6QY5rysTaTqW79juqLOxyYXJVaNDvQy0sX2KGfmpQybezQTeYcYOJtHaFhL+j3IoP4QFlH8PwCZPV/8QHmrGn0U8KPioHVkjsruOGxjUTzOBHvGRC5IXlEpYlBWXTxmfdUU848GdgZBsSQHhdRp4LDNJT8e5ccEbehrauDJlXi+odfiUb25yKXE7p+wudbCQbxpW+SjQ5+bLSulpXuAnT6KF6c5Pwxtbjw/cHfPhglY3v6y5ADWV5sofIcP3zlHhvOyf+Ed18D0Qi2e3rgnOqKP6JrALYww9j5fV9TFM4lvwRR7e6efrRYqgp7s/bMnjFw+9PrhsR5sfOccvgHjTt0U1elfIrkvRZ0qr/M9u3eDEfm1OAlmpcIT5vyFtH8ssK4NSVczUhGwbuhLIfHmFuaPx6P4lxYPWIbAP/ZSEtazIgQvAEqa4ryQMRvn27G5TgNdubQINqyypmI9eAta2SQxOPIa0/garxyq7hYsvhYZJblotaHJZnWqjVI7Db5O57QtluDaTlSzOVtm9P8FHucd2AU9m9TTK9+1WbxPsuyAfIktzarfsd0dqfdveUwNvc0mU5I4hfTUYNDrpHe44dBbdy+/nc4tWzhgAxNHnM40cJgemK1+HV8OzdW/jNJNvJ03eftPxrpsC83Rind1Gq+jBmMCCfZcQLfigXtwcf5+tJ5Yx2D1Omu/SlLjHh5IDSBZOZXqw4wCyFACSmEqjPNOpe4nsCxdQtVPegNLF5503QJsz94tEIgo3M/DdaDUZlaNbaYMGU7mSpBRhdpIeUUE3bxcxauht1JJkp7DEAet4F0OGlzNNuDza1Bxj0m3QrPtVPAGH3UBZn4+mM5mnM9MQawdNnkJybxT90L87kE8Er9qKeC34PvThcJtOoUAy/n4rdEKavDnO3gDC9Lo6zyS691MgGbqKFUwYArg77dDKHhlKrxg+x+fHU49d9fBdo+JLtoOGSZRJXOO1MGcAnPQj+qUcO3c+vkPJJPABctHjvPRM+SYA8fy78GOVNPS6QY4LjlWLEjKePvKWkVs0x9zQF3ybRjKM+tf8iFhIxlOfLnT0GRZT2jmcvE0gddrx82JA8DBc5SleScmBDUmbCoc+Fp+ZeBmQaaugHGsCNNUTeyJ7qkqZfvhZGsfKvPMGz1dLkW22ZpjEMu5YXpLn5xakX3pG6aY9rObB1IMJSv5cMypdoeaaDrDfT7NQp3J/hn03DkycSq9ss7NuGoOAX3XEF/iFGp24zLhQQHgSEiOQASy4RGSKR2tVOuUKi5vgdpM0xxVNksbNXgItgKJ39dAAkgjyhutBp4Jsf+8PRp7zYiUd+Y6F3TLAdM/2X297jF7ylM6gSQR5PQtsd4F84yE2XRte8oKFBJxCBn3Az1pgCnEW9gw29nYae8WKPtkdyfkH4MDLveqLzhaWNovJZ4VTmzwcMKbskZdVVIUxD8jfrDJBlozbhrN8hW+tq9Bwb1QX4M3uOgjx568EqRO9359OH0Vf28Q5RhptWzQ2UuvS5xUe1jrR3N+bs3FAmSB7lMhPBvAH+vwXCLctTrmhNvUc9sMzi9vczObtjcquCra1zutJqdldBThlu1q4HnbebKXNPlkl7ttkGIolT+qXRQ1P0j2fcnEE1JPM9j+zlxkbK8JdbWlT46vfav/qkCtA7AmzG/WmwC+pQ/vz0eudzM44vX4Mne3HG4IqpS3HtvYab3cjo0li6XwMeeUP8gWDZskiKQ9X/OZpLHOHlPboNRw/R5HRS4qTBdatSic2i0fkLcp86mgqT8gteMrW4S1ju0SJpQ2K7PA/Xf/5HXz7MNlgwUAmI6q1CABMsYTvUmC7IzNx/ek4IirP1Ay9F0xtfVY9dmjFz5D7achiKHrVyqL/zPLms/zj/u1VldMNUdsOpIHL1osGCC8WNwEfMbUcZEvArHaaB1HEJg7bp68J34ZTEeijON96g6d/d+rw3+pgHEPmyioL3xQxqwtJziuqXSFonAhqh75+EDSgjMONTZQM5oOiWwv/2FBmmtmQQv4jSl7i9/ndjpkuM+7zUKS5R2DWoiCTIFz+DSTxP0i59g1I7guEIFcvAab3mxKXCjWmCKYc0cYTaysL3KzmJJj7zeS8hfn8eD+rW4jqxxjIkDFJ4QaS/TS7+2RkTUdjYF36foagxV0FSS7iy3CCeEIzXdJNXCxvuqgQmr3Y19MI6AWGB0SZaFhKF4BDLBjHY+Qo9CxHRrRlnJZ9cXtzqt7vSMzs7AMJ7RSipihgj/PTjbeozYLsheQ8671n2XHgHyQvyCKCEhc/dVhx4AX/QmpChk4W5kIiQoF7ieA6Tighu9qc5IiJTNnCpiy4UsSjRpWpe+B9EQwsqQnJpxn14HOFXzsRPeuiJiIg6NhcmMP4EHtTdtzQtVjyIPLbJvDC4gXfbEOAiOYsr87Ku0ZNbEc+PN2fKTnyFwWyJnXoHhTWsIC2y8ID/VCQUoWQaoDBv2yuN2bm/6Ux6xHKgxuRW2eT4EbFCKfi6bjlHqjMZnnytkMqmctkmHfRjWKgBPnPBf6qfBPjALV5UecvmUgengsXtEpTEhHtgbjWB/nT5AjUxbrtoGKAucyqdnIC21Ox9KIVRjZkMioH6PWPi0J2BSukJ2O7ynQ4ICuUzt8tsgc/1LJl87hxM4XCguAA7VZDLTMkDZ17Orbar6Yo1H4i6RANAWKTBHofUZi8FCIz8xl9JKnCiacuOULrD0pxkSZqM+PMWn7INu+YnQ0E8hkuztkbPomHaQ4CMNijXrIvWFRRSUg3R0REghkmhz/IGaxsSx7AUbVdaY8LQ/wyyc3JhXwbYmTRtii3aNn3y/sSgRPAyjszeyO2GNNtuuhIdGK7KGNw5OIozk0S+Wi54rxM7yxyT43eGhek+A/m9P14s7fehbBvD7IOCX6ERsepyzt+fhS0b7GFd78TeJIFzkbno8Cbu0yCGFie3Ks6BsXCtPo/VhgI7s7mJCnr2+nbpsaB13EruIGCQU0SqGJI5QZ5cPlsPzayg1JNtul01Ans+Y3fTuOHr5RCRrjcPwymKP/2a16e7OEKl4dgBJwWDj170DyLW2RcMXeNIveIchPqCOENmRNg08iU5VWmQN+h1NZi2LxTEsKe1eAk7NBbEE59dU1Nw16nun2TP6J97LqOrKVQYMEphJ9CXqcYpRTxsIuwIoPg8y86YnINLsEJofxc/GjYj0WKNYnvlNIntaut/6apo91mzQfKlH00ztieh3YgOW1CiYQVFlu6AV+J1saHukkoY4Us4stw5HiHta5bKXR0MelopqeLSLTJ6+/iF5WuHKsfMEKNxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3799,"cache_read_input_tokens":119362,"cache_creation":{"ephemeral_5m_input_tokens":3799,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0145c29f-787f-43f3-b9e1-1563ec545f90"}} |
|
205 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz2cyaJTMsirCwt783o","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS+wMKpQEIERgCKkDDPGq8AutbzjIZXfMyeA392RaN7v5pkN+3hdwOVDIY7TTNAS2gDQMpgNQ6gqi3Jt0aSLsyoL/0uSvFALBHlljmMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB6MSe1AYSDCUquPqiw+B8xUcKJRoMNrbnUX9mh+yI1u+jIjBFML19TEvro9Cdx4r+kYb0mWno4THHz8ldjht9VxZujWTXc5Lwk9q+RGHGs+SEBK0qggK3VQpnv/IUBi7Qqo5UHbC0hwosmCs1rSnJmjvNe7mZb54EpIqMe6Qf4GpPw+3RurNqvMoG1zNiDyZgu7egY6PVh9tX6ngcUozul4U4ckyaSgT+1urRrooW6aUO7a/QZqQDthewgd1N6jFctPkjA/uvtcv7LYVx0tvuukXdDkBovQyfwW4Ij4sYbxTjnIZaxR2VDdvxV82Ue3kLrLyrmKQP8tDS1gEaiRtm5AsG9GjF2SyY1eT3sgHTImQIEwn8v3ivpnn77uq+GRAvxsjBQ/3GnbHcHxTJu5WYF8rx+mK6F2UriXEH0sRURlmFWbwq2OCw9leE2Xua3HGO2rwO7Zt0ZzoYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3799,"cache_read_input_tokens":119362,"cache_creation":{"ephemeral_5m_input_tokens":3799,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"502ca442-8793-4130-a4aa-92540eb82e03"}} |
|
206 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz2cyaJTMsirCwt783o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KADcidUKG1sqtxubcqkL1j","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3799,"cache_read_input_tokens":119362,"cache_creation":{"ephemeral_5m_input_tokens":3799,"ephemeral_1h_input_tokens":0},"output_tokens":9,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7b383ca3-c5a5-4b74-9f8a-af0059ecc068"}} |
|
207 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KADcidUKG1sqtxubcqkL1j","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/db.py\n4\t# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n5\t# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n6\t# ==============================================================================\n7\t\"\"\"Base de données Créa-Ka (SQLite, WAL).\n8\t\n9\t- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n10\t indexées pour la recherche).\n11\t- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n12\t l'index qui permet la déduplication inter-sources (§12.2).\n13\t- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n14\t\n15\tPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\n16\thorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\n17\tpasse `opted_out` (masqué, jamais ré-agrégé).\n18\t\"\"\"\n19\tfrom __future__ import annotations\n20\t\n21\timport json\n22\timport sqlite3\n23\timport sys\n24\timport time\n25\tfrom dataclasses import asdict\n26\tfrom datetime import datetime, timedelta, timezone\n27\tfrom pathlib import Path\n28\t\n29\tfrom . import ethics\n30\tfrom .dedup import merge_creators\n31\tfrom .identity import needs_review\n32\tfrom .normalize import slugify\n33\tfrom .schema import Creator, PlatformAccount, now_iso\n34\t\n35\tROOT = Path(__file__).resolve().parent.parent\n36\tDB_PATH = ROOT / \"data\" / \"creaka.db\"\n37\t\n38\t_SCHEMA = \"\"\"\n39\tCREATE TABLE IF NOT EXISTS creators (\n40\t id TEXT PRIMARY KEY,\n41\t display_name TEXT NOT NULL,\n42\t status TEXT NOT NULL DEFAULT 'active',\n43\t is_minor INTEGER NOT NULL DEFAULT 0,\n44\t region TEXT, city TEXT,\n45\t niches TEXT, languages TEXT,\n46\t creator_type TEXT, primary_platform TEXT,\n47\t audience_tier TEXT, total_reach INTEGER,\n48\t bio TEXT,\n49\t first_seen TEXT, last_seen TEXT, updated_at TEXT,\n50\t doc TEXT NOT NULL\n51\t);\n52\tCREATE TABLE IF NOT EXISTS accounts (\n53\t platform TEXT NOT NULL,\n54\t handle TEXT NOT NULL,\n55\t creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n56\t url TEXT NOT NULL,\n57\t followers INTEGER,\n58\t verified INTEGER,\n59\t confidence REAL NOT NULL,\n60\t signal TEXT,\n61\t needs_review INTEGER NOT NULL DEFAULT 0,\n62\t last_checked TEXT,\n63\t metrics TEXT,\n64\t PRIMARY KEY (platform, handle)\n65\t);\n66\tCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\n67\tCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\n68\tCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\n69\tCREATE TABLE IF NOT EXISTS sync_log (\n70\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n71\t ts TEXT NOT NULL,\n72\t source TEXT NOT NULL,\n73\t creators INTEGER, accounts INTEGER, avg_confidence REAL,\n74\t added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n75\t alert TEXT\n76\t);\n77\t\"\"\"\n78\t\n79\t\n80\tdef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n81\t Path(path).parent.mkdir(parents=True, exist_ok=True)\n82\t con = sqlite3.connect(path, check_same_thread=False)\n83\t con.row_factory = sqlite3.Row\n84\t con.execute(\"PRAGMA journal_mode=WAL\")\n85\t con.execute(\"PRAGMA foreign_keys=ON\")\n86\t con.executescript(_SCHEMA)\n87\t # migrations légères (bases existantes)\n88\t try:\n89\t con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n90\t except sqlite3.OperationalError:\n91\t pass\n92\t return con\n93\t\n94\t\n95\t# --- (dé)sérialisation ----------------------------------------------------------\n96\t\n97\tdef creator_to_doc(cr: Creator) -> str:\n98\t return json.dumps(asdict(cr), ensure_ascii=False)\n99\t\n100\t\n101\tdef creator_from_row(row: sqlite3.Row) -> Creator:\n102\t doc = json.loads(row[\"doc\"])\n103\t doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n104\t cr = Creator(**doc)\n105\t return cr\n106\t\n107\t\n108\tdef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n109\t \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n110\t doc = json.loads(row[\"doc\"])\n111\t doc[\"id\"] = row[\"id\"]\n112\t doc[\"first_seen\"] = row[\"first_seen\"]\n113\t doc[\"last_seen\"] = row[\"last_seen\"]\n114\t doc[\"updated_at\"] = row[\"updated_at\"]\n115\t doc[\"platforms\"] = [\n116\t {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n117\t \"followers\": a[\"followers\"],\n118\t \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n119\t \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n120\t \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n121\t for a in accounts if not a[\"needs_review\"]]\n122\t # champs internes / sensibles jamais exposés tels quels\n123\t doc.pop(\"source\", None)\n124\t doc.pop(\"external_id\", None)\n125\t doc.pop(\"legal_name\", None)\n126\t return doc\n127\t\n128\t\n129\t# --- écriture -------------------------------------------------------------------\n130\t\n131\tdef _unique_id(con: sqlite3.Connection, base: str) -> str:\n132\t cid, n = base, 2\n133\t while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n134\t cid = f\"{base}-{n}\"\n135\t n += 1\n136\t return cid\n137\t\n138\t\n139\tdef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n140\t first_seen: str, ts: str) -> None:\n141\t # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n142\t # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n143\t old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n144\t \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n145\t lost = old_keys - {a.key for a in cr.platforms}\n146\t if lost:\n147\t print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n148\t f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n149\t con.execute(\n150\t \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n151\t niches, languages, creator_type, primary_platform, audience_tier,\n152\t total_reach, bio, first_seen, last_seen, updated_at, doc)\n153\t VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n154\t ON CONFLICT(id) DO UPDATE SET\n155\t display_name=excluded.display_name, status=excluded.status,\n156\t is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n157\t niches=excluded.niches, languages=excluded.languages,\n158\t creator_type=excluded.creator_type,\n159\t primary_platform=excluded.primary_platform,\n160\t audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n161\t bio=excluded.bio, last_seen=excluded.last_seen,\n162\t updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n163\t (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n164\t \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n165\t cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n166\t first_seen, ts, ts, creator_to_doc(cr)))\n167\t con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n168\t for acc in cr.platforms:\n169\t con.execute(\n170\t \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n171\t verified, confidence, signal, needs_review, last_checked, metrics)\n172\t VALUES (?,?,?,?,?,?,?,?,?,?,?)\n173\t ON CONFLICT(platform, handle) DO UPDATE SET\n174\t creator_id=excluded.creator_id, url=excluded.url,\n175\t followers=excluded.followers, verified=excluded.verified,\n176\t confidence=excluded.confidence, signal=excluded.signal,\n177\t needs_review=excluded.needs_review,\n178\t last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n179\t (acc.platform, acc.handle, cid, acc.url, acc.followers,\n180\t None if acc.verified is None else int(acc.verified),\n181\t acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n182\t json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n183\t\n184\t\n185\tdef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n186\t \"\"\"Fiche existante possédant déjà un des comptes entrants.\n187\t\n188\t Même plateforme + même handle = MÊME compte : la table accounts n'admet\n189\t qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n190\t fait donc sur l'identité exacte du compte, sans condition de confiance —\n191\t exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n192\t 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n193\t snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n194\t comptes lui réassignait le compte, laissant l'originale orpheline\n195\t (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n196\t l'égalité stricte plateforme+handle identifie le compte lui-même.\n197\t \"\"\"\n198\t for acc in cr.platforms:\n199\t row = con.execute(\n200\t \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n201\t (acc.platform, acc.handle)).fetchone()\n202\t if row:\n203\t return row[\"creator_id\"]\n204\t return None\n205\t\n206\t\n207\tdef sync_source(con: sqlite3.Connection, source_id: str,\n208\t creators: list[Creator], *, started: float | None = None,\n209\t errors: int = 0) -> dict:\n210\t \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n211\t\n212\t `started` (time.time() du début du passage) → durée réelle persistée dans\n213\t sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n214\t connecteur (§16 logging structuré).\n215\t \"\"\"\n216\t ts = now_iso()\n217\t added = updated = skipped_optout = 0\n218\t confs: list[float] = []\n219\t for cr in creators:\n220\t cr = ethics.scrub(cr)\n221\t # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n222\t if ethics.is_opted_out(cr):\n223\t skipped_optout += 1\n224\t existing = _find_existing(con, cr)\n225\t if existing:\n226\t con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n227\t \"WHERE id=?\", (ts, existing))\n228\t continue\n229\t confs.extend(a.confidence or 0 for a in cr.platforms)\n230\t existing_id = _find_existing(con, cr)\n231\t if existing_id:\n232\t row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n233\t (existing_id,)).fetchone()\n234\t current = creator_from_row(row)\n235\t if current.status == \"opted_out\":\n236\t continue # masqué : on ne ré-agrège pas\n237\t # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n238\t # partagé : c'est le même compte, la fusion est toujours sûre —\n239\t # pas une fusion sur le nom (§12.2)\n240\t merged = merge_creators(current, cr)\n241\t _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n242\t updated += 1\n243\t continue\n244\t cid = _unique_id(con, slugify(cr.display_name))\n245\t _write_creator(con, cid, cr, ts, ts)\n246\t added += 1\n247\t con.commit()\n248\t n_acc = sum(len(c.platforms) for c in creators)\n249\t stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n250\t \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n251\t \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n252\t alert = None\n253\t if not creators:\n254\t alert = \"0 créateur retourné — source possiblement bloquée\"\n255\t stats[\"alert\"] = alert\n256\t seconds = round(time.time() - started, 1) if started else 0.0\n257\t stats[\"seconds\"] = seconds\n258\t if errors:\n259\t stats[\"errors\"] = errors\n260\t con.execute(\n261\t \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n262\t \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n263\t (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n264\t added, updated, errors, seconds, alert))\n265\t con.commit()\n266\t return stats\n267\t\n268\t\n269\tdef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n270\t \"\"\"Archivage des disparus (§13, politique de grâce).\n271\t\n272\t Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n273\t complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n274\t Réversible : dès qu'une source le revoit, la fusion (sync_source →\n275\t _write_creator) réécrit son statut actif et son `last_seen`.\n276\t Les opt-out ne sont JAMAIS touchés (status != 'active').\n277\t \"\"\"\n278\t cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n279\t .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n280\t ts = now_iso()\n281\t cur = con.execute(\n282\t \"UPDATE creators SET status='inactive', updated_at=? \"\n283\t \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n284\t (ts, cutoff))\n285\t con.commit()\n286\t if cur.rowcount:\n287\t print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n288\t f\"depuis {hours} h → inactive (réversible au retour)\")\n289\t return cur.rowcount\n290\t\n291\t\n292\tdef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n293\t con.execute(\n294\t \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n295\t \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n296\t con.commit()\n297\t\n298\t\n299\tdef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n300\t account: str | None = None) -> int:\n301\t \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n302\t ids: set[str] = set()\n303\t if account and \":\" in account:\n304\t platform, handle = account.lower().split(\":\", 1)\n305\t for row in con.execute(\n306\t \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n307\t (platform, handle.lstrip(\"@\"))):\n308\t ids.add(row[\"creator_id\"])\n309\t if name:\n310\t from .dedup import name_key\n311\t for row in con.execute(\"SELECT id, display_name FROM creators\"):\n312\t if name_key(row[\"display_name\"]) == name_key(name):\n313\t ids.add(row[\"id\"])\n314\t ts = now_iso()\n315\t for cid in ids:\n316\t con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n317\t (ts, cid))\n318\t con.commit()\n319\t return len(ids)\n320\t\n321\t\n322\t# --- lecture (API) ----------------------------------------------------------------\n323\t\n324\t_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n325\t\n326\t\n327\tdef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n328\t region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n329\t tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n330\t offset: int = 0) -> dict:\n331\t \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n332\t\n333\t Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n334\t exclues de l'annuaire public (prudence extrême, §15).\n335\t \"\"\"\n336\t where = [\"status='active'\", \"is_minor=0\"]\n337\t params: list = []\n338\t if q:\n339\t where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n340\t needle = f\"%{q.lower()}%\"\n341\t params += [needle, needle]\n342\t if niche:\n343\t where.append(\"(','||niches||',') LIKE ?\")\n344\t params.append(f\"%,{niche},%\")\n345\t if region:\n346\t where.append(\"region=?\")\n347\t params.append(region)\n348\t if langue:\n349\t where.append(\"(','||languages||',') LIKE ?\")\n350\t params.append(f\"%,{langue},%\")\n351\t if tier in _TIERS:\n352\t where.append(\"audience_tier=?\")\n353\t params.append(tier)\n354\t if plateforme:\n355\t where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n356\t \"AND needs_review=0)\")\n357\t params.append(plateforme)\n358\t order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n359\t \"nom\": \"display_name COLLATE NOCASE ASC\",\n360\t \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n361\t sql_where = \" AND \".join(where)\n362\t total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n363\t params).fetchone()[\"c\"]\n364\t rows = con.execute(\n365\t f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n366\t params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n367\t items = []\n368\t for row in rows:\n369\t accounts = con.execute(\n370\t \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n371\t (row[\"id\"],)).fetchall()\n372\t items.append(_public_dict(row, accounts))\n373\t return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n374\t\n375\t\n376\tdef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n377\t row = con.execute(\n378\t \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n379\t (cid,)).fetchone()\n380\t if not row:\n381\t return None\n382\t accounts = con.execute(\n383\t \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n384\t (row[\"id\"],)).fetchall()\n385\t return _public_dict(row, accounts)\n386\t\n387\t\n388\tdef stats(con: sqlite3.Connection) -> dict:\n389\t \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n390\t base = \"FROM creators WHERE status='active' AND is_minor=0\"\n391\t n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n392\t n_acc = con.execute(\n393\t \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n394\t \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n395\t ).fetchone()[\"c\"]\n396\t by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n397\t \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n398\t \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n399\t \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n400\t by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n401\t f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n402\t niches: dict[str, int] = {}\n403\t for row in con.execute(f\"SELECT niches {base}\"):\n404\t for nch in (row[\"niches\"] or \"\").split(\",\"):\n405\t if nch:\n406\t niches[nch] = niches.get(nch, 0) + 1\n407\t regions: dict[str, int] = {}\n408\t for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n409\t regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n410\t last_sync = con.execute(\n411\t \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n412\t return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n413\t \"by_tier\": by_tier,\n414\t \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n415\t \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n416\t \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n417\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bbc47cbb-203c-4864-b437-7282178e4a5c","timestamp":"2026-08-21T00:57:12.963Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/db.py\n# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n# ==============================================================================\n\"\"\"Base de données Créa-Ka (SQLite, WAL).\n\n- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n indexées pour la recherche).\n- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n l'index qui permet la déduplication inter-sources (§12.2).\n- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n\nPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\nhorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\npasse `opted_out` (masqué, jamais ré-agrégé).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport sys\nimport time\nfrom dataclasses import asdict\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom . import ethics\nfrom .dedup import merge_creators\nfrom .identity import needs_review\nfrom .normalize import slugify\nfrom .schema import Creator, PlatformAccount, now_iso\n\nROOT = Path(__file__).resolve().parent.parent\nDB_PATH = ROOT / \"data\" / \"creaka.db\"\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS creators (\n id TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'active',\n is_minor INTEGER NOT NULL DEFAULT 0,\n region TEXT, city TEXT,\n niches TEXT, languages TEXT,\n creator_type TEXT, primary_platform TEXT,\n audience_tier TEXT, total_reach INTEGER,\n bio TEXT,\n first_seen TEXT, last_seen TEXT, updated_at TEXT,\n doc TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS accounts (\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n url TEXT NOT NULL,\n followers INTEGER,\n verified INTEGER,\n confidence REAL NOT NULL,\n signal TEXT,\n needs_review INTEGER NOT NULL DEFAULT 0,\n last_checked TEXT,\n metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\nCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\nCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n source TEXT NOT NULL,\n creators INTEGER, accounts INTEGER, avg_confidence REAL,\n added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n alert TEXT\n);\n\"\"\"\n\n\ndef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(path, check_same_thread=False)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA journal_mode=WAL\")\n con.execute(\"PRAGMA foreign_keys=ON\")\n con.executescript(_SCHEMA)\n # migrations légères (bases existantes)\n try:\n con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n except sqlite3.OperationalError:\n pass\n return con\n\n\n# --- (dé)sérialisation ----------------------------------------------------------\n\ndef creator_to_doc(cr: Creator) -> str:\n return json.dumps(asdict(cr), ensure_ascii=False)\n\n\ndef creator_from_row(row: sqlite3.Row) -> Creator:\n doc = json.loads(row[\"doc\"])\n doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n cr = Creator(**doc)\n return cr\n\n\ndef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n doc = json.loads(row[\"doc\"])\n doc[\"id\"] = row[\"id\"]\n doc[\"first_seen\"] = row[\"first_seen\"]\n doc[\"last_seen\"] = row[\"last_seen\"]\n doc[\"updated_at\"] = row[\"updated_at\"]\n doc[\"platforms\"] = [\n {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n \"followers\": a[\"followers\"],\n \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n for a in accounts if not a[\"needs_review\"]]\n # champs internes / sensibles jamais exposés tels quels\n doc.pop(\"source\", None)\n doc.pop(\"external_id\", None)\n doc.pop(\"legal_name\", None)\n return doc\n\n\n# --- écriture -------------------------------------------------------------------\n\ndef _unique_id(con: sqlite3.Connection, base: str) -> str:\n cid, n = base, 2\n while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n cid = f\"{base}-{n}\"\n n += 1\n return cid\n\n\ndef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n first_seen: str, ts: str) -> None:\n # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n lost = old_keys - {a.key for a in cr.platforms}\n if lost:\n print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n con.execute(\n \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n niches, languages, creator_type, primary_platform, audience_tier,\n total_reach, bio, first_seen, last_seen, updated_at, doc)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(id) DO UPDATE SET\n display_name=excluded.display_name, status=excluded.status,\n is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n niches=excluded.niches, languages=excluded.languages,\n creator_type=excluded.creator_type,\n primary_platform=excluded.primary_platform,\n audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n bio=excluded.bio, last_seen=excluded.last_seen,\n updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n first_seen, ts, ts, creator_to_doc(cr)))\n con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n for acc in cr.platforms:\n con.execute(\n \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n verified, confidence, signal, needs_review, last_checked, metrics)\n VALUES (?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id, url=excluded.url,\n followers=excluded.followers, verified=excluded.verified,\n confidence=excluded.confidence, signal=excluded.signal,\n needs_review=excluded.needs_review,\n last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n\n\ndef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n \"\"\"Fiche existante possédant déjà un des comptes entrants.\n\n Même plateforme + même handle = MÊME compte : la table accounts n'admet\n qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n fait donc sur l'identité exacte du compte, sans condition de confiance —\n exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n comptes lui réassignait le compte, laissant l'originale orpheline\n (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n l'égalité stricte plateforme+handle identifie le compte lui-même.\n \"\"\"\n for acc in cr.platforms:\n row = con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (acc.platform, acc.handle)).fetchone()\n if row:\n return row[\"creator_id\"]\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source_id: str,\n creators: list[Creator], *, started: float | None = None,\n errors: int = 0) -> dict:\n \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n\n `started` (time.time() du début du passage) → durée réelle persistée dans\n sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n connecteur (§16 logging structuré).\n \"\"\"\n ts = now_iso()\n added = updated = skipped_optout = 0\n confs: list[float] = []\n for cr in creators:\n cr = ethics.scrub(cr)\n # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n if ethics.is_opted_out(cr):\n skipped_optout += 1\n existing = _find_existing(con, cr)\n if existing:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n \"WHERE id=?\", (ts, existing))\n continue\n confs.extend(a.confidence or 0 for a in cr.platforms)\n existing_id = _find_existing(con, cr)\n if existing_id:\n row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n (existing_id,)).fetchone()\n current = creator_from_row(row)\n if current.status == \"opted_out\":\n continue # masqué : on ne ré-agrège pas\n # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n # partagé : c'est le même compte, la fusion est toujours sûre —\n # pas une fusion sur le nom (§12.2)\n merged = merge_creators(current, cr)\n _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n updated += 1\n continue\n cid = _unique_id(con, slugify(cr.display_name))\n _write_creator(con, cid, cr, ts, ts)\n added += 1\n con.commit()\n n_acc = sum(len(c.platforms) for c in creators)\n stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n alert = None\n if not creators:\n alert = \"0 créateur retourné — source possiblement bloquée\"\n stats[\"alert\"] = alert\n seconds = round(time.time() - started, 1) if started else 0.0\n stats[\"seconds\"] = seconds\n if errors:\n stats[\"errors\"] = errors\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n added, updated, errors, seconds, alert))\n con.commit()\n return stats\n\n\ndef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n \"\"\"Archivage des disparus (§13, politique de grâce).\n\n Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n Réversible : dès qu'une source le revoit, la fusion (sync_source →\n _write_creator) réécrit son statut actif et son `last_seen`.\n Les opt-out ne sont JAMAIS touchés (status != 'active').\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n ts = now_iso()\n cur = con.execute(\n \"UPDATE creators SET status='inactive', updated_at=? \"\n \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n (ts, cutoff))\n con.commit()\n if cur.rowcount:\n print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n f\"depuis {hours} h → inactive (réversible au retour)\")\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n con.commit()\n\n\ndef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n account: str | None = None) -> int:\n \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n ids: set[str] = set()\n if account and \":\" in account:\n platform, handle = account.lower().split(\":\", 1)\n for row in con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (platform, handle.lstrip(\"@\"))):\n ids.add(row[\"creator_id\"])\n if name:\n from .dedup import name_key\n for row in con.execute(\"SELECT id, display_name FROM creators\"):\n if name_key(row[\"display_name\"]) == name_key(name):\n ids.add(row[\"id\"])\n ts = now_iso()\n for cid in ids:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n (ts, cid))\n con.commit()\n return len(ids)\n\n\n# --- lecture (API) ----------------------------------------------------------------\n\n_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n\n\ndef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n offset: int = 0) -> dict:\n \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n\n Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n exclues de l'annuaire public (prudence extrême, §15).\n \"\"\"\n where = [\"status='active'\", \"is_minor=0\"]\n params: list = []\n if q:\n where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n needle = f\"%{q.lower()}%\"\n params += [needle, needle]\n if niche:\n where.append(\"(','||niches||',') LIKE ?\")\n params.append(f\"%,{niche},%\")\n if region:\n where.append(\"region=?\")\n params.append(region)\n if langue:\n where.append(\"(','||languages||',') LIKE ?\")\n params.append(f\"%,{langue},%\")\n if tier in _TIERS:\n where.append(\"audience_tier=?\")\n params.append(tier)\n if plateforme:\n where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n \"AND needs_review=0)\")\n params.append(plateforme)\n order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n \"nom\": \"display_name COLLATE NOCASE ASC\",\n \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n sql_where = \" AND \".join(where)\n total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n params).fetchone()[\"c\"]\n rows = con.execute(\n f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n items = []\n for row in rows:\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n items.append(_public_dict(row, accounts))\n return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n row = con.execute(\n \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n (cid,)).fetchone()\n if not row:\n return None\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n return _public_dict(row, accounts)\n\n\ndef stats(con: sqlite3.Connection) -> dict:\n \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n base = \"FROM creators WHERE status='active' AND is_minor=0\"\n n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n n_acc = con.execute(\n \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n ).fetchone()[\"c\"]\n by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n niches: dict[str, int] = {}\n for row in con.execute(f\"SELECT niches {base}\"):\n for nch in (row[\"niches\"] or \"\").split(\",\"):\n if nch:\n niches[nch] = niches.get(nch, 0) + 1\n regions: dict[str, int] = {}\n for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n last_sync = con.execute(\n \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n \"by_tier\": by_tier,\n \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n","numLines":417,"startLine":1,"totalLines":417}}}} |
|
208 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"72a8f5b1-deea-459c-afcd-32795464679e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
209 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz5fYShVBn7NHPpkcSB","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS01UKpAEIERgCKkBvD5eTMXAbIde6Zl2uG8Xu212nHjoFHeDFGEDfr9Ad/ASJtbF8TJ1r+10hFpph9FlhSH/PngIKtlp24ZYMZK3PMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGpxZ7UBhIM+Q5jimz9ZN+InuIKGgzpKbbSbjlXbEtT2zkiMF85A8cgBUnF5Sk9txk78RSBipE+ltuAvn8zFE6V08Rw3hu8YrnOfEu8n08OklNdfyrbUyS/2xEUkTEsCTeW/VvagiIkXQUeWbPRKCOJCb2ADMl3l6/Sm6GWPpHfk5+JPTqGr+xkXvuklkoY63IUsma8Lr3pL7v0J+FDNrFV0sdhjgFJ9l3PGVqVimYS81sKEnAc9nmaQEBZ98KBhgrlnhG4h7uUawjH4t799wgdtp7OlMVyxscK92eGmjkZd+fMfSsTc1Q+Tf6hbuzIhjRv2LQMp+vijdbQ7WbcdxgvmnTYjxbSEpv00goB3Qv4vwCt5KMzSZSdXJatDD5XVgoFWYJVqwB+gvkZObJX0IsTa9wc0yGhyVy1MFc7j1ySjfg2XFRDcbr5/xUZ3pzxWhNgy7TF8dZEZpf+/LzfnXrvyavIF8HKWDxAh6cZLXCeFBH02Qd4i9HzZw6s0Q0UhBTAAoCHTcykNXTZsudqDn0cAPrgNSMAPBkijDc6Qsg5S2xDHymlD7xxZ4pzn4fme5dApMIbC/KoyqtlqgZfSbmQM969CkdBHZysNJ9fmd4QBWdTgGM2iILFznG/nI0m8wzueiURJNgbci905trKdDN4piLvAOq+k0WWjAiUJ1v2mPk54+0yz7zVbl0NRYaZ8bOJSUbOCBWamsBnoZiS1Crj8F2T5QMxKFpTZSosNCOonFDmBOGdMGBQdeBa3MLMVM6uXg7Z+3lPNqphMHH8JqgCoeKUr1Rec2ncqV4pihgIHxP3E1Vlwun9wCPwgtwEdhR7fhhy+urRaKGYSm2qg1AHhqfcvl3aS2UQihqoTWEc6jsj6blcqYtAp/IRZnIspt2ujCP2qqiM0Q0NQ0/UTaaOxvgAMumNLWrHH57Qp3VhZuogli7RY4FuSrMVNs4pnefd3VCQRhxpDODZaGRNGaolrE7/RSTuvTDK9wP+gg4++0P0sugbGV5RdN/Mf7blpJWeOX6sTNBlx/zeZRDGcrtFnNDH48+kDda1y7NUl8i+FNRJrRhAOk7gB5gCQ2l4cDpxXsOfKIcCG2H5x+C5epnRtp/AcqwjuySQ7eMvX5xPiZuIkbqSPEMgiRTHBAZ0LTOwGirjkcwuNKx266V+6A7mtYcMYQQUjb/cTLnSaY+yo0gDEVdrjdlntcggU7Aga0pS2eEUhR8lIrP+Eu2kapUH51THmFLp2ppCXZJYR4mbpH2tMota7LC+dUrJwd+kTiSkMFfcAEnKbfFJyJawun84hTIkd9/slpo3u2tHpq/EMbdCs2X3O0zhrwf7b7/B0qLcFHuG0C1djLunNqqal8mEyDbtJepRkUWRu/xk3lXHLuBWCD69FWjLLB7eMoY8Leq9esYQFBpaAl/s77p7djZquhN0/K4C6p3uIJO2naF6IZDz/fXLeVihdzrJkkA6dcJ5j7HLNmck3kbN3YzK4OMn2LbRs0CQqufoE3cFk3CDSNgOJwKdx0Lu8fpzeeCsH9K/SvaG+B4t28ivRVLixQIvwwAALA8g1cgLaET6l6rYwszuafPEp77/sUncCSsPjmNt4AOSqT3P3hhshkpliB1cQQdKdRvxoy0JbxjJFpLnydWviFXX5+KBNKacVdnZ+p9TMlhgSzj/bA15I+mPthV9yVMsSyZyhmSFe7mlkqeNULjg9fEwORFzVMFERzab+gurn22Cpf81++sWgbr8CsKvLpv9DBQSiFmVbenqIB1SLsChILrIse7w1mlIJE3/xyQ0TXFk2t125OXnI+4udv9rbtGMyzmJS9a7W1RxnK4L7VM3A+6Mww+4vL6pFu/rLvAqT6RYW++heNH7phu3On5TZcTj4h/57jJRVEONSSfRjdaP3Ws4hktYJzKtHZyevV4o7UptO8MNAt6UyBQgSTNtGNGGjbkIgmwey/mn+OkDcINARYmw+exTq6f8+PJZK5kWNXNtOrh5y98ax07u9Krz4VYZ9P5K7Nv+lLhRWAcZp09kcpkoqdzcuRPJpd7IXZ0jzgSd7nFOYYeFZ9cISk9iPcSTn9WiJj+bNY0VjFyDe1EqDH+//nrvct/pAUFUr6ebodR6NpYw1nG4c24Ekz3qECImfC8D08kBsCM06Q/eEnFJjFe24zVmj5sszBqRXq9gvS2aoRilSj4EmPETz/DQ7zf5u3WtYdjKZKC3GO7MoRRDd0UHkDwFntgpsMHtXe9sTtA5nwy2QVjDLP4YjRfR1qBQTbXBK/9zfu3gANKuk/RqB4eXm6pB+a9jYu4A5ylYmXDRmpo1ZEGaKlcQRlssjq0oyvn6cTtxNj7/EBEbBWxrZpWAKMHhD1lLCpd75r9PKcDTa4wVChTLitLgcwUhal0VWSHSXrm/r487US5Rm6mN6AzTzq8L+u1jaiuzImggMdfELTXZo2XLSHgfyxfBTCThY+XyK8cfadAdeoYMamD2shwSfUBRvzvXhbs14wUogD2NZuDXeGZhcB09uuoMUEOa+aSWuRzsWT4fEZ6NC+sRplqFxUYtAv6lzoJtS+2B/UxCDsQR9ZuysNEoz77GP+sbLV6NccGU+Ddc6IVrExAIHwm0cDJb9IPfvzNB1HoEO4hUNl0EdjoDoMBR1uw0Vd66NF9OaSvHejFSJpb9jF1+mSZ0NTlBJfgMB43hJ1wg/82v8JN2wfCQA4+Dfqm6ANyzJFQmHRc1h4iqi42CN5S4koEeX0yBBotndzkBAwZ1otI0ErKoE1MLU16mG2d2Ae0FvFxTlWuUK5aqn3plQadS9jmkMWPDNZQNBqqWth8exkUCxytkPMn+gKnkGM94g+Iv3btEVe6Ximfzdq5pJfzl5ZTCaLYlmnesaEAbHPEWTAczK8bX3u9LkY2YL2DumkXTTrwUE47oo/OnvLkBfSsARgoiU4u9WC82rrAvrV5upd4t4Ci4RCERqANmzcfnJxzLUG0q7IzhF9XkuIySJCICZatzmyiA/dToYFzrjQOtXhLhSbgNx1qrrAISzmoWnHJvXAkWaUAYr+W25lqMtwDJw4DzRLeeS5DjhLRslqIWbBwpFO9eWawWj7QXpr/gYgtC6Ov60FTng9YERVwLtYYyBR2hpSujCDFCG5JiLAVsKe0VNpdgIkwdpgHyNmT8MDnKij+rdySDup7dE8m907ARLUyanybW/LlzKHssShkTO1edUfXWRTx3MuUvUEH6oe/L+h4oGprWO/k/iZqafORi2zBDTBMSxGt3AhGRYNYjBFOPJIm73K6TAiycSWMZtKSwX7dRyVmj0saZeEbGNNQbdp4nQS/e155qEznYgv7ijjkzqRswU9G5dnAjhhKtIiAx+1Td3SQa4fqIUnlqPT4w6cD8B311KLvbBlY8R1ZxhnCZqcUlQQVjn6oifDEK2Pu1T7iSORDDt1RN9Yfd22UbQGRjWXeUOkCR8vR8gikpyNUJaQGbVxAVuMOA95uXYgACXu3dWWDG2cmfvq1+Bw/kbdM4DHMAz/paOBoM1w6G5X8tOIlo5naA+b9LZ4R5Ct5EsL7nAinKcpM3+xjkaopnFL07+SEo4CdBkKpFeFY3AwGl6Ya4dPzn+JxM2evS2UHL0YErxC8A+cqWW8KSd+Br1g7RJFL6eHojk4/KeyVyzelIk/sZOEJlNcZG6XyiCXFthazsLLfBBdwGzbYammRgxdaDEc0J2hmEUCeLbSRT2vMUpFz3Y4kK5GTG1Qhk2aVXjpuiABbMHM31dm2CMVvmfPhXObReWWC+GnMPZCXjmMRWUz2uC20pt6YFBwV4HhnYqMuJZSsT0murFUwwKXOiG0qHQesqNFxEjWSXFqfnYwVg6pHx0WktiFZf+H0xdBO3L8rf414OfE9aS2UjPNQdzQCgwsuu++bB3xcoyr81O6JtJGjUD8pN56PyEcDCeiS/9QV3peuAp1QzBN+gd8X5br5/NPQi345CHdCWul/5ab7wuNb2AQyDJ3BAWSedyQY0EMGd4isVIk0syN4ssrRHVYQp/ywDEQB8QmrcRT/soXz0rOm29P7Q91D6eNgGRZ4EPDLNtWnFVBNv6vUsUcJlqNX0Pp71/QXS3F4n5IHdLvbOU8eairT4DUNVO9dbfeEU10fXM/sy3cY6JWKADYIxTn7y8f1t12YSIT6djgWk83vc4gfV0akWcYDvnco0p6alASI/LjpnE3N5xi+S/ZgSMZB0h3DkjIaXb/+UQUFCdM+K9iJdSN50nAKbNnBYRgr3yxaxZAstUPnslgRTg0ay7wltv429O8Y9HVoDeDhQXksgjfFJcXDKJ80xYj5NyJhSpl4Yz8CHRzkLrvlkrFRt328j/Ha7b3ss2cDvbDR0dAeUXCaSYnF0xZVpjLfGlM9YPzt48dunsYySGkd23ga9cJk1YkzuxvufwUV9lgjRAJoFw4T5TDKOny77M7maDVWz0QYsjV9T+MHbYtl3gEKwciPkjVrt2oK+S73O8QEAw15WicbPd2jMJW8LYxG8ri2EDD6bqKaus1bWEuCEajdhl8iVSAodAk95v4/JfyXscVIQ81slQR1qahv33BHgxkvJ/fhGvr1LnTIvveLa1kZTXZKxGBZydIb/Z3ql71YcOKaav1mxTbYM+xhy/oHx9nWt0M8vV1aZa53KzJ9ie3cZ48CtPRXaH//DRsVTENRAgQT5Ok+WK+WtfLIjLQGF/AGDZvbe4MQH5fgl+KJxzJbULBgqvN6lAXUjfi9EeRap2BVFiABECtuBnZCW6NHECDNb4+rruo6JJKOZqgEUwQNx2a6GwWyXhru7mjkivCcndgrkWLqaySfdpQWJT0Ye7Noehr2OtRtW2E1JD6GIEHmj/FU/K0IXuv4go+xAE9vSJDlyDanhq4JNEhz7srO4nK7ODAy7mUJcAQ0REmdYxxFWEb7fSR5CLWb2ok6r6IK2rdngUDOsieiZ0pE7zNOnfSXcFlsanGYFNdHX/G37pe/n+DihlcSqYJP63tQA30999UILb7JeGh6obKWVi9FuuZMa+ukkINZCpVXROqAKiz18cM1XPMfTbz8/aOIWQubI4m7UkMkeLyRdYw7+peTBTCOxtQTA9mJDN/AMVJZKjCUpC4sudn8wZtgd6jWoQB4zTXrNtoLACvgN310vkMrP61/W2jhFnj5WkAXZ+bHZVv5CXumlrL//51OxxZ3Z3EXVH+dopKS5+07bWMP3mzZs8aPKg3rcCH0AQnGGYOn+lTNaRNyIwtwII6E1XBpvyFEOBUg+OZ5VeFYTkaBfl59Y2WfcjWgrOzFFhNcObr8G6vil5/owAGM06LOKI4bgV/SjJMcPB52ZJJAkZjX4d4WDJ5Lt2tQuy2+k56XgvB/nzqvWw+zBfBv/uofHFEFACwSe6Bb0WqU3etzejBNGoikPtmRpf2rV8FxIVCmpW/XHdgHZBqy4rwV8C7/WTD7/LrJFYDN6d0HcEufgkvn083EMD9uIIFjFSz6K9A/YuJ87/PhXA+2xEVMhyKpYo8HYeD3/bLVbOQsM3df/6/efo/xZwq6neHpZNsBfQR+cF0ybkvaBAvz8hSEkZfyd2J6Cu8vglS0EC4mhevArr6TcJQNjwRc4pOQ6YGDVZ/Pghtc62Gp00lpAakoDQ1p9ROmsc3tig1nIWRGlC/i7s2p/9Z6GbF4h8264Yo5bOSzqbC8Uu7TwPYAbSy5EkdK4kCwPKpuzi0cRXmF68jDlDVsswBDkqVXn/Zx/6gJ/nNIG/NBhzBGNgmPAgYjgCJVY86oBfegrVz0IDpvXKTbDYasE0a7xcwdWI9KjWCNsjK90EbvQERX2z3HS8B05QM5L/SdXcu9q5ABbGIhfnLcufct7YzPOCckvyi0Lwi7rN634Y4sLatl/xVc5ZB3k8aquhLgZjI2lshNL4Ru40YvhxLXAGLE5KQuC+jCZjGhTNYPT5sQDWlc6Zx9I7lwKFIkBOUTUdmwV9nGUmZzKdfVhbx5BA4rP4EexNTpjpgHktZ49Qq6Qn3UsmxV3vlXoJhqI0hyvN6SexMeAjrVC0eMtfKKqf1UJezAQ8EZRri29Yxyoy2mF32ZHC21W/qkxR+fQxH6T4I40F5fZgQrUBvaX6wiOAaspW/xmeXznj0lRPadz2O5WuO0+Uuo1FeVb/fhPrwuq9guRxqrjIm9Q+22ThH9gJl361bgJq+FZod6WCJUfu75zJFFvy8lE2uVF5lNmEQFPNQeypRfmqV5zqEAKsCz09y7NMCH/w7kALp9jEWynvuU9nIoNViV0vrCTYi4XuhMwFVwph//R3dQ3lNm+/RhBMT2Hb/o6lk/FhMYbw2SNlgoXEvvO2OkfP/5h/6MpfT2YS3WzNUQDxoBTMQFw8DtYnBlZ6/IL4Beh4o9DsZEUBjQkh6eWfELEHMrqLqa7ZAbAaiheuaLf7ckubT3mIdTGD3TOY1q7jnDMYg/78jL18CIEb39sn1YFT8HQK+wNOcLLUhXIA5suKq+QShZsbu8SoLR5ER39rVJkXdRQyu/hKAXTE1pZxx8dPsnRnbZjeuS76qXUaJVVaa0pi3RHquwFc7ox/rYnfVmeJOrCuHgof0u/DGeNyu5RPTu04DioZdTDad/Bg7hetco2LvyAZQSddFzGADia3O7Gbn1DkIaVi4VfaqSMahMibMbkRMh9sOCwZHdtFjWDLHsBiYaxTmXUA8Ail7RidiGVRStmKhKK7A6KWXsBMdTNAzZ8B8wAi1298s1VzOvv+BnuRIQXFvi27RdzPdX4n/PMMHw0JVp7AIlH4PI+VBvX6+J44iMGl0PW3PN/jcDXdYh6zGpX9hvCbUQzxO1wH93JVkIAdQy2e89A9QyFX3XG17OM8uPRaw/8FDzGAqODNnNcyU7ISw85g/Me7GaMK2uP4ZhlVAotaKyDKThX6UABwtEVHx1o2KzmzgZIQdr1OnOX0PZXFEplbA0cIWtz/2kNB8eeYSK/OY2UdomTHsQ61X/acZhjjfPuEsFpVtb7W1FzNdQRhCj1Wvm1s9FnGrBNQzNrqYita2g3Dpb5wOncL5Ow9DjyRclTH0DBFdDcp+QMH3aR1FM4rtjboE1WXDnQw1aKUg688RRBsWK2XkEH7zSpN0572pUNNRlAVC55j0agXJQoVxdDjtaG50Rzyke98llgTaJqtZR5/ENK53DZj6qhTLYn6l1n3L0p+tnLQz2mZ9C5tKm8OoYaCVw+INc9QQ1NBpjy4itJ7Uaqel75IOdfTOZp6ujqTZ/6tAQZiM13pBo9XLPD4a119BJFW/toKCsX+UwI7r27GkoGQF5FaeXj1QdFGku0VnMjcvQZSYkBk+pnL4i6V6MHMvakYlMlZZ4OPMLCBvc3kfv6yTaXqAWMMUTJrNPq/mePJfDsVLnQ3TB25hUM4nx2B02IdHRh/xJTHO+0SZei5xxvPxuYgxjZf5xw1CFRymlWoOh9WQWlYPKAUd3wrd7fFMKe+M2GsPwypxTuTmjfGxNNezlB3NoV8tPut6SmxXNtJJt5pTtpbfg6AdgO7l6TXOEVP43+kn+QLUkYidnjgRVnQ8z9fa5zFA/mBHcGk9ZN4mXGbTTaFIAQOsjtsZJ9/6GD0evU2Fd7vz6ZihYLUcDeY5R+zPdcY3mS51WkcHznC807Bwzmu3q42JHkh89cTIqhRpynU4ml+D8mNxhcbo/QSKB9tNO6/SsN/Z85N31ZB5wYtHlb7fYbQ02vthioOodVXKAZ4AjYaLeD7aR3/MMpJomJDvvn7mzQCR8jvEXIY559BGhh8RX+geW2jRYCsFn6eNNidDOYkofWhvtlRZG/iNcMo1WNkFvt5Xb8z6LNg0lK1hK64+Qxup1N5vLJ/p1vI08i6kOng1NHD7tvOXu0d8PwkGFs65c80qdmyQ/gkZAO6lGsZQJ/SOraLCxQEBRaDhpK+zBjCn7UPQIXEO+HGEYPgp1UN8IGe2OUGUld4xwtAzPFezuucmW/vgdRs3LYI0zzauKzpdzjkES7iJm0kNZB2Mdk/fkIVJMMpK4BOUmjUaYKfuMWcsmyBE0RMsiR8byVvjow6Q8HAJ5txbAY/b56sRCXP/aggdOUwcG97U+uiEgIV8ZAMnC8bktdlG793yAqbZ1/kDXDn+2jb5hqwB8PLgWDteYVY9XYksRTmX+nt4nLHzXgxBxwLB8h55xdBaAjRaqHifz3GjCvS3rijdbAo/fuyWRfbWOtXo30KduVQN9NzBc08/6Z6KbT7hnQTJtrIcH9ORZE4e65UQGwlhZvCLdLBKRWRcahfumLqHT/ITvilmrk6gEy3f9cPPnOAIPA23xFQ6vg3nxboMqfYM4eYkCjQZODs2y5spPtRy5Bk7exWYQw1GKOIZc7UqNhK/w0YvdYL13hkvn388feUcl5hMPMMlqOpi4iRglitBHNMXaySzOun7i1tkCPJX39t9vyOwOUeEsXOYEINsU3GnFS+vx7PP+7zf+LX7HWWJCNnde7DXkfjPtn9FbTb3trVgtB8ujaREEGAblZdwgVAKrM/T3KCpxNUe0xQR5b3TJtrE3BxFWeLCTK30uDH7G4n1VJt+xsTigal453pXN/Cw/BoIdSB04p55ppIHGDIA1dkzdQ2vjZldy9cJKKd0kCwx0vhhISJev/BB5ig0p00xkaRcs981QJ7E9jZcQk3v56R8xVll+SIr+mcUiPfO+3aYQUGh0gEQQLCCl5NkITnCPzYUrBx85ZX4RzjW4gsmgUKytyMqB4o6PzWk4aK7gsjVZRJIY3BuEKohKoVm8qkxMDmGSbJUt26MOy88K3zDrWh8rTT+QkO3QUqbiad4rCLiCTosKE256tsIl+2OdnwPhC3I6cvVKQZUcdYDKRd6s/lYzZOwOClVxJzwgs7E7UYE+DPCh5Sqh1K0da2Ww2IFBOX9V+HXW+xQ54F01f+DKew+NZ8Iyis4xs3LhatWzfNvgj2RgVrO27FJgIsu4htIDzWvwudEWJqkNcwGreT4hYGuM2fK3SXTIEWzeqYjfXGY9/VvSaAyracWap/9wD14bZSTUx+ESznAdY9T9s1MyPgpOk4rfu3cHT209oLBh9q++VtMlx4V4JLwtq2fXAX6T7NFHUSVIGfkdW2cElqDHwU+52BZB2gMkl9VXTLkBWDJL6TDJwWW4hireEGH+qu/YAM3VXri00nisE0EHCXJX3JpLzHRICVRi6/wig+LnbW+MEtLJ25PiHSSavomXtObMwJhI4+b/oaXHPqVXrufAEjYgldjyqNZgKtnvEqgPwvWdASgxwnwlZQDpNC/TAjeBpEXSUNqAneg2jiYguGkbNZdCio8zd5QMpubRURjrNp6nkWKR4HEpm9EqsrJQjzv9l+ncdaHS4tkJRUvjPmk1Fc6PMHyGsCoomKIiw02EMeAz4zjzGX+3AfKgRGbE3iju+YGGbTd0oi3Jda+MZC3gxEdSiAyq/EsBfXeTWC+2ehyUJcv3/SM4mvFeIXfe2s6F0tIlAzPqX93MtQDVhuA48GL8FBtVxiQHq8t05tqR+GE8FG4vztpvVSpJ+loDI9mXWxD9PevzJppSN88Xbn/qHYGwZiexKFlb9kXSePmiKCz1J0I7zWxgCVl3lXast4jYWR0WvVyLAxdFJ64/H2OFUT2twsDqj4Kk1ORhDMsD3v00eId3smyxrKsuDZnHiMjJ0iFRkF0MDt28pJG3JfIcnuvaGl/EtYpXNEmd6sJOwRTJiX8mos1k9G5p/lot+Ev36eSZ1Z0Ris24F4dCMxOZhVE/UFgsv284G6dBJPPpeo2hMLoljEggmW3sYjFHpsHazhdCXggsYv7e3Ry/PuTVwHvQfGbu6LZeVW7GXfmqJccXAm5aGdHE+nieXKKiIgOcd7eLMhEhy4wPDBtanY3ZiGKAj3wgj/bgUjbLNYXTSsqji4hTdbQE9/ct3x/H9SxD5TwvuuWvOzBffMVt7ZfL69803HJ3XZ9gq/iQFwAP8H664mLon3Vm3+dQvfi5JxYRSARlVXo0ncDy2krLVqa149dk0iLRCVADXHhudmH7laHHd4JAGiyTcn+98d+xS9dJnmM80tOEiWPKDFUo8sz5A+UKttfxbFXmMnThzuCOtbu216C15Jvy5sQ/H+3CDoxzp46jpHTM/R/lXj3mFKKOEuOiZqjquZBmkgbQDNQZq7KCmRBfihG9czmcgu7N4EFXKD8gpj0FJyxGqO3ge5UfS4/I/z08waVFPCnPiSga24qaTdqjFdeey90GMcGLXStBuc5uhRPzHqcaqDG+0A18nE9S2IpxFD/JBNldmkZ1PzazNLCwaNrkw1/lbL5WaVk9HNbw6LyodyARjjRPAcykSiI57s01OeguwqjiYlrlG9SVKe4kUcK0VRiXUZVfgVPWaw3U0vmueo2Q/An167KOwlACfn9+rgvLl9LDEL5qVHEnDPLJhBQ72wkA8mipeFUjTcpo6esjEaugBydZ9BbY1irikr9tA+CuyAHLbGuKPdDZBXaxqJIE7PCW0s5wHnX/Qcf1a1GPD3wShDqilkmiPrUw24S6AIJiVJjZfuYLy4pRvsz//3kaK5OmsCvSQQ8OJ0HgLXHo/CtL6T8Z7L5Y5TB0oedhtMbBSVRD50PMFbwaa4CGIEiD8bgUxHecTGbxDLUNMfC35AqwoFhUoOusfG6x+ztnvyxSPIlHDqy3aXoqWmyhrgqdn5RuogvFCp1WvupKi27LzExp5oyivR449+qhyTBealkJRLvABAD6f9ylKNxExcTkKTTdw0m2Y8rFOXb6GcvkyWek6mwp6ZFuNC0eJz4b8odPNU6Vzp4Pj+tTbngeDf5ZFD2a7Fro8/GXQWiAQid9nZJO738yp59jcIAT/GkGg4PDY0OYzKPVYaOH9EDSUil7RGDgiMQcmkcaUMpLIvjoOD2wtiT1fI2sZpIigWmo9qPe8NqChhN3RjUjEvZKbk0US1++TrbkuMRwVkb5RVmA0/bGU6f13H0iDvzaoNZpI2qJjsg9FIwvDwnqF+YKzp1n/SXQrJa4Qxd+171/lDDRwhiKnJV6lw7xju9H4u3igUd+YHt2hM3pky/DmRbrx3GTZAVhjKU8lOryT5sD71JeFfj3pYKhf5ryvwdPOiBfCPl4xwS1OaX37uv32eFBFEXUueKEEyX7w49wFGYRHDK81V0ZygQX9MCMiDK9ImeyqDmZYS27/1JzMAkxd571WMgYhnh8B+MuMua7R+KG8ipOEggcUbTBFmkZ7hA+IDgqfHdVynwgUXz+RI8GNDQZVnwcTro7si8ZluS6zuL3/2dyfruRE4Q+f3Lui2o8kCzSVbQeTOAdIF28XPJchonEDU8yUbG8z2qZgAFL7QK9uuKGIcZczWIIovpBAfXJLEWBCJCSQffO/vtPKTJISlhulkmmL34DAdeHkYXnsPiijfzjmT2DOTjFTgjWVKTukiSD4467+IrWXx1rJFhvb9KvGChArTAB5R2eoOhdIfNwbHW3NtbOYH7BkW0CJAe2yGoxe45I3eIt12PjBHCEnPD1V1jT9hWhqy6XFDJU9mO1x5aNmeIfDTSSGyKujG8rRG0Ppbq1Jqqml//x3BOrI34hc0i/NPkZr+PLcKU7tZDklViDXa3fVzHgERVrwN73WnoJQt7r9gl+Kc2/kguE5z025r792O/Z8QT7sCL2NqJbbTp7Crx/GOpDlG53k/OvEXN5bFen6rDUl/Mg4NnkB9mpUtSKA+JXqJ1BXegycgyPXjLKqmjdBLb1+N11ZpXJuS/2k8ZAjSVAY8GD1L0A6uhezYlsRLZj2r3gdHX8qAButHTsU61TrsMNLdlwpO/JT4v7I4ZyVSljx4xVUfjxT5PWbVe9IgsoFxG2n0EVYa9QEWOijiTRXqi8auHtF8I3VBjfz7Jt20MrK5SRvrvHylWuQ+cEHJ5iX+pIaM+mi7M6SkoK6DwrMbOxz/tE2eTFfkV2qg0LuQNf75TCN9FcaQjLwXIXfslNvc+S5lEnHu+1IyDCLnIwm0+nGwbceiU1uhvU0Do5iMe3eegjDp5Dg5TO0ND4oM7IAGxa7jU8Ey5Tuk0v9vckgwxnafJlIwS6aiOHSTLv6SAJLwd9KFHtzxCWI/LN2qzEMxX9l06Zhy+sSZ4TwDibCJrV4oF2K41cm+52DlaqesUYnWdiYgo2Xp9SNpgx3PzLj8/nCq60iTK9FVoF7XZiUnBC8GatU4lwvnshhQk6mjICkaAQSEkjt2/28K44cllKC1jp2ghrItEvGviQd9RIx8lAmjQvSpSVkFd5tgCAqpJ/lDD3dYkwAz7FzDmVGMTRGTyvkJsDxCcDOND9KdqBBDPO+pHcy0nmnJ3/CVM2HuP/QHdrgUZHVETrF1jVn/sBTvehYQ7nu0y4lXhPAAab4FAnF+EB6o0Oyv0eNMqndt+BL1xOskoh1bmyI1lqYsGSCoOzXT0scWwEjoL39VudnIjDZuj27PVflis9KRJP/bcRxmdoD/8Q6DbYcUlGFD3ljtUYUfQ0lvEgMXogkP0YZjiEU9TNwUJzzNXqyCrWbGkGTG9JsbWPfJdJL507/ig5zPwnysIdiD1DAHTwjweJJbYMP+nvtC4VAgwEAwV3Sx7VCq/K/P3WF8NLsREG6ItEWOYAVY0uKJFom5RTSbvvhhEmXsqA09r16J4+6dCXCHoAazd+9P0hb2Pm3UvtPFziIRJQjWY8gFuWlqcSzyixGqHPQ/exU5zf6/wkSDsdsJwV9aSpkfWyQm7G/NWk8ZFIBroptfZK/IhTjPkL7GAasQ1DfGd8kfVw/QA6TRm34nWtQQoVqiBYqgw5W+SXhMQoHkWHp0oU+7ujdfI4lxUcBTR9uN1OeDKV3VLcc31spIfsCQvqHk1rjET3ew8V0JojnFZhH5JTT6ryLwOqO6VvMSh4zE4pqhFhVi/ULcph8BKNiq+ZUuDjNYM0HeEba8py/iKS6BABvn7LY4zHVHx6tsimSK1eXD7B8IpVmBKBVZ8iHiv3ufnb25TFr2XKbz0OxSgZMWf6Xu3GV++PSYr4hcMH0O3WZqjEkjohB4n69c6Tt2GXuCs/BgpuHX/SMTIchjIRSoq1jxN0nn4beXuO278itm9J1FikzwGEyXiwlTLYCqRr6WQiREv2JI5H7e/bJfpYxo42klreKiXOb+03K06/E9iNjGi8xl6iw5aIoZMUZP7equ+MwhpPV6MT1fIDmAsYo4NJlKoN/UDekK+ZODJVC43RWPME7VK0ZBbihdMCxQ9kBIdwrHhgOe1TrjESmH+31OmruIi6b3BOApHutwO4dBFzQTjLEa6yKbXJ+SQ1MJB11evfFCRMHOSl7PdIu8RQFajTx/WFsmAetmiVOgMZKDua704sP9BGIBc+kHMm/lCz4xBDXWqOK2m+GJrarmPEXL/NKrGiFHMl4TGBz9PTcU9gG/EK7jwndxtn3Fto9ty7YnKSohszfJ3/kndqbkoSQSAshhX0CIpuyXIQUOoL0zFgP3CLd5u8goHv2Y2P/INEFFlxCoW78OBRmLmH2dDxiyMo74DDRAQd+oFoYX+9Ayasd4oW8EuqQ5OFcOgVyV8bbXIJXdpPBd9SlTNu3v7rnAp6jbygYzuW8LN2vub9iskozVkUj+OuzKiUuXzXbZByANhyuv82a7u8fQCXj08TLZJjTBn0dYFvjacwcFuXR3sZd1YkCgs49BOrXg+1z/38yvXpxw2qRdssSB+UN0OnWt+C5zmaVmnAIOnaB4F7REAEeIWiocv5obCB1m5F3Cy5shpNXyRWxLr57wqQ4ylqF6bRmtXWBS7UZuWFWeb51bZZPD1nuvkn/ic8v60+sKGTVeRqxpG5NhC0xIUStv/nXpp2ZFeQlDrMRR/ZIM6PrNLXfKB527Xq1PUdsnF7Uwjs4b2ERNhvtzqOz9IwPvOdyk6PZLmXrEUBDyKNxwediFxs6IFyMDJZhurg/OHSCdQBxlnJqoIblow2yAWsnyeQ3pZOGvx/P70Lqevgn3s8iZu6kEF13IFYxw58xtDFJRs6HoONltOizVAcx/2zWX6xKOMzF8z8iiYZHWsSiLjNfWnjxup6AaYedF2hHmv+iEqVOOhiLjKTbFKWIshuN9POgb5Jl7A7r0ZfUVoDJcmw80yWclN3BHLqlcYPnnB11A9yOLxdJGThjkd4GaLu8sWGfOFPt9T/OULtVzIyBTxDUSDS5sS5NzuPt4/TljuNpUNo3b3dFUKIYuxunET5G6w0oNqaFssVhVbkO/TdV9BTetIsnuft0P1uQEzWNHRn0fV/Jvq8ZAJYZ/V8Mix5yE3sW7X5IAlflyvi34S0vNRmD4pMWTcVOLm2KYGN7zH2pSaDxtl8BfqWz5u5IaRH/wI23tle6xvJwiWo3wMZEoZnWvVXKOl8rSs1k8kyuB0r5I8tJTZzJoWa9BmzEYW6yg2BwmrnIi8pbVHQbBXPgBugRGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11135,"cache_read_input_tokens":123161,"cache_creation":{"ephemeral_5m_input_tokens":11135,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b6d951bc-f9fc-4625-9191-446184a448e8"}} |
|
210 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz5fYShVBn7NHPpkcSB","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiAQKpQEIERgCKkDYefg/ZM8O5nKGITz2JZVz4igi13jA8tALO60HgXSTVT4lDHUmSmdG8Hl9gKhfQTTwjSVk3NSJBocRPvpPT++rMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBqcWe1AYSDOHyxgNEH5gR4pMQzhoM2dlOoinnJeDSMW47IjAUZjU/I/sfkAC3jOOnPpWX/e+wFn8WJgaNgBH4nXD571aPVDoxpYKbut0etYMzda4qjwL8R4wxe2DiCLaaq/lrl3/bT3Vxs++U3KBycakIkoxWUXQUrU5g6YICoZFZzWzxLDKZPsDZGTHg/KYsAfcZpdxxUjREAPVxEZRdZlQYMNCT92fgrNGaQxhLV27++7pAO54IYzi8lcA/tcA4N07FkSXzHfhjNa2d80DGESR6Upl8XCqUZBmeLfR8Q4uGuKRyXcSH7pcroWiE1Du3o5PSdCl3ygGYaCQai2uEQ6+PIPWvXFMzNNh0+4I7Bnyf9iACpyVsNa8lUzYyXnqh/VxG700HV8xlrlhGGIniKuXEKoX9ICiPPglDfzBXUaZr1uG2wbMQQ4kNxcbwoO7b0RtDXMUmDU4MK5h7bMv8pZUzd6miGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11135,"cache_read_input_tokens":123161,"cache_creation":{"ephemeral_5m_input_tokens":11135,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b308dcf3-7a5b-43ad-88c2-18d01c913909"}} |
|
211 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEz5fYShVBn7NHPpkcSB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JBFdrvtvmwyXrT4WUBWfjf","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"completed","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch) : + d'infos et d'images","status":"in_progress","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) : nouveaux champs, avatars rafraîchis, bannières, cross-links Twitch/Patreon","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter snapshots historisés (db.py) + insights/Ka-Score par créateur (stats.py, web.py)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11135,"cache_read_input_tokens":123161,"cache_creation":{"ephemeral_5m_input_tokens":11135,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"783156b6-7795-476f-8e5d-e966b97b49d4"}} |
|
212 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JBFdrvtvmwyXrT4WUBWfjf","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4acb80b7-eea9-4372-a61a-c0c288c461d3","timestamp":"2026-08-21T00:58:23.791Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"in_progress","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (instagram, tiktok, youtube, twitch, x) : + d'infos et d'images","status":"pending","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, facebook, threads, snapchat, patreon, discord, fansly, onlyfans)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) pour consommer les nouveaux champs + avatars/bannières","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter métriques/stats par créateur (snapshots historisés + stats calculées)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (galerie d'images, stats, bannières)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"completed","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch) : + d'infos et d'images","status":"in_progress","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) : nouveaux champs, avatars rafraîchis, bannières, cross-links Twitch/Patreon","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter snapshots historisés (db.py) + insights/Ka-Score par créateur (stats.py, web.py)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
213 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"fceb0601-394e-4cee-a0bc-5f7312a8e4fb","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
214 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzAtJnZYUqLA1qVeCa1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HXX5pGcVvwJdR5uSEyjq26","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n })\n return items","new_string":" for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n video = vid.get(\"video\") or {}\n music = vid.get(\"music\") or {}\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"saves\": _num(stats.get(\"collectCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")\n or video.get(\"originCover\")),\n \"duration_s\": _num(video.get(\"duration\")),\n \"width\": _num(video.get(\"width\")),\n \"height\": _num(video.get(\"height\")),\n \"is_pinned\": bool(vid.get(\"isPinnedItem\")),\n \"hashtags\": [c.get(\"hashtagName\")\n for c in (vid.get(\"textExtra\") or [])\n if c.get(\"hashtagName\")][:8],\n \"music\": ({\"title\": music.get(\"title\"),\n \"author\": music.get(\"authorName\"),\n \"original\": music.get(\"original\")}\n if music.get(\"title\") else None),\n })\n return items"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5469,"cache_read_input_tokens":134296,"cache_creation":{"ephemeral_5m_input_tokens":5469,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a50804f5-30df-4e5d-bbbf-41d7730b5e3f"}} |
|
215 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HXX5pGcVvwJdR5uSEyjq26","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4ad0568d-1e86-459f-9e4d-881ab0d85cfa","timestamp":"2026-08-21T00:58:34.485Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n })\n return items","newString":" for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n video = vid.get(\"video\") or {}\n music = vid.get(\"music\") or {}\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"saves\": _num(stats.get(\"collectCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")\n or video.get(\"originCover\")),\n \"duration_s\": _num(video.get(\"duration\")),\n \"width\": _num(video.get(\"width\")),\n \"height\": _num(video.get(\"height\")),\n \"is_pinned\": bool(vid.get(\"isPinnedItem\")),\n \"hashtags\": [c.get(\"hashtagName\")\n for c in (vid.get(\"textExtra\") or [])\n if c.get(\"hashtagName\")][:8],\n \"music\": ({\"title\": music.get(\"title\"),\n \"author\": music.get(\"authorName\"),\n \"original\": music.get(\"original\")}\n if music.get(\"title\") else None),\n })\n return items","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-tiktok)\n# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.tiktok.com/@{u}\"\n_STATE_RE = re.compile(\n r'<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"[^>]*>(.*?)</script>',\n re.S)\n_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n\n\ndef parse_videos(html: str, data: dict) -> list[dict]:\n \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n items: list[dict] = []\n raw: dict = {}\n scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n detail = scope.get(\"webapp.user-detail\") or {}\n for it in (detail.get(\"itemList\") or []):\n raw[it.get(\"id\", str(len(raw)))] = it\n if not raw:\n m = _ITEM_RE.search(html)\n if m:\n try:\n raw = json.loads(m.group(1))\n except Exception:\n raw = {}\n for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n })\n return items\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n \"username\": u, \"found\": False}\n resp = None\n m = None\n for attempt in range(3): # coquille sans SSR → nouvelle IP\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=f\"{u}t{attempt}\")\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _STATE_RE.search(resp.text or \"\")\n if m and \"webapp.user-detail\" in (resp.text or \"\"):\n break\n info = None\n data: dict = {}\n if m:\n try:\n data = json.loads(m.group(1))\n info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n except Exception:\n info = None\n if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n await Actor.push_data(\n {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n return\n user = info.get(\"user\") or {}\n stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n if user.get(\"privateAccount\"):\n await Actor.push_data({**miss, \"error\": \"private\"})\n return\n followers = _num(stats.get(\"followerCount\"))\n videos = parse_videos(resp.text, data)\n views = [v[\"views\"] for v in videos\n if isinstance(v[\"views\"], int)]\n likes = [v[\"likes\"] for v in videos\n if isinstance(v[\"likes\"], int)]\n comments = [v[\"comments\"] for v in videos\n if isinstance(v[\"comments\"], int)]\n shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n avg_views = round(sum(views) / len(views)) if views else None\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round(\n (avg_likes\n + (round(sum(comments) / len(comments))\n if comments else 0)\n + (round(sum(shares) / len(shares))\n if shares else 0)) / followers * 100, 3)\n top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n commerce = user.get(\"commerceUserInfo\") or {}\n bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n or \"\").strip()\n total_likes = _num(stats.get(\"heartCount\")\n or stats.get(\"heart\"))\n videos_count = _num(stats.get(\"videoCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"tiktok\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"sec_uid\": user.get(\"secUid\"),\n \"username\": user.get(\"uniqueId\"),\n \"full_name\": user.get(\"nickname\"),\n \"biography\": user.get(\"signature\"),\n \"bio_link\": bio_link,\n \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,\n \"avg_likes_per_video_lifetime\": (\n round(total_likes / videos_count)\n if total_likes and videos_count else None),\n \"is_verified\": bool(user.get(\"verified\")),\n \"is_organization\": bool(user.get(\"isOrganization\")),\n \"is_seller\": bool(user.get(\"ttSeller\")),\n \"is_live_now\": bool(user.get(\"roomId\")),\n \"commerce_category\": (commerce.get(\"category\") or None\n if commerce.get(\"commerceUser\")\n else None),\n \"account_created_at\": user.get(\"createTime\"),\n \"region\": user.get(\"region\"),\n \"language\": user.get(\"language\"),\n \"avatar\": user.get(\"avatarLarger\")\n or user.get(\"avatarMedium\"),\n # agrégats sur les vidéos présentes dans le SSR\n \"avg_views\": avg_views,\n \"avg_likes\": avg_likes,\n \"avg_comments\": (round(sum(comments) / len(comments))\n if comments else None),\n \"avg_shares\": (round(sum(shares) / len(shares))\n if shares else None),\n \"engagement_rate_pct\": engagement,\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"]}\n if top_video else None),\n \"recent_videos\": videos,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":43,"oldLines":6,"newStart":43,"newLines":8,"lines":[" author = vid.get(\"author\")"," handle = author if isinstance(author, str) else \\"," (author or {}).get(\"uniqueId\", \"\")","+ video = vid.get(\"video\") or {}","+ music = vid.get(\"music\") or {}"," items.append({"," \"id\": vid.get(\"id\"),"," \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\","]},{"oldStart":51,"oldLines":7,"newStart":53,"newLines":21,"lines":[" \"likes\": _num(stats.get(\"diggCount\")),"," \"comments\": _num(stats.get(\"commentCount\")),"," \"shares\": _num(stats.get(\"shareCount\")),","+ \"saves\": _num(stats.get(\"collectCount\")),"," \"timestamp\": vid.get(\"createTime\"),","+ \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")","+ or video.get(\"originCover\")),","+ \"duration_s\": _num(video.get(\"duration\")),","+ \"width\": _num(video.get(\"width\")),","+ \"height\": _num(video.get(\"height\")),","+ \"is_pinned\": bool(vid.get(\"isPinnedItem\")),","+ \"hashtags\": [c.get(\"hashtagName\")","+ for c in (vid.get(\"textExtra\") or [])","+ if c.get(\"hashtagName\")][:8],","+ \"music\": ({\"title\": music.get(\"title\"),","+ \"author\": music.get(\"authorName\"),","+ \"original\": music.get(\"original\")}","+ if music.get(\"title\") else None),"," })"," return items"," "]}],"userModified":false,"replaceAll":false}}} |
|
216 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"efc7a3f4-7252-4987-9f5b-849cb0a999df","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
217 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzBg4cRXNa4TVN67Akm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISgQMKpAEIERgCKkCa/vpk59HQOxgbxu/ZjyMLd5ls0VgWh+A4Zc4CWKUcZ0HgAyeUxilXRWimIZtVffSYBN3XWVv9/4iTc1srO1HlMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG+xZ7UBhIMh7sm0wEKW/NfJok7GgyosrEGfNP2WnLkSfAiMGCgKzyEo8JDG7StD2/wWXfBt/yZbJxb/1vLI5wRxYzRvMfktzFKN+A1dbEWa2Z4BCqJAS6lctip1qbp18y2BUj1A+K6FsVC3g9YdkdwU1hkPwpYwE2KflFJrIwSX2Tnh3Ri/ntObXC8N0FoFY+Bbui46nSjKaqmAsJ1HpY0JyfsffhpfVQjMP73eD+YqjkP6nClI1X0/ZXFI+8ZPmqHV36bU+eb7R3i/2gxSOrHylhUDKBjM5NNpRT3Ofm/GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1028,"cache_read_input_tokens":139765,"cache_creation":{"ephemeral_5m_input_tokens":1028,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1850d21a-b415-4aac-bf0b-0f86fe34cc24"}} |
|
218 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzBg4cRXNa4TVN67Akm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FG947zj9i1wH69KVUwMKx4","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)","new_string":" top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n stamps = sorted(_num(v[\"timestamp\"]) for v in videos\n if _num(v[\"timestamp\"]))\n videos_per_week = None\n if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n videos_per_week = round(\n (len(stamps) - 1)\n / ((stamps[-1] - stamps[0]) / 604_800), 2)\n last_video_at = None\n if stamps:\n from datetime import datetime, timezone\n last_video_at = datetime.fromtimestamp(\n stamps[-1], tz=timezone.utc) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n tag_counts: dict[str, int] = {}\n for v in videos:\n for h in v.get(\"hashtags\") or []:\n tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1028,"cache_read_input_tokens":139765,"cache_creation":{"ephemeral_5m_input_tokens":1028,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9e83aeac-8e45-4e8e-bc60-e504e7c7ef40"}} |
|
219 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FG947zj9i1wH69KVUwMKx4","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3a3ee85a-68aa-4d5c-bd83-ad1e0be01f0d","timestamp":"2026-08-21T00:58:44.965Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)","newString":" top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n stamps = sorted(_num(v[\"timestamp\"]) for v in videos\n if _num(v[\"timestamp\"]))\n videos_per_week = None\n if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n videos_per_week = round(\n (len(stamps) - 1)\n / ((stamps[-1] - stamps[0]) / 604_800), 2)\n last_video_at = None\n if stamps:\n from datetime import datetime, timezone\n last_video_at = datetime.fromtimestamp(\n stamps[-1], tz=timezone.utc) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n tag_counts: dict[str, int] = {}\n for v in videos:\n for h in v.get(\"hashtags\") or []:\n tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-tiktok)\n# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.tiktok.com/@{u}\"\n_STATE_RE = re.compile(\n r'<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"[^>]*>(.*?)</script>',\n re.S)\n_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n\n\ndef parse_videos(html: str, data: dict) -> list[dict]:\n \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n items: list[dict] = []\n raw: dict = {}\n scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n detail = scope.get(\"webapp.user-detail\") or {}\n for it in (detail.get(\"itemList\") or []):\n raw[it.get(\"id\", str(len(raw)))] = it\n if not raw:\n m = _ITEM_RE.search(html)\n if m:\n try:\n raw = json.loads(m.group(1))\n except Exception:\n raw = {}\n for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n video = vid.get(\"video\") or {}\n music = vid.get(\"music\") or {}\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"saves\": _num(stats.get(\"collectCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")\n or video.get(\"originCover\")),\n \"duration_s\": _num(video.get(\"duration\")),\n \"width\": _num(video.get(\"width\")),\n \"height\": _num(video.get(\"height\")),\n \"is_pinned\": bool(vid.get(\"isPinnedItem\")),\n \"hashtags\": [c.get(\"hashtagName\")\n for c in (vid.get(\"textExtra\") or [])\n if c.get(\"hashtagName\")][:8],\n \"music\": ({\"title\": music.get(\"title\"),\n \"author\": music.get(\"authorName\"),\n \"original\": music.get(\"original\")}\n if music.get(\"title\") else None),\n })\n return items\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n \"username\": u, \"found\": False}\n resp = None\n m = None\n for attempt in range(3): # coquille sans SSR → nouvelle IP\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=f\"{u}t{attempt}\")\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _STATE_RE.search(resp.text or \"\")\n if m and \"webapp.user-detail\" in (resp.text or \"\"):\n break\n info = None\n data: dict = {}\n if m:\n try:\n data = json.loads(m.group(1))\n info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n except Exception:\n info = None\n if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n await Actor.push_data(\n {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n return\n user = info.get(\"user\") or {}\n stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n if user.get(\"privateAccount\"):\n await Actor.push_data({**miss, \"error\": \"private\"})\n return\n followers = _num(stats.get(\"followerCount\"))\n videos = parse_videos(resp.text, data)\n views = [v[\"views\"] for v in videos\n if isinstance(v[\"views\"], int)]\n likes = [v[\"likes\"] for v in videos\n if isinstance(v[\"likes\"], int)]\n comments = [v[\"comments\"] for v in videos\n if isinstance(v[\"comments\"], int)]\n shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n avg_views = round(sum(views) / len(views)) if views else None\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round(\n (avg_likes\n + (round(sum(comments) / len(comments))\n if comments else 0)\n + (round(sum(shares) / len(shares))\n if shares else 0)) / followers * 100, 3)\n top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n commerce = user.get(\"commerceUserInfo\") or {}\n bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n or \"\").strip()\n total_likes = _num(stats.get(\"heartCount\")\n or stats.get(\"heart\"))\n videos_count = _num(stats.get(\"videoCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"tiktok\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"sec_uid\": user.get(\"secUid\"),\n \"username\": user.get(\"uniqueId\"),\n \"full_name\": user.get(\"nickname\"),\n \"biography\": user.get(\"signature\"),\n \"bio_link\": bio_link,\n \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,\n \"avg_likes_per_video_lifetime\": (\n round(total_likes / videos_count)\n if total_likes and videos_count else None),\n \"is_verified\": bool(user.get(\"verified\")),\n \"is_organization\": bool(user.get(\"isOrganization\")),\n \"is_seller\": bool(user.get(\"ttSeller\")),\n \"is_live_now\": bool(user.get(\"roomId\")),\n \"commerce_category\": (commerce.get(\"category\") or None\n if commerce.get(\"commerceUser\")\n else None),\n \"account_created_at\": user.get(\"createTime\"),\n \"region\": user.get(\"region\"),\n \"language\": user.get(\"language\"),\n \"avatar\": user.get(\"avatarLarger\")\n or user.get(\"avatarMedium\"),\n # agrégats sur les vidéos présentes dans le SSR\n \"avg_views\": avg_views,\n \"avg_likes\": avg_likes,\n \"avg_comments\": (round(sum(comments) / len(comments))\n if comments else None),\n \"avg_shares\": (round(sum(shares) / len(shares))\n if shares else None),\n \"engagement_rate_pct\": engagement,\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"]}\n if top_video else None),\n \"recent_videos\": videos,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":150,"oldLines":6,"newStart":150,"newLines":23,"lines":[" top_video = max("," (v for v in videos if isinstance(v[\"views\"], int)),"," key=lambda v: v[\"views\"], default=None)","+ stamps = sorted(_num(v[\"timestamp\"]) for v in videos","+ if _num(v[\"timestamp\"]))","+ videos_per_week = None","+ if len(stamps) >= 3 and stamps[-1] > stamps[0]:","+ videos_per_week = round(","+ (len(stamps) - 1)","+ / ((stamps[-1] - stamps[0]) / 604_800), 2)","+ last_video_at = None","+ if stamps:","+ from datetime import datetime, timezone","+ last_video_at = datetime.fromtimestamp(","+ stamps[-1], tz=timezone.utc) \\","+ .strftime(\"%Y-%m-%dT%H:%M:%SZ\")","+ tag_counts: dict[str, int] = {}","+ for v in videos:","+ for h in v.get(\"hashtags\") or []:","+ tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1"," commerce = user.get(\"commerceUserInfo\") or {}"," bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")"," or \"\").strip()"]}],"userModified":false,"replaceAll":false}}} |
|
220 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"223e3fc6-4abe-4d52-9a2c-ee20e3bf9079","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
221 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzCSjxMU4u7bPqTNjg2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Xc2TuMQuCeeGzUoJNbHFM6","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" \"engagement_rate_pct\": engagement,\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"]}\n if top_video else None),\n \"recent_videos\": videos,\n })","new_string":" \"engagement_rate_pct\": engagement,\n \"videos_per_week\": videos_per_week,\n \"last_video_at\": last_video_at,\n \"top_hashtags\": sorted(tag_counts, key=tag_counts.get,\n reverse=True)[:8],\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"],\n \"cover\": top_video.get(\"cover\")}\n if top_video else None),\n \"recent_videos\": videos,\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":678,"cache_read_input_tokens":140793,"cache_creation":{"ephemeral_5m_input_tokens":678,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8bc2a96c-ad87-4e04-805b-e256694b74f4"}} |
|
222 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Xc2TuMQuCeeGzUoJNbHFM6","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"08319f85-84e1-409e-b2ec-54f3bd4b2317","timestamp":"2026-08-21T00:58:51.301Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" \"engagement_rate_pct\": engagement,\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"]}\n if top_video else None),\n \"recent_videos\": videos,\n })","newString":" \"engagement_rate_pct\": engagement,\n \"videos_per_week\": videos_per_week,\n \"last_video_at\": last_video_at,\n \"top_hashtags\": sorted(tag_counts, key=tag_counts.get,\n reverse=True)[:8],\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"],\n \"cover\": top_video.get(\"cover\")}\n if top_video else None),\n \"recent_videos\": videos,\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-tiktok)\n# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.tiktok.com/@{u}\"\n_STATE_RE = re.compile(\n r'<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"[^>]*>(.*?)</script>',\n re.S)\n_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n\n\ndef parse_videos(html: str, data: dict) -> list[dict]:\n \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n items: list[dict] = []\n raw: dict = {}\n scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n detail = scope.get(\"webapp.user-detail\") or {}\n for it in (detail.get(\"itemList\") or []):\n raw[it.get(\"id\", str(len(raw)))] = it\n if not raw:\n m = _ITEM_RE.search(html)\n if m:\n try:\n raw = json.loads(m.group(1))\n except Exception:\n raw = {}\n for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n video = vid.get(\"video\") or {}\n music = vid.get(\"music\") or {}\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"saves\": _num(stats.get(\"collectCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")\n or video.get(\"originCover\")),\n \"duration_s\": _num(video.get(\"duration\")),\n \"width\": _num(video.get(\"width\")),\n \"height\": _num(video.get(\"height\")),\n \"is_pinned\": bool(vid.get(\"isPinnedItem\")),\n \"hashtags\": [c.get(\"hashtagName\")\n for c in (vid.get(\"textExtra\") or [])\n if c.get(\"hashtagName\")][:8],\n \"music\": ({\"title\": music.get(\"title\"),\n \"author\": music.get(\"authorName\"),\n \"original\": music.get(\"original\")}\n if music.get(\"title\") else None),\n })\n return items\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n \"username\": u, \"found\": False}\n resp = None\n m = None\n for attempt in range(3): # coquille sans SSR → nouvelle IP\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=f\"{u}t{attempt}\")\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _STATE_RE.search(resp.text or \"\")\n if m and \"webapp.user-detail\" in (resp.text or \"\"):\n break\n info = None\n data: dict = {}\n if m:\n try:\n data = json.loads(m.group(1))\n info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n except Exception:\n info = None\n if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n await Actor.push_data(\n {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n return\n user = info.get(\"user\") or {}\n stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n if user.get(\"privateAccount\"):\n await Actor.push_data({**miss, \"error\": \"private\"})\n return\n followers = _num(stats.get(\"followerCount\"))\n videos = parse_videos(resp.text, data)\n views = [v[\"views\"] for v in videos\n if isinstance(v[\"views\"], int)]\n likes = [v[\"likes\"] for v in videos\n if isinstance(v[\"likes\"], int)]\n comments = [v[\"comments\"] for v in videos\n if isinstance(v[\"comments\"], int)]\n shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n avg_views = round(sum(views) / len(views)) if views else None\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round(\n (avg_likes\n + (round(sum(comments) / len(comments))\n if comments else 0)\n + (round(sum(shares) / len(shares))\n if shares else 0)) / followers * 100, 3)\n top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n stamps = sorted(_num(v[\"timestamp\"]) for v in videos\n if _num(v[\"timestamp\"]))\n videos_per_week = None\n if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n videos_per_week = round(\n (len(stamps) - 1)\n / ((stamps[-1] - stamps[0]) / 604_800), 2)\n last_video_at = None\n if stamps:\n from datetime import datetime, timezone\n last_video_at = datetime.fromtimestamp(\n stamps[-1], tz=timezone.utc) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n tag_counts: dict[str, int] = {}\n for v in videos:\n for h in v.get(\"hashtags\") or []:\n tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1\n commerce = user.get(\"commerceUserInfo\") or {}\n bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n or \"\").strip()\n total_likes = _num(stats.get(\"heartCount\")\n or stats.get(\"heart\"))\n videos_count = _num(stats.get(\"videoCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"tiktok\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"sec_uid\": user.get(\"secUid\"),\n \"username\": user.get(\"uniqueId\"),\n \"full_name\": user.get(\"nickname\"),\n \"biography\": user.get(\"signature\"),\n \"bio_link\": bio_link,\n \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,\n \"avg_likes_per_video_lifetime\": (\n round(total_likes / videos_count)\n if total_likes and videos_count else None),\n \"is_verified\": bool(user.get(\"verified\")),\n \"is_organization\": bool(user.get(\"isOrganization\")),\n \"is_seller\": bool(user.get(\"ttSeller\")),\n \"is_live_now\": bool(user.get(\"roomId\")),\n \"commerce_category\": (commerce.get(\"category\") or None\n if commerce.get(\"commerceUser\")\n else None),\n \"account_created_at\": user.get(\"createTime\"),\n \"region\": user.get(\"region\"),\n \"language\": user.get(\"language\"),\n \"avatar\": user.get(\"avatarLarger\")\n or user.get(\"avatarMedium\"),\n # agrégats sur les vidéos présentes dans le SSR\n \"avg_views\": avg_views,\n \"avg_likes\": avg_likes,\n \"avg_comments\": (round(sum(comments) / len(comments))\n if comments else None),\n \"avg_shares\": (round(sum(shares) / len(shares))\n if shares else None),\n \"engagement_rate_pct\": engagement,\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"]}\n if top_video else None),\n \"recent_videos\": videos,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":211,"oldLines":9,"newStart":211,"newLines":14,"lines":[" \"avg_shares\": (round(sum(shares) / len(shares))"," if shares else None),"," \"engagement_rate_pct\": engagement,","+ \"videos_per_week\": videos_per_week,","+ \"last_video_at\": last_video_at,","+ \"top_hashtags\": sorted(tag_counts, key=tag_counts.get,","+ reverse=True)[:8],"," \"top_video\": ({\"url\": top_video[\"url\"],"," \"views\": top_video[\"views\"],","- \"likes\": top_video[\"likes\"]}","+ \"likes\": top_video[\"likes\"],","+ \"cover\": top_video.get(\"cover\")}"," if top_video else None),"," \"recent_videos\": videos,"," })"]}],"userModified":false,"replaceAll":false}}} |
|
223 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c7ca02fb-776a-42cd-93b2-5f02185750f6","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
224 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzCup8GNxhaxFAKAEwc","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8gMKpAEIERgCKkA9vF9fZl9ixz8gJFSAPBrpJVYkQ4S99fCf+o+t3H8156sJOTV+R31sYazed/n4R3W3b14cVi5lpncr1BmB0CJ5Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHQxZ7UBhIMjwKEovtxRf6wOt7uGgydmbA3spfoBIB8KrMiMHKBWD+YowRwdedAvBMzP4wzYf2te9NeM+UeCB/l3JX6vt/Vcuo48c1yIuU7gAJ22Sr6AYb20pTGYJ36rVlGEOHT8VGRRAF5uQqQ0srqZsyGucAhfk9WVoEHcFxsgcoFR2jiGkxil1P2BbdPT2zIBVxLNYfyXc5rA93qFbxdtkqHPg9fQtnSslke2Y/PwCrwCqvzCZ1/OqFnDfihl0G+6Qol+/Pi6FoMXb6Z0aUGurhk4GG2QtWYZCDoy0QJlki8GCrE+PBc5M0z59kDSBrOStxlOFz13rQl2LwtflUzYdfZq2SHw/bByqmsvIP28NnJ75qDBt7/vj3xowXvp+QHC+7t7Py+teiVjsyJI1a4Jo9DoHwXvEfCSsuxJBFKTV/YVMkOXmokk4W6sz0PgosYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":495,"cache_read_input_tokens":141471,"cache_creation":{"ephemeral_5m_input_tokens":495,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"024d1f6e-4cdc-4382-8ae6-1421b5f83213"}} |
|
225 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzCup8GNxhaxFAKAEwc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0119qvHJWMucKymUsY8ogQsV","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":"# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).","new_string":"# Desc: Profils TikTok publics ULTRA DÉTAILLÉS via la page @handle : le JSON\n# SSR __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; vidéos récentes avec COVERS (miniatures),\n# durée, hashtags, musique, épinglés + cadence (videos_per_week) et\n# top hashtags quand TikTok les inclut dans le SSR (ItemModule/itemList)."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":495,"cache_read_input_tokens":141471,"cache_creation":{"ephemeral_5m_input_tokens":495,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"707ce634-c1b5-46ae-aafb-55ebf4f12ab7"}} |
|
226 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0119qvHJWMucKymUsY8ogQsV","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"99f1bb16-3f7c-4f87-ab8a-d3323afaaa1f","timestamp":"2026-08-21T00:59:00.629Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":"# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).","newString":"# Desc: Profils TikTok publics ULTRA DÉTAILLÉS via la page @handle : le JSON\n# SSR __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; vidéos récentes avec COVERS (miniatures),\n# durée, hashtags, musique, épinglés + cadence (videos_per_week) et\n# top hashtags quand TikTok les inclut dans le SSR (ItemModule/itemList).","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-tiktok)\n# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR\n# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand\n# TikTok les inclut dans le SSR (ItemModule / itemList).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.tiktok.com/@{u}\"\n_STATE_RE = re.compile(\n r'<script id=\"__UNIVERSAL_DATA_FOR_REHYDRATION__\"[^>]*>(.*?)</script>',\n re.S)\n_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n\n\ndef parse_videos(html: str, data: dict) -> list[dict]:\n \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n items: list[dict] = []\n raw: dict = {}\n scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n detail = scope.get(\"webapp.user-detail\") or {}\n for it in (detail.get(\"itemList\") or []):\n raw[it.get(\"id\", str(len(raw)))] = it\n if not raw:\n m = _ITEM_RE.search(html)\n if m:\n try:\n raw = json.loads(m.group(1))\n except Exception:\n raw = {}\n for vid in list(raw.values())[:20]:\n stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n author = vid.get(\"author\")\n handle = author if isinstance(author, str) else \\\n (author or {}).get(\"uniqueId\", \"\")\n video = vid.get(\"video\") or {}\n music = vid.get(\"music\") or {}\n items.append({\n \"id\": vid.get(\"id\"),\n \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n \"caption\": (vid.get(\"desc\") or \"\")[:500],\n \"views\": _num(stats.get(\"playCount\")),\n \"likes\": _num(stats.get(\"diggCount\")),\n \"comments\": _num(stats.get(\"commentCount\")),\n \"shares\": _num(stats.get(\"shareCount\")),\n \"saves\": _num(stats.get(\"collectCount\")),\n \"timestamp\": vid.get(\"createTime\"),\n \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")\n or video.get(\"originCover\")),\n \"duration_s\": _num(video.get(\"duration\")),\n \"width\": _num(video.get(\"width\")),\n \"height\": _num(video.get(\"height\")),\n \"is_pinned\": bool(vid.get(\"isPinnedItem\")),\n \"hashtags\": [c.get(\"hashtagName\")\n for c in (vid.get(\"textExtra\") or [])\n if c.get(\"hashtagName\")][:8],\n \"music\": ({\"title\": music.get(\"title\"),\n \"author\": music.get(\"authorName\"),\n \"original\": music.get(\"original\")}\n if music.get(\"title\") else None),\n })\n return items\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n \"username\": u, \"found\": False}\n resp = None\n m = None\n for attempt in range(3): # coquille sans SSR → nouvelle IP\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=f\"{u}t{attempt}\")\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _STATE_RE.search(resp.text or \"\")\n if m and \"webapp.user-detail\" in (resp.text or \"\"):\n break\n info = None\n data: dict = {}\n if m:\n try:\n data = json.loads(m.group(1))\n info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n except Exception:\n info = None\n if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n await Actor.push_data(\n {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n return\n user = info.get(\"user\") or {}\n stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n if user.get(\"privateAccount\"):\n await Actor.push_data({**miss, \"error\": \"private\"})\n return\n followers = _num(stats.get(\"followerCount\"))\n videos = parse_videos(resp.text, data)\n views = [v[\"views\"] for v in videos\n if isinstance(v[\"views\"], int)]\n likes = [v[\"likes\"] for v in videos\n if isinstance(v[\"likes\"], int)]\n comments = [v[\"comments\"] for v in videos\n if isinstance(v[\"comments\"], int)]\n shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n avg_views = round(sum(views) / len(views)) if views else None\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round(\n (avg_likes\n + (round(sum(comments) / len(comments))\n if comments else 0)\n + (round(sum(shares) / len(shares))\n if shares else 0)) / followers * 100, 3)\n top_video = max(\n (v for v in videos if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)\n stamps = sorted(_num(v[\"timestamp\"]) for v in videos\n if _num(v[\"timestamp\"]))\n videos_per_week = None\n if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n videos_per_week = round(\n (len(stamps) - 1)\n / ((stamps[-1] - stamps[0]) / 604_800), 2)\n last_video_at = None\n if stamps:\n from datetime import datetime, timezone\n last_video_at = datetime.fromtimestamp(\n stamps[-1], tz=timezone.utc) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n tag_counts: dict[str, int] = {}\n for v in videos:\n for h in v.get(\"hashtags\") or []:\n tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1\n commerce = user.get(\"commerceUserInfo\") or {}\n bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n or \"\").strip()\n total_likes = _num(stats.get(\"heartCount\")\n or stats.get(\"heart\"))\n videos_count = _num(stats.get(\"videoCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"tiktok\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"sec_uid\": user.get(\"secUid\"),\n \"username\": user.get(\"uniqueId\"),\n \"full_name\": user.get(\"nickname\"),\n \"biography\": user.get(\"signature\"),\n \"bio_link\": bio_link,\n \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,\n \"avg_likes_per_video_lifetime\": (\n round(total_likes / videos_count)\n if total_likes and videos_count else None),\n \"is_verified\": bool(user.get(\"verified\")),\n \"is_organization\": bool(user.get(\"isOrganization\")),\n \"is_seller\": bool(user.get(\"ttSeller\")),\n \"is_live_now\": bool(user.get(\"roomId\")),\n \"commerce_category\": (commerce.get(\"category\") or None\n if commerce.get(\"commerceUser\")\n else None),\n \"account_created_at\": user.get(\"createTime\"),\n \"region\": user.get(\"region\"),\n \"language\": user.get(\"language\"),\n \"avatar\": user.get(\"avatarLarger\")\n or user.get(\"avatarMedium\"),\n # agrégats sur les vidéos présentes dans le SSR\n \"avg_views\": avg_views,\n \"avg_likes\": avg_likes,\n \"avg_comments\": (round(sum(comments) / len(comments))\n if comments else None),\n \"avg_shares\": (round(sum(shares) / len(shares))\n if shares else None),\n \"engagement_rate_pct\": engagement,\n \"videos_per_week\": videos_per_week,\n \"last_video_at\": last_video_at,\n \"top_hashtags\": sorted(tag_counts, key=tag_counts.get,\n reverse=True)[:8],\n \"top_video\": ({\"url\": top_video[\"url\"],\n \"views\": top_video[\"views\"],\n \"likes\": top_video[\"likes\"],\n \"cover\": top_video.get(\"cover\")}\n if top_video else None),\n \"recent_videos\": videos,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":1,"oldLines":10,"newStart":1,"newLines":11,"lines":[" # =============================================================================="," # Author: Simon-Pierre Boucher <contact@spboucher.ai>"," # File: src/main.py (ka-tiktok)","-# Desc: Profils TikTok publics détaillés via la page @handle : le JSON SSR","-# __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes","-# (abonnés, cœurs, vidéos) ; les vidéos récentes sont extraites quand","-# TikTok les inclut dans le SSR (ItemModule / itemList).","+# Desc: Profils TikTok publics ULTRA DÉTAILLÉS via la page @handle : le JSON","+# SSR __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes","+# (abonnés, cœurs, vidéos) ; vidéos récentes avec COVERS (miniatures),","+# durée, hashtags, musique, épinglés + cadence (videos_per_week) et","+# top hashtags quand TikTok les inclut dans le SSR (ItemModule/itemList)."," # =============================================================================="," from __future__ import annotations"," "]}],"userModified":false,"replaceAll":false}}} |
|
227 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b06bb401-6a45-4541-81f4-f0cc56ff62a0","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
228 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzDbkxbzMv5SC5pWrzn","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4AMKpQEIERgCKkDnFIfFUrnv/PfaPmKDVps9+SJ83EiZLA7fo21tV+EgvK+S1iQSZWEhRy+hs3b0t/aj8QjkS3PBqLFRn69AkyyMMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB2MWe1AYSDEvnG0JWBlPLMgn7nxoMuhQvLVRwlVt7EMZNIjCZ9TzAm8qs67e9kkk60B+sjljTtHQGRYhUl23oqv3fbhCXoZoimNwRWFJkRhAnG64q5wH2Jl6dR+SaJ4sNa+6KK09enISrvNsPDPkidFD2NmKklbh4raGrfAPHkKvwcFBehRZasDnFZB3KL0jdV40mItDfq2v+vxiiq0wiYV3setgHhulK+5yXj3P6YMv7am4gTpLeZ+aX8Uo8/iccTmm/iPUTmvAPYdGUvEed/iJcfYKTafDA3t0wG/bskC/PWcd9RBmHLzqpZwGn6Y12Kk8p4QtBcnKhjhtDG+Qr1T8MNoZhdKRqqHrey8MRQJfJ84iCAmpMsRp+QBFuO4b8l8wqhoXNXmOS4/MxVpoAr3MKNgufz4AjsSc3CloYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":632,"cache_read_input_tokens":141966,"cache_creation":{"ephemeral_5m_input_tokens":632,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"761847b3-9aa6-492a-91ea-37d6bdc8d62a"}} |
|
229 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzDbkxbzMv5SC5pWrzn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PE3UdyhPDg3LAfvyRarn3s","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":"# Desc: Chaînes YouTube détaillées SANS clé API via le JSON ytInitialData de\n# la page /videos : abonnés, nb de vidéos, description, avatar, liens\n# externes + vidéos récentes (vues, durée, date relative) et agrégats.\n# Accepte @handle, handle nu, ou id de chaîne (UC…).","new_string":"# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…)."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":632,"cache_read_input_tokens":141966,"cache_creation":{"ephemeral_5m_input_tokens":632,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e70cb852-eb42-4a0c-838a-a3a4af2b187d"}} |
|
230 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PE3UdyhPDg3LAfvyRarn3s","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b82ff9ad-df69-4b31-8b69-cde012cbc918","timestamp":"2026-08-21T00:59:09.133Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":"# Desc: Chaînes YouTube détaillées SANS clé API via le JSON ytInitialData de\n# la page /videos : abonnés, nb de vidéos, description, avatar, liens\n# externes + vidéos récentes (vues, durée, date relative) et agrégats.\n# Accepte @handle, handle nu, ou id de chaîne (UC…).","newString":"# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…).","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube détaillées SANS clé API via le JSON ytInitialData de\n# la page /videos : abonnés, nb de vidéos, description, avatar, liens\n# externes + vidéos récentes (vues, durée, date relative) et agrégats.\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":1,"oldLines":9,"newStart":1,"newLines":11,"lines":[" # =============================================================================="," # Author: Simon-Pierre Boucher <contact@spboucher.ai>"," # File: src/main.py (ka-youtube)","-# Desc: Chaînes YouTube détaillées SANS clé API via le JSON ytInitialData de","-# la page /videos : abonnés, nb de vidéos, description, avatar, liens","-# externes + vidéos récentes (vues, durée, date relative) et agrégats.","+# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData","+# de la page /videos : abonnés, nb de vidéos, description, avatar,","+# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,","+# date relative) et agrégats — plus un 2e passage /about (vues totales de","+# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés)."," # Accepte @handle, handle nu, ou id de chaîne (UC…)."," # =============================================================================="," from __future__ import annotations"]}],"userModified":false,"replaceAll":false}}} |
|
231 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b3e36f69-6169-4995-907c-d8da0af7bd7f","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
232 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzEEmgDv6TnFwTGEsrF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Tf5PB6F4SmCEBjZrGCAKvY","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":" return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n }","new_string":" return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n }"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":631,"cache_read_input_tokens":142598,"cache_creation":{"ephemeral_5m_input_tokens":631,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"374ec0ca-2c8b-4439-821f-43721057a2c4"}} |
|
233 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Tf5PB6F4SmCEBjZrGCAKvY","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"303a3fc8-49ae-47fa-bd94-14f3314896ed","timestamp":"2026-08-21T00:59:16.202Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":" return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n }","newString":" return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n }","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":114,"oldLines":6,"newStart":114,"newLines":7,"lines":[" \"views\": views,"," \"published\": published,"," \"duration\": duration,","+ \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\","," }"," "," "]}],"userModified":false,"replaceAll":false}}} |
|
234 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b6089b45-ff76-4fbc-826a-9bcc7ff6c400","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
235 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzEkTcSxS8yskWCxvhm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017avVZrfytG21BgKA6UuuxW","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":" vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n })\n return","new_string":" vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"\n f\"/hqdefault.jpg\"),\n })\n return"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":401,"cache_read_input_tokens":143229,"cache_creation":{"ephemeral_5m_input_tokens":401,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e105d060-f81b-41f1-9f31-b54b9e4e9594"}} |
|
236 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017avVZrfytG21BgKA6UuuxW","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"557d7bc6-d1dc-444a-9884-5a15b26faf03","timestamp":"2026-08-21T00:59:25.611Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":" vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n })\n return","newString":" vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"\n f\"/hqdefault.jpg\"),\n })\n return","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":145,"oldLines":6,"newStart":145,"newLines":8,"lines":[" .get(\"simpleText\")),"," \"duration\": ((vr.get(\"lengthText\") or {})"," .get(\"simpleText\")),","+ \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"","+ f\"/hqdefault.jpg\"),"," })"," return"," for v in node.values():"]}],"userModified":false,"replaceAll":false}}} |
|
237 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"08c35550-5901-427c-a672-4d1b0cfd7cb6","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
238 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzFSVemk7PsjLLLjXdJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvBYKpAEIERgCKkCuLY77j3WB2ePHkzhC+mo+82AgvY+IuSezHhoJrHEcrZ70OEIgjP9Mtuc16wNwN1oU3QJovLnBcA5Xtjh2mQwsMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH/xZ7UBhIMlk3ahGW+QqPp9hhLGgyBecTYKTbe7VllVaoiMDAEf54BnEy2pzRIh5r8JhTi08sjq+5PZVyEYScAYVCATLGibD6sHM8TAxO2GVmTUSrEFNwf0IQLpdUzhT9t0eZOrelW4ClprjUTStvqv+SgbkkqEwt+dTltRJII8Necw0gdBn89AKP5AGuhnRdqvvp2uIrfVugsgx01DmSKbcUUyPotQ+xi1KTWJLhCyaTJ/stx5Yv+GiFokycUCIxYtgyR0qKS2qxFKW1pDyccXRu4pAbH9CcXX5GCaQMLqo+fCQcGcTCMT/pmQBKlDru9s6d0w60kjlGSqdsistGlZlTu95N2kmQCxK54rbTqZeelFfAiuJ/Ut7eoBuMKKWMTDpzjRoiCBn60YJkIraNcVN7odQmjGVI6ma9/2sd61daBV4gBrZPJ9PIjT3Kt0KmervR/Wu9Otp1p4UkH6Zfo53jLq/Cvc/w3BMG2MVBD0EszFXxkN/r/G45YoIYNJszzpS0EhAr8yd+Ip4fKV+bGqOwUaqr8uLGb3QYQTqnbRUqdUk4ER9REtE7y4bRfNsut9AiaWif3NEv1h/sO/EtiY21iJjpeKRJeMDihgyuXGTPbBNFANAQrsWQ9fPLWkYPiG//yi5nR6yg2QD9dEUTi5C0DQ3sK65jaWoVOhclSiChFbGkEyo5cBi12QB1TKnPay3lPzZBP3vvfnJ+bdRkikg0womJbW++FK27qvRNLAHBMXG6Ij5AwjqNWA96GXU32Wcv2JbaIkBqxt9jH46pIYZTF6NE8JJkoHzc6kwNJnunzp9p9xYDytOmYN+3AjYkFmk3obHMDrY8MeUJvNvD/lD8Hahqfl66L83Gw+SoBMXp0CzbUuL2iFK9Ppv7EqPxs6Fq2UMaL7XonyPQUnXsIc0tXyA7b/LdHONTU6SrSkicxIe+13VNPknRrRCBEPX+LCsXbSOkcxiFdAIl+0TGv3e7Pgq+5aYQ5UZBRPr4uofjH0phileMw3BUWBEyaf2lPB+PjaOepYjKosSosSq1K6XY1NC62HFScZyxEEKlNqJb9d8iF9oGibG4QLh4kfTgUpA4WnT8RIB0I70XYbgq/1KS1RHqLbvUQBjmll9LD3+1JxjlaCDXI2Ebz8f9W6pTqB25rqQYu30737/iWvprxKxkaRMsLwExDnXjffX0M56zfd+xFxLTqrrfwsvMwpMWbrsjdmVf9sUR7hkdp+HmNXvw193c1eETp0wPHe2NAudhkP+Cv+EUQvNDwcvk+4o3AT3fIIfH3JpxD5u2p4+jFP0VDt96KenvFfnOffDSZcmfquiqOVU+pZ0qEdQ65p6sEAiDsDGQ0zrKB5iHatGMU8mQ36pS+L81RbvwY2cFj4QOGbQDcTy+VjEBalBx9cA5zOD9ZLiY/YOLkwxUdJXV+Rjlipzfgl2JY6zC3RC0F0i+1tGyrZ6qgP2bd5ajnEm4xLNq1VYm+kilXwGzDmGQB98Bew4kRYiGivx6IoUBzPcyz20cKufwyNnIC3l5dZBGBo0yshlMv6mepvqe+aBgkbXoCDyR97IsJfoA0AE9V8A9BogmARU2hFih9wmvVpavy02xFwa4iWU4x9Vv0HkD3Gk8A7fU4OpOSNqIWE/33beUAIYIXxlozVwzR41z+EM9SMlGo/Q+csO3/n38A8L9tSIZIGDDh1/8D8bJi9FCqL4Y4QRPJ/OMgDrsSrcJCkREsZluxyvHfJllhsda+YPvlEVeqJK2tUbifdeJQgXqEpFycM/DvRo/I7o4M6sF/MR8k+yJo1waoDiiK4Yvky7n8fDcbWHVCoB4KTRhgVahYiGic9SV6OKEt6H33ZEsJgQYGMHyQVpZJucVf3Vq81xwQct1DAVRKgPRkBvETsG3lpMfiab+PfJOIxSs6tvOXfUytAMpm1tpQ0mi+no6yVKtvnEjEVk/HZ/SncvC332n7bfLLgolN15+uciL2DVYKX88gvFjeyNhYyq09d0EH/ExFM8hnz1fMzcgcTCrG2UFzXsJOk/dDDbrq2vOVrn1Um5QBalBBQTwpOY+zA18X2RRNCZ1Tyg2396yL6KjlzO55LUj1a1ZfBiln9RQ60TuN94RFWi8oj+EXBqP8blSkKwKbtTe5pMVnDRzF5u4f+gxtK3t5LUb/oebNjH4XYAWIjNMkl4webDRc5jfmalClxX94+I196a8nqSGGpFo8npoApzZC2AN+/jZtEfm+slNrt/THnLXRG1HfSaA7LLEvn4IjbkLCNJWt8mRYLwmodry2aQoED6lqaGOotWqZPon735SFdtXoLMXGLdm216o/uWLY6tvjUhSdwOvZpzbWaoL11BAkhpQzU0kTSqBd/onHxx1Tl4bOKklHXcqOVmE8x7Nn/3UUEqzuwvdQCSXyrkU0v0so0J4MBA5ZuyeFpiIyO4es1keoPpmo+JdqEzPhSkAlBOMGPFvSFTcqSJOte6ysYPsq4yFUU9WnItafmpBggLBnSm8w+FaTAj2SRJWavNEWUSwJvsznhF26P8sS+9F+0UU2hkUcZ+Mi/DDneY9SipJX6ez5wyIxr6YDqt9Cw/t6G7Y+S6VJ124kzJcR4DStHyrthc/rLv65kmtCr3DNi/sC+rxl8h6FEJgNw8mdAJCcMtaR7c8SkkbkheT6AoC8rzf9JtOAbZC5cQPgYqy/7L0gjg7noI6reD+KfknipyFDm9KK8CAKHCnqrogRHd8dDUpViYn8NFe+YAeU06WlCNHkl9JmEgLwydbU71PIlCmwZvtDkHeMoR02TM9BU1OorYyKkiHGA6zVel4x//u4GSfJ8wioIa0xc776lOwJAo0jPjBwVvBWx3AyStf2CFap4jAB+nx7dkjCM3ycGXFgeR3GBA4oWD6pzR0IucCa4aJPneo+aH9VDyxjqlXtL8xm4Y3xPJfo1Ym1Ik3QLQ0KedVF0FtL0qH/2kZgmlWtMt44FLw6lPdFhFMeCwpM7HnKj4rKncUB3UGj91mMOIux+6QXcYd77gMj6WTI+qG2pcWZzpk/td3S33a5W47oL0Nd3DBCVkwe4r4XUE1VyFqN0tRZiMZ28haqaS4dd8lROajMbEg8fS+wpgiz0UcKmsfYZ7NCaRaYWzTdHsbHY++VTRXOL2S+g1IVuslEaEn0+ZwwQ+qOBqBGSn5q+a40+6slS7Zyc0jlFzHDbaHkd6PqDW51S/IN8ftAlyn9BhI4CfiaSpsZ2x66GQ4SlHARLkvGqBMEaqxaxxwjrdCuzksA3rYd5q5PEHqsr4LJ0TV00EnZ5bzIZrMF85o+xrnWupSSClYuaVz83bogsag9rVJvcwkSeLrCbEqxv6EXFVd9V0XFHh1q2t1BnZIrtkmnXvnOfIpxBL1tm03jN1ohPmbC10zP52VcNH3vqDWdV6gaE9VhtKlsoAHBYractywAIeenin8/myW8Vjhr4O3h2yuJEf+toVVLPtN3Zi38c4IMh3VdfJC3DbovBnvWtFQYIJ2afJrzwTmBWZQ/wCxX5aEFEvO7Z++ALDQwRFl0OKtZRx+Plrrvdf4iY0SObbXh2EdF8cRwHSAUh84aW3a4ZFBaPOWE+ShqPyaBgaTsQ0tzHCaS7+UxzK+WTHfSnRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":796,"cache_read_input_tokens":143630,"cache_creation":{"ephemeral_5m_input_tokens":796,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c8a18df5-db85-48b1-8e74-1283f2e6bf7a"}} |
|
239 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzFSVemk7PsjLLLjXdJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Wotekd4159rP4hGYupemsz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":" rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]","new_string":" rec(data)\n return out\n\n\ndef _s(v):\n \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n return v.get(\"content\") if isinstance(v, dict) else v\n\n\ndef _banner_of(data: dict) -> str | None:\n \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\"\n b = _walk(data, \"imageBannerViewModel\")\n if isinstance(b, dict):\n srcs = (b.get(\"image\") or {}).get(\"sources\") or []\n if srcs and srcs[-1].get(\"url\"):\n return srcs[-1][\"url\"]\n thumbs = (((_walk(data, \"c4TabbedHeaderRenderer\") or {}).get(\"banner\")\n or {}).get(\"thumbnails\")) or []\n return thumbs[-1].get(\"url\") if thumbs else None\n\n\ndef _about_of(data: dict) -> dict:\n \"\"\"Panneau « À propos » (/about) → vues totales, date, pays, liens.\"\"\"\n acv = _walk(data, \"aboutChannelViewModel\") or {}\n if not acv:\n return {}\n links = []\n for entry in (acv.get(\"links\") or []):\n lv = entry.get(\"channelExternalLinkViewModel\") or {}\n url = _s(lv.get(\"link\"))\n if url:\n links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n return {\n \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n \"joined_date\": _s(acv.get(\"joinedDateText\")),\n \"country\": _s(acv.get(\"country\")),\n \"external_links\": links[:10],\n }\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n fetch_about = bool(inp.get(\"fetchAbout\", True))\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":796,"cache_read_input_tokens":143630,"cache_creation":{"ephemeral_5m_input_tokens":796,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"407757db-fb1f-469a-9b90-3c8d4574c0e4"}} |
|
240 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Wotekd4159rP4hGYupemsz","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4c1c61f6-8289-478a-8074-345665ec9e06","timestamp":"2026-08-21T00:59:51.348Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":" rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]","newString":" rec(data)\n return out\n\n\ndef _s(v):\n \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n return v.get(\"content\") if isinstance(v, dict) else v\n\n\ndef _banner_of(data: dict) -> str | None:\n \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\"\n b = _walk(data, \"imageBannerViewModel\")\n if isinstance(b, dict):\n srcs = (b.get(\"image\") or {}).get(\"sources\") or []\n if srcs and srcs[-1].get(\"url\"):\n return srcs[-1][\"url\"]\n thumbs = (((_walk(data, \"c4TabbedHeaderRenderer\") or {}).get(\"banner\")\n or {}).get(\"thumbnails\")) or []\n return thumbs[-1].get(\"url\") if thumbs else None\n\n\ndef _about_of(data: dict) -> dict:\n \"\"\"Panneau « À propos » (/about) → vues totales, date, pays, liens.\"\"\"\n acv = _walk(data, \"aboutChannelViewModel\") or {}\n if not acv:\n return {}\n links = []\n for entry in (acv.get(\"links\") or []):\n lv = entry.get(\"channelExternalLinkViewModel\") or {}\n url = _s(lv.get(\"link\"))\n if url:\n links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n return {\n \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n \"joined_date\": _s(acv.get(\"joinedDateText\")),\n \"country\": _s(acv.get(\"country\")),\n \"external_links\": links[:10],\n }\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n fetch_about = bool(inp.get(\"fetchAbout\", True))\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"\n f\"/hqdefault.jpg\"),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":159,"oldLines":9,"newStart":159,"newLines":46,"lines":[" return out"," "," ","+def _s(v):","+ \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"","+ return v.get(\"content\") if isinstance(v, dict) else v","+","+","+def _banner_of(data: dict) -> str | None:","+ \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\"","+ b = _walk(data, \"imageBannerViewModel\")","+ if isinstance(b, dict):","+ srcs = (b.get(\"image\") or {}).get(\"sources\") or []","+ if srcs and srcs[-1].get(\"url\"):","+ return srcs[-1][\"url\"]","+ thumbs = (((_walk(data, \"c4TabbedHeaderRenderer\") or {}).get(\"banner\")","+ or {}).get(\"thumbnails\")) or []","+ return thumbs[-1].get(\"url\") if thumbs else None","+","+","+def _about_of(data: dict) -> dict:","+ \"\"\"Panneau « À propos » (/about) → vues totales, date, pays, liens.\"\"\"","+ acv = _walk(data, \"aboutChannelViewModel\") or {}","+ if not acv:","+ return {}","+ links = []","+ for entry in (acv.get(\"links\") or []):","+ lv = entry.get(\"channelExternalLinkViewModel\") or {}","+ url = _s(lv.get(\"link\"))","+ if url:","+ links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})","+ return {","+ \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),","+ \"joined_date\": _s(acv.get(\"joinedDateText\")),","+ \"country\": _s(acv.get(\"country\")),","+ \"external_links\": links[:10],","+ }","+","+"," async def main() -> None:"," async with Actor:"," inp = await Actor.get_input() or {}","+ fetch_about = bool(inp.get(\"fetchAbout\", True))"," usernames = [u.strip() for u in (inp.get(\"usernames\") or [])"," if u and u.strip()]"," proxy = await Actor.create_proxy_configuration("]}],"userModified":false,"replaceAll":false}}} |
|
241 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"572246b5-3504-4018-98a2-61c617d0f952","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
242 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzHMbn1rjY7U7AJztvn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Gk1MceJA4buMf4Ks3VzPHa","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":" subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({","new_string":" subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n text = resp.text or \"\"\n is_verified = True if (\n '\"BADGE_STYLE_TYPE_VERIFIED\"' in text\n or '\"BADGE_STYLE_TYPE_VERIFIED_ARTIST\"' in text\n or '\"OFFICIAL_ARTIST_BADGE\"' in text) else None\n about: dict = {}\n if fetch_about:\n try:\n resp_a = await fetcher.get(\n _url_for(u).replace(\"/videos\", \"/about\"),\n session_id=f\"{u}a\",\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n ma = _INITIAL_RE.search(resp_a.text or \"\")\n if ma:\n about = _about_of(json.loads(ma.group(1)))\n except Exception as exc:\n Actor.log.warning(f\"/about {u} : {exc}\")\n await Actor.push_data({"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2053,"cache_read_input_tokens":144426,"cache_creation":{"ephemeral_5m_input_tokens":2053,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bebad099-3b5c-49e3-a39b-13d546852eac"}} |
|
243 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Gk1MceJA4buMf4Ks3VzPHa","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c9289f98-4b91-4193-8f20-7b1d21145aaa","timestamp":"2026-08-21T01:00:00.429Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":" subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({","newString":" subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n text = resp.text or \"\"\n is_verified = True if (\n '\"BADGE_STYLE_TYPE_VERIFIED\"' in text\n or '\"BADGE_STYLE_TYPE_VERIFIED_ARTIST\"' in text\n or '\"OFFICIAL_ARTIST_BADGE\"' in text) else None\n about: dict = {}\n if fetch_about:\n try:\n resp_a = await fetcher.get(\n _url_for(u).replace(\"/videos\", \"/about\"),\n session_id=f\"{u}a\",\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n ma = _INITIAL_RE.search(resp_a.text or \"\")\n if ma:\n about = _about_of(json.loads(ma.group(1)))\n except Exception as exc:\n Actor.log.warning(f\"/about {u} : {exc}\")\n await Actor.push_data({","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"\n f\"/hqdefault.jpg\"),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\ndef _s(v):\n \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n return v.get(\"content\") if isinstance(v, dict) else v\n\n\ndef _banner_of(data: dict) -> str | None:\n \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\"\n b = _walk(data, \"imageBannerViewModel\")\n if isinstance(b, dict):\n srcs = (b.get(\"image\") or {}).get(\"sources\") or []\n if srcs and srcs[-1].get(\"url\"):\n return srcs[-1][\"url\"]\n thumbs = (((_walk(data, \"c4TabbedHeaderRenderer\") or {}).get(\"banner\")\n or {}).get(\"thumbnails\")) or []\n return thumbs[-1].get(\"url\") if thumbs else None\n\n\ndef _about_of(data: dict) -> dict:\n \"\"\"Panneau « À propos » (/about) → vues totales, date, pays, liens.\"\"\"\n acv = _walk(data, \"aboutChannelViewModel\") or {}\n if not acv:\n return {}\n links = []\n for entry in (acv.get(\"links\") or []):\n lv = entry.get(\"channelExternalLinkViewModel\") or {}\n url = _s(lv.get(\"link\"))\n if url:\n links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n return {\n \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n \"joined_date\": _s(acv.get(\"joinedDateText\")),\n \"country\": _s(acv.get(\"country\")),\n \"external_links\": links[:10],\n }\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n fetch_about = bool(inp.get(\"fetchAbout\", True))\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":251,"oldLines":6,"newStart":251,"newLines":23,"lines":[" vids = _collect_videos(data)"," views = [v[\"views\"] for v in vids"," if isinstance(v[\"views\"], int)]","+ text = resp.text or \"\"","+ is_verified = True if (","+ '\"BADGE_STYLE_TYPE_VERIFIED\"' in text","+ or '\"BADGE_STYLE_TYPE_VERIFIED_ARTIST\"' in text","+ or '\"OFFICIAL_ARTIST_BADGE\"' in text) else None","+ about: dict = {}","+ if fetch_about:","+ try:","+ resp_a = await fetcher.get(","+ _url_for(u).replace(\"/videos\", \"/about\"),","+ session_id=f\"{u}a\",","+ headers={\"Accept-Language\": \"en-US,en;q=0.9\"})","+ ma = _INITIAL_RE.search(resp_a.text or \"\")","+ if ma:","+ about = _about_of(json.loads(ma.group(1)))","+ except Exception as exc:","+ Actor.log.warning(f\"/about {u} : {exc}\")"," await Actor.push_data({"," \"kind\": \"profile\","," \"platform\": \"youtube\","]}],"userModified":false,"replaceAll":false}}} |
|
244 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"df1f2612-5bce-42ea-a620-f489ede547b3","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
245 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzJ1WZusGXzxJD6wmm3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016zCFUvpiJjPpseSN1AJnRA","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":" \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),","new_string":" \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"is_verified\": is_verified,\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": about.get(\"country\") or _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"banner\": _banner_of(data),\n \"total_views\": about.get(\"total_views\"),\n \"joined_date\": about.get(\"joined_date\"),\n \"external_links\": about.get(\"external_links\") or [],"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":706,"cache_read_input_tokens":146479,"cache_creation":{"ephemeral_5m_input_tokens":706,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f14de896-3dca-4336-b7bf-f55f63cb85c1"}} |
|
246 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016zCFUvpiJjPpseSN1AJnRA","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"eab9025b-cb62-4dd5-810d-08ac929b0a38","timestamp":"2026-08-21T01:00:07.858Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":" \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),","newString":" \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"is_verified\": is_verified,\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": about.get(\"country\") or _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"banner\": _banner_of(data),\n \"total_views\": about.get(\"total_views\"),\n \"joined_date\": about.get(\"joined_date\"),\n \"external_links\": about.get(\"external_links\") or [],","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-youtube)\n# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n# de la page /videos : abonnés, nb de vidéos, description, avatar,\n# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n# date relative) et agrégats — plus un 2e passage /about (vues totales de\n# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n# Accepte @handle, handle nu, ou id de chaîne (UC…).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport urllib.parse\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\n_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});</script>\", re.S)\n_COUNT_RE = re.compile(r\"([\\d][\\d\\s .,]*)\\s*([KkMB]?)\")\n\n\ndef _url_for(handle: str) -> str:\n h = urllib.parse.unquote(handle).strip()\n if h.startswith(\"UC\") and len(h) == 24:\n return f\"https://www.youtube.com/channel/{h}/videos\"\n if not h.startswith(\"@\"):\n h = \"@\" + h\n return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n\n\ndef parse_text_count(text: str) -> int | None:\n \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n if not text:\n return None\n m = _COUNT_RE.search(str(text))\n if not m:\n return None\n num = re.sub(r\"[\\s ]\", \"\", m.group(1))\n unit = (m.group(2) or \"\").upper()\n if unit:\n num = num.replace(\",\", \".\")\n if num.count(\".\") > 1:\n num = num.replace(\".\", \"\", num.count(\".\") - 1)\n try:\n val = float(num)\n except ValueError:\n return None\n return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n num = re.sub(r\"[.,]\", \"\", num)\n return int(num) if num.isdigit() else None\n\n\ndef _walk(node, key: str):\n \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n if isinstance(node, dict):\n if key in node:\n return node[key]\n for v in node.values():\n hit = _walk(v, key)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _walk(v, key)\n if hit is not None:\n return hit\n return None\n\n\ndef _texts(node) -> list[str]:\n \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n out: list[str] = []\n\n def rec(n):\n if isinstance(n, dict):\n for k in (\"content\", \"simpleText\", \"text\"):\n if isinstance(n.get(k), str):\n out.append(n[k])\n for v in n.values():\n rec(v)\n elif isinstance(n, list):\n for v in n:\n rec(v)\n rec(node)\n return out\n\n\ndef _lockup_video(lv: dict) -> dict | None:\n \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n return None\n vid = lv.get(\"contentId\")\n if not vid:\n return None\n md = lv.get(\"metadata\") or {}\n title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n .get(\"content\")\n strings = _texts(md)\n views = next((parse_text_count(s) for s in strings\n if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n published = next((s for s in strings\n if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n overlays = _texts(lv.get(\"contentImage\") or {})\n duration = next((s for s in overlays\n if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n return {\n \"id\": vid,\n \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n \"title\": (title or \"\")[:200],\n \"views\": views,\n \"published\": published,\n \"duration\": duration,\n \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n }\n\n\ndef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n out: list[dict] = []\n\n def rec(node):\n if len(out) >= cap:\n return\n if isinstance(node, dict):\n lv = node.get(\"lockupViewModel\")\n if isinstance(lv, dict):\n v = _lockup_video(lv)\n if v:\n out.append(v)\n return\n vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n if vr and vr.get(\"videoId\"):\n out.append({\n \"id\": vr[\"videoId\"],\n \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n \"title\": \"\".join(\n r.get(\"text\", \"\") for r in\n (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n \"views\": parse_text_count(\n (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n \"published\": ((vr.get(\"publishedTimeText\") or {})\n .get(\"simpleText\")),\n \"duration\": ((vr.get(\"lengthText\") or {})\n .get(\"simpleText\")),\n \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"\n f\"/hqdefault.jpg\"),\n })\n return\n for v in node.values():\n rec(v)\n elif isinstance(node, list):\n for v in node:\n rec(v)\n\n rec(data)\n return out\n\n\ndef _s(v):\n \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n return v.get(\"content\") if isinstance(v, dict) else v\n\n\ndef _banner_of(data: dict) -> str | None:\n \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\"\n b = _walk(data, \"imageBannerViewModel\")\n if isinstance(b, dict):\n srcs = (b.get(\"image\") or {}).get(\"sources\") or []\n if srcs and srcs[-1].get(\"url\"):\n return srcs[-1][\"url\"]\n thumbs = (((_walk(data, \"c4TabbedHeaderRenderer\") or {}).get(\"banner\")\n or {}).get(\"thumbnails\")) or []\n return thumbs[-1].get(\"url\") if thumbs else None\n\n\ndef _about_of(data: dict) -> dict:\n \"\"\"Panneau « À propos » (/about) → vues totales, date, pays, liens.\"\"\"\n acv = _walk(data, \"aboutChannelViewModel\") or {}\n if not acv:\n return {}\n links = []\n for entry in (acv.get(\"links\") or []):\n lv = entry.get(\"channelExternalLinkViewModel\") or {}\n url = _s(lv.get(\"link\"))\n if url:\n links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n return {\n \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n \"joined_date\": _s(acv.get(\"joinedDateText\")),\n \"country\": _s(acv.get(\"country\")),\n \"external_links\": links[:10],\n }\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n fetch_about = bool(inp.get(\"fetchAbout\", True))\n usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(\n _url_for(u), session_id=u,\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _INITIAL_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n meta = _walk(data, \"channelMetadataRenderer\") or {}\n if not meta.get(\"externalId\"):\n await Actor.push_data({**miss, \"error\": \"no_channel\"})\n return\n # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n # on lit les chaînes « N subscribers » / « N videos » du header\n header = _walk(data, \"pageHeaderViewModel\") or {}\n header_txt = _texts(header) or _texts(\n _walk(data, \"c4TabbedHeaderRenderer\") or {})\n sub_text = next((t for t in header_txt\n if \"subscriber\" in t.lower()\n or \"abonné\" in t.lower()), \"\")\n vid_text = next((t for t in header_txt\n if (\"video\" in t.lower()\n or \"vidéo\" in t.lower())\n and any(c.isdigit() for c in t)), \"\")\n if not sub_text: # ancien en-tête\n sub_text = ((_walk(data, \"subscriberCountText\") or {})\n .get(\"simpleText\") or \"\")\n subs = parse_text_count(sub_text)\n videos_count = parse_text_count(vid_text)\n vids = _collect_videos(data)\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n text = resp.text or \"\"\n is_verified = True if (\n '\"BADGE_STYLE_TYPE_VERIFIED\"' in text\n or '\"BADGE_STYLE_TYPE_VERIFIED_ARTIST\"' in text\n or '\"OFFICIAL_ARTIST_BADGE\"' in text) else None\n about: dict = {}\n if fetch_about:\n try:\n resp_a = await fetcher.get(\n _url_for(u).replace(\"/videos\", \"/about\"),\n session_id=f\"{u}a\",\n headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n ma = _INITIAL_RE.search(resp_a.text or \"\")\n if ma:\n about = _about_of(json.loads(ma.group(1)))\n except Exception as exc:\n Actor.log.warning(f\"/about {u} : {exc}\")\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"youtube\",\n \"found\": True,\n \"username\": u,\n \"channel_id\": meta.get(\"externalId\"),\n \"full_name\": meta.get(\"title\"),\n \"biography\": (meta.get(\"description\") or \"\")[:1000],\n \"followers\": subs,\n \"videos_count\": videos_count,\n \"is_family_safe\": meta.get(\"isFamilySafe\"),\n \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n \"country\": _walk(data, \"country\"),\n \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n or [{}])[-1].get(\"url\")),\n \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"top_video\": (max(\n (v for v in vids if isinstance(v[\"views\"], int)),\n key=lambda v: v[\"views\"], default=None)),\n \"last_video_published\": (vids[0][\"published\"]\n if vids else None),\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":279,"oldLines":11,"newStart":279,"newLines":16,"lines":[" \"followers\": subs,"," \"videos_count\": videos_count,"," \"is_family_safe\": meta.get(\"isFamilySafe\"),","+ \"is_verified\": is_verified,"," \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,","- \"country\": _walk(data, \"country\"),","+ \"country\": about.get(\"country\") or _walk(data, \"country\"),"," \"canonical_url\": meta.get(\"vanityChannelUrl\"),"," \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")"," or [{}])[-1].get(\"url\")),","+ \"banner\": _banner_of(data),","+ \"total_views\": about.get(\"total_views\"),","+ \"joined_date\": about.get(\"joined_date\"),","+ \"external_links\": about.get(\"external_links\") or [],"," \"avg_views\": (round(sum(views) / len(views))"," if views else None),"," \"top_video\": (max("]}],"userModified":false,"replaceAll":false}}} |
|
247 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2a906546-b136-49ff-bf72-868a3b5f6566","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
248 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzJa3QvhgcXbF7dnaXp","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISkQQKpAEIERgCKkDcDxKrR3U/Ph3Q2hlf8gBPtQhu+10cF9urXlj/VYIv47EFeCAYyfLFQn0aPFgu5VAtq3/ub4Qs18ul9jTWfqabMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGdxp7UBhIMVAVa0vWlgHEggcx0GgxdHG+JOIlXG0N05eciMIGcG3IaxWJUVeArk5RPPXRpNan0ofS/AjDTh564dea3DXJvkBuEz64d1xh4ylgndyqZAuOXTBmDP+hOt3iAIVsPlA1gHtrcb5QWG7Cfl3AL1zbN3pKshoB7mGBtLl4Xs8vz7BskGenjEVuSvNc1PVUaVoLKzXNQ9lpQQVOHno/LiHVg5YnEbnFDNVm+3VUP96cic/6Q6OZgN2/PcsFfwFwfvCa4RQnp8OsW7PdCOW/izjX4ga6/IKPJ7DYxOPk15ZEjJhRh+8+1bwgoIyqd7yLY/bLtXpEhVkSyqsCX7wJhCcrPnKixDcnfHYHOEvrArRbVF0zOzlW63gWrJVlyhlRKEno07+22zmo2pHNiCMNXEG5Q9B9SfLLopNN1W+0zm9CCh7VvzCklL7vWUWZBqXuL32qpEN9s7dq6YiJSQnFFN/KM4gZlgF+m620JGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1132,"cache_read_input_tokens":147185,"cache_creation":{"ephemeral_5m_input_tokens":1132,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d3725c7e-3f94-4734-951b-fcdfd952df69"}} |
|
249 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzJa3QvhgcXbF7dnaXp","type":"message","role":"assistant","content":[{"type":"text","text":"**ka-x** : URLs des médias par tweet (photos + miniatures vidéo)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1132,"cache_read_input_tokens":147185,"cache_creation":{"ephemeral_5m_input_tokens":1132,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"762a980f-545b-4a0d-8928-bf10aa87e2bd"}} |
|
250 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzJa3QvhgcXbF7dnaXp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018bFeEF8A1LjXqkYHdpyzsw","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" \"media_type\": (media[0].get(\"type\") if media else None),\n \"media_count\": len(media) or None,","new_string":" \"media_type\": (media[0].get(\"type\") if media else None),\n \"media_count\": len(media) or None,\n \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1132,"cache_read_input_tokens":147185,"cache_creation":{"ephemeral_5m_input_tokens":1132,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"674194a6-62c5-4e9d-919e-ae427182aa80"}} |
|
251 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018bFeEF8A1LjXqkYHdpyzsw","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bc121a28-2de8-4e82-9336-26b7232daff9","timestamp":"2026-08-21T01:00:16.506Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","oldString":" \"media_type\": (media[0].get(\"type\") if media else None),\n \"media_count\": len(media) or None,","newString":" \"media_type\": (media[0].get(\"type\") if media else None),\n \"media_count\": len(media) or None,\n \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-x)\n# Desc: Profils X (Twitter) SANS clé via le service OFFICIEL de syndication\n# des widgets (syndication.twitter.com/srv/timeline-profile) : objet\n# user complet + timeline récente avec métriques par tweet. Conçu pour\n# être appelé sans authentification ; l'empreinte TLS Chrome de\n# curl_cffi évite les 429 réservés aux clients python-requests.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\nimport time\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nSYNDICATION_URL = (\"https://syndication.twitter.com/srv/timeline-profile/\"\n \"screen-name/{u}\")\n_NEXT_DATA_RE = re.compile(\n r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n\n\ndef _find_user(node, handle: str):\n \"\"\"Parcourt le JSON Next.js : l'objet user dont screen_name == handle.\"\"\"\n if isinstance(node, dict):\n if str(node.get(\"screen_name\", \"\")).lower() == handle and \\\n \"followers_count\" in node:\n return node\n for v in node.values():\n hit = _find_user(v, handle)\n if hit is not None:\n return hit\n elif isinstance(node, list):\n for v in node:\n hit = _find_user(v, handle)\n if hit is not None:\n return hit\n return None\n\n\ndef parse_tweets(data: dict, handle: str, cap: int) -> list[dict]:\n entries = (((data.get(\"props\") or {}).get(\"pageProps\") or {})\n .get(\"timeline\") or {}).get(\"entries\") or []\n tweets: list[dict] = []\n for entry in entries:\n t = (entry.get(\"content\") or {}).get(\"tweet\") or {}\n if not t.get(\"id_str\"):\n continue\n u = (t.get(\"user\") or {})\n ent = t.get(\"entities\") or {}\n media = (t.get(\"extended_entities\") or ent).get(\"media\") or []\n tweets.append({\n \"id\": t.get(\"id_str\"),\n \"url\": f\"https://x.com/{u.get('screen_name', handle)}\"\n f\"/status/{t.get('id_str')}\",\n \"text\": (t.get(\"full_text\") or t.get(\"text\") or \"\")[:500],\n \"likes\": t.get(\"favorite_count\"),\n \"retweets\": t.get(\"retweet_count\"),\n \"replies\": t.get(\"reply_count\"),\n \"quotes\": t.get(\"quote_count\"),\n \"created_at\": t.get(\"created_at\"),\n \"is_retweet\": bool(t.get(\"retweeted_status\")),\n \"is_reply\": bool(t.get(\"in_reply_to_status_id_str\")),\n \"is_quote\": bool(t.get(\"is_quote_status\")),\n \"lang\": t.get(\"lang\"),\n \"hashtags\": [h.get(\"text\") for h in (ent.get(\"hashtags\") or [])\n if h.get(\"text\")][:8],\n \"mentions\": [m.get(\"screen_name\")\n for m in (ent.get(\"user_mentions\") or [])\n if m.get(\"screen_name\")][:8],\n \"media_type\": (media[0].get(\"type\") if media else None),\n \"media_count\": len(media) or None,\n \"links\": [x.get(\"expanded_url\") for x in (ent.get(\"urls\") or [])\n if x.get(\"expanded_url\")][:4],\n })\n if len(tweets) >= cap:\n break\n return tweets\n\n\ndef _ts(created_at: str) -> float | None:\n \"\"\"« Tue Sep 12 23:56:24 +0000 2023 » → epoch (None si illisible).\"\"\"\n try:\n from datetime import datetime\n return datetime.strptime(created_at,\n \"%a %b %d %H:%M:%S %z %Y\").timestamp()\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n max_tweets = int(inp.get(\"maxTweets\", 20))\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"x\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=u, impersonate=None)\n if resp.status_code == 429:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}b\",\n impersonate=\"chrome\")\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _NEXT_DATA_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n data = json.loads(m.group(1))\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n user = _find_user(data, u)\n if not user:\n await Actor.push_data({**miss, \"error\": \"user_not_found\"})\n return\n tweets = parse_tweets(data, u, max_tweets)\n own = [t for t in tweets if not t[\"is_retweet\"]]\n likes = [t[\"likes\"] for t in own if isinstance(t[\"likes\"], int)]\n rts = [t[\"retweets\"] for t in own\n if isinstance(t[\"retweets\"], int)]\n reps = [t[\"replies\"] for t in own\n if isinstance(t[\"replies\"], int)]\n followers = user.get(\"followers_count\")\n avg_likes = round(sum(likes) / len(likes)) if likes else None\n avg_rts = round(sum(rts) / len(rts)) if rts else None\n engagement = None\n if followers and avg_likes is not None:\n engagement = round((avg_likes + (avg_rts or 0))\n / followers * 100, 3)\n stamps = sorted(s for s in (_ts(t[\"created_at\"])\n for t in own) if s)\n tweets_per_week = None\n if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n tweets_per_week = round(\n (len(stamps) - 1) / ((stamps[-1] - stamps[0])\n / 604_800), 2)\n top = max((t for t in own if isinstance(t[\"likes\"], int)),\n key=lambda t: t[\"likes\"], default=None)\n hashtags: dict[str, int] = {}\n for t in own:\n for h in t[\"hashtags\"]:\n hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1\n created_ts = _ts(user.get(\"created_at\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"x\",\n \"found\": True,\n \"id\": user.get(\"id_str\"),\n \"username\": user.get(\"screen_name\"),\n \"full_name\": user.get(\"name\"),\n \"biography\": user.get(\"description\"),\n \"location\": user.get(\"location\"),\n \"website\": ((user.get(\"entities\") or {}).get(\"url\") or {})\n .get(\"urls\", [{}])[0].get(\"expanded_url\")\n if (user.get(\"entities\") or {}).get(\"url\") else None,\n \"followers\": followers,\n \"following\": user.get(\"friends_count\"),\n \"tweets_count\": user.get(\"statuses_count\"),\n \"listed_count\": user.get(\"listed_count\"),\n \"likes_given\": user.get(\"favourites_count\"),\n \"is_verified\": bool(user.get(\"verified\")\n or user.get(\"is_blue_verified\")),\n \"is_blue_verified\": user.get(\"is_blue_verified\"),\n \"is_protected\": user.get(\"protected\"),\n \"created_at\": user.get(\"created_at\"),\n \"account_age_days\": (round((time.time() - created_ts)\n / 86_400)\n if created_ts else None),\n \"avatar\": (user.get(\"profile_image_url_https\") or \"\")\n .replace(\"_normal.\", \"_400x400.\"),\n \"banner\": user.get(\"profile_banner_url\"),\n # agrégats sur les tweets récents (hors retweets)\n \"avg_likes\": avg_likes,\n \"avg_retweets\": avg_rts,\n \"avg_replies\": (round(sum(reps) / len(reps))\n if reps else None),\n \"engagement_rate_pct\": engagement,\n \"tweets_per_week\": tweets_per_week,\n \"last_tweet_at\": own[0][\"created_at\"] if own else None,\n \"reply_share_pct\": (round(sum(t[\"is_reply\"]\n for t in own)\n / len(own) * 100)\n if own else None),\n \"media_share_pct\": (round(sum(bool(t[\"media_count\"])\n for t in own)\n / len(own) * 100)\n if own else None),\n \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n reverse=True)[:8],\n \"top_tweet\": ({\"url\": top[\"url\"], \"likes\": top[\"likes\"],\n \"retweets\": top[\"retweets\"]}\n if top else None),\n \"recent_tweets\": tweets,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":74,"oldLines":6,"newStart":74,"newLines":8,"lines":[" if m.get(\"screen_name\")][:8],"," \"media_type\": (media[0].get(\"type\") if media else None),"," \"media_count\": len(media) or None,","+ \"media_urls\": [x.get(\"media_url_https\") for x in media","+ if x.get(\"media_url_https\")][:4],"," \"links\": [x.get(\"expanded_url\") for x in (ent.get(\"urls\") or [])"," if x.get(\"expanded_url\")][:4],"," })"]}],"userModified":false,"replaceAll":false}}} |
|
252 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"1f0a66c4-d743-422e-a14d-1a0be8511b30","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
253 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzKDDYsaJFpLZUHcTXv","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrwUKpAEIERgCKkDgZZ2kFrYzihWNFedwXzgTZGNtRfPsiiRrXmg5nmm2WItxRv79GeJEmvNE9//yIjv+373sZZDatYJrHLuB5DMOMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGoxp7UBhIModIp3BMl54+nEBBCGgw4G922fDkAvEszv6kiMHHiPlpA8ceuwM78qFirMizRV4GgJnMzg3EKO/tmZzGBz643UVVyX4HVS+/i1hYnnCq3A9zquUQC4H0VBcQ+OF/dhrhR3Ehk+z5k/agHajpAenMhi3a+SDDnL7BRJcybw+wyTI2Gdj8Kgt6HQGVDc+rkqffm0jMSBvAhAFpicdq6Fkwza00FYrCF8JT74ZkWGyO8qw/jbT6RRGi7oyedEps2IWLWCb9b6TjkunfYpLOpEab2+cK8MxAh5vYdcYubuGhK/Nilk/OL3PdBC33mgkj5xzgPd1zQQfmgQNAT3nv7kXr2U/jKnYjZYI1TjeLcE75tiJQDM/B8XcPseDg3LF62L9EInlccOnXLWHBVwJu1pZJvkkYorQITaIxVoROEwy8IDa3rrK2rfXUddBsIgS/Xgp73zCEXbQXvN25RWS5Sd0pO26UUh0f9wkdGeNU9F80w1lbaY5pdz8HL3/XHG7qE2fCpVL8nBN5d5/NZKhlYKytD6/bKD9zFk9YgLP1ewYa9YUF+xxtBKuTCu9+GdcvwyliAVvlZkORfm06x0KQOE3ZLAR0bUDNXdnZBW0/REaLFEaXgKAX2XW3jfqT/GJ2NPFBUOHXl4wxlm65CbrHIf3ie00GBW52EMA5lwHDTOOxhY576OxW87QUYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":441,"cache_read_input_tokens":148317,"cache_creation":{"ephemeral_5m_input_tokens":441,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"621baaa7-10a5-4f57-87d8-f4b2f35a27c0"}} |
|
254 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzKDDYsaJFpLZUHcTXv","type":"message","role":"assistant","content":[{"type":"text","text":"**ka-twitch** : miniatures des VOD, titre/miniature du live, et surtout les liens sociaux auto-déclarés de la chaîne (panneau « À propos ») → futurs cross-links forts."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":441,"cache_read_input_tokens":148317,"cache_creation":{"ephemeral_5m_input_tokens":441,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"59587585-cb0e-4d65-a728-b4e40d557ff7"}} |
|
255 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzKDDYsaJFpLZUHcTXv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BSvFH13MeTios5GyzEeUEq","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":" primaryTeam { displayName }\n channel { chanlets { id } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt game { displayName }\n } }\n }\n }\n}\n\"\"\"","new_string":" primaryTeam { displayName }\n channel { socialMedias { name title url } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }\n }\n}\n\"\"\""},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":441,"cache_read_input_tokens":148317,"cache_creation":{"ephemeral_5m_input_tokens":441,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2ab89c05-19da-4020-930b-d05c3b303c2e"}} |
|
256 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BSvFH13MeTios5GyzEeUEq","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b547f8dc-e511-4404-a5ed-62f67a7048bc","timestamp":"2026-08-21T01:00:29.331Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":" primaryTeam { displayName }\n channel { chanlets { id } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt game { displayName }\n } }\n }\n }\n}\n\"\"\"","newString":" primaryTeam { displayName }\n channel { socialMedias { name title url } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }\n }\n}\n\"\"\"","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-twitch)\n# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher, UA\n\nGQL_URL = \"https://gql.twitch.tv/gql\"\nCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n\nQUERY = \"\"\"\nquery($login: String!) {\n user(login: $login) {\n id\n login\n displayName\n description\n createdAt\n profileImageURL(width: 300)\n bannerImageURL\n followers { totalCount }\n roles { isPartner isAffiliate }\n primaryTeam { displayName }\n channel { chanlets { id } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt game { displayName }\n } }\n }\n }\n}\n\"\"\"\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n import random\n from curl_cffi import requests as cffi\n\n async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n p = None\n if fetcher.proxy_configuration:\n p = await fetcher.proxy_configuration.new_url(\n session_id=f\"tw{random.randint(1, 999_999)}\")\n await fetcher._throttle()\n resp = await asyncio.to_thread(\n cffi.post, GQL_URL, data=body,\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"},\n impersonate=\"chrome\", timeout=45,\n proxies={\"http\": p, \"https\": p} if p else None)\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n \"username\": u, \"found\": False}\n try:\n user = await gql(u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if not user:\n await Actor.push_data({**miss, \"error\": \"not_found\"})\n return\n vids = []\n for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n n = e.get(\"node\") or {}\n vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n games = [v[\"game\"] for v in vids if v[\"game\"]]\n stream = user.get(\"stream\") or {}\n last = user.get(\"lastBroadcast\") or {}\n roles = user.get(\"roles\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"twitch\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"login\"),\n \"full_name\": user.get(\"displayName\"),\n \"biography\": user.get(\"description\"),\n \"followers\": ((user.get(\"followers\") or {})\n .get(\"totalCount\")),\n \"is_partner\": roles.get(\"isPartner\"),\n \"is_affiliate\": roles.get(\"isAffiliate\"),\n \"is_verified\": bool(roles.get(\"isPartner\")),\n \"team\": ((user.get(\"primaryTeam\") or {})\n .get(\"displayName\")),\n \"created_at\": user.get(\"createdAt\"),\n \"avatar\": user.get(\"profileImageURL\"),\n \"banner\": user.get(\"bannerImageURL\"),\n \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n \"last_broadcast_at\": last.get(\"startedAt\"),\n \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n or None,\n \"last_broadcast_game\": ((last.get(\"game\") or {})\n .get(\"displayName\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":31,"oldLines":12,"newStart":31,"newLines":16,"lines":[" followers { totalCount }"," roles { isPartner isAffiliate }"," primaryTeam { displayName }","- channel { chanlets { id } }","+ channel { socialMedias { name title url } }"," lastBroadcast { startedAt title game { displayName } }","- stream { viewersCount createdAt game { displayName } }","+ stream { viewersCount createdAt title","+ previewImageURL(width: 640, height: 360)","+ game { displayName } }"," videos(first: 10, sort: TIME) {"," edges { node {","- id title viewCount lengthSeconds publishedAt game { displayName }","+ id title viewCount lengthSeconds publishedAt","+ previewThumbnailURL(width: 320, height: 180)","+ game { displayName }"," } }"," }"," }"]}],"userModified":false,"replaceAll":false}}} |
|
257 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"637d9374-7604-47a8-9d47-02114903758d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
258 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzL99Ft9zZT4VXgie2i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FfV1oovffW5RMBtiaMio7b","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":" vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })","new_string":" vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"thumbnail\": n.get(\"previewThumbnailURL\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":757,"cache_read_input_tokens":148758,"cache_creation":{"ephemeral_5m_input_tokens":757,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"92bd023e-3095-47dd-9193-35dc57fb5517"}} |
|
259 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FfV1oovffW5RMBtiaMio7b","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d881bf56-3ac3-4bdb-85f5-fe7f80112b37","timestamp":"2026-08-21T01:00:36.250Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":" vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })","newString":" vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"thumbnail\": n.get(\"previewThumbnailURL\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-twitch)\n# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher, UA\n\nGQL_URL = \"https://gql.twitch.tv/gql\"\nCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n\nQUERY = \"\"\"\nquery($login: String!) {\n user(login: $login) {\n id\n login\n displayName\n description\n createdAt\n profileImageURL(width: 300)\n bannerImageURL\n followers { totalCount }\n roles { isPartner isAffiliate }\n primaryTeam { displayName }\n channel { socialMedias { name title url } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }\n }\n}\n\"\"\"\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n import random\n from curl_cffi import requests as cffi\n\n async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n p = None\n if fetcher.proxy_configuration:\n p = await fetcher.proxy_configuration.new_url(\n session_id=f\"tw{random.randint(1, 999_999)}\")\n await fetcher._throttle()\n resp = await asyncio.to_thread(\n cffi.post, GQL_URL, data=body,\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"},\n impersonate=\"chrome\", timeout=45,\n proxies={\"http\": p, \"https\": p} if p else None)\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n \"username\": u, \"found\": False}\n try:\n user = await gql(u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if not user:\n await Actor.push_data({**miss, \"error\": \"not_found\"})\n return\n vids = []\n for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n n = e.get(\"node\") or {}\n vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n games = [v[\"game\"] for v in vids if v[\"game\"]]\n stream = user.get(\"stream\") or {}\n last = user.get(\"lastBroadcast\") or {}\n roles = user.get(\"roles\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"twitch\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"login\"),\n \"full_name\": user.get(\"displayName\"),\n \"biography\": user.get(\"description\"),\n \"followers\": ((user.get(\"followers\") or {})\n .get(\"totalCount\")),\n \"is_partner\": roles.get(\"isPartner\"),\n \"is_affiliate\": roles.get(\"isAffiliate\"),\n \"is_verified\": bool(roles.get(\"isPartner\")),\n \"team\": ((user.get(\"primaryTeam\") or {})\n .get(\"displayName\")),\n \"created_at\": user.get(\"createdAt\"),\n \"avatar\": user.get(\"profileImageURL\"),\n \"banner\": user.get(\"bannerImageURL\"),\n \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n \"last_broadcast_at\": last.get(\"startedAt\"),\n \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n or None,\n \"last_broadcast_game\": ((last.get(\"game\") or {})\n .get(\"displayName\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":101,"oldLines":6,"newStart":101,"newLines":7,"lines":[" \"views\": n.get(\"viewCount\"),"," \"duration_s\": n.get(\"lengthSeconds\"),"," \"published_at\": n.get(\"publishedAt\"),","+ \"thumbnail\": n.get(\"previewThumbnailURL\"),"," \"game\": (n.get(\"game\") or {}).get(\"displayName\"),"," })"," views = [v[\"views\"] for v in vids"]}],"userModified":false,"replaceAll":false}}} |
|
260 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"55f023b2-204c-4ddc-993b-4c8af51ee747","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
261 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzLgLyqT4zY4etSDY7B","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019zNgJAHS7VxLJA6mLrey4P","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":" \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),","new_string":" \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,\n \"live_thumbnail\": stream.get(\"previewImageURL\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":544,"cache_read_input_tokens":149515,"cache_creation":{"ephemeral_5m_input_tokens":544,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d8f59211-33c8-4fce-a8c8-598849903b9c"}} |
|
262 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019zNgJAHS7VxLJA6mLrey4P","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c4abf43d-3ba9-4b89-83d4-f6e676f3f89a","timestamp":"2026-08-21T01:00:43.798Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":" \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),","newString":" \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,\n \"live_thumbnail\": stream.get(\"previewImageURL\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-twitch)\n# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher, UA\n\nGQL_URL = \"https://gql.twitch.tv/gql\"\nCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n\nQUERY = \"\"\"\nquery($login: String!) {\n user(login: $login) {\n id\n login\n displayName\n description\n createdAt\n profileImageURL(width: 300)\n bannerImageURL\n followers { totalCount }\n roles { isPartner isAffiliate }\n primaryTeam { displayName }\n channel { socialMedias { name title url } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }\n }\n}\n\"\"\"\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n import random\n from curl_cffi import requests as cffi\n\n async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n p = None\n if fetcher.proxy_configuration:\n p = await fetcher.proxy_configuration.new_url(\n session_id=f\"tw{random.randint(1, 999_999)}\")\n await fetcher._throttle()\n resp = await asyncio.to_thread(\n cffi.post, GQL_URL, data=body,\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"},\n impersonate=\"chrome\", timeout=45,\n proxies={\"http\": p, \"https\": p} if p else None)\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n \"username\": u, \"found\": False}\n try:\n user = await gql(u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if not user:\n await Actor.push_data({**miss, \"error\": \"not_found\"})\n return\n vids = []\n for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n n = e.get(\"node\") or {}\n vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"thumbnail\": n.get(\"previewThumbnailURL\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n games = [v[\"game\"] for v in vids if v[\"game\"]]\n stream = user.get(\"stream\") or {}\n last = user.get(\"lastBroadcast\") or {}\n roles = user.get(\"roles\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"twitch\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"login\"),\n \"full_name\": user.get(\"displayName\"),\n \"biography\": user.get(\"description\"),\n \"followers\": ((user.get(\"followers\") or {})\n .get(\"totalCount\")),\n \"is_partner\": roles.get(\"isPartner\"),\n \"is_affiliate\": roles.get(\"isAffiliate\"),\n \"is_verified\": bool(roles.get(\"isPartner\")),\n \"team\": ((user.get(\"primaryTeam\") or {})\n .get(\"displayName\")),\n \"created_at\": user.get(\"createdAt\"),\n \"avatar\": user.get(\"profileImageURL\"),\n \"banner\": user.get(\"bannerImageURL\"),\n \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n \"last_broadcast_at\": last.get(\"startedAt\"),\n \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n or None,\n \"last_broadcast_game\": ((last.get(\"game\") or {})\n .get(\"displayName\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":130,"oldLines":6,"newStart":130,"newLines":8,"lines":[" \"banner\": user.get(\"bannerImageURL\"),"," \"is_live_now\": bool(stream),"," \"live_viewers\": stream.get(\"viewersCount\"),","+ \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,","+ \"live_thumbnail\": stream.get(\"previewImageURL\"),"," \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),"," \"last_broadcast_at\": last.get(\"startedAt\"),"," \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]"]}],"userModified":false,"replaceAll":false}}} |
|
263 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"37307620-9ff7-4cac-8db1-6e6c115ad8e2","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
264 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzMCpYf9BE5Dw7PkXEv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NMqEL6xh33iaxhybqrrNXW","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":" \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })","new_string":" \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →\n # cross_link fort côté crea-ka (§12.1)\n \"social_links\": [\n {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),\n \"url\": s.get(\"url\")}\n for s in (((user.get(\"channel\") or {})\n .get(\"socialMedias\")) or [])\n if s.get(\"url\")][:10],\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":386,"cache_read_input_tokens":150059,"cache_creation":{"ephemeral_5m_input_tokens":386,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"56f44106-f89f-4813-8148-94deebbff02b"}} |
|
265 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NMqEL6xh33iaxhybqrrNXW","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"dab7aa94-090c-4066-8e51-9e4d41b333c5","timestamp":"2026-08-21T01:00:50.723Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":" \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })","newString":" \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →\n # cross_link fort côté crea-ka (§12.1)\n \"social_links\": [\n {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),\n \"url\": s.get(\"url\")}\n for s in (((user.get(\"channel\") or {})\n .get(\"socialMedias\")) or [])\n if s.get(\"url\")][:10],\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-twitch)\n# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher, UA\n\nGQL_URL = \"https://gql.twitch.tv/gql\"\nCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n\nQUERY = \"\"\"\nquery($login: String!) {\n user(login: $login) {\n id\n login\n displayName\n description\n createdAt\n profileImageURL(width: 300)\n bannerImageURL\n followers { totalCount }\n roles { isPartner isAffiliate }\n primaryTeam { displayName }\n channel { socialMedias { name title url } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }\n }\n}\n\"\"\"\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n import random\n from curl_cffi import requests as cffi\n\n async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n p = None\n if fetcher.proxy_configuration:\n p = await fetcher.proxy_configuration.new_url(\n session_id=f\"tw{random.randint(1, 999_999)}\")\n await fetcher._throttle()\n resp = await asyncio.to_thread(\n cffi.post, GQL_URL, data=body,\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"},\n impersonate=\"chrome\", timeout=45,\n proxies={\"http\": p, \"https\": p} if p else None)\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n \"username\": u, \"found\": False}\n try:\n user = await gql(u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if not user:\n await Actor.push_data({**miss, \"error\": \"not_found\"})\n return\n vids = []\n for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n n = e.get(\"node\") or {}\n vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"thumbnail\": n.get(\"previewThumbnailURL\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n games = [v[\"game\"] for v in vids if v[\"game\"]]\n stream = user.get(\"stream\") or {}\n last = user.get(\"lastBroadcast\") or {}\n roles = user.get(\"roles\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"twitch\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"login\"),\n \"full_name\": user.get(\"displayName\"),\n \"biography\": user.get(\"description\"),\n \"followers\": ((user.get(\"followers\") or {})\n .get(\"totalCount\")),\n \"is_partner\": roles.get(\"isPartner\"),\n \"is_affiliate\": roles.get(\"isAffiliate\"),\n \"is_verified\": bool(roles.get(\"isPartner\")),\n \"team\": ((user.get(\"primaryTeam\") or {})\n .get(\"displayName\")),\n \"created_at\": user.get(\"createdAt\"),\n \"avatar\": user.get(\"profileImageURL\"),\n \"banner\": user.get(\"bannerImageURL\"),\n \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,\n \"live_thumbnail\": stream.get(\"previewImageURL\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n \"last_broadcast_at\": last.get(\"startedAt\"),\n \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n or None,\n \"last_broadcast_game\": ((last.get(\"game\") or {})\n .get(\"displayName\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":142,"oldLines":6,"newStart":142,"newLines":14,"lines":[" if views else None),"," \"recent_games\": list(dict.fromkeys(games))[:5],"," \"recent_videos\": vids,","+ # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →","+ # cross_link fort côté crea-ka (§12.1)","+ \"social_links\": [","+ {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),","+ \"url\": s.get(\"url\")}","+ for s in (((user.get(\"channel\") or {})","+ .get(\"socialMedias\")) or [])","+ if s.get(\"url\")][:10],"," })"," "," await asyncio.gather(*[one(u) for u in usernames])"]}],"userModified":false,"replaceAll":false}}} |
|
266 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"574236f5-d613-4c33-8bda-9aa815d04219","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
267 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzMiTG2uXx4zPKxBCHq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISugMKpAEIERgCKkDUo6hhG6+SaNtNPZSx+KMQci436edFYI10SZGKZv8XGUzW9TQN7jhJ9D1S/MqoybAtEnoYA5ONuY8kULvzSjBuMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHHxp7UBhIMkz6K7hyRNsH6bBnSGgzOy2zMZ0N7kbCyd78iMEbY1rvfsOJJnG2laIyI0gMnzxLz0gmsSB7702PiYIrZRPp0oN5hdK/w7ztrnOEe2SrCAcDgwn0bcHP2z1jqn55fHKrTH1rJysC1qQSSUjNz29rjj2oYILgMfKCpArn0RSltjLXAS6XuMvkDsyOJ/ziiz+LbUYSjTysoDJ9OhEsc4w7K646iKqpYsJnMcDVPgQ1QXR3Ki0AhbJ+jNHMYv56xU8D154r88e0O64G+wxES4SOk3Y5HKH1dZyp4PY2EfCQ2ON53oK1PAq77pjRvv2KCHJfpnG0WGiYwSyPM1nlNnBqSMFd8Jdzn9hPoe5vyN/UcnrKUGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":508,"cache_read_input_tokens":150445,"cache_creation":{"ephemeral_5m_input_tokens":508,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"79ba82e3-eca3-48a3-abed-20f48340086f"}} |
|
268 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzMiTG2uXx4zPKxBCHq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01G6XVU21TK62cvLbYx3gEJ4","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":"# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.","new_string":"# Desc: Profils Twitch ULTRA DÉTAILLÉS via le GQL PUBLIC du web (Client-ID\n# public kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) :\n# abonnés, partenaire/affilié, live en cours (titre + MINIATURE),\n# dernière diffusion, jeux récents, vidéos récentes avec MINIATURES et\n# vues + LIENS SOCIAUX auto-déclarés du panneau « À propos ».\n# Pas de clé requise."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":508,"cache_read_input_tokens":150445,"cache_creation":{"ephemeral_5m_input_tokens":508,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"08dae68f-91f2-40ff-ab3a-0cb1ad01860d"}} |
|
269 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01G6XVU21TK62cvLbYx3gEJ4","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3fedd34a-fb0e-468c-a6b0-2d29124f6173","timestamp":"2026-08-21T01:00:59.587Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":"# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.","newString":"# Desc: Profils Twitch ULTRA DÉTAILLÉS via le GQL PUBLIC du web (Client-ID\n# public kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) :\n# abonnés, partenaire/affilié, live en cours (titre + MINIATURE),\n# dernière diffusion, jeux récents, vidéos récentes avec MINIATURES et\n# vues + LIENS SOCIAUX auto-déclarés du panneau « À propos ».\n# Pas de clé requise.","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-twitch)\n# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public\n# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,\n# partenaire/affilié, live en cours, dernière diffusion, jeux récents,\n# vidéos récentes avec vues. Pas de clé requise.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher, UA\n\nGQL_URL = \"https://gql.twitch.tv/gql\"\nCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n\nQUERY = \"\"\"\nquery($login: String!) {\n user(login: $login) {\n id\n login\n displayName\n description\n createdAt\n profileImageURL(width: 300)\n bannerImageURL\n followers { totalCount }\n roles { isPartner isAffiliate }\n primaryTeam { displayName }\n channel { socialMedias { name title url } }\n lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }\n }\n}\n\"\"\"\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n import random\n from curl_cffi import requests as cffi\n\n async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n p = None\n if fetcher.proxy_configuration:\n p = await fetcher.proxy_configuration.new_url(\n session_id=f\"tw{random.randint(1, 999_999)}\")\n await fetcher._throttle()\n resp = await asyncio.to_thread(\n cffi.post, GQL_URL, data=body,\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"},\n impersonate=\"chrome\", timeout=45,\n proxies={\"http\": p, \"https\": p} if p else None)\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n \"username\": u, \"found\": False}\n try:\n user = await gql(u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if not user:\n await Actor.push_data({**miss, \"error\": \"not_found\"})\n return\n vids = []\n for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n n = e.get(\"node\") or {}\n vids.append({\n \"id\": n.get(\"id\"),\n \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n \"title\": (n.get(\"title\") or \"\")[:200],\n \"views\": n.get(\"viewCount\"),\n \"duration_s\": n.get(\"lengthSeconds\"),\n \"published_at\": n.get(\"publishedAt\"),\n \"thumbnail\": n.get(\"previewThumbnailURL\"),\n \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n })\n views = [v[\"views\"] for v in vids\n if isinstance(v[\"views\"], int)]\n games = [v[\"game\"] for v in vids if v[\"game\"]]\n stream = user.get(\"stream\") or {}\n last = user.get(\"lastBroadcast\") or {}\n roles = user.get(\"roles\") or {}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"twitch\",\n \"found\": True,\n \"id\": user.get(\"id\"),\n \"username\": user.get(\"login\"),\n \"full_name\": user.get(\"displayName\"),\n \"biography\": user.get(\"description\"),\n \"followers\": ((user.get(\"followers\") or {})\n .get(\"totalCount\")),\n \"is_partner\": roles.get(\"isPartner\"),\n \"is_affiliate\": roles.get(\"isAffiliate\"),\n \"is_verified\": bool(roles.get(\"isPartner\")),\n \"team\": ((user.get(\"primaryTeam\") or {})\n .get(\"displayName\")),\n \"created_at\": user.get(\"createdAt\"),\n \"avatar\": user.get(\"profileImageURL\"),\n \"banner\": user.get(\"bannerImageURL\"),\n \"is_live_now\": bool(stream),\n \"live_viewers\": stream.get(\"viewersCount\"),\n \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,\n \"live_thumbnail\": stream.get(\"previewImageURL\"),\n \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n \"last_broadcast_at\": last.get(\"startedAt\"),\n \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n or None,\n \"last_broadcast_game\": ((last.get(\"game\") or {})\n .get(\"displayName\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),\n \"recent_games\": list(dict.fromkeys(games))[:5],\n \"recent_videos\": vids,\n # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →\n # cross_link fort côté crea-ka (§12.1)\n \"social_links\": [\n {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),\n \"url\": s.get(\"url\")}\n for s in (((user.get(\"channel\") or {})\n .get(\"socialMedias\")) or [])\n if s.get(\"url\")][:10],\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":1,"oldLines":10,"newStart":1,"newLines":12,"lines":[" # =============================================================================="," # Author: Simon-Pierre Boucher <contact@spboucher.ai>"," # File: src/main.py (ka-twitch)","-# Desc: Profils Twitch détaillés via le GQL PUBLIC du web (Client-ID public","-# kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) : abonnés,","-# partenaire/affilié, live en cours, dernière diffusion, jeux récents,","-# vidéos récentes avec vues. Pas de clé requise.","+# Desc: Profils Twitch ULTRA DÉTAILLÉS via le GQL PUBLIC du web (Client-ID","+# public kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) :","+# abonnés, partenaire/affilié, live en cours (titre + MINIATURE),","+# dernière diffusion, jeux récents, vidéos récentes avec MINIATURES et","+# vues + LIENS SOCIAUX auto-déclarés du panneau « À propos ».","+# Pas de clé requise."," # =============================================================================="," from __future__ import annotations"," "]}],"userModified":false,"replaceAll":false}}} |
|
270 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a61564c6-2afb-46bd-9575-8f49b8a3b38b","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
271 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzNNZTmrYh8iMqQLfH8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P1WJ1nJpTJEb64LWTJf3Tv","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"completed","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch) : + d'infos et d'images","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"in_progress","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) : nouveaux champs, avatars rafraîchis, bannières, cross-links Twitch/Patreon","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter snapshots historisés (db.py) + insights/Ka-Score par créateur (stats.py, web.py)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":627,"cache_read_input_tokens":150953,"cache_creation":{"ephemeral_5m_input_tokens":627,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"77d22d80-5f73-44cb-ab75-82d2b93ce584"}} |
|
272 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01P1WJ1nJpTJEb64LWTJf3Tv","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1e1aa25e-7078-4c07-bc36-f7fcff4705ad","timestamp":"2026-08-21T01:01:09.117Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"completed","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch) : + d'infos et d'images","status":"in_progress","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"pending","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) : nouveaux champs, avatars rafraîchis, bannières, cross-links Twitch/Patreon","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter snapshots historisés (db.py) + insights/Ka-Score par créateur (stats.py, web.py)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"completed","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch) : + d'infos et d'images","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"in_progress","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) : nouveaux champs, avatars rafraîchis, bannières, cross-links Twitch/Patreon","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter snapshots historisés (db.py) + insights/Ka-Score par créateur (stats.py, web.py)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
273 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"55d78e4b-e0e6-45d9-b5d5-fcd30a5f92c6","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
274 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzP5cGUmxjJ4KhHKeac","type":"message","role":"assistant","content":[{"type":"text","text":"Acteurs secondaires. **ka-kick** : miniature/catégorie du live et VOD activées."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":815,"cache_read_input_tokens":151580,"cache_creation":{"ephemeral_5m_input_tokens":815,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6dbab91c-b010-495b-8698-aa877810e8cd"}} |
|
275 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzP5cGUmxjJ4KhHKeac","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FMeHSiJ9k9p1ru6Y2gQcVG","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py","old_string":" \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,","new_string":" \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,\n \"live_thumbnail\": ((live.get(\"thumbnail\") or {}).get(\"url\")\n if isinstance(live.get(\"thumbnail\"),\n dict) else None),\n \"live_category\": next(\n (c.get(\"name\") for c in (live.get(\"categories\") or [])\n if isinstance(c, dict) and c.get(\"name\")), None),\n \"vod_enabled\": ch.get(\"vod_enabled\"),\n \"playback_url\": None, # jamais republié (flux)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":815,"cache_read_input_tokens":151580,"cache_creation":{"ephemeral_5m_input_tokens":815,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"16c20ce4-f5f4-4dd8-8bd4-97e8ae409303"}} |
|
276 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FMeHSiJ9k9p1ru6Y2gQcVG","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"aabdca3a-b459-4421-846d-34336a458f7b","timestamp":"2026-08-21T01:01:16.972Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py","oldString":" \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,","newString":" \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,\n \"live_thumbnail\": ((live.get(\"thumbnail\") or {}).get(\"url\")\n if isinstance(live.get(\"thumbnail\"),\n dict) else None),\n \"live_category\": next(\n (c.get(\"name\") for c in (live.get(\"categories\") or [])\n if isinstance(c, dict) and c.get(\"name\")), None),\n \"vod_enabled\": ch.get(\"vod_enabled\"),\n \"playback_url\": None, # jamais republié (flux)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-kick)\n# Desc: Chaînes Kick détaillées via l'API publique /api/v2/channels/{slug}\n# (Cloudflare : proxy résidentiel + empreinte Chrome) : abonnés, badge,\n# live en cours, bio + LIENS SOCIAUX AUTO-DÉCLARÉS (instagram/twitter/\n# youtube/tiktok/facebook/discord → cross_link 0.90 côté crea-ka).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nAPI_URL = \"https://kick.com/api/v2/channels/{u}\"\nSOCIAL_KEYS = (\"instagram\", \"twitter\", \"youtube\", \"tiktok\", \"facebook\",\n \"discord\")\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"kick\",\n \"username\": u, \"found\": False}\n resp = None\n for attempt in range(3): # mur Cloudflare → nouvelle IP\n try:\n resp = await fetcher.get(\n API_URL.format(u=u), session_id=f\"{u}k{attempt}\",\n headers={\"Accept\": \"application/json\"})\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n if resp.status_code == 200 and \\\n (resp.text or \"\").startswith(\"{\"):\n break\n if resp.status_code != 200 or \\\n not (resp.text or \"\").startswith(\"{\"):\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n ch = json.loads(resp.text)\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n user = ch.get(\"user\") or {}\n live = ch.get(\"livestream\") or {}\n cats = [((c.get(\"category\") or {}).get(\"name\"))\n for c in (ch.get(\"recent_categories\") or [])\n if isinstance(c, dict)]\n socials = {k: (user.get(k) or \"\").strip()\n for k in SOCIAL_KEYS if (user.get(k) or \"\").strip()}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"kick\",\n \"found\": True,\n \"id\": ch.get(\"id\"),\n \"username\": ch.get(\"slug\"),\n \"full_name\": user.get(\"username\"),\n \"biography\": user.get(\"bio\"),\n \"followers\": ch.get(\"followers_count\")\n or ch.get(\"followersCount\"),\n \"is_verified\": bool(ch.get(\"verified\")),\n \"is_banned\": bool(ch.get(\"is_banned\")),\n \"subscription_enabled\": ch.get(\"subscription_enabled\"),\n \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,\n \"avatar\": user.get(\"profile_pic\"),\n \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")\n if isinstance(ch.get(\"banner_image\"), dict)\n else None),\n \"recent_categories\": [c for c in cats if c][:5],\n \"social_links\": socials,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":84,"oldLines":6,"newStart":84,"newLines":14,"lines":[" \"live_viewers\": live.get(\"viewer_count\"),"," \"live_title\": (live.get(\"session_title\") or \"\")[:200]"," or None,","+ \"live_thumbnail\": ((live.get(\"thumbnail\") or {}).get(\"url\")","+ if isinstance(live.get(\"thumbnail\"),","+ dict) else None),","+ \"live_category\": next(","+ (c.get(\"name\") for c in (live.get(\"categories\") or [])","+ if isinstance(c, dict) and c.get(\"name\")), None),","+ \"vod_enabled\": ch.get(\"vod_enabled\"),","+ \"playback_url\": None, # jamais republié (flux)"," \"avatar\": user.get(\"profile_pic\"),"," \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")"," if isinstance(ch.get(\"banner_image\"), dict)"]}],"userModified":false,"replaceAll":false}}} |
|
277 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7a76868c-ec27-4beb-8f5f-8d8f8be2639e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
278 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzPewUkqo8jLu5x44ZV","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS0QIKpAEIERgCKkBU6N9H0mYVLhV22MWjZBM02vPUaee3BUp30tF+Cl5yB6ePjOxAEaIizBAejPtaw5dXh+SVuWoQk9im9E2tllNIMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHhxp7UBhIM84gLWA0QtJzJDTFIGgxwoalGO4X/o+SoXwYiMKxRzvC6KgmQHi0NMHBK20JtJbhQlnZylNtXYaqtcJsXM67sWmpMLLmgR63hUlGBvSpaTKEK1eDD32M+G2yV7WiGzzkTmq5VK2cgkVEWTpcImQDZja5xMcaW4DTbVi5IsUcYxTuuQoh8dEO8t1Qba22fNLWtgAARcqxjRwjQjr72ZhNZ9abFTpFCHzOLGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":541,"cache_read_input_tokens":152395,"cache_creation":{"ephemeral_5m_input_tokens":541,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"886998e3-eddb-4828-a4ac-348b3930afb3"}} |
|
279 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzPewUkqo8jLu5x44ZV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NtZzQSkjcgE9wa9kVCjWKg","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py","old_string":" \"vod_enabled\": ch.get(\"vod_enabled\"),\n \"playback_url\": None, # jamais republié (flux)","new_string":" \"vod_enabled\": ch.get(\"vod_enabled\"),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":541,"cache_read_input_tokens":152395,"cache_creation":{"ephemeral_5m_input_tokens":541,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6a367e6a-e1a3-4223-8fdb-0bcde4c4062e"}} |
|
280 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NtZzQSkjcgE9wa9kVCjWKg","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0b8c5a2f-de46-4687-9e69-beeb8f3bc488","timestamp":"2026-08-21T01:01:22.572Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-kick/src/main.py","oldString":" \"vod_enabled\": ch.get(\"vod_enabled\"),\n \"playback_url\": None, # jamais republié (flux)","newString":" \"vod_enabled\": ch.get(\"vod_enabled\"),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-kick)\n# Desc: Chaînes Kick détaillées via l'API publique /api/v2/channels/{slug}\n# (Cloudflare : proxy résidentiel + empreinte Chrome) : abonnés, badge,\n# live en cours, bio + LIENS SOCIAUX AUTO-DÉCLARÉS (instagram/twitter/\n# youtube/tiktok/facebook/discord → cross_link 0.90 côté crea-ka).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nAPI_URL = \"https://kick.com/api/v2/channels/{u}\"\nSOCIAL_KEYS = (\"instagram\", \"twitter\", \"youtube\", \"tiktok\", \"facebook\",\n \"discord\")\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"kick\",\n \"username\": u, \"found\": False}\n resp = None\n for attempt in range(3): # mur Cloudflare → nouvelle IP\n try:\n resp = await fetcher.get(\n API_URL.format(u=u), session_id=f\"{u}k{attempt}\",\n headers={\"Accept\": \"application/json\"})\n except Exception as exc:\n await Actor.push_data({**miss,\n \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n if resp.status_code == 200 and \\\n (resp.text or \"\").startswith(\"{\"):\n break\n if resp.status_code != 200 or \\\n not (resp.text or \"\").startswith(\"{\"):\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n ch = json.loads(resp.text)\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n user = ch.get(\"user\") or {}\n live = ch.get(\"livestream\") or {}\n cats = [((c.get(\"category\") or {}).get(\"name\"))\n for c in (ch.get(\"recent_categories\") or [])\n if isinstance(c, dict)]\n socials = {k: (user.get(k) or \"\").strip()\n for k in SOCIAL_KEYS if (user.get(k) or \"\").strip()}\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"kick\",\n \"found\": True,\n \"id\": ch.get(\"id\"),\n \"username\": ch.get(\"slug\"),\n \"full_name\": user.get(\"username\"),\n \"biography\": user.get(\"bio\"),\n \"followers\": ch.get(\"followers_count\")\n or ch.get(\"followersCount\"),\n \"is_verified\": bool(ch.get(\"verified\")),\n \"is_banned\": bool(ch.get(\"is_banned\")),\n \"subscription_enabled\": ch.get(\"subscription_enabled\"),\n \"is_live_now\": bool(live),\n \"live_viewers\": live.get(\"viewer_count\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,\n \"live_thumbnail\": ((live.get(\"thumbnail\") or {}).get(\"url\")\n if isinstance(live.get(\"thumbnail\"),\n dict) else None),\n \"live_category\": next(\n (c.get(\"name\") for c in (live.get(\"categories\") or [])\n if isinstance(c, dict) and c.get(\"name\")), None),\n \"vod_enabled\": ch.get(\"vod_enabled\"),\n \"playback_url\": None, # jamais republié (flux)\n \"avatar\": user.get(\"profile_pic\"),\n \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")\n if isinstance(ch.get(\"banner_image\"), dict)\n else None),\n \"recent_categories\": [c for c in cats if c][:5],\n \"social_links\": socials,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n","structuredPatch":[{"oldStart":91,"oldLines":7,"newStart":91,"newLines":6,"lines":[" (c.get(\"name\") for c in (live.get(\"categories\") or [])"," if isinstance(c, dict) and c.get(\"name\")), None),"," \"vod_enabled\": ch.get(\"vod_enabled\"),","- \"playback_url\": None, # jamais republié (flux)"," \"avatar\": user.get(\"profile_pic\"),"," \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")"," if isinstance(ch.get(\"banner_image\"), dict)"]}],"userModified":false,"replaceAll":false}}} |
|
281 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a8f7fe76-a09c-4f95-b40e-78c0d98d965d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
282 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzQ4jDJPtsZYiqs261p","type":"message","role":"assistant","content":[{"type":"text","text":"**ka-snapchat** : aperçus de story et vignettes spotlight (best-effort)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":303,"cache_read_input_tokens":152936,"cache_creation":{"ephemeral_5m_input_tokens":303,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6ba1d137-e163-45b4-a097-25ef90fa55cf"}} |
|
283 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzQ4jDJPtsZYiqs261p","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01C6WaAVfN2GFpqAQc2GtA9g","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","old_string":" related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]","new_string":" related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]\n story_previews = []\n for s in snaps[:6]:\n if not isinstance(s, dict):\n continue\n urls = s.get(\"snapUrls\") or {}\n prev = (urls.get(\"mediaPreviewUrl\") or {}) \\\n if isinstance(urls.get(\"mediaPreviewUrl\"), dict) \\\n else {\"value\": urls.get(\"mediaPreviewUrl\")}\n url = prev.get(\"value\") or urls.get(\"mediaUrl\")\n if url:\n story_previews.append(url)\n spotlight_previews = [\n h.get(\"thumbnailUrl\")\n for h in spotlight[:6]\n if isinstance(h, dict) and h.get(\"thumbnailUrl\")]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":303,"cache_read_input_tokens":152936,"cache_creation":{"ephemeral_5m_input_tokens":303,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7eb2a01e-1b0a-4cc7-a49f-90098ecc77d9"}} |
|
284 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01C6WaAVfN2GFpqAQc2GtA9g","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7de650e1-8fef-460c-9c08-d7f68e37eace","timestamp":"2026-08-21T01:01:31.585Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","oldString":" related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]","newString":" related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]\n story_previews = []\n for s in snaps[:6]:\n if not isinstance(s, dict):\n continue\n urls = s.get(\"snapUrls\") or {}\n prev = (urls.get(\"mediaPreviewUrl\") or {}) \\\n if isinstance(urls.get(\"mediaPreviewUrl\"), dict) \\\n else {\"value\": urls.get(\"mediaPreviewUrl\")}\n url = prev.get(\"value\") or urls.get(\"mediaUrl\")\n if url:\n story_previews.append(url)\n spotlight_previews = [\n h.get(\"thumbnailUrl\")\n for h in spotlight[:6]\n if isinstance(h, dict) and h.get(\"thumbnailUrl\")]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-snapchat)\n# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page\n# snapchat.com/add/<user> : publicProfileInfo (abonnés, bio, badge,\n# catégorie, avatar) + présence story/spotlight + comptes reliés.\n# Structure validée le 2026-08-18 (pageProps.userProfile.$case).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.snapchat.com/add/{u}\"\n_NEXT_DATA_RE = re.compile(\n r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"snapchat\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _NEXT_DATA_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n props = (json.loads(m.group(1)).get(\"props\") or {}) \\\n .get(\"pageProps\") or {}\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n up = props.get(\"userProfile\") or {}\n info = up.get(\"publicProfileInfo\") or {}\n story = (props.get(\"story\") or {})\n snaps = story.get(\"snapList\") or []\n lenses = props.get(\"lenses\") or []\n spotlight = props.get(\"spotlightHighlights\") or []\n if not info.get(\"username\"):\n await Actor.push_data({**miss, \"error\": \"no_profile\"})\n return\n related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]\n subs = _num(info.get(\"subscriberCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"snapchat\",\n \"found\": True,\n \"username\": info.get(\"username\"),\n \"full_name\": info.get(\"title\"),\n \"biography\": info.get(\"bio\"),\n \"followers\": subs if subs else None, # 0 = masqué\n \"is_verified\": str(info.get(\"badge\")) == \"1\",\n \"category\": info.get(\"categoryStringId\"),\n \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),\n \"has_story\": bool(info.get(\"hasStory\")),\n \"has_spotlight\": bool(info.get(\"hasSpotlightHighlights\")),\n \"has_curated_highlights\": bool(\n info.get(\"hasCuratedHighlights\")),\n \"story_snaps_count\": len(snaps) or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,\n \"publisher_type\": info.get(\"publisherType\") or None,\n \"subcategory\": info.get(\"subcategoryStringId\") or None,\n \"related_accounts\": related,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":74,"oldLines":6,"newStart":74,"newLines":21,"lines":[" if isinstance(info.get(\"relatedAccountsInfo\"),"," list) else [])"," if isinstance(r, dict) and r.get(\"username\")]","+ story_previews = []","+ for s in snaps[:6]:","+ if not isinstance(s, dict):","+ continue","+ urls = s.get(\"snapUrls\") or {}","+ prev = (urls.get(\"mediaPreviewUrl\") or {}) \\","+ if isinstance(urls.get(\"mediaPreviewUrl\"), dict) \\","+ else {\"value\": urls.get(\"mediaPreviewUrl\")}","+ url = prev.get(\"value\") or urls.get(\"mediaUrl\")","+ if url:","+ story_previews.append(url)","+ spotlight_previews = [","+ h.get(\"thumbnailUrl\")","+ for h in spotlight[:6]","+ if isinstance(h, dict) and h.get(\"thumbnailUrl\")]"," subs = _num(info.get(\"subscriberCount\"))"," await Actor.push_data({"," \"kind\": \"profile\","]}],"userModified":false,"replaceAll":false}}} |
|
285 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"288f08f6-6a93-4e4e-8228-9d4187cf1ff2","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
286 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzQjUcP9Dz4fbNB3Nct","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RYG1tpRmHDmurZ6Cy2naCt","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","old_string":" \"story_snaps_count\": len(snaps) or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,","new_string":" \"story_snaps_count\": len(snaps) or None,\n \"story_previews\": story_previews or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,\n \"spotlight_previews\": spotlight_previews or None,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":717,"cache_read_input_tokens":153239,"cache_creation":{"ephemeral_5m_input_tokens":717,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3e781974-e763-42c8-888e-52c4406454d8"}} |
|
287 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RYG1tpRmHDmurZ6Cy2naCt","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"162a95ff-4e38-46a0-a431-1954ebd47e08","timestamp":"2026-08-21T01:01:37.014Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","oldString":" \"story_snaps_count\": len(snaps) or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,","newString":" \"story_snaps_count\": len(snaps) or None,\n \"story_previews\": story_previews or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,\n \"spotlight_previews\": spotlight_previews or None,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-snapchat)\n# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page\n# snapchat.com/add/<user> : publicProfileInfo (abonnés, bio, badge,\n# catégorie, avatar) + présence story/spotlight + comptes reliés.\n# Structure validée le 2026-08-18 (pageProps.userProfile.$case).\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPROFILE_URL = \"https://www.snapchat.com/add/{u}\"\n_NEXT_DATA_RE = re.compile(\n r'<script id=\"__NEXT_DATA__\"[^>]*>(.*?)</script>', re.S)\n\n\ndef _num(v):\n try:\n return int(v)\n except (TypeError, ValueError):\n return None\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"snapchat\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PROFILE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n m = _NEXT_DATA_RE.search(resp.text or \"\")\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n props = (json.loads(m.group(1)).get(\"props\") or {}) \\\n .get(\"pageProps\") or {}\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n up = props.get(\"userProfile\") or {}\n info = up.get(\"publicProfileInfo\") or {}\n story = (props.get(\"story\") or {})\n snaps = story.get(\"snapList\") or []\n lenses = props.get(\"lenses\") or []\n spotlight = props.get(\"spotlightHighlights\") or []\n if not info.get(\"username\"):\n await Actor.push_data({**miss, \"error\": \"no_profile\"})\n return\n related = [\n {\"username\": r.get(\"username\"), \"title\": r.get(\"title\")}\n for r in ((info.get(\"relatedAccountsInfo\") or [])\n if isinstance(info.get(\"relatedAccountsInfo\"),\n list) else [])\n if isinstance(r, dict) and r.get(\"username\")]\n story_previews = []\n for s in snaps[:6]:\n if not isinstance(s, dict):\n continue\n urls = s.get(\"snapUrls\") or {}\n prev = (urls.get(\"mediaPreviewUrl\") or {}) \\\n if isinstance(urls.get(\"mediaPreviewUrl\"), dict) \\\n else {\"value\": urls.get(\"mediaPreviewUrl\")}\n url = prev.get(\"value\") or urls.get(\"mediaUrl\")\n if url:\n story_previews.append(url)\n spotlight_previews = [\n h.get(\"thumbnailUrl\")\n for h in spotlight[:6]\n if isinstance(h, dict) and h.get(\"thumbnailUrl\")]\n subs = _num(info.get(\"subscriberCount\"))\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"snapchat\",\n \"found\": True,\n \"username\": info.get(\"username\"),\n \"full_name\": info.get(\"title\"),\n \"biography\": info.get(\"bio\"),\n \"followers\": subs if subs else None, # 0 = masqué\n \"is_verified\": str(info.get(\"badge\")) == \"1\",\n \"category\": info.get(\"categoryStringId\"),\n \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),\n \"has_story\": bool(info.get(\"hasStory\")),\n \"has_spotlight\": bool(info.get(\"hasSpotlightHighlights\")),\n \"has_curated_highlights\": bool(\n info.get(\"hasCuratedHighlights\")),\n \"story_snaps_count\": len(snaps) or None,\n \"lenses_count\": len(lenses) or None,\n \"spotlight_highlights_count\": len(spotlight) or None,\n \"publisher_type\": info.get(\"publisherType\") or None,\n \"subcategory\": info.get(\"subcategoryStringId\") or None,\n \"related_accounts\": related,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} profils\")\n","structuredPatch":[{"oldStart":108,"oldLines":8,"newStart":108,"newLines":10,"lines":[" \"has_curated_highlights\": bool("," info.get(\"hasCuratedHighlights\")),"," \"story_snaps_count\": len(snaps) or None,","+ \"story_previews\": story_previews or None,"," \"lenses_count\": len(lenses) or None,"," \"spotlight_highlights_count\": len(spotlight) or None,","+ \"spotlight_previews\": spotlight_previews or None,"," \"publisher_type\": info.get(\"publisherType\") or None,"," \"subcategory\": info.get(\"subcategoryStringId\") or None,"," \"related_accounts\": related,"]}],"userModified":false,"replaceAll":false}}} |
|
288 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"403915f6-ed87-4aab-aa4d-afb966bb8751","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
289 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzR8YRvvDWCBprLEAKK","type":"message","role":"assistant","content":[{"type":"text","text":"**ka-patreon** : bannière (cover photo), nom de la création, tarifs et liens sociaux de la campagne."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":375,"cache_read_input_tokens":153956,"cache_creation":{"ephemeral_5m_input_tokens":375,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"09e685dc-33b6-48dc-9c81-660879c0302a"}} |
|
290 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzR8YRvvDWCBprLEAKK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012om7g6qmUa7tjqPRn6UqF1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","old_string":"_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')","new_string":"_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n_COVER = re.compile(r'\"cover_photo_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_CREATION = re.compile(r'\"creation_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# liens sociaux auto-déclarés de la campagne (objets campaign de l'app Patreon)\n_SOCIALS = {\n \"facebook\": re.compile(r'\"facebook\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"twitter\": re.compile(r'\"twitter\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"youtube\": re.compile(r'\"youtube\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n}\n\n\ndef _dec(raw: str) -> str:\n try:\n return htmllib.unescape(json.loads(f'\"{raw}\"'))\n except Exception:\n return raw"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":375,"cache_read_input_tokens":153956,"cache_creation":{"ephemeral_5m_input_tokens":375,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"29becf72-ce4d-4b40-8082-5dad1354101f"}} |
|
291 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012om7g6qmUa7tjqPRn6UqF1","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"18b486a8-4f92-47b4-8e21-bf7cd46aeed0","timestamp":"2026-08-21T01:01:47.916Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","oldString":"_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')","newString":"_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n_COVER = re.compile(r'\"cover_photo_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_CREATION = re.compile(r'\"creation_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# liens sociaux auto-déclarés de la campagne (objets campaign de l'app Patreon)\n_SOCIALS = {\n \"facebook\": re.compile(r'\"facebook\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"twitter\": re.compile(r'\"twitter\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"youtube\": re.compile(r'\"youtube\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n}\n\n\ndef _dec(raw: str) -> str:\n try:\n return htmllib.unescape(json.loads(f'\"{raw}\"'))\n except Exception:\n return raw","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page /<vanity> embarque l'objet campagne\n# (patron_count, creation_count, is_monthly, pay_per_name, résumé) +\n# og:title/og:image. Aucune clé. Proxy résidentiel recommandé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPAGE_URL = \"https://www.patreon.com/{u}\"\n_NUM = {\n \"patrons\": re.compile(r'\"patron_count\"\\s*:\\s*(\\d+)'),\n \"posts\": re.compile(r'\"creation_count\"\\s*:\\s*(\\d+)'),\n \"paid_posts\": re.compile(r'\"paid_member_count\"\\s*:\\s*(\\d+)'),\n}\n_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"patreon\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PAGE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n text = resp.text or \"\"\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _NUM[\"patrons\"].search(text)\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n\n def num(key):\n mm = _NUM[key].search(text)\n return int(mm.group(1)) if mm else None\n title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"patreon\",\n \"found\": True,\n \"username\": u,\n \"full_name\": name,\n \"biography\": (htmllib.unescape(desc.group(1))\n if desc else None),\n \"followers\": num(\"patrons\"), # patrons = « abonnés » KA\n \"patrons\": num(\"patrons\"),\n \"posts_count\": num(\"posts\"),\n \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","structuredPatch":[{"oldStart":27,"oldLines":8,"newStart":27,"newLines":23,"lines":[" _OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')"," _MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')"," _NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')","+_COVER = re.compile(r'\"cover_photo_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')","+_CREATION = re.compile(r'\"creation_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')","+# liens sociaux auto-déclarés de la campagne (objets campaign de l'app Patreon)","+_SOCIALS = {","+ \"facebook\": re.compile(r'\"facebook\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+ \"twitter\": re.compile(r'\"twitter\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+ \"youtube\": re.compile(r'\"youtube\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+}"," "," ","+def _dec(raw: str) -> str:","+ try:","+ return htmllib.unescape(json.loads(f'\"{raw}\"'))","+ except Exception:","+ return raw","+","+"," async def main() -> None:"," async with Actor:"," inp = await Actor.get_input() or {}"]}],"userModified":false,"replaceAll":false}}} |
|
292 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0113110b-c1e2-455e-bd23-f1f7943d4d83","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
293 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzRwBqZeGgej6hNLXMC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Vo2nXDahGcsV8wxMv4Bz4z","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","old_string":" title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()","new_string":" title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n cover = _COVER.search(text)\n creation = _CREATION.search(text)\n socials = {k: _dec(m.group(1))\n for k, rx in _SOCIALS.items()\n if (m := rx.search(text))}\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":950,"cache_read_input_tokens":154331,"cache_creation":{"ephemeral_5m_input_tokens":950,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"eda1bf68-a599-4d46-b783-af09be49f43d"}} |
|
294 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Vo2nXDahGcsV8wxMv4Bz4z","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1cbbda54-eb98-4da8-9adc-12a7b695fca0","timestamp":"2026-08-21T01:01:55.127Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","oldString":" title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()","newString":" title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n cover = _COVER.search(text)\n creation = _CREATION.search(text)\n socials = {k: _dec(m.group(1))\n for k, rx in _SOCIALS.items()\n if (m := rx.search(text))}\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page /<vanity> embarque l'objet campagne\n# (patron_count, creation_count, is_monthly, pay_per_name, résumé) +\n# og:title/og:image. Aucune clé. Proxy résidentiel recommandé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPAGE_URL = \"https://www.patreon.com/{u}\"\n_NUM = {\n \"patrons\": re.compile(r'\"patron_count\"\\s*:\\s*(\\d+)'),\n \"posts\": re.compile(r'\"creation_count\"\\s*:\\s*(\\d+)'),\n \"paid_posts\": re.compile(r'\"paid_member_count\"\\s*:\\s*(\\d+)'),\n}\n_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n_COVER = re.compile(r'\"cover_photo_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_CREATION = re.compile(r'\"creation_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# liens sociaux auto-déclarés de la campagne (objets campaign de l'app Patreon)\n_SOCIALS = {\n \"facebook\": re.compile(r'\"facebook\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"twitter\": re.compile(r'\"twitter\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"youtube\": re.compile(r'\"youtube\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n}\n\n\ndef _dec(raw: str) -> str:\n try:\n return htmllib.unescape(json.loads(f'\"{raw}\"'))\n except Exception:\n return raw\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"patreon\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PAGE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n text = resp.text or \"\"\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _NUM[\"patrons\"].search(text)\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n\n def num(key):\n mm = _NUM[key].search(text)\n return int(mm.group(1)) if mm else None\n title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"patreon\",\n \"found\": True,\n \"username\": u,\n \"full_name\": name,\n \"biography\": (htmllib.unescape(desc.group(1))\n if desc else None),\n \"followers\": num(\"patrons\"), # patrons = « abonnés » KA\n \"patrons\": num(\"patrons\"),\n \"posts_count\": num(\"posts\"),\n \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","structuredPatch":[{"oldStart":82,"oldLines":6,"newStart":82,"newLines":11,"lines":[" desc = _OG_DESC.search(text)"," monthly = _MONTHLY.search(text)"," nsfw = _NSFW.search(text)","+ cover = _COVER.search(text)","+ creation = _CREATION.search(text)","+ socials = {k: _dec(m.group(1))","+ for k, rx in _SOCIALS.items()","+ if (m := rx.search(text))}"," name = htmllib.unescape(title.group(1)) if title else u"," name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()"," await Actor.push_data({"]}],"userModified":false,"replaceAll":false}}} |
|
295 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4119b073-c02c-4093-90ff-0b9308705d7b","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
296 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzSTkrmkYetea6ReqLb","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H7isc29qPqEjpW4eiotjZn","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","old_string":" \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n })","new_string":" \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"creation_name\": (_dec(creation.group(1))[:200]\n if creation else None),\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n \"banner\": _dec(cover.group(1)) if cover else None,\n \"social_links\": socials or None,\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":574,"cache_read_input_tokens":155281,"cache_creation":{"ephemeral_5m_input_tokens":574,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"da40591e-85b2-4429-809f-8b33f1705edb"}} |
|
297 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H7isc29qPqEjpW4eiotjZn","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"25102237-f5df-4438-8ebc-4f45e08cbc0a","timestamp":"2026-08-21T01:02:00.927Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","oldString":" \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n })","newString":" \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"creation_name\": (_dec(creation.group(1))[:200]\n if creation else None),\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n \"banner\": _dec(cover.group(1)) if cover else None,\n \"social_links\": socials or None,\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page /<vanity> embarque l'objet campagne\n# (patron_count, creation_count, is_monthly, pay_per_name, résumé) +\n# og:title/og:image. Aucune clé. Proxy résidentiel recommandé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport html as htmllib\nimport json\nimport re\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nPAGE_URL = \"https://www.patreon.com/{u}\"\n_NUM = {\n \"patrons\": re.compile(r'\"patron_count\"\\s*:\\s*(\\d+)'),\n \"posts\": re.compile(r'\"creation_count\"\\s*:\\s*(\\d+)'),\n \"paid_posts\": re.compile(r'\"paid_member_count\"\\s*:\\s*(\\d+)'),\n}\n_OG_TITLE = re.compile(r'<meta property=\"og:title\" content=\"([^\"]*)\"')\n_OG_IMG = re.compile(r'<meta property=\"og:image\" content=\"([^\"]*)\"')\n_OG_DESC = re.compile(r'<meta property=\"og:description\" content=\"([^\"]*)\"')\n_MONTHLY = re.compile(r'\"is_monthly\"\\s*:\\s*(true|false)')\n_NSFW = re.compile(r'\"is_nsfw\"\\s*:\\s*(true|false)')\n_COVER = re.compile(r'\"cover_photo_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_CREATION = re.compile(r'\"creation_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# liens sociaux auto-déclarés de la campagne (objets campaign de l'app Patreon)\n_SOCIALS = {\n \"facebook\": re.compile(r'\"facebook\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"twitter\": re.compile(r'\"twitter\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"youtube\": re.compile(r'\"youtube\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n}\n\n\ndef _dec(raw: str) -> str:\n try:\n return htmllib.unescape(json.loads(f'\"{raw}\"'))\n except Exception:\n return raw\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n usernames = [u.strip().lstrip(\"@\").lower()\n for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n\n async def one(u: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"patreon\",\n \"username\": u, \"found\": False}\n try:\n resp = await fetcher.get(PAGE_URL.format(u=u),\n session_id=u)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n text = resp.text or \"\"\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"http_404\"})\n return\n m = _NUM[\"patrons\"].search(text)\n if resp.status_code != 200 or not m:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n\n def num(key):\n mm = _NUM[key].search(text)\n return int(mm.group(1)) if mm else None\n title = _OG_TITLE.search(text)\n img = _OG_IMG.search(text)\n desc = _OG_DESC.search(text)\n monthly = _MONTHLY.search(text)\n nsfw = _NSFW.search(text)\n cover = _COVER.search(text)\n creation = _CREATION.search(text)\n socials = {k: _dec(m.group(1))\n for k, rx in _SOCIALS.items()\n if (m := rx.search(text))}\n name = htmllib.unescape(title.group(1)) if title else u\n name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"patreon\",\n \"found\": True,\n \"username\": u,\n \"full_name\": name,\n \"biography\": (htmllib.unescape(desc.group(1))\n if desc else None),\n \"followers\": num(\"patrons\"), # patrons = « abonnés » KA\n \"patrons\": num(\"patrons\"),\n \"posts_count\": num(\"posts\"),\n \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","structuredPatch":[{"oldStart":103,"oldLines":7,"newStart":103,"newLines":11,"lines":[" \"is_monthly\": (monthly.group(1) == \"true\""," if monthly else None),"," \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,","+ \"creation_name\": (_dec(creation.group(1))[:200]","+ if creation else None),"," \"avatar\": htmllib.unescape(img.group(1)) if img else None,","+ \"banner\": _dec(cover.group(1)) if cover else None,","+ \"social_links\": socials or None,"," })"," "," await asyncio.gather(*[one(u) for u in usernames])"]}],"userModified":false,"replaceAll":false}}} |
|
298 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2a91b4f9-f2c0-44be-b086-c04104f67737","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
299 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzStac82tbbR1RF9Ys2","type":"message","role":"assistant","content":[{"type":"text","text":"**ka-discord** : bannière et splash du serveur."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":956,"cache_read_input_tokens":155855,"cache_creation":{"ephemeral_5m_input_tokens":956,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a33f0bbc-c850-4b17-a9f3-b653e03ff663"}} |
|
300 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzStac82tbbR1RF9Ys2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H4KFzCrmsVwVsrprJSquc5","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","old_string":" guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")","new_string":" guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")\n banner_hash = guild.get(\"banner\")\n splash_hash = guild.get(\"splash\")"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":956,"cache_read_input_tokens":155855,"cache_creation":{"ephemeral_5m_input_tokens":956,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8ecc636a-8e6f-494c-a7c6-e3abed773045"}} |
|
301 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H4KFzCrmsVwVsrprJSquc5","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"94aca776-71f7-4c7f-b476-27fdab0adc67","timestamp":"2026-08-21T01:02:07.003Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","oldString":" guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")","newString":" guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")\n banner_hash = guild.get(\"banner\")\n splash_hash = guild.get(\"splash\")","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-discord)\n# Desc: Serveurs Discord via l'API PUBLIQUE d'invitation\n# /api/v9/invites/{code}?with_counts=true : membres approximatifs,\n# présents en ligne, nom/description/boosts du serveur. Le CODE d'invit\n# est SENSIBLE À LA CASSE → il vient de l'URL, pas du handle minusculé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nINVITE_API = (\"https://discord.com/api/v9/invites/{code}\"\n \"?with_counts=true&with_expiration=true\")\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n # ici « usernames » = codes d'invitation (casse préservée par l'appelant)\n codes = [c.strip() for c in (inp.get(\"usernames\") or []) if c.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 6))\n\n async def one(code: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"discord\",\n \"username\": code, \"found\": False}\n try:\n resp = await fetcher.get(\n INVITE_API.format(code=code), session_id=code[:12],\n headers={\"Accept\": \"application/json\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"invite_invalide\"})\n return\n if resp.status_code != 200:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n d = json.loads(resp.text)\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"discord\",\n \"found\": True,\n \"username\": code,\n \"guild_id\": gid,\n \"full_name\": guild.get(\"name\"),\n \"biography\": guild.get(\"description\"),\n \"followers\": d.get(\"approximate_member_count\"), # membres\n \"members\": d.get(\"approximate_member_count\"),\n \"online\": d.get(\"approximate_presence_count\"),\n \"boosts\": guild.get(\"premium_subscription_count\"),\n \"verified\": (\"VERIFIED\" in (guild.get(\"features\") or [])),\n \"partnered\": (\"PARTNERED\" in (guild.get(\"features\") or [])),\n \"channel\": (d.get(\"channel\") or {}).get(\"name\"),\n \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n })\n\n await asyncio.gather(*[one(c) for c in codes])\n Actor.log.info(f\"terminé : {len(codes)} invitations\")\n","structuredPatch":[{"oldStart":55,"oldLines":6,"newStart":55,"newLines":8,"lines":[" guild = d.get(\"guild\") or {}"," icon = guild.get(\"icon\")"," gid = guild.get(\"id\")","+ banner_hash = guild.get(\"banner\")","+ splash_hash = guild.get(\"splash\")"," await Actor.push_data({"," \"kind\": \"profile\","," \"platform\": \"discord\","]}],"userModified":false,"replaceAll":false}}} |
|
302 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8bba5574-e180-4f36-829f-239b2e6dc608","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
303 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzTMDVNBucR3gXHQuoP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AQELiEb5MBjJMbDREetE4t","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","old_string":" \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n })","new_string":" \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n \"banner\": (f\"https://cdn.discordapp.com/banners/{gid}/\"\n f\"{banner_hash}.png?size=1024\"\n if gid and banner_hash else None),\n \"splash\": (f\"https://cdn.discordapp.com/splashes/{gid}/\"\n f\"{splash_hash}.jpg?size=1024\"\n if gid and splash_hash else None),\n \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":335,"cache_read_input_tokens":156811,"cache_creation":{"ephemeral_5m_input_tokens":335,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"42680aa4-4804-443c-a51d-fb014b6b8324"}} |
|
304 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AQELiEb5MBjJMbDREetE4t","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6aed120d-33d1-4ec4-b1b8-244408ab6e14","timestamp":"2026-08-21T01:02:13.333Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","oldString":" \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n })","newString":" \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n \"banner\": (f\"https://cdn.discordapp.com/banners/{gid}/\"\n f\"{banner_hash}.png?size=1024\"\n if gid and banner_hash else None),\n \"splash\": (f\"https://cdn.discordapp.com/splashes/{gid}/\"\n f\"{splash_hash}.jpg?size=1024\"\n if gid and splash_hash else None),\n \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: src/main.py (ka-discord)\n# Desc: Serveurs Discord via l'API PUBLIQUE d'invitation\n# /api/v9/invites/{code}?with_counts=true : membres approximatifs,\n# présents en ligne, nom/description/boosts du serveur. Le CODE d'invit\n# est SENSIBLE À LA CASSE → il vient de l'URL, pas du handle minusculé.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport asyncio\nimport json\n\nfrom apify import Actor\n\nfrom .net import Fetcher\n\nINVITE_API = (\"https://discord.com/api/v9/invites/{code}\"\n \"?with_counts=true&with_expiration=true\")\n\n\nasync def main() -> None:\n async with Actor:\n inp = await Actor.get_input() or {}\n # ici « usernames » = codes d'invitation (casse préservée par l'appelant)\n codes = [c.strip() for c in (inp.get(\"usernames\") or []) if c.strip()]\n proxy = await Actor.create_proxy_configuration(\n actor_proxy_input=inp.get(\"proxyConfiguration\"))\n fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 6))\n\n async def one(code: str) -> None:\n async with sem:\n miss = {\"kind\": \"profile\", \"platform\": \"discord\",\n \"username\": code, \"found\": False}\n try:\n resp = await fetcher.get(\n INVITE_API.format(code=code), session_id=code[:12],\n headers={\"Accept\": \"application/json\"})\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return\n if resp.status_code == 404:\n await Actor.push_data({**miss, \"error\": \"invite_invalide\"})\n return\n if resp.status_code != 200:\n await Actor.push_data(\n {**miss, \"error\": f\"http_{resp.status_code}\"})\n return\n try:\n d = json.loads(resp.text)\n except Exception:\n await Actor.push_data({**miss, \"error\": \"bad_json\"})\n return\n guild = d.get(\"guild\") or {}\n icon = guild.get(\"icon\")\n gid = guild.get(\"id\")\n banner_hash = guild.get(\"banner\")\n splash_hash = guild.get(\"splash\")\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"discord\",\n \"found\": True,\n \"username\": code,\n \"guild_id\": gid,\n \"full_name\": guild.get(\"name\"),\n \"biography\": guild.get(\"description\"),\n \"followers\": d.get(\"approximate_member_count\"), # membres\n \"members\": d.get(\"approximate_member_count\"),\n \"online\": d.get(\"approximate_presence_count\"),\n \"boosts\": guild.get(\"premium_subscription_count\"),\n \"verified\": (\"VERIFIED\" in (guild.get(\"features\") or [])),\n \"partnered\": (\"PARTNERED\" in (guild.get(\"features\") or [])),\n \"channel\": (d.get(\"channel\") or {}).get(\"name\"),\n \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n if gid and icon else None),\n })\n\n await asyncio.gather(*[one(c) for c in codes])\n Actor.log.info(f\"terminé : {len(codes)} invitations\")\n","structuredPatch":[{"oldStart":74,"oldLines":6,"newStart":74,"newLines":13,"lines":[" \"channel\": (d.get(\"channel\") or {}).get(\"name\"),"," \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\""," if gid and icon else None),","+ \"banner\": (f\"https://cdn.discordapp.com/banners/{gid}/\"","+ f\"{banner_hash}.png?size=1024\"","+ if gid and banner_hash else None),","+ \"splash\": (f\"https://cdn.discordapp.com/splashes/{gid}/\"","+ f\"{splash_hash}.jpg?size=1024\"","+ if gid and splash_hash else None),","+ \"vanity_url_code\": guild.get(\"vanity_url_code\"),"," })"," "," await asyncio.gather(*[one(c) for c in codes])"]}],"userModified":false,"replaceAll":false}}} |
|
305 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d84ef7fc-a098-4997-81ff-c5ebd568ffbf","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
306 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzTpEwTBiUoZhQmhPvA","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2QMKpAEIERgCKkCvPA2KXAU+L6HxbpLsoNKT/whQP2kndcYScDl7FGYAXY00Q9GOQBlgU1pX/Da9v3bYdyt0MJie7JF+OWv8/qgiMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGZx57UBhIM1t6wrOv3P0/6cs6IGgwYO2mJukjVHuSR7vwiMJU01dwAFSBJqczRa6tdPswP0NhraLf/QQc0VB8ABL6F0kW/HH3Ffzvg8mE5UD0+ACrhAfRuMINCI2ALDbMrjG6BVmY/pZhhFLl0D7vJ3ifTwMe8q4UAlB/NaZodMGWtnlLMOjkiTZVHvXRikbyxo3dV3rYp09qvnnBGVD+fxzDZOXhNNz8nZ53RU2ZSByqjNS4mV76Y9UTrDGMOcW5NGGbsvySy6wqLoUHOAJleus5lJPULZ4JhGpsKgpZqy9riEqGrx7INKPh2VQZUxAOxExXwhCB+ca9dfU36wfn7EekRjsI56//e8OUQ/oUuoQI7OFEBxPt6OdasnrwuRHhQ5RNX8mwJOdutxD3E5rqo0K1kPuvfBxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":474,"cache_read_input_tokens":157146,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f76c1e96-95ba-480a-86aa-88164dfbf6d7"}} |
|
307 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzTpEwTBiUoZhQmhPvA","type":"message","role":"assistant","content":[{"type":"text","text":"Je vérifie la syntaxe des acteurs modifiés et j'ajoute `fetchAbout` au schéma d'input de ka-youtube."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":474,"cache_read_input_tokens":157146,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"abe7783d-09f6-4c3f-a2bb-57f84e6de14f"}} |
|
308 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzTpEwTBiUoZhQmhPvA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VqtiZ9gG5Etzt3KgR3BUnK","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && for a in ka-tiktok ka-youtube ka-x ka-twitch ka-kick ka-snapchat ka-patreon ka-discord; do python3 -m py_compile $a/src/main.py && echo \"OK $a\" || echo \"FAIL $a\"; done","description":"Syntax-check all edited actor mains"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":474,"cache_read_input_tokens":157146,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4ad63d08-9083-4f1b-bed3-49806570ac3b"}} |
|
309 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzTpEwTBiUoZhQmhPvA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Rzb2y1GMbNhEwNCveYz5pA","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/.actor/input_schema.json"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":474,"cache_read_input_tokens":157146,"cache_creation":{"ephemeral_5m_input_tokens":474,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"139e4a1a-717e-426e-8d3b-4872180744ca"}} |
|
310 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VqtiZ9gG5Etzt3KgR3BUnK","type":"tool_result","content":"OK ka-tiktok\nOK ka-youtube\nOK ka-x\nOK ka-twitch\nOK ka-kick\nOK ka-snapchat\nOK ka-patreon\nOK ka-discord","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"13a35ab7-2b84-431a-a63e-3c698384294a","timestamp":"2026-08-21T01:02:20.374Z","tool_use_result":{"stdout":"OK ka-tiktok\nOK ka-youtube\nOK ka-x\nOK ka-twitch\nOK ka-kick\nOK ka-snapchat\nOK ka-patreon\nOK ka-discord","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
311 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Rzb2y1GMbNhEwNCveYz5pA","type":"tool_result","content":"1\t{\n2\t \"title\": \"ka-youtube input\",\n3\t \"type\": \"object\",\n4\t \"schemaVersion\": 1,\n5\t \"properties\": {\n6\t \"usernames\": {\n7\t \"title\": \"Handles à enrichir\",\n8\t \"type\": \"array\",\n9\t \"description\": \"Handles/slugs à traiter (sans @).\",\n10\t \"editor\": \"stringList\",\n11\t \"prefill\": [\n12\t \"@mrbeast\"\n13\t ]\n14\t },\n15\t \"requestDelay\": {\n16\t \"title\": \"Délai entre requêtes (s)\",\n17\t \"type\": \"integer\",\n18\t \"default\": 1,\n19\t \"minimum\": 0,\n20\t \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n21\t },\n22\t \"concurrency\": {\n23\t \"title\": \"Requêtes simultanées\",\n24\t \"type\": \"integer\",\n25\t \"default\": 3,\n26\t \"minimum\": 1,\n27\t \"maximum\": 10,\n28\t \"description\": \"Nombre de requêtes menées en parallèle.\"\n29\t },\n30\t \"proxyConfiguration\": {\n31\t \"title\": \"Proxy\",\n32\t \"type\": \"object\",\n33\t \"editor\": \"proxy\",\n34\t \"description\": \"Configuration du proxy Apify.\",\n35\t \"prefill\": {\n36\t \"useApifyProxy\": true\n37\t }\n38\t }\n39\t }\n40\t}"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a94d58d4-99dc-4fa8-81fe-dd29c9e3f9d3","timestamp":"2026-08-21T01:02:20.375Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/.actor/input_schema.json","content":"{\n \"title\": \"ka-youtube input\",\n \"type\": \"object\",\n \"schemaVersion\": 1,\n \"properties\": {\n \"usernames\": {\n \"title\": \"Handles à enrichir\",\n \"type\": \"array\",\n \"description\": \"Handles/slugs à traiter (sans @).\",\n \"editor\": \"stringList\",\n \"prefill\": [\n \"@mrbeast\"\n ]\n },\n \"requestDelay\": {\n \"title\": \"Délai entre requêtes (s)\",\n \"type\": \"integer\",\n \"default\": 1,\n \"minimum\": 0,\n \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n },\n \"concurrency\": {\n \"title\": \"Requêtes simultanées\",\n \"type\": \"integer\",\n \"default\": 3,\n \"minimum\": 1,\n \"maximum\": 10,\n \"description\": \"Nombre de requêtes menées en parallèle.\"\n },\n \"proxyConfiguration\": {\n \"title\": \"Proxy\",\n \"type\": \"object\",\n \"editor\": \"proxy\",\n \"description\": \"Configuration du proxy Apify.\",\n \"prefill\": {\n \"useApifyProxy\": true\n }\n }\n }\n}","numLines":40,"startLine":1,"totalLines":40}}}} |
|
312 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d369edd6-a34e-4967-bf44-e26e8c6f0232","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
313 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzUKhyjHfhBMg98cMRk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M1DkXXzqJ69ckXnY34vCdn","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/.actor/input_schema.json","old_string":" \"requestDelay\": {\n \"title\": \"Délai entre requêtes (s)\",\n \"type\": \"integer\",\n \"default\": 1,\n \"minimum\": 0,\n \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n },","new_string":" \"fetchAbout\": {\n \"title\": \"Récupérer la page À propos\",\n \"type\": \"boolean\",\n \"default\": true,\n \"description\": \"2e requête /about par chaîne : vues totales, date de création, pays, liens externes auto-déclarés.\"\n },\n \"requestDelay\": {\n \"title\": \"Délai entre requêtes (s)\",\n \"type\": \"integer\",\n \"default\": 1,\n \"minimum\": 0,\n \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n },"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1002,"cache_read_input_tokens":157620,"cache_creation":{"ephemeral_5m_input_tokens":1002,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8de62e63-143d-4139-b93c-450f91960959"}} |
|
314 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M1DkXXzqJ69ckXnY34vCdn","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/.actor/input_schema.json has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"78f582ec-2130-45ed-b0e3-23e134c07f47","timestamp":"2026-08-21T01:02:29.169Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/.actor/input_schema.json","oldString":" \"requestDelay\": {\n \"title\": \"Délai entre requêtes (s)\",\n \"type\": \"integer\",\n \"default\": 1,\n \"minimum\": 0,\n \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n },","newString":" \"fetchAbout\": {\n \"title\": \"Récupérer la page À propos\",\n \"type\": \"boolean\",\n \"default\": true,\n \"description\": \"2e requête /about par chaîne : vues totales, date de création, pays, liens externes auto-déclarés.\"\n },\n \"requestDelay\": {\n \"title\": \"Délai entre requêtes (s)\",\n \"type\": \"integer\",\n \"default\": 1,\n \"minimum\": 0,\n \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n },","originalFile":"{\n \"title\": \"ka-youtube input\",\n \"type\": \"object\",\n \"schemaVersion\": 1,\n \"properties\": {\n \"usernames\": {\n \"title\": \"Handles à enrichir\",\n \"type\": \"array\",\n \"description\": \"Handles/slugs à traiter (sans @).\",\n \"editor\": \"stringList\",\n \"prefill\": [\n \"@mrbeast\"\n ]\n },\n \"requestDelay\": {\n \"title\": \"Délai entre requêtes (s)\",\n \"type\": \"integer\",\n \"default\": 1,\n \"minimum\": 0,\n \"description\": \"Pause minimale entre deux requêtes sortantes, en secondes.\"\n },\n \"concurrency\": {\n \"title\": \"Requêtes simultanées\",\n \"type\": \"integer\",\n \"default\": 3,\n \"minimum\": 1,\n \"maximum\": 10,\n \"description\": \"Nombre de requêtes menées en parallèle.\"\n },\n \"proxyConfiguration\": {\n \"title\": \"Proxy\",\n \"type\": \"object\",\n \"editor\": \"proxy\",\n \"description\": \"Configuration du proxy Apify.\",\n \"prefill\": {\n \"useApifyProxy\": true\n }\n }\n }\n}","structuredPatch":[{"oldStart":12,"oldLines":6,"newStart":12,"newLines":12,"lines":[" \"@mrbeast\""," ]"," },","+ \"fetchAbout\": {","+ \"title\": \"Récupérer la page À propos\",","+ \"type\": \"boolean\",","+ \"default\": true,","+ \"description\": \"2e requête /about par chaîne : vues totales, date de création, pays, liens externes auto-déclarés.\"","+ },"," \"requestDelay\": {"," \"title\": \"Délai entre requêtes (s)\","," \"type\": \"integer\","]}],"userModified":false,"replaceAll":false}}} |
|
315 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"dd33e567-f8e5-48be-976a-5224d30ff46a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
316 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzUyULx9tszEwxNJBjQ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIShwcKpAEIERgCKkAtxiNiB1N9n99Q/YvSY30AWu7Djs9iPKmYVqoYRs8hDhI8mWCJIfVH/hcIyv6RyWEeF82uPeurs5aGcnd+4Xb5Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGtx57UBhIMaP2SSez6mePUt96KGgzgXjJ+qdGEjnOdBr8iMJM4gS3rTIpoBHqGR0NN5zenCGubc+7AIRC6zrpWD3Juib2qMn/GTYuGBoBngLTs+CqPBc6oKDukXQ1xe1s45wqBC2xJHndO2/EJCd7YzeaC02zHZ5v6bGC5hI3MlC9o9mRpzXJl8WOF2gbnnICKFuKQ3XBLIAUKXIjX69eA2gqmdTQqCu1ORVa485FinN4OeGEh2TizsNWJP7UlzodR4ICtl6//UH1M0ocPW0jqzsHcL+t6n7axg3YN1qrP1Y5bMpq4Q8cM148nEbl3mQsXYSw9ShiVy5vfkmGkZoPl5FyoPxYxl3YpDJMvEIRi+3zrS8FgOs7JDmjF4iw7FROPVNlc5Hhn4SP04SsgYjn2RIOeuylFJ2YY2GrN38LJDIJkukTpfG+Is1falSOWhox5Bf7Tv2+hJNqmfk6DdUWibnIp47/qKJOeLEvJvidrr4ALWvSCJX+CYIWrBbdagVMuv1Oq4L12bYD/nzjzCRft0JPhwA4lwvmBjTO0gVZvtiPRFlmHpscrsiDUDUp6dFUtL/WP86yqr8XME7vF+vinMZBe3BwaMmCwMpnHeannDErhpQ/7NhQRCCRP9uf89btROVDDsRhXlSERLUPkciIzxwIHs8JcxXyUh0Rk6WdQ9A+qp44KzI7l+33stD9IkPLMIf0FEoFeEv+vKH7V3fwOkxPnDWrsLOggIPCK3TClCX72KGDYalz173BEPCg3qMYU1IyyoXMBY5o4ToL386JYZqTJVwU8qW6lO5rTQLbOgt2Ze1ZEaKtKIJuypp3PehrbKDbRWTgrGkTxBWYlgHdhKiaSE+1YpkU/svQQNMomdjLmlEFpgRearIKgVHUmkrTk2JgKRCoOj2qcNo5i6vlvdKi97rHljB7tg3A9bzTleBtb+5XpaeIORR2VWHHN+VJ7qnn9aVYQV+l4NhJqW6EOPHTdQHQYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":483,"cache_read_input_tokens":158622,"cache_creation":{"ephemeral_5m_input_tokens":483,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"541bb37d-64f0-428e-a607-927a0a7780d3"}} |
|
317 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzUyULx9tszEwxNJBjQ","type":"message","role":"assistant","content":[{"type":"text","text":"Syntaxe OK. Je pousse les 8 acteurs modifiés vers la plateforme Apify (builds en parallèle, en arrière-plan) pendant que j'attaque le pipeline."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":483,"cache_read_input_tokens":158622,"cache_creation":{"ephemeral_5m_input_tokens":483,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8a710ed4-20df-46e9-abd8-b6acdb91d5d8"}} |
|
318 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzUyULx9tszEwxNJBjQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JVVNELjdn7Kw43zNL7HRm5","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && source ~/.claude/.env 2>/dev/null; for a in ka-tiktok ka-youtube ka-x ka-twitch ka-kick ka-snapchat ka-patreon ka-discord; do (cd $a && apify push --no-prompt > /tmp/apify-push-$a.log 2>&1 && echo \"PUSH OK $a\" || echo \"PUSH FAIL $a\") & done; wait","description":"Push 8 upgraded actors to Apify platform","timeout":600000,"run_in_background":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":483,"cache_read_input_tokens":158622,"cache_creation":{"ephemeral_5m_input_tokens":483,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3a7273fc-26a1-4562-b585-7f78f7269cea"}} |
|
319 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"b1jmt3qkp","tool_use_id":"toolu_01JVVNELjdn7Kw43zNL7HRm5","description":"Push 8 upgraded actors to Apify platform","task_type":"local_bash","uuid":"116049ae-96c2-4a76-8572-e8dd938d51a0","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
320 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JVVNELjdn7Kw43zNL7HRm5","type":"tool_result","content":"Command running in background with ID: b1jmt3qkp. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/b1jmt3qkp.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6ec19c8c-4f53-4bd2-9cb1-5ff25e101725","timestamp":"2026-08-21T01:02:39.622Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"b1jmt3qkp"}}} |
|
321 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9cd493ee-c876-4dd1-b1b2-6f786b734660","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
322 |
+{"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"b1jmt3qkp","patch":{"status":"completed","end_time":1787274160355},"uuid":"69e5c23d-566a-4a2d-aa57-19ad2880eb45","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
323 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzVkJrXxCGZqjv7xtac","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4ygKpAEIERgCKkA7cgxFfjzTxw3pL2ENo5U1lqY5QDk2AESpEu/amEsH+jxAlF+IUx2hc/OywCoQIkpv00VUAs8S6J83OuZEdMuzMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHIx57UBhIMqmVRDxiYrNKJeB3zGgzGVKQ7uoEZUDZ1gjIiMKLw192hKQkPha8uYpitwBb0cGVSNeHhfxWRJBGv4ELf3QaCoXxeuVSf0H6fLiEWZCrrJmJCoqFgVf2/0nzeKololZsUMPPE6xoCWE8lyEQB+F5CRdLEI3sOThrrMf5I03kdGdMgljT2o3cCeYO6T/EN08/HFN6ezNsC1Ec59yJcm6z7uQawrsTU8MkZ7gte7Un5sXTnhFhNpVK1yUFJOCdP6ZN2k4EOMY98NxFH3evlmNtuet3wIIFyCiLDH9n5+ZYgliKoA/FYu6xp5h5wsWA0d9TqYSba24dTk12RivhQwW3lTM+2DAKbNfbuaC3rvQPzblPHBK9W0wCBcDC9rj/FEYcf3smjhuWFbTpCXmw2u0hMnkVuSbCqtXBNPAEolOJCAazUUgKOuc6xVQyGncSdjcnVLvOigZaNGvmF8PEg/ouUZCpXHYO44pPxKPYHK2z4qc5CgWIdgFH+PVRRLFkVocqsYL2Mdd3pm0Neh+QkW24UrA4zn5letgdujALolFOXQzzyKPw509TziLsNQGdRyHBlIQepbASiwl1Fq8TZfXfLrUQJnYxyoj0pHpyCgYZUa90izHWeMKBi1nsx3QXmo/rLJbycUaBi90IbxvL3aW/wxl9yKhnSnkoyGSP4Uex4FmnmuFZCtTy8I1C3uQJGRNH938qsDU3Pkq3XNXfnIxEck0fsK+oa3FneCJ04kW0M1mJfAyfmP49MI90buU6rtW4o9SZJADWz4n7eFzA3oA97RPRzCoZUM1ljXdAnSqR43lEFunESZ4WR/VwUq0AVPQ8MV+48Rmo6SMjZFeIOtX32plgJq2FtLs2say067Aspo0f8D6xcI/H5+9IshGvDVZH4cMvrLcAyihw7avjehgKQtjhQoFQSFN5ClV12FQlDzbLlTPq1OSKBe6Om+PwoER2QPql/laVgGYd1UGHFHC31FlKWtgytoMeURPhd52aA7CAMp2y/rj05tBMvFk/aqvQjGgGgZyuURSVQWRM0J7py5Keok+RLqMLly41j20LDyhI2WZeAhX/j+QAfpTRuNH5ydHBNoKxNbTU7xHUptRSVj6koMH+uCUyU/UcucDjO85iB4iqsQ+pY52/mrPZ8XX6RH37QzfjrgIFJFRqeDZ4SFG9URXINJq/tOuEywObZngjpr3S40O5RHC+74/BfDs5fBiDmCuubtujr4CjD9yN4ggq2lFQygLFdnQi+dvOe4MUpbFVP6oSuEgVN6FqR6rinnXJvNQD8KDKjxObe7gpb4A6cPMGu6SwZLZXjfNxxdk9hsLPdUqqNA9d/gJMFquxoEEpDmTw++vpqC15AcHDh2ZZG/2I+88QKrVTpf3ElQ5M+nZ6wCAABtrxJ3TCNyniz8Fc4R838fsjoMI1BGr/tZ4UC7kvgbQ2FHvZFH95UYzfysiwLTWQOQeyPuNjmdBciiadxyUjBoQX+yCXySlI27LodKSgWl14cyPiB6KiVadQa+XEr+jW69TvbOiUreiFsNW4C7VP+ZjBUgYbPngmqZvlNTCRRrqxic84pRroFikgtXk8NIclFrRkba9dwZLEE2zDVDk6c6MCrwqbCnpgpNaPZpTXDgS5FyrpRywkOGM7a6CgRzlz2HPmsWbEO9HEcnQShu7t5BdXFnEo52xT2Ab2P6vpCbDaDiwRDNBXXD34EZfCeIEPBys7feCzbCgCFwnaMMl81MXMoe6JLZ1qjMDlmU9FPgJBBQfNIwOoHTzibP5JHFBcqsyPr5YvEUU/xFydd3wGu8P0+khuMQfArRQR2B2n+90XuEgSAYNkXgJYWukdjHOjAYAUw1gxNuKPJRlitSO+ihl6VsMR2EBBGz5jdTfncQc+38lsBFTc9ZUCqxnU1U1WGRkL5WfR9ANiaOb3iGQLzM9Cea3VKmAEW7Rm+BSHOYTdRUKHkRe2NUpFMHumQ+47GCiJGsYtT8pxT+2FjNtQAesffLZMAomXpBHfiMTqXz2LQnakdkBztG4cLjINIhvybQeO7QS2GvdsN1xocYXXwiJwFYfBVB1SGVQ/1rw/gXv08PdhIVBOPOap/T9QiDu6zZKQssoa/lZWia8cA8SHg8urSTKhupHCKl6+1j4KsL1hNvc//H1uSbcvqrUwKBPaXJPEwI2wUzHiLIzrl2arCsXeAuxF9ixxW8ouTk5cSZTF/OEfbbEKOF5A4IZZi+HiHtOxWa9UcSuX/l2lQ9unQ96Pu3HFdwMiPJhaoeIRNnVuSpF92YUFks87ZwPuQHXTZE2NRiNBjJR1/97FNeejaDZeLBldf3zn7pQEJpC2eiu1VxSerPF38MEW/WYZaFsXJBxDi1K3IsbEYN5mvr0q5e8pkr4VambkSvTT0osIdk/4G0pXDu3nezCGK2RtCN/iHrTgxcWaTuXKRSfWDBtf7VABK2siSMFBP+lBIrxgo3d/2FA8M0Zpf4DrlKthd6ut+pGTjLx9dIf8LiK0T7Qlp3Ht0HdZ/lAwEsPkmWMKx94gQv3TUPYJIYFyvHZxYs/UdxQkSAM3fT3Aiv+zHlrR4q9GLntLXAr1sbI30+46rjClNR9kT70t0q7f9dBUXihBfOl5nizY0AFMlsZ6P+BMAm5z+1tjR+Bis8t6x+Zq7s1AgcXkY1Br0k+k45ulZlO2plF2NveY67JBH0dxyZv6AYq1XH7fwv23ZBd3+5wxC+7rC4Z2vMGU0RY5TOtEmMqJkQYPPVwUIZbLTd6U+oOyGXH5uGYTyh7JeRi7dgk1B47IYIKl1GYMemVMkvb15XUxqxpFUaQnhQk8PhtusIueHkO/dGL+Ww+igphG6YCifo7ECu9r3IdKCda+JX8CEBJZrcr3MLXjPM5Vhm3CHNa2KJmbLiA9Q49WyADqZCkgDcUauqRcmvUki6F/Hd6m3CVQu6z7aCQUDMmie5zoOc53neJ2pZ/KUij7mFoOfQVykMUwzifmZQBnk8xXa5HVzIvRC+z9xHywc0qkoTwkNyE0C/kkO6fziyMHO/hKSS2hZIjJOYB+apwQow0T77eSkCpGbaKA+Ru45Qn2bbE/JiYQsAt54r5wIkcVUjGt9iaI8Bs9shZ3uOF4VjJDwUfxrAB4ow5NQTRCk2VmSGp+PgAZ7BhKuzVNO2M8huPciEiz6SfvdxlSiezS95n//1kM/vxB9Yq1bzCBr6lSQTutmuW+HS2m34G2A1UH2mMs1Y8cIqB0bG/6Z96voZtcUucP6aqnNDV86VFqM6IGk49nzk3KlrfvKqdGHWMJO/r+UCE3M1A8EXvDkq7mOW2MzZRuLLPkb52/EOEsgd/1CMqJ/3PWbCp6djMcgXVR3zAxPr71F988WnTAb7gdG3HCIfAh5U8YjKFDCUEihyoSDNPY/0H4nBlaCrsU54vOeymUa7vXPtHE7IqDlCMB7c5ggWlnwkQ7xdmIv2rUsefrjlvEAGXaI0jvPxjanHNFDPHbzBf3EoA35xDHM4GiWLF+m99ktTY45iHqCuR9AHmPIwXFJTRxw+DjsBbz8ymGFsGYgceyMmL8v308n+f6zWtirDL/GzRHck84fdj67eTIP06VpQ1fn/D4yC+J5RsSQ5Tv7HfZUzLF9rM4DhQ79Wf8qxZraMFrxgIJ5JearkNg0WGGuBU+Hud7jKF9dMXlbClUPxxcFr7q/v/398/6Z22RTgM60cDwc+LviveGqfJObTz+ThniCUYhCecVB2SE4lOjGHb2ihHBrfJwNWCNHe5vj66HI6Fo5tU8TtTuIfzhCWO71Tk8QJU/rMzVt6o/Kq3mFniN6k4kWmw4tgvu0sGB/in0QA9FeEP5mszqDSNpdtILYkUQkRP7pb+XUtquHNXgpPnjNGGDaY/QMJcw+I7tNPCUQCgUKUKQzeKWaThCQTUsCDMwutsX0nV/jUiXsk87I5ELU0khzx/2BtNIwilSWC/XnofrpP9KM5HF1vwEl4aqLewkm6t5k38s9SaZglwbxXpeB6CPxlOf2BdYmbvMoqCYkCuWChWKBHB5sl88wgMoKzvCq7cpxVzY+r5P7RSZWKSflwzfp0kpj9aj4wQgJI7VrKSuczHStrtLRFx8cUB5G40S8PpPZAWMP5gjFUR3eo/D2iAJXJG2XN8f03ZeFIbA4pL1A7ntifAExwgDLcucyGif4qNuQGLVjJY3jVBuPiTyvraQtYOPKz1px7Vp/dQvauKTl7yKwEgYZEClkW/0UY+/nxP/E77X03W2Ntc+rtE34iJWUCSXj43QFlzv1TfGs009zLwhBPCrfr8XgoAsVbotqfvmDXb2O3Vc7zPwGSQwX/Nj58+/Wnt7lxjgT0Pr+maLMWew2TOo4sw5D6gOzRWfthNg1Q/neSy73qquWCUMZHrCQLpSDyNUspVs0/Y/46Kds2Z6jtNEeigt8jgm5aLQjDIdUD5IZgo2mj7R8tM9BdNGtWNyqhZQtMQlUX6GsWoQzL9KQjFZBJTBJaHg7/yiEdocZvqASdqWjEIsz3c5/X+YHttG2WqEuCVbSZVJpnVmSDpttnsFOIbkL/XCSxj/Jj1a02qkB3xd7c9AwngPNEZSvrKPf0XsHyaJP9aaFD3AFdn5lIxjau90BcVYnstbkbjSnfe/iscYnB30fT4/Y/3kcrAZX7RUiRmTwhwPCNz46gXXYbLP8uBkr8kr5iPiBgBGjr63M7kXPNi4/8dEhdlP0ucfSxPMtnbfj5ExJ67+K0hH7KtrQC64tYaEj8j+wPnZZGyNejrIIyJ/CR18Vl8m+ppXYPBDh9gHtOkWcOv31TOtTHnB1dSXS+yDsqLXfhZSE1K8ZbhlBHdwfj2xRanfUx6K82XOUSP6mJ66dRJUJIaP+Yg/ehcxkDggMNUUMwCuz6A29iCpvvVEnJg+98Zd+pAcKzfdZ5ktV82Ie9aGSt/BM6iYqL3fKNBIW2cOclZLZvdmgzDdPse2076kVE8mHXnUIQR8pkoGxVaE966oeXsZHqYjxCpytXxnQ+Yc3n5qzGivsldgIy/123FvFkSjSlOO22w9GlPv5NfxMtN1QfZI8XrpcwaRPp6BqN0ZMZK4Iw+ymtIAN0Y2Prey3Y99K2rF1380kvRzps+TWhUAhDFacJoU5ZBVvwkCD9kX5wKIVhFNeVuTgIpNq92yJGSiyJtdLfByN8U3uG3A/WWnjWRdNxp+Bmn7FSPMQFjo5ZQ/v2t1xxBNdXPfc3tVWJCPJ9cfr82PnUPhhoJpyCSYFrMDbLOSSeGWXx9x1QdWFFuicBRknJPiXoAmKllS095xjTUvoo255LeHMtDt/br0J22mX1EIDVE18EkANVUo8wkblQMzUL/0lnEBGYSK0A/gzVbN7iVvl0v99diMoJWw6NfUbAE/Cec5Hkeavr/MuZPsUc5hsoELT9PL5McGN6656j0+ELQBPAvBFJS1oTDiOqm+CvLuvf7H04SROQjxaXcah1U9JVilSX8PiZTZueAmS/7SU7gwahNJhfzyB5UKfPIAWpTAHqqwRMjmEHmv+q+w/DMq3AYx5nedvhnVWzVxAkWejBT9bjux/WphdxJtZbvdj6yz+R3mG5nnXfIlfimNkAo/A9n0vJr7IfT1joI4S8Rj2jP9v3rrlv/9BqEC7csGdI4jzFLAYte3ATQ/DoZ5EhHmhympgWREEwrkOAltD8ItRBAPYAavhbZQV61mRx2uNs2ByCL3BSoJsuJliALmC1Uk44DdmD1mEXZM5m3aO7DxVvLFpzeNAung/8xX2Q23evFl7CCr39wmHfvGGpjYab7uokdEU5ldXEWXQkqKFgnqLyz4u+l2n1R7hQjhC2LutWiQhqtBIcn6MuCJVrx45r0hHhkZ9gOvoMLLVlE3vz/z1DaL4tFIgXN8aiMwsPe0xQhIJxxU/jNqD6gU3qLvifFXCva8trg8OpPFiqHRGXT5/8s7Vw8/EMzBSOZ9sqZ8z1vFr6FC9W/RTTQCbFZBC8fqmsHxL9XNuqgfPa+CQs+93Vejugwk0TcwRNAsiobbeNTbesquNXpm32SMsWfkbJ3Ng4aPe/sHe3MfuUujWfC9L0UODnR25TtfCanstKYwX78VPj/D6tfZ57RTI4ODYIU7LRp8jb1UrE7GueSTryCzw4Vn+tfub33CU0DCHvPoXqIZf+qXNwCMTvlLb4mEdw266VVTLiJTdC2KzrcQJ4iwNA4VSFg/iESDC2+DB8anA0kxwtQWcWq6SZ1jPhbLTqng0Vxi/5OirTaRh/r5DLwOt7aouWENjxY+SDDgQHdNipeFhyui8PjtI37mG7HHBv1ilDoxEx+4zIm+f9AQFm6NT8weKGGwMPtHdvY0gn09lavGbZyXFowmPWJyrcG9JsS3bVuaJXCPC2c2gF0FwiiTfQ8zwmPLoedDYXSjZRKWxGCZGQoRmsvMZxiAu6K3pqC8BT9poQvK9M204mcOZcT4ysaPHJgVU7NrwWsLAbSLtQ0w4o/E0gMlQeM71F3Dd23v850GCS3fxIfmL0kaMKJ3zK/TnQSeWFDsOnwnbpKMzwurui7mOE0IcHntwfGIgfc1J3SnQeAhBvDpcOWmVBcNwKU9UgTgkoLttjZmoiscQ5235tgIRBjkUBKJHP3sd8pHUs3Y4CiKTmrDfxGFHu1fw0zAJELFljChJsticgllcN5oHbiplybU9UZzOzT7L4AQhOeq10rMUVyNqBDoG3P/oq6YKZDSu7Gz7ov29JMTCjsb4K6Ukr8OdqL5Riq7aXxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":670,"cache_read_input_tokens":159105,"cache_creation":{"ephemeral_5m_input_tokens":670,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"21634dc6-5480-4b61-8907-f6671b4602ca"}} |
|
324 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzVkJrXxCGZqjv7xtac","type":"message","role":"assistant","content":[{"type":"text","text":"Pendant que les builds tournent, j'étends le pipeline `apify_social.py`."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":670,"cache_read_input_tokens":159105,"cache_creation":{"ephemeral_5m_input_tokens":670,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ec19ebc4-c89d-407c-a267-561ca25cdb11"}} |
|
325 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzVkJrXxCGZqjv7xtac","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017vbHY1tK99hu3LKuRacJNc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n if not cr.avatar_url and it.get(\"avatar\"):\n cr.avatar_url = it[\"avatar\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)","new_string":" # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":670,"cache_read_input_tokens":159105,"cache_creation":{"ephemeral_5m_input_tokens":670,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b61fb1e9-b35d-4b6b-9a4e-3035346091a9"}} |
|
326 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017vbHY1tK99hu3LKuRacJNc","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"824c6fe2-a8d0-46c7-9826-dc920c053614","timestamp":"2026-08-21T01:03:12.772Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n if not cr.avatar_url and it.get(\"avatar\"):\n cr.avatar_url = it[\"avatar\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)","newString":" # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n if not cr.avatar_url and it.get(\"avatar\"):\n cr.avatar_url = it[\"avatar\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":246,"oldLines":10,"newStart":246,"newLines":22,"lines":[" if it.get(src) is not None}"," if self.content_key and it.get(self.content_key):"," metrics[self.content_key] = it[self.content_key][:12]","+ # images du profil : avatar/bannière conservés PAR COMPTE (affichage","+ # par plateforme + chaîne de repli côté frontend)","+ for img_key in (\"avatar\", \"banner\"):","+ if it.get(img_key):","+ metrics[img_key] = it[img_key]"," metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre"," acc.metrics.update(metrics)","- if not cr.avatar_url and it.get(\"avatar\"):","+ # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la","+ # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;","+ # sinon on remplit seulement les manquants","+ if it.get(\"avatar\") and (not cr.avatar_url","+ or acc.platform == cr.primary_platform):"," cr.avatar_url = it[\"avatar\"]","+ if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)","+ or acc.platform == cr.primary_platform):","+ cr.banner_url = it[\"banner\"]"," if not cr.bio and it.get(\"biography\"):"," cr.bio = it[\"biography\"]"," self.cross_links(cr, it)"]}],"userModified":false,"replaceAll":false}}} |
|
327 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8c8aed95-951c-4d2e-a32c-f6ce90e6e540","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
328 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzYEejoUcteALazVZgL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HDnBRsLioTcT57dG2VoMFP","name":"Bash","input":{"command":"cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/b1jmt3qkp.output","description":"Check apify push results"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4068,"cache_read_input_tokens":159775,"cache_creation":{"ephemeral_5m_input_tokens":4068,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9f5e060c-392e-48f4-bc80-99474d127a5a"}} |
|
329 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HDnBRsLioTcT57dG2VoMFP","type":"tool_result","content":"PUSH FAIL ka-kick\nPUSH FAIL ka-twitch\nPUSH FAIL ka-youtube\nPUSH FAIL ka-patreon\nPUSH FAIL ka-discord\nPUSH FAIL ka-tiktok\nPUSH FAIL ka-x\nPUSH FAIL ka-snapchat","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a5672e47-24b4-4ed0-bdf6-82048dafd72e","timestamp":"2026-08-21T01:03:20.187Z","tool_use_result":{"stdout":"PUSH FAIL ka-kick\nPUSH FAIL ka-twitch\nPUSH FAIL ka-youtube\nPUSH FAIL ka-patreon\nPUSH FAIL ka-discord\nPUSH FAIL ka-tiktok\nPUSH FAIL ka-x\nPUSH FAIL ka-snapchat","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
330 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"cba74c7e-0e60-4247-85b2-8e8f4be58aed","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
331 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzYjSMCGtjho7rdbfAB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DZdhrRSm7t3Pn2ims7xuJA","name":"Bash","input":{"command":"tail -25 /tmp/apify-push-ka-tiktok.log","description":"Inspect push failure log"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":273,"cache_read_input_tokens":163843,"cache_creation":{"ephemeral_5m_input_tokens":273,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"67595a78-e637-4c9e-a0e2-450466894e10"}} |
|
332 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DZdhrRSm7t3Pn2ims7xuJA","type":"tool_result","content":" continue even when secret values are not found in\n the local secrets storage.\n -b, --build-tag=<value> Build tag to be\n applied to the successful Actor build. By default,\n it is taken from the '.actor/actor.json' file.\n --dir=<value> Directory where the\n Actor is located.\n -f, --force Push an Actor even\n when the local files are older than the Actor on\n the platform.\n --json Format the command\n output as JSON.\n --open Whether to open the\n browser automatically to the Actor details page.\n -v, --version=<value> Actor version number\n to which the files should be pushed. By default, it\n is taken from the '.actor/actor.json' file.\n -w, --wait-for-finish=<value> In seconds, how long\n to wait for the build to finish. If no value\n passed, it waits forever. To return as soon as the\n build is queued (fire-and-forget), pass 0. The exit\n code reflects the build outcome only — if the wait\n elapses with the build still running, the command\n exits 0; check status via the printed link or\n --json output.","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2ac7c15a-d468-42fc-90e4-71cccbfad97f","timestamp":"2026-08-21T01:03:25.202Z","tool_use_result":{"stdout":" continue even when secret values are not found in\n the local secrets storage.\n -b, --build-tag=<value> Build tag to be\n applied to the successful Actor build. By default,\n it is taken from the '.actor/actor.json' file.\n --dir=<value> Directory where the\n Actor is located.\n -f, --force Push an Actor even\n when the local files are older than the Actor on\n the platform.\n --json Format the command\n output as JSON.\n --open Whether to open the\n browser automatically to the Actor details page.\n -v, --version=<value> Actor version number\n to which the files should be pushed. By default, it\n is taken from the '.actor/actor.json' file.\n -w, --wait-for-finish=<value> In seconds, how long\n to wait for the build to finish. If no value\n passed, it waits forever. To return as soon as the\n build is queued (fire-and-forget), pass 0. The exit\n code reflects the build outcome only — if the wait\n elapses with the build still running, the command\n exits 0; check status via the printed link or\n --json output.","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
333 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3375d259-5d17-4a2d-b137-d030b6689e3b","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
334 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzZ6uBiKLi4Pz8EALCF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3gMKpAEIERgCKkBg5aYgnvS4dMON3wHzw+gmschhFPBJbT5b0R9edTg3kAlf/Lp0uDPyDnhjus06R61tplb5cq+AGSIJ0+829Z8WMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHkx57UBhIMtPRDZa+P9KJea6tUGgyrnJt39/oaSI4RLr4iMFE7aIsDMOZTYLJ90SDG81ARwUhrAnHDAW/yu8eBwcWdpfa/mON0asO9DBv0ehKcPCrmAXvrW4PX4W4Hk2D/bYhxytd50f71EaYYj86qu2Y2jNa02tPbt9NKtaJJNZlvTOXwvqNs308cal2dPdEEUx+xSrY2gajHX2At+TG4WTKOUQLfnl90JpYShIsZv60VKKlxvtVZN74ddWLSLnRf7AWILYiqHONmQmUZi5qdGabYQZ6vai7xIid4/yvBoPMmGFJqusDr2M3Q4fKaAnHcguPpASN90f4ibWiZvUGaJVJFJHrUPXoCympYjITePVcGXC0JenAoWeANvbJK8byhkhN9s7hD+19lN5mxNwO/Gkf6BKvXLgrN7rLfGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":506,"cache_read_input_tokens":164116,"cache_creation":{"ephemeral_5m_input_tokens":506,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9ab3fc4b-a157-4c48-b3d5-3631e568d532"}} |
|
335 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzZ6uBiKLi4Pz8EALCF","type":"message","role":"assistant","content":[{"type":"text","text":"Mauvais flag (`--no-prompt` n'existe pas). Je relance avec `--force`."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":506,"cache_read_input_tokens":164116,"cache_creation":{"ephemeral_5m_input_tokens":506,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2e422096-7163-48c5-9bb0-a3ef9c27d236"}} |
|
336 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzZ6uBiKLi4Pz8EALCF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016h1C8YbQXA87KDm8tyBJUT","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && source ~/.claude/.env 2>/dev/null; for a in ka-tiktok ka-youtube ka-x ka-twitch ka-kick ka-snapchat ka-patreon ka-discord; do (cd $a && apify push --force > /tmp/apify-push-$a.log 2>&1 && echo \"PUSH OK $a\" || echo \"PUSH FAIL $a\") & done; wait","description":"Push 8 actors with --force","timeout":600000,"run_in_background":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":506,"cache_read_input_tokens":164116,"cache_creation":{"ephemeral_5m_input_tokens":506,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4147fcab-6b04-46e6-a75d-a5f3c8cd96f2"}} |
|
337 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"baocvzuhc","tool_use_id":"toolu_016h1C8YbQXA87KDm8tyBJUT","description":"Push 8 actors with --force","task_type":"local_bash","uuid":"cb23fb81-c1e2-47e6-bf3e-acbcadd044ae","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
338 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016h1C8YbQXA87KDm8tyBJUT","type":"tool_result","content":"Command running in background with ID: baocvzuhc. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/baocvzuhc.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"91131099-e785-43d4-8185-6e1b437d4da1","timestamp":"2026-08-21T01:03:34.482Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"baocvzuhc"}}} |
|
339 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e8bf9f54-9e1b-4892-910f-765be125135a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
340 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzZnj59vShe4QegpZfJ","type":"message","role":"assistant","content":[{"type":"text","text":"Pendant les builds, je continue le pipeline : metric_maps étendus et cross-links."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":492,"cache_read_input_tokens":164622,"cache_creation":{"ephemeral_5m_input_tokens":492,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ecf6edef-7472-486c-b624-dc1f08e2c50a"}} |
|
341 |
+{"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"baocvzuhc","patch":{"status":"completed","end_time":1787274223481},"uuid":"4ee18c7a-c846-42da-9f28-821f7811e8b6","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
342 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzZnj59vShe4QegpZfJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Xe5pZr6kYwaueWzFXpNMWj","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}","new_string":" content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":492,"cache_read_input_tokens":164622,"cache_creation":{"ephemeral_5m_input_tokens":492,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d0475105-59f7-4e6f-9c98-60ce41532863"}} |
|
343 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Xe5pZr6kYwaueWzFXpNMWj","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"201b09e6-985b-4e13-aab9-79ab134b433b","timestamp":"2026-08-21T01:03:43.692Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}","newString":" content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":297,"oldLines":6,"newStart":297,"newLines":7,"lines":[" \"category\": \"category\", \"is_business\": \"is_business\","," \"business_category\": \"business_category\","," \"highlight_reels\": \"highlight_reels\",","+ \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\","," \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\","," \"avg_video_views\": \"avg_video_views\","," \"engagement_rate_pct\": \"engagement_rate_pct\","]}],"userModified":false,"replaceAll":false}}} |
|
344 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0b189da0-f7ef-4bc8-b856-cb44a696c3ea","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
345 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzaU5gbnjNom4Ts3zNz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017ou2E6rdw4UmTfKRDCvpFs","name":"Bash","input":{"command":"cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/baocvzuhc.output; grep -l \"failed\" /tmp/apify-push-*.log 2>/dev/null","description":"Check push results"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1023,"cache_read_input_tokens":165114,"cache_creation":{"ephemeral_5m_input_tokens":1023,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0b014f67-f119-4236-b5eb-e0acd959445f"}} |
|
346 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017ou2E6rdw4UmTfKRDCvpFs","type":"tool_result","content":"PUSH FAIL ka-discord\nPUSH OK ka-patreon\nPUSH OK ka-tiktok\nPUSH OK ka-snapchat\nPUSH OK ka-youtube\nPUSH OK ka-x\nPUSH OK ka-twitch\nPUSH OK ka-kick","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bf106baa-b7c5-4886-8360-9e536222a22a","timestamp":"2026-08-21T01:03:49.183Z","tool_use_result":{"stdout":"PUSH FAIL ka-discord\nPUSH OK ka-patreon\nPUSH OK ka-tiktok\nPUSH OK ka-snapchat\nPUSH OK ka-youtube\nPUSH OK ka-x\nPUSH OK ka-twitch\nPUSH OK ka-kick","stderr":"","interrupted":false,"isImage":false,"returnCodeInterpretation":"No matches found","noOutputExpected":false}}} |
|
347 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2abd1484-95bc-43cc-abc8-5418cdd3d041","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
348 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzasuQ7n3iawmDmN4LT","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017NkrFTW75JJi5UYtBGXNp5","name":"Bash","input":{"command":"tail -30 /tmp/apify-push-ka-discord.log","description":"Inspect ka-discord push failure"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":285,"cache_read_input_tokens":166137,"cache_creation":{"ephemeral_5m_input_tokens":285,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c9e5e69d-8bdd-4a78-b6b6-0f798476b79d"}} |
|
349 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017NkrFTW75JJi5UYtBGXNp5","type":"tool_result","content":"Error: Some other Actor already has this name (\"ka-discord\").","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1f70d8f0-8a41-45d4-83f5-c9912e045cd5","timestamp":"2026-08-21T01:03:55.625Z","tool_use_result":{"stdout":"Error: Some other Actor already has this name (\"ka-discord\").","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
350 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d3376646-6ee8-434a-901c-87dad375c3a9","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
351 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzbMuda3Cgue1n12Wzc","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmgUKpAEIERgCKkCZBa117SQzBA+hdYKTA5j8XbNjxH2sHHYAsCNPubtunW8CdCMtYs58qOaDWg2Lpf0EOaBrhhuIpSRtePpkyWiqMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGDyJ7UBhIM8/tjxxpiNKBcB7EbGgwjUWe3K6muCCciUFAiMI9xyZDDd7BQ6cCyJalEwAzIvp85E4eRC9MbjbX3eK7CK+7mMhVQgVXCialcpfa1yiqiAx0FTscbFRQrn5XKLT/rA9NYpnY67JTci+tuzqJ2apfXd7ID8ptDUd2tVtZq44HCor4LCddWuUaWmmGnTEza0Pr1QD8cS1ZseyldIMTs6sbGK8k3WTcbk8R7/N/bIMQhE6vvwPOmd2FUimZ72Iultrhq7NZ9xFVp4cjxDSEc4+utvckRH3H83TXS3GfnxBZVllAWHBcdQThV/0K/agPfAtatI1MTq0CXqKDPb/fgPkiDMJLs8fMjn1xJbE2YqKjSnkjVd/BtP9dLwK6yOzhnL8bnT0wGaPUyFHuUR7IyEo6Qo9Xv3eP2SHGIvQeqf/05Zf/HZleU8w+qDZP6IH4XtBUogMnWp9y7IIxUvj6zA/XVcaFUfY4WjvRm1R+thg9B17brMOs4trCzd9i3GbhG+4ds1JvbKPflZZmQ/aB1MeCGRsxBkCa+7JHAbjfogl1NLcl3EUngUrtTVq4KKRFwuSdeob7LSvujUeb9meJLMVI2Hd61f8z90f/8H1Qjj2t/Lx6aKbYCWjGeqcIheVA+NELvLPUoJMBQ8azD0sEaTTtZIcwYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":166422,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3f6858d7-0d2c-4ead-95cb-9fe885e90c36"}} |
|
352 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzbMuda3Cgue1n12Wzc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XdgUNb8azcWQrWfC2nuTXV","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && cat ka-discord/.actor/actor.json; echo ---; cat ka-patreon/.actor/actor.json; echo ---; ls -a ka-discord ka-patreon","description":"Compare actor.json of failing vs OK actor"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":126,"cache_read_input_tokens":166422,"cache_creation":{"ephemeral_5m_input_tokens":126,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b46c6d0b-df92-41ff-b056-550043cf71b1"}} |
|
353 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XdgUNb8azcWQrWfC2nuTXV","type":"tool_result","content":"{\n \"actorSpecification\": 1,\n \"name\": \"ka-discord\",\n \"title\": \"KA Discord — serveurs via invitation\",\n \"description\": \"Serveurs Discord via l'API publique d'invitation : membres approximatifs, en ligne, boosts, nom/description. Code d'invit sensible à la casse.\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}---\n{\n \"actorSpecification\": 1,\n \"name\": \"ka-patreon\",\n \"title\": \"KA Patreon — pages publiques\",\n \"description\": \"Pages Patreon publiques : patrons, publications, nom, avatar, résumé (données embarquées de la campagne).\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}---\nka-discord:\n.\n..\n.actor\nrequirements.txt\nsrc\n\nka-patreon:\n.\n..\n.actor\nrequirements.txt\nsrc","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f180b29d-ef1f-4cde-a61c-9aeaf9a3d019","timestamp":"2026-08-21T01:04:04.359Z","tool_use_result":{"stdout":"{\n \"actorSpecification\": 1,\n \"name\": \"ka-discord\",\n \"title\": \"KA Discord — serveurs via invitation\",\n \"description\": \"Serveurs Discord via l'API publique d'invitation : membres approximatifs, en ligne, boosts, nom/description. Code d'invit sensible à la casse.\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}---\n{\n \"actorSpecification\": 1,\n \"name\": \"ka-patreon\",\n \"title\": \"KA Patreon — pages publiques\",\n \"description\": \"Pages Patreon publiques : patrons, publications, nom, avatar, résumé (données embarquées de la campagne).\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}---\nka-discord:\n.\n..\n.actor\nrequirements.txt\nsrc\n\nka-patreon:\n.\n..\n.actor\nrequirements.txt\nsrc","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
354 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e8cd8ea6-6388-4a4c-857f-ee835c75625f","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
355 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"compacting","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"09beb047-edac-401d-adf9-ceee5f4cce29"}} |
|
356 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b22e8d07-98e2-4777-b80d-9e0b955275ef"}} |
|
357 |
+{"type":"claude","event":{"type":"system","subtype":"compact_boundary","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"088dba6e-0913-4c71-9a7a-3b830c37749f","compact_metadata":{"trigger":"auto","pre_tokens":167121,"post_tokens":6557,"duration_ms":99887}}} |
|
358 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"text","text":"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nSummary:\n1. Primary Request and Intent:\n Le user (message unique, en français) : beaucoup de créateurs dans **crea-ka** ont des infos incomplètes pour les différents médias sociaux. Il demande :\n 1. Un **major upgrade des acteurs Apify** (compte gorgeous_thistle) pour capturer **le plus d'infos possible** et présenter **plus d'images**.\n 2. Pour **chaque créateur québécois**, ajouter des **métriques et stats** — « ça doit être légendaire ».\n \n Implicitement (conventions projet) : commit + push, redéploiement PM2, en-tête auteur « Simon-Pierre Boucher <contact@spboucher.ai> » sur chaque fichier.\n\n2. Key Technical Concepts:\n - Apify actors Python maison (13) : `apify pull`/`apify push --force`, curl_cffi (empreinte TLS Chrome), proxy RESIDENTIAL, structure `.actor/{actor.json,input_schema.json,Dockerfile}` + `src/{main.py,net.py,__main__.py}`\n - Pipeline crea-ka : `creaka/connectors/apify_social.py` — classes `_ApifySocialEnrich` avec `source_id`, `actor`, `cap`, `content_key`, `metric_map`, `apply()`, `cross_links()`/`bio_urls()` ; sharding (SHARD_SIZE=60, MAX_SHARDS=32, RUN_MEMORY_MB=2048, RUN_CONCURRENCY=10, REVISIT_DAYS=7) ; tampon `{source_id}_at` dans account.metrics\n - SQLite WAL (`data/creaka.db`) : tables creators (doc JSON = dataclass Creator sérialisée), accounts (PK platform+handle, metrics JSON), sync_log ; PAS d'historisation → à ajouter (table snapshots)\n - Dataclasses `Creator` (avatar_url, PAS de banner_url — à ajouter) / `PlatformAccount` dans `creaka/schema.py`\n - API FastAPI (`creaka/web.py`, port 8160) ; frontend SPA zéro-build : `frontend/src/index.template.html` → `dist/index.html` via `scripts/build_frontend.py`\n - Avatars hotlinkés (URLs CDN Instagram signées qui EXPIRENT → rafraîchissement hebdo nécessaire), fallback unavatar.io\n - Sources par plateforme : IG web_profile_info, TikTok SSR `__UNIVERSAL_DATA_FOR_REHYDRATION__`, YouTube ytInitialData (+page /about → aboutChannelViewModel), X syndication.twitter.com, Twitch GQL public (Client-ID kimne78kx3ncx6brgo4mv6wki5h1ko), Kick API v2, Discord API invites, Patreon/OnlyFans/Threads regex sur JSON embarqué, Fansly apiv3, Snapchat `__NEXT_DATA__`\n - APIFY_TOKEN dans `~/.claude/.env` ; PM2 : watch `crea-ka-sync` (un restart déclenche un passage complet)\n\n3. Files and Code Sections:\n - **~/apps/crea-ka/actors/** (créé) : les 13 acteurs tirés via `apify pull gorgeous_thistle/ka-*`\n - **actors/ka-tiktok/src/main.py** (MODIFIÉ) : dans `parse_videos` ajout par vidéo de `\"saves\"`, `\"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\") or video.get(\"originCover\"))`, `\"duration_s\"`, `\"width\"/\"height\"`, `\"is_pinned\"`, `\"hashtags\"` (textExtra), `\"music\"` ; au profil : `videos_per_week`, `last_video_at` (ISO depuis timestamps), `top_hashtags`, `top_video` avec cover ; Desc mis à jour\n - **actors/ka-youtube/src/main.py** (MODIFIÉ) : `\"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\"` dans `_lockup_video` ET la branche videoRenderer ; nouveaux helpers :\n ```python\n def _s(v): # ViewModel : chaîne nue ou {content: \"...\"}\n return v.get(\"content\") if isinstance(v, dict) else v\n def _banner_of(data): ... # imageBannerViewModel.image.sources[-1].url ou c4TabbedHeaderRenderer.banner.thumbnails[-1]\n def _about_of(data): ... # aboutChannelViewModel → total_views, joined_date, country, external_links (channelExternalLinkViewModel)\n ```\n `fetch_about = bool(inp.get(\"fetchAbout\", True))` → 2e requête `/about` ; `is_verified` si `\"BADGE_STYLE_TYPE_VERIFIED\"`/`\"BADGE_STYLE_TYPE_VERIFIED_ARTIST\"`/`\"OFFICIAL_ARTIST_BADGE\"` dans le HTML ; émission de `banner`, `total_views`, `joined_date`, `external_links`, `is_verified`, `country` (about d'abord)\n - **actors/ka-youtube/.actor/input_schema.json** (MODIFIÉ) : propriété `fetchAbout` (boolean, default true)\n - **actors/ka-x/src/main.py** (MODIFIÉ) : par tweet `\"media_urls\": [x.get(\"media_url_https\") for x in media if x.get(\"media_url_https\")][:4]`\n - **actors/ka-twitch/src/main.py** (MODIFIÉ) : requête GQL — `channel { socialMedias { name title url } }` (remplace chanlets), `stream { ... title previewImageURL(width: 640, height: 360) ... }`, `videos { ... previewThumbnailURL(width: 320, height: 180) ... }` ; émissions : `thumbnail` par vidéo, `live_title`, `live_thumbnail`, `social_links` (liste {name,title,url}[:10])\n - **actors/ka-kick/src/main.py** (MODIFIÉ) : `live_thumbnail` ((live.thumbnail or {}).url), `live_category` (première categories[].name), `vod_enabled`\n - **actors/ka-snapchat/src/main.py** (MODIFIÉ) : `story_previews` (snapUrls.mediaPreviewUrl dict-ou-string, max 6), `spotlight_previews` (thumbnailUrl, max 6)\n - **actors/ka-patreon/src/main.py** (MODIFIÉ) : regex `_COVER` (cover_photo_url), `_CREATION` (creation_name), `_SOCIALS` {facebook,twitter,youtube: regex https}, helper `_dec()` ; émissions `creation_name`, `banner`, `social_links`\n - **actors/ka-discord/src/main.py** (MODIFIÉ) : `banner` (cdn.discordapp.com/banners/{gid}/{hash}.png?size=1024), `splash`, `vanity_url_code`\n - **creaka/connectors/apify_social.py** (EN COURS DE MODIFICATION) : base `apply()` réécrite —\n ```python\n # images du profil : avatar/bannière conservés PAR COMPTE\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n ...\n # avatar/bannière de la FICHE : rafraîchis depuis la plateforme principale\n if it.get(\"avatar\") and (not cr.avatar_url or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None) or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n ```\n ApifyInstagram metric_map : ajout `\"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\"`. **Les autres metric_maps restent à étendre.**\n - **creaka/schema.py** (LU, à modifier) : il FAUT ajouter `banner_url: str | None = None` au dataclass Creator sinon asdict()/désérialisation ne portera pas la bannière\n - **creaka/db.py** (LU) : `_write_creator` (point d'insertion des snapshots), `get_creator`, `_public_dict`\n - **creaka/web.py** (LU) : `get_creator` endpoint (à enrichir avec history/insights)\n - **frontend/src/index.template.html** (1619 lignes, PAS ENCORE LU) : rendu fiche créateur à enrichir puis rebuild via `scripts/build_frontend.py`\n\n4. Errors and fixes:\n - **`apify push --no-prompt` : flag inexistant** → les 8 push ont échoué ; corrigé en `apify push --force` (7/8 OK)\n - **ka-discord push FAIL : « Some other Actor already has this name (\"ka-discord\") »** — malgré `--force`. Comparaison actor.json avec ka-patreon (OK) : structurellement identiques. NON RÉSOLU — piste : pousser en ciblant l'ID explicite (actor ID plateforme : `5uRKpcBDnoumIPe20`), ex. renommer/lier ou utiliser l'API build directe.\n - **`\"playback_url\": None` inutile ajouté dans ka-kick** → retiré aussitôt (auto-correction)\n - Gotcha connu anticipé : URLs CDN Instagram signées expirent → justifie le rafraîchissement de l'avatar à chaque passage depuis la plateforme principale (au lieu de « seulement si manquant »)\n\n5. Problem Solving:\n - Cartographie complète du pipeline via agent Explore + lectures ciblées (13 acteurs, pipeline, DB, API)\n - Diagnostic précis des lacunes : miniatures manquantes (TikTok covers, YouTube thumbnails, Twitch previews, X media_urls), bannières capturées mais jamais consommées, avatar jamais rafraîchi, liens sociaux Twitch (GQL socialMedias)/Patreon/YouTube-about non exploités, zéro historisation de métriques\n - Design de l'historisation : table `snapshots (day, platform, handle, creator_id, followers, engagement, PK(day,platform,handle))` écrite dans `_write_creator` ; insights créateur (croissance 7j/30j, engagement moyen pondéré, Ka Score composite ~ audience 40 pts log10, engagement 25, multi-plateformes 20, activité 10, vérifié 5) exposés via `get_creator`\n - RISQUE À VALIDER : le champ GQL `socialMedias`/`previewImageURL` sur Twitch n'est pas confirmé — un run de test est indispensable (si champ invalide → data.user null → tous not_found)\n\n6. All user messages:\n - « J'ai remarquer que beaucoup de créateur dans crea-ka on des info pas trop complète pour les différent média sociaux. Je veux que tu fasse un major upgrade des apify actor pour qu'il capture le plus info possible et présente plus image . De plus pour chaque créateur québécois tu dois ajouté des metric et stats sur celui ci ça doit être légendaire »\n\n7. Pending Tasks (todo list) :\n 1. ✅ Tirer les 13 acteurs (apify pull)\n 2. ✅ Analyse pipeline + acteurs\n 3. ✅ Upgrade acteurs majeurs (tiktok, youtube, x, twitch)\n 4. ✅ (code fait) Upgrade acteurs secondaires (kick, snapchat, patreon, discord)\n 5. 🔄 Push + tests : 7/8 poussés ; **réparer le push ka-discord** ; **runs de test de validation** (surtout ka-twitch GQL socialMedias, ka-youtube /about)\n 6. 🔄 Pipeline apify_social.py : base apply() faite + IG ; **reste metric_maps étendus** (tiktok: videos_per_week/last_video_at/top_hashtags/language/bio_link ; x: is_blue_verified/likes_given/reply_share_pct/website ; youtube: total_views/joined_date/external_links + bio_urls ; twitch: live_title/live_thumbnail/last_broadcast_title/social_links + bio_urls ; kick: live_title/live_thumbnail/live_category/vod_enabled ; snapchat: story_previews/spotlight_previews/website ; patreon: is_nsfw/creation_name/social_links + bio_urls ; discord: channel/splash) + **ajouter banner_url au dataclass Creator (schema.py)** — indispensable\n 7. ⬜ db.py : table snapshots + écriture dans _write_creator + fonction follower_history ; stats.py : creator_insights (Ka Score) ; web.py : enrichir get_creator (history + insights)\n 8. ⬜ Frontend index.template.html : bannière héro, cartes stats, galerie d'images (recent_posts/videos avec covers/thumbnails), sparkline SVG ; rebuild `python scripts/build_frontend.py`\n 9. ⬜ Redéploiement (pm2 restart crea-ka + crea-ka-sync), healthcheck (GET /healthz, /api/creators/{cid}), commit + push origin main (en-têtes auteur sur tout fichier)\n\n8. Current Work:\n Deux fronts simultanés :\n - **Push Apify** : 7/8 acteurs poussés OK avec `apify push --force`. Je venais d'inspecter l'échec ka-discord (« Some other Actor already has this name ») et de comparer `ka-discord/.actor/actor.json` avec `ka-patreon/.actor/actor.json` (identiques structurellement, version 0.1, buildTag latest) — la cause n'est pas encore identifiée.\n - **Pipeline** : `creaka/connectors/apify_social.py` — la méthode base `apply()` est réécrite (avatar/banner par compte dans metrics, rafraîchissement avatar/bannière fiche depuis la plateforme principale via `cr.banner_url`), et le metric_map d'ApifyInstagram vient d'être étendu (pronouns, bio_links). Les metric_maps des autres plateformes ne sont pas encore étendus, et `banner_url` n'existe pas encore dans le dataclass `Creator` (schema.py) — c'est un prérequis pour que `cr.banner_url` soit sérialisé dans le doc JSON.\n\n9. Optional Next Step:\n Continuer exactement là où j'étais (todo 5-6 en cours) :\n 1. **Ajouter `banner_url: str | None = None` dans le dataclass Creator** de `creaka/schema.py` (sinon la bannière ne sera jamais persistée dans le doc) — juste après `avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)`.\n 2. **Étendre les metric_maps restants** dans apify_social.py (TikTok, X, YouTube+bio_urls externes, Twitch+bio_urls social_links, Kick, Snapchat, Patreon+bio_urls, Discord, OnlyFans website) conformément à la liste du point 7.6.\n 3. **Réparer le push ka-discord** (erreur « Some other Actor already has this name » — essayer de lier via l'ID plateforme `5uRKpcBDnoumIPe20` ou vérifier `apify info`/état local du répertoire).\n 4. **Lancer des runs de test** des acteurs poussés (priorité ka-twitch pour valider les champs GQL `socialMedias`/`previewThumbnailURL`, et ka-youtube pour `/about` + bannière), puis poursuivre avec snapshots DB, insights/Ka-Score, frontend, rebuild, pm2 restart, commit + push.\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca.jsonl\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with \"I'll continue\" or similar. Pick up the last task as if the break never happened."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5b565a63-7e8a-490f-a5fc-023306562bf3","timestamp":"2026-08-21T01:05:44.249Z","isSynthetic":true}} |
|
359 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzjMmuyZ4KcoGncftxE","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISuQMKpAEIERgCKkAx5Q6jFI9Rb4QDtDegL/7rRoT3DvQcJqKRe+z5CKzZI3WdWdBlx+eeszFnnN24pos3quDRG78mC7E06DAcEY7oMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHsyJ7UBhIMIcQQfbIHQmW19ZbIGgzvbpDzWFPs6PwBEJYiMK3Ud+F59/RGD8djuL5Ybb9rfpMwbdzM6yBDHjwQ3+BK+wWsgMbV7NfRINan7H/WbSrBAcA1rELVFhHUJyRwmHUbiTa4DDmCmLmV1X5pOAPWeGAMlvMIOYAvpEUDsN9XPsl4PAIQEh3Ik8e/aF0+JeD/G3KkUbb589KSw/QChCJL+RVwvt+zZPWO9KqorOPsJ5/xiCbHswaMBHEpJBUC8frI6kijX4egiGO87aLvAjgYOuFWi9DPUPtDXS0p1kDO7gdu/Un6dqPn3Dw0SGg5xqj8FnY21Pqyzl197Gba2Mn/NaSG9vfHxRm3d4pw0uzL3SwwSVwYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":32370,"cache_read_input_tokens":21857,"cache_creation":{"ephemeral_5m_input_tokens":32370,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a9732c4a-5adf-4d37-9eee-de8ae0ac4ba1"}} |
|
360 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzjMmuyZ4KcoGncftxE","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01J9S8wQRSvLqyUdmURZnk5u","name":"Grep","input":{"pattern":"avatar_url|banner_url","path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","output_mode":"content","-n":true,"-C":4},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":32370,"cache_read_input_tokens":21857,"cache_creation":{"ephemeral_5m_input_tokens":32370,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4ebcdb71-9456-4b3a-9caa-0987c1c4216c"}} |
|
361 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01J9S8wQRSvLqyUdmURZnk5u","type":"tool_result","content":"98- languages: list[str] = field(default_factory=list)\n99- primary_platform: str = \"\"\n100- platforms: list[PlatformAccount] = field(default_factory=list)\n101- link_in_bio_url: str | None = None\n102: avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n103- total_reach: int | None = None\n104- audience_tier: str = \"\"\n105- business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n106- agency: str | None = None"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bfa96475-1099-4193-999d-2838a88fc5af","timestamp":"2026-08-21T01:05:49.693Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"98- languages: list[str] = field(default_factory=list)\n99- primary_platform: str = \"\"\n100- platforms: list[PlatformAccount] = field(default_factory=list)\n101- link_in_bio_url: str | None = None\n102: avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n103- total_reach: int | None = None\n104- audience_tier: str = \"\"\n105- business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n106- agency: str | None = None","numLines":9}}} |
|
362 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzjMmuyZ4KcoGncftxE","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013f8P5LJMKEC4TX5ornuJKi","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors/ka-discord && apify info 2>&1 | head -20 && cat .actor/actor.json","description":"Check apify auth and ka-discord actor.json"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":32370,"cache_read_input_tokens":21857,"cache_creation":{"ephemeral_5m_input_tokens":32370,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"72408fde-4fbf-4274-a13d-bb2ff0de20d3"}} |
|
363 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013f8P5LJMKEC4TX5ornuJKi","type":"tool_result","content":"username: gorgeous_thistle\nuserId: RaDVymgJGNzUYrt1b\n{\n \"actorSpecification\": 1,\n \"name\": \"ka-discord\",\n \"title\": \"KA Discord — serveurs via invitation\",\n \"description\": \"Serveurs Discord via l'API publique d'invitation : membres approximatifs, en ligne, boosts, nom/description. Code d'invit sensible à la casse.\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7652c10d-4616-4915-bb79-7905cdb4dad2","timestamp":"2026-08-21T01:05:52.396Z","tool_use_result":{"stdout":"username: gorgeous_thistle\nuserId: RaDVymgJGNzUYrt1b\n{\n \"actorSpecification\": 1,\n \"name\": \"ka-discord\",\n \"title\": \"KA Discord — serveurs via invitation\",\n \"description\": \"Serveurs Discord via l'API publique d'invitation : membres approximatifs, en ligne, boosts, nom/description. Code d'invit sensible à la casse.\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
364 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0ccc7f19-96b5-4d4e-9266-d7d866534b60","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
365 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzk4Udyi6rPdgizcr4Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Dp81qjuCoSSKv4gaJn8PT7","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","old_string":" avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)","new_string":" avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n banner_url: str | None = None # bannière/cover PUBLIQUE (URL source)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":823,"cache_read_input_tokens":54227,"cache_creation":{"ephemeral_5m_input_tokens":823,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"609b11f8-dde7-49aa-814e-af49cf014c81"}} |
|
366 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>","is_error":true,"tool_use_id":"toolu_01Dp81qjuCoSSKv4gaJn8PT7"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"49948d63-6f80-461b-8131-05f53ca2635a","timestamp":"2026-08-21T01:05:58.816Z","tool_use_result":"Error: File has not been read yet. Read it first before writing to it."}} |
|
367 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzk4Udyi6rPdgizcr4Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SaZ87n5AjtqJzmiZadjGWb","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors/ka-discord && ls -la && curl -s \"https://api.apify.com/v2/acts/gorgeous_thistle~ka-discord\" -H \"Authorization: Bearer $APIFY_TOKEN\" | python3 -c \"import json,sys; d=json.load(sys.stdin)['data']; print(d['id'], d['name'], d.get('taggedBuilds',{}).keys() if 'taggedBuilds' in d else '')\"","description":"List ka-discord dir and fetch actor ID from API"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":823,"cache_read_input_tokens":54227,"cache_creation":{"ephemeral_5m_input_tokens":823,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9ae7e214-86bc-4c30-9b71-de611037ea07"}} |
|
368 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SaZ87n5AjtqJzmiZadjGWb","type":"tool_result","content":"total 8\ndrwxr-xr-x@ 5 simon-pierreboucher staff 160 Aug 20 20:54 .\ndrwxr-xr-x@ 15 simon-pierreboucher staff 480 Aug 20 20:54 ..\ndrwxr-xr-x@ 5 simon-pierreboucher staff 160 Aug 20 20:54 .actor\n-rw-r--r--@ 1 simon-pierreboucher staff 21 Aug 20 20:54 requirements.txt\ndrwxr-xr-x@ 6 simon-pierreboucher staff 192 Aug 20 21:02 src\n5uRKpcBDnoumIPe20 ka-discord dict_keys(['latest'])","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ae34a80e-147d-4e67-82c9-e94bece405d2","timestamp":"2026-08-21T01:06:01.309Z","tool_use_result":{"stdout":"total 8\ndrwxr-xr-x@ 5 simon-pierreboucher staff 160 Aug 20 20:54 .\ndrwxr-xr-x@ 15 simon-pierreboucher staff 480 Aug 20 20:54 ..\ndrwxr-xr-x@ 5 simon-pierreboucher staff 160 Aug 20 20:54 .actor\n-rw-r--r--@ 1 simon-pierreboucher staff 21 Aug 20 20:54 requirements.txt\ndrwxr-xr-x@ 6 simon-pierreboucher staff 192 Aug 20 21:02 src\n5uRKpcBDnoumIPe20 ka-discord dict_keys(['latest'])","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
369 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6720884d-e823-4fb4-8289-e3d6fdafcfe0","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
370 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzkc3SXJauFyZsVZCZZ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStAUKpAEIERgCKkBSGXMTP50CdLeIRvG+hNDcKY9futJhyxoMvmA/mTYZ5Iw+3denkGM0Pav4jX8CZi+0u6wCxeW5nkHsjlRESv/BMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH/yJ7UBhIMeZ/FhMy8bgShD7cqGgzxK4ZqpbSX2ccvp7kiMF6LB4+fOSFUH3cptJRLiMENNBO9TNMLVvWiwwE+RVFEsykC43oLsGE7qmKegN3uVSq8Ay6A54ZRQTGTF2KR8hmCwl/l2Fa/PIqs0U9aryMKGW1CWCKRs+OoaZ3pWPVRgQJbKslfz/0GccVAq0pQwIRkYU+cjGnESy+wZ9iOlI9uDhBNbxcblqd+l/j9LSIy7juc5/A6/WxsqAPw11geLLZARedyC1TMEqTFVNDX6E7b6W5h1Vep9ydQnCRHBhrtnkG/v9y5s9wASS61wnMg5+8Fgkvxl1pPw2mq0jtAfGGfyCDZs6FIsDueCooKTWkgnC/Zmr0Qm9QChcBon9xKEp/W/tNjnoQOJsPlui7p4Fl/mBn72S8cC74JIIawUwRZ6kJ6hGqWqWQeeGnWhobLbe81jKbj2AbwEw12vYOvGV+B/exrSbe9CyPLZjpK20e3HT2vZKL23AonZjAOWQpEQPfJwo+a8j7rjFNEIfCo7jBGLjYzGjEn7SAh3jIVYaxfQ1KEuQPSZBe79EggMXKQL17iColE4Bz/rZPJ3SAcMxy0H58mJr+zsYuF9GZs1WWRaCZFRWnhW1yIh5yEUitzubEAbjtBl2FySy5K+UacheUvE1SOZ8gXue9H1fsV88xj9FFlFycSff8KU1zPaV3szBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":822,"cache_read_input_tokens":55050,"cache_creation":{"ephemeral_5m_input_tokens":822,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d54e6c2c-9eb3-44ce-ab4b-1a19ac0a55b6"}} |
|
371 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzkc3SXJauFyZsVZCZZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018r2dDjgWmVp2Zds8ytKeR8","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","offset":90,"limit":30},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":822,"cache_read_input_tokens":55050,"cache_creation":{"ephemeral_5m_input_tokens":822,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8d83413a-2806-46b3-9928-e25f73466f46"}} |
|
372 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018r2dDjgWmVp2Zds8ytKeR8","type":"tool_result","content":"90\t external_id: str # identifiant chez la source\n91\t display_name: str # * nom public / nom de scène\n92\t legal_name: str | None = None # SEULEMENT si rendu public par le créateur\n93\t bio: str = \"\"\n94\t niches: list[str] = field(default_factory=list)\n95\t creator_type: str = \"\"\n96\t region: str | None = None # une des 17 régions §7, sinon None\n97\t city: str | None = None\n98\t languages: list[str] = field(default_factory=list)\n99\t primary_platform: str = \"\"\n100\t platforms: list[PlatformAccount] = field(default_factory=list)\n101\t link_in_bio_url: str | None = None\n102\t avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n103\t total_reach: int | None = None\n104\t audience_tier: str = \"\"\n105\t business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n106\t agency: str | None = None\n107\t is_minor: bool = False\n108\t status: str = \"active\" # active | inactive | opted_out\n109\t notes: str = \"\" # contexte public (listes médias), affichable\n110\t source_ids: list[str] = field(default_factory=list)\n111\t\n112\t @property\n113\t def uid(self) -> str:\n114\t return f\"{self.source}:{self.external_id}\"\n115\t\n116\t def content_hash(self) -> str:\n117\t \"\"\"Hash du contenu pour la détection de changements.\"\"\"\n118\t payload = asdict(self)\n119\t for acc in payload[\"platforms\"]:"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0c48eea6-8db0-446b-b596-269339d3019e","timestamp":"2026-08-21T01:06:08.082Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","content":" external_id: str # identifiant chez la source\n display_name: str # * nom public / nom de scène\n legal_name: str | None = None # SEULEMENT si rendu public par le créateur\n bio: str = \"\"\n niches: list[str] = field(default_factory=list)\n creator_type: str = \"\"\n region: str | None = None # une des 17 régions §7, sinon None\n city: str | None = None\n languages: list[str] = field(default_factory=list)\n primary_platform: str = \"\"\n platforms: list[PlatformAccount] = field(default_factory=list)\n link_in_bio_url: str | None = None\n avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n total_reach: int | None = None\n audience_tier: str = \"\"\n business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n agency: str | None = None\n is_minor: bool = False\n status: str = \"active\" # active | inactive | opted_out\n notes: str = \"\" # contexte public (listes médias), affichable\n source_ids: list[str] = field(default_factory=list)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements.\"\"\"\n payload = asdict(self)\n for acc in payload[\"platforms\"]:","numLines":30,"startLine":90,"totalLines":192}}}} |
|
373 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzkc3SXJauFyZsVZCZZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YETnjmKyYUkWzAZcXYvQeP","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors/ka-discord && apify push 5uRKpcBDnoumIPe20 --force 2>&1 | tail -5","description":"Push ka-discord targeting explicit actor ID","run_in_background":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":822,"cache_read_input_tokens":55050,"cache_creation":{"ephemeral_5m_input_tokens":822,"ephemeral_1h_input_tokens":0},"output_tokens":8,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bb7314a3-377d-4076-a443-2afbf6ad3025"}} |
|
374 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"b7e9npu8h","tool_use_id":"toolu_01YETnjmKyYUkWzAZcXYvQeP","description":"Push ka-discord targeting explicit actor ID","task_type":"local_bash","uuid":"804832e4-ee1d-4cac-aa62-1f467d3a3b84","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
375 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YETnjmKyYUkWzAZcXYvQeP","type":"tool_result","content":"Command running in background with ID: b7e9npu8h. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/b7e9npu8h.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f1ab6499-a7f7-4f36-bac3-2e942d5ba83c","timestamp":"2026-08-21T01:06:09.193Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"b7e9npu8h"}}} |
|
376 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e689a625-6fa4-45b6-a90f-b4c24f44a276","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
377 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzmBpSbQUM9kVrhrdCB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q91xb9LJKJjfofK9qChFBk","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","old_string":" avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n total_reach: int | None = None","new_string":" avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n banner_url: str | None = None # bannière/cover PUBLIQUE (URL source)\n total_reach: int | None = None"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1151,"cache_read_input_tokens":55872,"cache_creation":{"ephemeral_5m_input_tokens":1151,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"583c49b2-ced2-4a3d-8e53-83e31dfd49ba"}} |
|
378 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q91xb9LJKJjfofK9qChFBk","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"23524f93-d45b-42d4-aa7f-41776c00b42d","timestamp":"2026-08-21T01:06:14.026Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py","oldString":" avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n total_reach: int | None = None","newString":" avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n banner_url: str | None = None # bannière/cover PUBLIQUE (URL source)\n total_reach: int | None = None","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/schema.py\n# Desc: Modèle de données standardisé — Creator + PlatformAccount (CLAUDE.md §5)\n# + normalisation centrale via finalize()\n# ==============================================================================\n\"\"\"Schéma standard d'une fiche créateur et de ses comptes de plateformes.\n\nChaque connecteur, peu importe la source, doit produire des objets `Creator`\nconformes. `finalize()` applique la couche commune (creaka/normalize.py) :\nhandles nettoyés, URLs canoniques, niches/région/langue/tier — les connecteurs\nrestent simples et remplissent les champs bruts.\n\nValidation stricte (CLAUDE.md §18) : un compte sans `url` ou sans `confidence`\nest REJETÉ à la finalisation.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nfrom dataclasses import dataclass, field, asdict\nfrom datetime import datetime, timezone\n\nfrom .normalize import (\n LANGUAGES,\n NICHES,\n PLATFORMS,\n audience_tier,\n canonical_url,\n clean_bio,\n clean_handle,\n clean_name,\n guess_language,\n infer_creator_type,\n map_niche,\n normalize_platform,\n slugify,\n)\n\n__all__ = [\"Creator\", \"PlatformAccount\", \"now_iso\"]\n\n\ndef now_iso() -> str:\n return datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n\n\n@dataclass\nclass PlatformAccount:\n \"\"\"Un compte public d'un créateur sur UNE plateforme (§5).\"\"\"\n\n platform: str # * canonique §6.2\n handle: str # * sans @, minuscules\n url: str = \"\" # * forme canonique\n followers: int | None = None # abonnés (None = inconnu)\n verified: bool | None = None # badge vérifié (None = inconnu)\n confidence: float | None = None # * confiance du rattachement (§12)\n last_checked: str = \"\" # * ISO-8601\n signal: str = \"\" # signal d'origine du rattachement (§12.1)\n metrics: dict = field(default_factory=dict) # métriques étendues publiques :\n # following, posts, likes, views, videos, category… (selon la plateforme)\n\n def finalize(self) -> \"PlatformAccount\":\n self.platform = normalize_platform(self.platform)\n self.handle = clean_handle(self.handle)\n self.url = canonical_url(self.platform, self.handle, fallback=self.url)\n if not self.handle:\n raise ValueError(\"compte sans handle\")\n if not self.url:\n raise ValueError(f\"compte {self.platform}:{self.handle} sans url\")\n if self.confidence is None:\n raise ValueError(f\"compte {self.platform}:{self.handle} sans confidence\")\n self.confidence = max(0.0, min(1.0, float(self.confidence)))\n if self.followers is not None:\n self.followers = max(0, int(self.followers))\n if not self.last_checked:\n self.last_checked = now_iso()\n return self\n\n @property\n def key(self) -> str:\n \"\"\"Clé d'unicité d'un compte : plateforme + handle normalisé.\"\"\"\n return f\"{self.platform}:{self.handle}\"\n\n\n@dataclass\nclass Creator:\n \"\"\"Fiche créateur canonique Créa-Ka (CLAUDE.md §5).\"\"\"\n\n source: str # id de la source (registre data/sources.json)\n external_id: str # identifiant chez la source\n display_name: str # * nom public / nom de scène\n legal_name: str | None = None # SEULEMENT si rendu public par le créateur\n bio: str = \"\"\n niches: list[str] = field(default_factory=list)\n creator_type: str = \"\"\n region: str | None = None # une des 17 régions §7, sinon None\n city: str | None = None\n languages: list[str] = field(default_factory=list)\n primary_platform: str = \"\"\n platforms: list[PlatformAccount] = field(default_factory=list)\n link_in_bio_url: str | None = None\n avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)\n total_reach: int | None = None\n audience_tier: str = \"\"\n business_contact: str | None = None # courriel PRO affiché publiquement (§15)\n agency: str | None = None\n is_minor: bool = False\n status: str = \"active\" # active | inactive | opted_out\n notes: str = \"\" # contexte public (listes médias), affichable\n source_ids: list[str] = field(default_factory=list)\n\n @property\n def uid(self) -> str:\n return f\"{self.source}:{self.external_id}\"\n\n def content_hash(self) -> str:\n \"\"\"Hash du contenu pour la détection de changements.\"\"\"\n payload = asdict(self)\n for acc in payload[\"platforms\"]:\n acc.pop(\"last_checked\", None) # horodatage ≠ changement de contenu\n blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)\n return hashlib.sha256(blob.encode(\"utf-8\")).hexdigest()\n\n def finalize(self) -> \"Creator\":\n \"\"\"Applique la normalisation commune. Appelé par le pipeline d'ingestion.\n\n Idempotent ; ne remplace jamais une valeur explicite du connecteur.\n Rejette la fiche si aucun compte valide (un créateur sans lien n'a pas\n sa place dans un annuaire de liens).\n \"\"\"\n self.display_name = clean_name(self.display_name)\n if not self.display_name:\n raise ValueError(\"créateur sans nom d'affichage\")\n if not self.external_id:\n self.external_id = slugify(self.display_name)\n self.bio = clean_bio(self.bio)\n\n # comptes : normaliser, valider, dédupliquer (meilleure confiance gagne)\n seen: dict[str, PlatformAccount] = {}\n for acc in self.platforms:\n try:\n acc = acc.finalize()\n except ValueError:\n continue # un compte invalide ne bloque pas la fiche (§18 : rejeté)\n prev = seen.get(acc.key)\n if prev is None or (acc.confidence or 0) > (prev.confidence or 0):\n if prev is not None and prev.followers and not acc.followers:\n acc.followers = prev.followers\n if prev is not None:\n acc.metrics = {**prev.metrics, **acc.metrics}\n seen[acc.key] = acc\n # tri : abonnés décroissants (None en dernier), pour primary_platform\n self.platforms = sorted(\n seen.values(), key=lambda a: (a.followers is None, -(a.followers or 0)))\n if not self.platforms:\n raise ValueError(f\"{self.display_name}: aucun compte valide\")\n\n self.niches = [map_niche(n) for n in (self.niches or [\"autre\"])]\n self.niches = sorted({n for n in self.niches if n in NICHES}) or [\"autre\"]\n\n if not self.primary_platform:\n self.primary_platform = self.platforms[0].platform\n self.primary_platform = normalize_platform(self.primary_platform)\n if self.primary_platform not in PLATFORMS:\n self.primary_platform = self.platforms[0].platform\n\n if not self.languages:\n self.languages = [guess_language(self.bio, self.notes)]\n self.languages = [l for l in self.languages if l in LANGUAGES] or [\"fr\"]\n\n if not self.creator_type:\n self.creator_type = infer_creator_type(\n [a.platform for a in self.platforms], self.niches[0])\n\n # portée totale : somme indicative des audiences connues\n known = [a.followers for a in self.platforms if a.followers]\n self.total_reach = sum(known) if known else None\n primary = next((a.followers for a in self.platforms\n if a.platform == self.primary_platform and a.followers), None)\n if not self.audience_tier:\n self.audience_tier = audience_tier(primary or (max(known) if known else None))\n\n if not self.source_ids:\n self.source_ids = [self.uid]\n\n # régime restreint pour les mineurs (§15) — appliqué aussi dans ethics.py\n if self.is_minor:\n self.business_contact = None\n self.city = None\n self.region = None\n return self\n","structuredPatch":[{"oldStart":100,"oldLines":6,"newStart":100,"newLines":7,"lines":[" platforms: list[PlatformAccount] = field(default_factory=list)"," link_in_bio_url: str | None = None"," avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)","+ banner_url: str | None = None # bannière/cover PUBLIQUE (URL source)"," total_reach: int | None = None"," audience_tier: str = \"\""," business_contact: str | None = None # courriel PRO affiché publiquement (§15)"]}],"userModified":false,"replaceAll":false}}} |
|
379 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"51b998df-2761-4ac7-a22f-bc2cf9431c6b","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
380 |
+{"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"b7e9npu8h","patch":{"status":"completed","end_time":1787274376298},"uuid":"9ee40f08-a5e2-47c7-a919-00e0b35c16ef","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
381 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzmYYNnKhR8uENvueyq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/gQKpAEIERgCKkA8egkU3XsBrznk6ldAhCN0lgMXFtWp11a8IlipukEgklyFxOgaMhSeNY2ie3ornyhp5z4LUeqW5MoL4VklgJ89Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGMyZ7UBhIMNi1AzYUjFzsnyAMCGgyb6TQRtz3GYf8fVcciMM06yVhDqfjozohSILAlj0QVEsoAKpX/eLxczW+KuF4orR4l4awMePATBFGKm+Zy0yqGA0He0G4GrZsob8mRtOeZrNFmqRnPBqgzYV0KC6Emp4zELzA7Xu1mU9g3v9DgUWldfBQxaVB1O4XyP2aYtToLwKxFexkY0ydPQXDmeB7Y6x6+RGy4tKmOxYRY629IdXYYlvcTRTmKE0cLN3DQ4TPOlLiDHY7unG8bWczf/nvULaLJ5fpFt8maveFxb7SBSWW6yo9t2zz6R9EX2bgHHNcs60hU8XkQKEzhI3Fy2Q6ubzFsf4NpwjzW/Jg8Eo3WNTk8ET63na/7EwCRFRiFIGPSRtMn/2ZClxbTqzurw0iLGe1byd7mYbQgp7tQucgnuYtTQkwglCITrSaVVDr2Z7gGL0yt5Elkr2xdq57fpNJaIXO+zmWtMIngXKslF1Rh8iAqR+5F09hakgooylAeRd2zZA/8277hEW5WkmcE1lfjhoBrgDmCbWNcLl6QjU/wo0InRvRAXjAAJ79PuNdxPZpg0tQbpS4EU0Xr5/gP+1FSzULufuaGVSo5oeyA3sK5/9gXrbmu3DnhzBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":314,"cache_read_input_tokens":57023,"cache_creation":{"ephemeral_5m_input_tokens":314,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c3ca30f5-0376-4fdb-9fa8-f1279116cb4f"}} |
|
382 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzmYYNnKhR8uENvueyq","type":"message","role":"assistant","content":[{"type":"text","text":"banner_url ajouté au schéma. Je vérifie la désérialisation des docs existants puis j'étends les metric_maps."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":314,"cache_read_input_tokens":57023,"cache_creation":{"ephemeral_5m_input_tokens":314,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4f95384b-8e7a-461a-beeb-ab0ee7c26052"}} |
|
383 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzmYYNnKhR8uENvueyq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DU9rixaVRjnWRatvaxRWuj","name":"Grep","input":{"pattern":"def _creator_from_doc|Creator\\(\\*\\*|from_doc","path":"/Users/simon-pierreboucher/apps/crea-ka/creaka","output_mode":"content","-n":true,"-C":3},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":314,"cache_read_input_tokens":57023,"cache_creation":{"ephemeral_5m_input_tokens":314,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"113fe49a-17fd-4679-b4f6-9bb41fd3e9fc"}} |
|
384 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DU9rixaVRjnWRatvaxRWuj","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-101-def creator_from_row(row: sqlite3.Row) -> Creator:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-102- doc = json.loads(row[\"doc\"])\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-103- doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:104: cr = Creator(**doc)\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-105- return cr\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-106-\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-107-"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5366c39b-21d6-4bb9-b2b1-74a002b3a8a1","timestamp":"2026-08-21T01:06:21.530Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-101-def creator_from_row(row: sqlite3.Row) -> Creator:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-102- doc = json.loads(row[\"doc\"])\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-103- doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:104: cr = Creator(**doc)\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-105- return cr\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-106-\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py-107-","numLines":7}}} |
|
385 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6c318931-a282-486e-bad7-a7ff6558020a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
386 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzn6gQ7uZ6Y5etchWdD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XHAJnDx6BDrt5DJG3XWsoT","name":"Bash","input":{"command":"tail -8 /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/b7e9npu8h.output","description":"Read ka-discord push output"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":997,"cache_read_input_tokens":57337,"cache_creation":{"ephemeral_5m_input_tokens":997,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"49d9ceba-49ba-4c53-ad3d-1c93ce7996fa"}} |
|
387 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XHAJnDx6BDrt5DJG3XWsoT","type":"tool_result","content":"Build ID: 7cWckjmHs9kw7kJkb\nBuild number: 0.1.2\n\nActor URL: https://console.apify.com/actors/5uRKpcBDnoumIPe20\nBuild URL: https://console.apify.com/actors/5uRKpcBDnoumIPe20#/builds/0.1.2","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9d963bad-9d45-4e71-94b6-eb0c4ea83a7f","timestamp":"2026-08-21T01:06:28.170Z","tool_use_result":{"stdout":"Build ID: 7cWckjmHs9kw7kJkb\nBuild number: 0.1.2\n\nActor URL: https://console.apify.com/actors/5uRKpcBDnoumIPe20\nBuild URL: https://console.apify.com/actors/5uRKpcBDnoumIPe20#/builds/0.1.2","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
388 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8f1dea1a-d7ce-45d4-8866-43784f0f9d1a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
389 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEznb1UtyWErCRTf86ny","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS+QQKpAEIERgCKkC63+NLRu8wfLm332xmJ4HJMPTUNHZPJprwhmEM4lvgfFVIogbMzUSFKTmmETeqJy3KTkztFuGTOmRs4ydB1U9+Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGZyZ7UBhIMkFYRqAFxx2578eHqGgxM0wyQvbDc7REgVwwiMH7xxGtY8sCUXx9UhaUKt3gSDTkvlMphGjuL75S8gOnnPYyOr0SUb13nt3baWnSi7iqBAytR7DUYHIH6LzVYsdWWG9aqxD6CSTXMbIXj6O7VPkJH0YS7LDC18zoEfkpIlXehM6GzmWzFHabMDQEkNJZkSNmAtmaNAy+LiVCmcgJ+8njpxy9uiusa/q5p6wP97MEx72lHJHYNKrxDbDgHd7kC0acjBrKHrn7uN57y/2ESHKS8RdvzJgbJ07ueT9YAO9TG2StIZz46BjVBl1WU2WUn3M4wCE3sDWuuAXYO1Sym2tncmRV1YfKvN9AUnrOCoZKNZLrFWr6bD08STPNmEky5mDlPeitSDnLHUkk0nR78iwCWUHHueQaxASawHJzrpkWgzTHGDOc8S6xc+EbesiFrocuGyROlWpUP6eZvvQmkVIDL7D3sRzNDIcXtrK+aJqtMCj/gO2K/UElw4SaBFxGEkBQqZ9x/TlbIHPyxDVMmrI03ZDixpOaRINm++q6/tbipGDbg496Tp6thcRMZTLBTzL0Z94lGtR6/ZJoA0S5zg23TnVaeyfz18f6TvNhWvhFsK7kYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":821,"cache_read_input_tokens":58334,"cache_creation":{"ephemeral_5m_input_tokens":821,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ad9af1a8-958f-41d9-8338-1d5b18b79265"}} |
|
390 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEznb1UtyWErCRTf86ny","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018AnA7BpCvszHa7zniY1Rmg","name":"ToolSearch","input":{"query":"select:TodoWrite","max_results":1},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":821,"cache_read_input_tokens":58334,"cache_creation":{"ephemeral_5m_input_tokens":821,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4947f966-3a44-4a80-a08f-10ce7f5e67e9"}} |
|
391 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_018AnA7BpCvszHa7zniY1Rmg","content":[{"type":"tool_reference","tool_name":"TodoWrite"}]}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3f4509ec-f7cd-4cd6-8a86-b528c0fa6206","timestamp":"2026-08-21T01:06:33.928Z","tool_use_result":{"matches":["TodoWrite"],"query":"select:TodoWrite","total_deferred_tools":17}}} |
|
392 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEznb1UtyWErCRTf86ny","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XBsckTmPWN1EKjkDubMFz4","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":821,"cache_read_input_tokens":58334,"cache_creation":{"ephemeral_5m_input_tokens":821,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5fc677b5-97da-4e11-85bc-103d3ab28942"}} |
|
393 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XBsckTmPWN1EKjkDubMFz4","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/connectors/apify_social.py\n4\t# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n5\t# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n6\t# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n7\t# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n8\t# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n9\t# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n10\t# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n11\t# résidentielles + empreinte TLS Chrome côté acteur).\n12\t# ==============================================================================\n13\t\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n14\t\n15\tChaque connecteur d'enrichissement :\n16\t1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n17\t que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n18\t2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n19\t (proxy résidentiel Apify forcé) et attend le dataset ;\n20\t3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n21\t récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n22\t `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n23\t\n24\tNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\n25\tUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n26\t\"\"\"\n27\tfrom __future__ import annotations\n28\t\n29\timport os\n30\timport random\n31\timport time\n32\tfrom concurrent.futures import ThreadPoolExecutor, as_completed\n33\tfrom datetime import datetime, timedelta, timezone\n34\t\n35\timport requests\n36\t\n37\tfrom ..identity import account, merge_accounts\n38\tfrom ..normalize import parse_count, platform_from_url\n39\tfrom ..schema import Creator, now_iso\n40\tfrom .base import BaseConnector, SkipSource\n41\tfrom .linkinbio import is_supported as is_linkinbio\n42\t\n43\tAPIFY_API = \"https://api.apify.com/v2\"\n44\tACTOR_OWNER = \"gorgeous_thistle\"\n45\tRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n46\t\n47\t# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n48\t# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n49\t# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n50\t# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n51\t# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n52\t# (large sous les 256 Go / 128 runs concurrents du plan).\n53\tSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\n54\tMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\n55\tRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\n56\tRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n57\t# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n58\t# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\n59\tREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n60\t\n61\t\n62\tdef _token() -> str:\n63\t token = os.environ.get(\"APIFY_TOKEN\")\n64\t if not token:\n65\t raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n66\t return token\n67\t\n68\t\n69\tdef _launch(name: str, run_input: dict, memory_mb: int,\n70\t retries: int = 6) -> str:\n71\t \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n72\t elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n73\t headers = {\"Authorization\": f\"Bearer {_token()}\"}\n74\t for attempt in range(retries):\n75\t resp = requests.post(\n76\t f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n77\t json=run_input, headers=headers,\n78\t params={\"memory\": memory_mb}, timeout=60)\n79\t if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n80\t if attempt < retries - 1:\n81\t # backoff + jitter : désynchronise les tranches (évite le\n82\t # lockstep où toutes redemandent la RAM au même instant)\n83\t time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n84\t continue\n85\t resp.raise_for_status()\n86\t return resp.json()[\"data\"][\"id\"]\n87\t resp.raise_for_status()\n88\t return resp.json()[\"data\"][\"id\"]\n89\t\n90\t\n91\tdef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n92\t headers = {\"Authorization\": f\"Bearer {_token()}\"}\n93\t t0 = time.time()\n94\t while True:\n95\t time.sleep(15)\n96\t r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n97\t headers=headers, timeout=60)\n98\t r.raise_for_status()\n99\t status = r.json()[\"data\"]\n100\t if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n101\t break\n102\t if time.time() - t0 > timeout_s:\n103\t requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n104\t headers=headers, timeout=60)\n105\t raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n106\t if status[\"status\"] != \"SUCCEEDED\":\n107\t raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n108\t dataset = status[\"defaultDatasetId\"]\n109\t items: list[dict] = []\n110\t offset = 0\n111\t while True:\n112\t r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n113\t params={\"offset\": offset, \"limit\": 1000,\n114\t \"clean\": \"true\"},\n115\t headers=headers, timeout=120)\n116\t r.raise_for_status()\n117\t chunk = r.json()\n118\t items.extend(chunk)\n119\t if len(chunk) < 1000:\n120\t return items\n121\t offset += 1000\n122\t\n123\t\n124\tdef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n125\t memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n126\t \"\"\"Un seul run (recherche, petit lot).\"\"\"\n127\t return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n128\t\n129\t\n130\tdef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n131\t timeout_s: int = 2400,\n132\t shard_size: int = SHARD_SIZE,\n133\t max_shards: int = MAX_SHARDS,\n134\t memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n135\t \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n136\t\n137\t Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n138\t plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n139\t 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n140\t n'annule pas les autres.\n141\t \"\"\"\n142\t if not usernames:\n143\t return []\n144\t n = max(1, min(max_shards,\n145\t (len(usernames) + shard_size - 1) // shard_size))\n146\t shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n147\t items: list[dict] = []\n148\t with ThreadPoolExecutor(max_workers=n) as pool:\n149\t futs = {pool.submit(run_actor, name,\n150\t {**base_input, \"usernames\": shard},\n151\t timeout_s=timeout_s, memory_mb=memory_mb): i\n152\t for i, shard in enumerate(shards) if shard}\n153\t for fut in as_completed(futs):\n154\t try:\n155\t items.extend(fut.result())\n156\t except Exception as exc: # une tranche morte ne bloque pas le reste\n157\t print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n158\t return items\n159\t\n160\t\n161\tclass _ApifySocialEnrich(BaseConnector):\n162\t \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n163\t\n164\t kind = \"enrichment\"\n165\t platform = \"\" # plateforme canonique (§6.2)\n166\t actor = \"\" # nom court de l'acteur (ka-instagram…)\n167\t cap = 3000 # comptes max par passage (shardé en parallèle)\n168\t revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n169\t content_key = \"\" # clé du contenu récent dans l'item acteur\n170\t # clés d'item copiées telles quelles dans account.metrics si non nulles\n171\t metric_map: dict[str, str] = {}\n172\t\n173\t @property\n174\t def _stamp_key(self) -> str:\n175\t # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n176\t # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n177\t # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n178\t return f\"{self.source_id}_at\"\n179\t\n180\t def _stale(self, acc) -> bool:\n181\t stamp = (acc.metrics or {}).get(self._stamp_key)\n182\t if not stamp:\n183\t return True\n184\t try:\n185\t seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n186\t except ValueError:\n187\t return True\n188\t return (datetime.now(timezone.utc) - seen\n189\t > timedelta(days=self.revisit_days))\n190\t\n191\t def extra_input(self) -> dict:\n192\t return {}\n193\t\n194\t def target_of(self, acc) -> str:\n195\t \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n196\t\n197\t Certaines plateformes exigent autre chose que le handle minusculé —\n198\t ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n199\t être repris depuis l'URL. Le rapprochement se fait ensuite en\n200\t minuscules des deux côtés.\n201\t \"\"\"\n202\t return acc.handle\n203\t\n204\t def enrich(self, creators: list[Creator]) -> list[Creator]:\n205\t targets: dict[str, list] = {}\n206\t for cr in creators:\n207\t acc = next((a for a in cr.platforms\n208\t if a.platform == self.platform and a.handle), None)\n209\t if acc is None or not self._stale(acc):\n210\t continue\n211\t targets.setdefault(self.target_of(acc), []).append((cr, acc))\n212\t if len(targets) >= self.cap:\n213\t break\n214\t if not targets:\n215\t return []\n216\t items = run_actor_sharded(\n217\t self.actor,\n218\t {\"proxyConfiguration\": RESIDENTIAL,\n219\t \"concurrency\": RUN_CONCURRENCY,\n220\t **self.extra_input()},\n221\t sorted(targets))\n222\t by_handle = {str(it.get(\"username\") or \"\").lower(): it\n223\t for it in items if it.get(\"kind\") == \"profile\"}\n224\t enriched: list[Creator] = []\n225\t self.errors = 0\n226\t for handle, pairs in targets.items():\n227\t it = by_handle.get(handle.lower())\n228\t if it is None:\n229\t continue\n230\t if not it.get(\"found\"):\n231\t if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n232\t self.errors += 1\n233\t continue\n234\t for cr, acc in pairs:\n235\t self.apply(cr, acc, it)\n236\t enriched.append(cr)\n237\t return enriched\n238\t\n239\t # -- application aux fiches ------------------------------------------------\n240\t def apply(self, cr: Creator, acc, it: dict) -> None:\n241\t acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n242\t if it.get(\"is_verified\") is not None:\n243\t acc.verified = bool(it[\"is_verified\"])\n244\t acc.last_checked = now_iso()\n245\t metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n246\t if it.get(src) is not None}\n247\t if self.content_key and it.get(self.content_key):\n248\t metrics[self.content_key] = it[self.content_key][:12]\n249\t # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n250\t # par plateforme + chaîne de repli côté frontend)\n251\t for img_key in (\"avatar\", \"banner\"):\n252\t if it.get(img_key):\n253\t metrics[img_key] = it[img_key]\n254\t metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n255\t acc.metrics.update(metrics)\n256\t # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n257\t # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n258\t # sinon on remplit seulement les manquants\n259\t if it.get(\"avatar\") and (not cr.avatar_url\n260\t or acc.platform == cr.primary_platform):\n261\t cr.avatar_url = it[\"avatar\"]\n262\t if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n263\t or acc.platform == cr.primary_platform):\n264\t cr.banner_url = it[\"banner\"]\n265\t if not cr.bio and it.get(\"biography\"):\n266\t cr.bio = it[\"biography\"]\n267\t self.cross_links(cr, it)\n268\t\n269\t def cross_links(self, cr: Creator, it: dict) -> None:\n270\t \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n271\t for url in self.bio_urls(it):\n272\t url = (url or \"\").strip()\n273\t if url and not url.startswith(\"http\"):\n274\t url = \"https://\" + url\n275\t if not url:\n276\t continue\n277\t if is_linkinbio(url) and not cr.link_in_bio_url:\n278\t cr.link_in_bio_url = url\n279\t continue\n280\t hit = platform_from_url(url)\n281\t if hit and hit[0] != self.platform:\n282\t cr.platforms = merge_accounts(cr.platforms, [\n283\t account(hit[0], hit[1], \"cross_link\",\n284\t url=url).finalize()])\n285\t\n286\t def bio_urls(self, it: dict) -> list[str]:\n287\t return []\n288\t\n289\t\n290\tclass ApifyInstagram(_ApifySocialEnrich):\n291\t source_id = \"instagram-apify\"\n292\t platform = \"instagram\"\n293\t actor = \"ka-instagram\"\n294\t cap = 1800\n295\t content_key = \"recent_posts\"\n296\t metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n297\t \"category\": \"category\", \"is_business\": \"is_business\",\n298\t \"business_category\": \"business_category\",\n299\t \"highlight_reels\": \"highlight_reels\",\n300\t \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n301\t \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n302\t \"avg_video_views\": \"avg_video_views\",\n303\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n304\t \"posts_per_week\": \"posts_per_week\",\n305\t \"last_post_at\": \"last_post_at\",\n306\t \"video_share_pct\": \"video_share_pct\",\n307\t \"top_post\": \"top_post\"}\n308\t\n309\t def bio_urls(self, it: dict) -> list[str]:\n310\t return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n311\t\n312\t def apply(self, cr, acc, it):\n313\t super().apply(cr, acc, it)\n314\t related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n315\t or []) if r.get(\"username\")]\n316\t if related: # matière première de découvertes futures (§12.1 mention)\n317\t acc.metrics[\"related_profiles\"] = related[:10]\n318\t\n319\t\n320\tclass ApifyTikTok(_ApifySocialEnrich):\n321\t source_id = \"tiktok-apify\"\n322\t platform = \"tiktok\"\n323\t actor = \"ka-tiktok\"\n324\t cap = 1800\n325\t content_key = \"recent_videos\"\n326\t metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n327\t \"videos_count\": \"videos\", \"region\": \"region\",\n328\t \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n329\t \"is_live_now\": \"is_live_now\",\n330\t \"commerce_category\": \"commerce_category\",\n331\t \"account_created_at\": \"account_created_at\",\n332\t \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n333\t \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n334\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n335\t \"top_video\": \"top_video\"}\n336\t\n337\t def bio_urls(self, it: dict) -> list[str]:\n338\t return [it.get(\"bio_link\") or \"\"]\n339\t\n340\t\n341\tclass ApifyX(_ApifySocialEnrich):\n342\t source_id = \"x-apify\"\n343\t platform = \"x\"\n344\t actor = \"ka-x\"\n345\t cap = 1800\n346\t content_key = \"recent_tweets\"\n347\t metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n348\t \"location\": \"location\", \"created_at\": \"created_at\",\n349\t \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n350\t \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n351\t \"avg_replies\": \"avg_replies\",\n352\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n353\t \"tweets_per_week\": \"tweets_per_week\",\n354\t \"last_tweet_at\": \"last_tweet_at\",\n355\t \"media_share_pct\": \"media_share_pct\",\n356\t \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n357\t\n358\t def extra_input(self) -> dict:\n359\t return {\"maxTweets\": 20}\n360\t\n361\t def bio_urls(self, it: dict) -> list[str]:\n362\t return [it.get(\"website\") or \"\"]\n363\t\n364\t\n365\tclass ApifyFacebook(_ApifySocialEnrich):\n366\t source_id = \"facebook-apify\"\n367\t platform = \"facebook\"\n368\t actor = \"ka-facebook\"\n369\t cap = 400\n370\t revisit_days = 7\n371\t metric_map = {\"category\": \"category\"}\n372\t\n373\t\n374\tclass ApifyThreads(_ApifySocialEnrich):\n375\t source_id = \"threads-apify\"\n376\t platform = \"threads\"\n377\t actor = \"ka-threads\"\n378\t cap = 60\n379\t revisit_days = 7\n380\t content_key = \"recent_posts\"\n381\t metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n382\t\n383\t\n384\tclass ApifySnapchat(_ApifySocialEnrich):\n385\t source_id = \"snapchat-apify\"\n386\t platform = \"snapchat\"\n387\t actor = \"ka-snapchat\"\n388\t cap = 60\n389\t revisit_days = 7\n390\t metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n391\t \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n392\t \"subcategory\": \"subcategory\",\n393\t \"publisher_type\": \"publisher_type\",\n394\t \"story_snaps_count\": \"story_snaps\",\n395\t \"lenses_count\": \"lenses\",\n396\t \"spotlight_highlights_count\": \"spotlight_highlights\"}\n397\t\n398\t def bio_urls(self, it: dict) -> list[str]:\n399\t return [it.get(\"website\") or \"\"]\n400\t\n401\t\n402\tclass ApifyYouTube(_ApifySocialEnrich):\n403\t source_id = \"youtube-apify\"\n404\t platform = \"youtube\"\n405\t actor = \"ka-youtube\"\n406\t cap = 1800\n407\t content_key = \"recent_videos\"\n408\t metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n409\t \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n410\t \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n411\t \"last_video_published\": \"last_video_published\"}\n412\t\n413\t\n414\tclass ApifyTwitch(_ApifySocialEnrich):\n415\t source_id = \"twitch-apify\"\n416\t platform = \"twitch\"\n417\t actor = \"ka-twitch\"\n418\t cap = 1200\n419\t content_key = \"recent_videos\"\n420\t metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n421\t \"team\": \"team\", \"created_at\": \"created_at\",\n422\t \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n423\t \"live_game\": \"live_game\",\n424\t \"last_broadcast_at\": \"last_broadcast_at\",\n425\t \"last_broadcast_game\": \"last_broadcast_game\",\n426\t \"avg_video_views\": \"avg_video_views\",\n427\t \"recent_games\": \"recent_games\"}\n428\t\n429\t\n430\tclass ApifyKick(_ApifySocialEnrich):\n431\t source_id = \"kick-apify\"\n432\t platform = \"kick\"\n433\t actor = \"ka-kick\"\n434\t cap = 600\n435\t metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n436\t \"subscription_enabled\": \"subscription_enabled\",\n437\t \"recent_categories\": \"recent_categories\"}\n438\t\n439\t def cross_links(self, cr: Creator, it: dict) -> None:\n440\t \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n441\t socials = it.get(\"social_links\") or {}\n442\t tmpl = {\"instagram\": \"https://instagram.com/{}\",\n443\t \"twitter\": \"https://x.com/{}\",\n444\t \"youtube\": \"https://youtube.com/{}\",\n445\t \"tiktok\": \"https://www.tiktok.com/@{}\",\n446\t \"facebook\": \"https://facebook.com/{}\",\n447\t \"discord\": \"https://discord.gg/{}\"}\n448\t for key, val in socials.items():\n449\t val = (val or \"\").strip()\n450\t if not val:\n451\t continue\n452\t url = val if val.startswith(\"http\") else \\\n453\t tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n454\t hit = platform_from_url(url)\n455\t if hit and hit[0] != self.platform:\n456\t cr.platforms = merge_accounts(cr.platforms, [\n457\t account(hit[0], hit[1], \"cross_link\",\n458\t url=url).finalize()])\n459\t\n460\t\n461\tclass ApifyOnlyFans(_ApifySocialEnrich):\n462\t source_id = \"onlyfans-apify\"\n463\t platform = \"onlyfans\"\n464\t actor = \"ka-onlyfans\"\n465\t cap = 100\n466\t revisit_days = 7\n467\t metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n468\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n469\t \"streams_count\": \"streams\",\n470\t \"subscribe_price_usd\": \"subscribe_price_usd\",\n471\t \"is_free\": \"is_free\", \"location\": \"location\"}\n472\t\n473\t def bio_urls(self, it: dict) -> list[str]:\n474\t return [it.get(\"website\") or \"\"]\n475\t\n476\t\n477\tclass ApifyFansly(_ApifySocialEnrich):\n478\t source_id = \"fansly-apify\"\n479\t platform = \"fansly\"\n480\t actor = \"ka-fansly\"\n481\t cap = 600\n482\t revisit_days = 7\n483\t metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n484\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n485\t \"following\": \"following\", \"location\": \"location\"}\n486\t\n487\t\n488\tclass ApifyPatreon(_ApifySocialEnrich):\n489\t source_id = \"patreon-apify\"\n490\t platform = \"patreon\"\n491\t actor = \"ka-patreon\"\n492\t cap = 400\n493\t revisit_days = 7\n494\t metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n495\t \"is_monthly\": \"is_monthly\"}\n496\t\n497\t\n498\tclass ApifyDiscord(_ApifySocialEnrich):\n499\t source_id = \"discord-apify\"\n500\t platform = \"discord\"\n501\t actor = \"ka-discord\"\n502\t cap = 400\n503\t revisit_days = 7\n504\t metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n505\t \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n506\t\n507\t def target_of(self, acc) -> str:\n508\t \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n509\t handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n510\t url = acc.url or \"\"\n511\t for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n512\t if sep in url:\n513\t return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n514\t return acc.handle\n515\t\n516\t\n517\t# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n518\t\n519\t# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n520\t# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n521\t_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n522\t \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n523\t \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n524\t \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n525\t \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n526\t \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n527\t \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n528\t \"créateur de contenu\", \"créatrice de contenu\"]\n529\t_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n530\t\n531\t\n532\tclass InstagramDecouverteApify(BaseConnector):\n533\t \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n534\t\n535\t L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n536\t mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n537\t (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n538\t résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n539\t EST la source de la fiche.\n540\t \"\"\"\n541\t\n542\t source_id = \"instagram-decouverte\"\n543\t kind = \"discovery\"\n544\t queries_per_pass = 40\n545\t\n546\t def build_queries(self) -> list[str]:\n547\t combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n548\t day = datetime.now(timezone.utc).timetuple().tm_yday\n549\t start = (day * self.queries_per_pass) % len(combos)\n550\t rotated = combos[start:] + combos[:start]\n551\t return rotated[:self.queries_per_pass]\n552\t\n553\t def fetch(self) -> list[Creator]:\n554\t # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n555\t # une session authentifiée (401 « Server Error » depuis toute IP non\n556\t # connectée). La découverte par mots-clés est donc suspendue proprement\n557\t # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n558\t # (instagram-apify) alimente `related_profiles` comme graine future.\n559\t raise SkipSource(\"topsearch Instagram exige une session connectée \"\n560\t \"(voie publique fermée le 2026-08-18)\")\n561\t items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n562\t \"usernames\": [],\n563\t \"queries\": self.build_queries(),\n564\t \"proxyConfiguration\": RESIDENTIAL,\n565\t })\n566\t creators: list[Creator] = []\n567\t seen: set[str] = set()\n568\t for it in items:\n569\t if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n570\t continue\n571\t handle = str(it[\"username\"]).lower()\n572\t if handle in seen:\n573\t continue\n574\t seen.add(handle)\n575\t acc = account(\"instagram\", handle, \"profil_source\",\n576\t followers=parse_count(it.get(\"followers\")),\n577\t verified=it.get(\"is_verified\"),\n578\t url=f\"https://www.instagram.com/{handle}/\")\n579\t creators.append(Creator(\n580\t source=self.source_id,\n581\t external_id=handle,\n582\t display_name=it.get(\"full_name\") or handle,\n583\t avatar_url=it.get(\"avatar\") or None,\n584\t platforms=[acc],\n585\t notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n586\t ))\n587\t return creators\n588\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"34462620-29ff-48fc-868a-f162f313ceec","timestamp":"2026-08-21T01:06:34.901Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","numLines":588,"startLine":1,"totalLines":588}}}} |
|
394 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"603a679a-aa7f-4c21-a350-37fdf9ee74ff","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
395 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzo5qade15U2xZhrHQK","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8QoKpAEIERgCKkBrFYOh64ZdKdYY99LGQ1zKjkqzZr3sZllrTxtP5v7k0G7aqDMgYvixoacccArDZwdZ8vq8eurAzCOgY3gAs5VeMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGkyZ7UBhIM4A/in55rXYpWy4j0Ggw3GieIEV3ZRz5oyqYiMDzbNVow1wSQiRkdYpnicpPyblFwGYBv3UTz4VBc6QKeYVQb1mz65FlNvaYl4RwIQyr5CBfvLdwaqUocVaq0NBDKdXyIJiDrBGzzvH7AoR823brYt2GP8doHnHgXn+TAz8TK67A0ien7+1jelx7KbYA1S3brCY6M0gZKnCxHEboccYEzIXb2eB2P42wyRO1znlsk8qH4IBPQMIRYYwNersM6fzfgnHoXxo3d03F4PkFR3m5PozppbZFo0P9OjBw+s9edyxcVyhIyko4ZYLYAM33ELgT6BsvukVRODE3ohIHGpAVc6zMqvT7rPS9+gqgX1Nh4sJ4Rw9MTxWyNVN4Y9D7uuVHU4zP/gTB6EhPPoAo3BM4DYiBnrU089k5zCgSItIphIXl6xUUSHOwFgYumzGhda2btv5XzMfRlwGzA+ODyhRUlQx3vmFUNscyTe4uL99qhgbIExuXH82motqLXjE7p0GdFPSVQ9ltg5HfgpR5ZkPY7BJGYh7uhqDWD06dwmJgMoSfNmJeEfABrmQsf/iBLFXJIN3zVFQDU8z5xIluN/PGHApCSJ0LRRseHWGy0+UHlqfnT28VK3lb+pNYiLq38JxSJAd9INi5G/05eYg+99L378yFt/6sn3bp+t9E6OVVs9zeBtQbHuXX5ZaAZ/B4K7wZ9/Fmqb/10oFYYuaw5WUtG7Zxr8tsuM+5eU2rq8eoTQONNjTC6N93xZmbnnkn/cgFEKMGtYP1EAIp1Nt9P79Wi8X27OiFe3xG+RmZ+JPL5d2Wjc1X956VD/+mpEhetELEEYaEuwCRqhPOsgh85uA5adIQYqEHRlt5vLfj7RRN4+cPff6zDrBD7gUXyM/PRaJY0JRSU1wOb4euQ/2CuB7uxnsSZSSmo9hR8KVb6C+AgfTn9JokSnjIvqAqX7qynlZYHzagFKu/qw1zRHLczm/dEYbg/XmtMd7/GW1QKJXjd136tGvibDEsp/amsWRXYsTH6PSLizScLFDIAGejRzO8qLGn2OJcwt28SSjQMcZld1zzIcveq0VrS7bdJn6BlT5kXXQFNGS7+rquI/L+S56jPor8+etCztu2Y/d03oipUx20PqdjZcxRr2SQTtbUMaeYlVUbpmDb59WBZfZgIUUhvGnvJ+ih1aoTGMvXVIXQeqBAAQkTtaOQo3WhnJOJqg6Ok1PsRvtxMaGih+qDr5TT+xTp9PFSIzHP/mMeIfSA+c6KZ8RWr2loa0FEdaDV7YCfFsJ6u+PGN7fNi8ygm964h/oODNqIAns2ymG9RPZIRrQ+ycOTDZA3cG5QZl2oyJQq+1AmrJJRgFy00MLHgNaxZWNaOBSCewz1OrPvQQgoIrRNu8SpkC5iF3dUmnZqElZMqvwq/8fBKNQJUV20cItq5jZ+p9T3MtUzJuq6QEqik84i7zidzx7WRB8NRA/Y6wRvzwzEzbj6/15nrhw9Ho/nDbjhh68VnS0GbkcBqXUQDzziGOmwL6yBQm+nr9nxf2iAqBP83YXdVWWJRlXmOXVIUTZzUaEe3FxivaZT0Gey3u32BflWGtXhzHzEos3zDlOJPVaN5aBPUjzNEZ8d14qJn7MG3paBLu/haGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15056,"cache_read_input_tokens":59155,"cache_creation":{"ephemeral_5m_input_tokens":15056,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4b0ab6ff-e5f8-4a85-ac50-5b68103855a3"}} |
|
396 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzo5qade15U2xZhrHQK","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2QMKpQEIERgCKkBIPOzaJP9T6Noib8TwyHIsG+EEa6faBpEE25vU2HAL8fufXA1WgVHTAkaZqh6xsglPaJkGfds91ZTP3E+fArHqMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBpMme1AYSDGj+iRT6+OgWGNQ6pRoMhUfFi/HoUJJhYXobIjCBKTFcNVzUXEXW19etfOEyy7O5DlmSQqQeKy7AgvoIDdXO6T0xsy3A3iFgg1TaXVUq4AEYYPCQxuONaLI8wjwvYU64gR2vRP5ayeiEWiW9JuWAvop7Ugq6R00DW+8D6SXbjb0qaTWa3uPxxsa65l7Zz8LfruHFq6/4B7sXyGRH/svYnQATDc1gjnk9qF0gnxzUSMNc0Y9xTigI1+dd9OnnSYLmLBeRWj6VIt1tYTN81VaTCd834ZxWn4FF7tR5Xnia9l9ZKCnQ/doMIB1qx1XDBjqlBDOvLyN/+FmblS1VPA6b83NEZty0efGUKWkwDLm4dlsQyuwem6Sw+Ptt5f070Zq4pKwPtnZu/MtU141egLIhWRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15056,"cache_read_input_tokens":59155,"cache_creation":{"ephemeral_5m_input_tokens":15056,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0fe4ab70-2874-433c-b411-fb4eedefb386"}} |
|
397 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzo5qade15U2xZhrHQK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RBazFgfyrSYAQ4rBgkCTCX","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"pending","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"in_progress","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"pending","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"pending","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15056,"cache_read_input_tokens":59155,"cache_creation":{"ephemeral_5m_input_tokens":15056,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"393e0d33-ec35-4bfd-b8dd-61742ad6a8f9"}} |
|
398 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RBazFgfyrSYAQ4rBgkCTCX","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"acf2ea03-e9cf-43ac-9590-1191f51ca7b5","timestamp":"2026-08-21T01:06:50.530Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les sources des acteurs Apify"},{"content":"Lire apify_social.py + acteurs actuels pour cerner les lacunes par plateforme","status":"completed","activeForm":"Analysant le pipeline et les acteurs"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch) : + d'infos et d'images","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"in_progress","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les acteurs (apify push) et valider avec des runs de test","status":"pending","activeForm":"Poussant et testant les acteurs"},{"content":"Étendre le pipeline (apify_social.py) : nouveaux champs, avatars rafraîchis, bannières, cross-links Twitch/Patreon","status":"pending","activeForm":"Étendant le pipeline d'ingestion"},{"content":"Ajouter snapshots historisés (db.py) + insights/Ka-Score par créateur (stats.py, web.py)","status":"pending","activeForm":"Ajoutant les métriques par créateur"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline)","status":"pending","activeForm":"Enrichissant la fiche créateur frontend"},{"content":"Rebuild/redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"pending","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"in_progress","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"pending","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"pending","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
399 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzo5qade15U2xZhrHQK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HT6ad8x4MchSdULJYKBeGn","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && for a in ka-tiktok ka-x ka-youtube ka-twitch ka-kick ka-snapchat; do echo \"=== $a ===\"; grep -oE '\"\\w+\":' $a/src/main.py | sort -u | tr -d '\":' | tr '\\n' ' '; echo; done","description":"List emitted field keys per actor"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":15056,"cache_read_input_tokens":59155,"cache_creation":{"ephemeral_5m_input_tokens":15056,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c5fe051f-5548-4a74-95fa-954515d681f9"}} |
|
400 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HT6ad8x4MchSdULJYKBeGn","type":"tool_result","content":"=== ka-tiktok ===\naccount_created_at author avatar avg_comments avg_likes avg_likes_per_video_lifetime avg_shares avg_views bio_link biography caption comments commerce_category cover duration_s engagement_rate_pct error followers following found friends full_name hashtags height id is_live_now is_organization is_pinned is_seller is_verified kind language last_video_at likes music original platform recent_videos region saves sec_uid shares timestamp title top_hashtags top_video total_likes url username videos_count videos_per_week views width \n=== ka-x ===\naccount_age_days avatar avg_likes avg_replies avg_retweets banner biography created_at engagement_rate_pct error followers following found full_name hashtags id is_blue_verified is_protected is_quote is_reply is_retweet is_verified kind lang last_tweet_at likes likes_given links listed_count location media_count media_share_pct media_type media_urls mentions platform quotes recent_tweets replies reply_share_pct retweets text top_hashtags top_tweet tweets_count tweets_per_week url username website \n=== ka-youtube ===\nB K LOCKUP_CONTENT_TYPE_VIDEO M avatar avg_views banner biography canonical_url channel_id country duration error external_links followers found full_name id is_family_safe is_verified joined_date keywords kind last_video_published platform published recent_videos thumbnail title top_video total_views url username videos_count views \n=== ka-twitch ===\navatar avg_video_views banner biography created_at duration_s error followers found full_name game http https id is_affiliate is_live_now is_partner is_verified kind last_broadcast_at last_broadcast_game last_broadcast_title live_game live_thumbnail live_title live_viewers login name platform published_at query recent_games recent_videos social_links team thumbnail title url username variables views \n=== ka-kick ===\nAccept avatar banner biography error followers found full_name id is_banned is_live_now is_verified kind live_category live_thumbnail live_title live_viewers platform recent_categories social_links subscription_enabled username vod_enabled \n=== ka-snapchat ===\naddress avatar biography category error followers found full_name has_curated_highlights has_spotlight has_story is_verified kind lenses_count platform publisher_type related_accounts spotlight_highlights_count spotlight_previews story_previews story_snaps_count subcategory title username value website","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f78b99e5-ab45-469b-aecf-3fe96995d0c2","timestamp":"2026-08-21T01:06:51.678Z","tool_use_result":{"stdout":"=== ka-tiktok ===\naccount_created_at author avatar avg_comments avg_likes avg_likes_per_video_lifetime avg_shares avg_views bio_link biography caption comments commerce_category cover duration_s engagement_rate_pct error followers following found friends full_name hashtags height id is_live_now is_organization is_pinned is_seller is_verified kind language last_video_at likes music original platform recent_videos region saves sec_uid shares timestamp title top_hashtags top_video total_likes url username videos_count videos_per_week views width \n=== ka-x ===\naccount_age_days avatar avg_likes avg_replies avg_retweets banner biography created_at engagement_rate_pct error followers following found full_name hashtags id is_blue_verified is_protected is_quote is_reply is_retweet is_verified kind lang last_tweet_at likes likes_given links listed_count location media_count media_share_pct media_type media_urls mentions platform quotes recent_tweets replies reply_share_pct retweets text top_hashtags top_tweet tweets_count tweets_per_week url username website \n=== ka-youtube ===\nB K LOCKUP_CONTENT_TYPE_VIDEO M avatar avg_views banner biography canonical_url channel_id country duration error external_links followers found full_name id is_family_safe is_verified joined_date keywords kind last_video_published platform published recent_videos thumbnail title top_video total_views url username videos_count views \n=== ka-twitch ===\navatar avg_video_views banner biography created_at duration_s error followers found full_name game http https id is_affiliate is_live_now is_partner is_verified kind last_broadcast_at last_broadcast_game last_broadcast_title live_game live_thumbnail live_title live_viewers login name platform published_at query recent_games recent_videos social_links team thumbnail title url username variables views \n=== ka-kick ===\nAccept avatar banner biography error followers found full_name id is_banned is_live_now is_verified kind live_category live_thumbnail live_title live_viewers platform recent_categories social_links subscription_enabled username vod_enabled \n=== ka-snapchat ===\naddress avatar biography category error followers found full_name has_curated_highlights has_spotlight has_story is_verified kind lenses_count platform publisher_type related_accounts spotlight_highlights_count spotlight_previews story_previews story_snaps_count subcategory title username value website","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
401 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3f1898a0-7784-4a30-963e-b9bdda968822","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
402 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzpKYNJ2DyoTeAGqokd","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvw0KpAEIERgCKkDVCjDbm2P1dd1CKn0cClI/MBFP+LyYU7GtyP8E98jNy4c3KfV0+9Vabm92Ufn1gAcAr7FJhCRt9RiSgLmmIK+vMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG2yZ7UBhIMXl5tgpyqXkJVCWqiGgy9GTsGzVsGnB4ChJEiMNiDOhRj38Xc7Oz45GbVigwEn/UvFa77ctjHsMP4eXMsj8Lau4gIIqbHtMUeu6w4xSrHC23T8jlkzryVIlr+DK0xniwiHbpsBLHckcdfL+6aUCbqmDYbO17jPVEjdISSh7qqLJwCoRcRyinLyRqBzAbvMh+FZN4phIEEykIRYHSYOLdBwgGpG5y0w9RewpnjC4LZkCCa53h2vxDbbdd4M0e05Zo4uMyQcwoycyseoeJ/KBvnORwKonRSMZXbnMrkkeRFrXc8DfHhNCNphAVPuHi42LOHoKvy9d5VRK73tY19wGiht8LvtJURKnurnoNDOQfJLbPpyqXQgzYNQ0xtz902hktqRbgFm4m8dmmt76KPFWG95niHq7VJqheGXPMJbWNiN8reWxiq6MWNfCZTidzgT2h+5FrvCJR5Zrh+qcE9UOJWlbXSjByuKBo3lFRku4Nl1MXQwnWjp4A0qfacDeGF7G1AckiQNYVCcr7UjPvJ/rdny8HyIcYtneRd09xo6t+h/Hkln3JirC1Kqq2IgbEegdSF/A7+s4EEBaQAMUwCUntwv74IMmqfNm7v7MakAWvsfDoWu7JtWPpQ0uWLdM2fJ2DZ1nloAD0uytN0NpXdw/pivbE7YcEEK/wO7ehpBpCeYsKV6U1Mg0XTMWdudmMa99X6uGeLQXQPzn2UyEb2kANdffOMmXP73nlr1SShDI5OsFY99CwHCQ8kelb4R+YI2wbZ1P4BwdD2cldK7MJFSD0gdplQEOykfloAbrgidtJCZvdHXEj+uv0hpgctnBPfJIw+iESuiDpD1M8ACmxD+7MLwPSt9zd4FDKikd4mlpvatmV9eZgVEdA64NqohuGlB46bZWRTuXndOYjEPQcTSyUc6AqjTum81xd9VOu2rM37QtQTzTJ9Rdpn9w7OzkPRhz3OMm6sB51w5XAinlR+lZy7oCgQQoX0nPhO8KuiNoBQHy+1YnhCp3BomnKzGCZxmfmLv3MJTA0XEu4hEDdcIf6m5xn2YrqqTjOLYYEq92X1NFtcLCz6cjRWPflZYcTvkb18Iz0AJ5vOf1AvZgEKWBvkzUUA0eChiPXSKJZeoe3YBg+3q39Z3hRFAegLtyTOlnJp5oBzt+cIpfFerQazX71yTtUUuq79oAgGVj/x0GPQs/XG2u+/g742QvZM89YU7llj4hx2k1vIx988KRNL6D5gI7HC0o941M/fyiuekwHuAoK361xxzZm2LXterON1p+n0p3Z0/eYGHffUWKJdQS/cb8EaVt/7kAli9Z/I226oQfaMxDOEvYTM9KQ8q/uwyAqNE5QvS5NCCihI5Vsn/7ZSclFOa546Upk4V1ffo+6oIyyUrIycralOzhkevtuhhVKt/7wLxXSY2TrdK96eysf5pzayVWkZftB0t46hu3TZpvTcLSd6m3VwgFQwM5F+VB9zEsmUMxfYpvCw3TWCXUEKZjHu55pnWYHusyTDCRt7qcFAgksS+J2BXstRJSdXkNx0lwVxT+QR9UNuUjgf4UwLO8pOYteqKknjt92r/xyiqMsX74h30/xogNqk18sfC87oNy8SfOPPP0Ddk9rmDGfmBraAW1u8EYBLf9XS5JaiYPEtyXMts3YWyqSIJcxX9qy3Je9sEkmzMgVS1mbfs0OSLrKPObw5upBdFNvJQJHW8iaa9ptWaPyT6IR32dH9QTT44QU5BL/GudvNaVHP/D5o8gUJgyUS7OFurqXbt4CcG6pQCoYhwX7CWJ/v1LkFVcuw/ai8N0VDHr1nRN+B+eLJtRO2eBlOhAa1HEnqrCIycPm5fIYc2D6lmaP9bfNpgcRhWie0ZVgI8NjUDmXI8pRm6oLCypsETOt/q7jLVlWdqIpwTenfpVUpMOO1Y16ms19BUJZyNp4w7V6rEd0dOzFFUudbRSZe1LZCSdSyuyW5YGQmCn9Y3LIvhAoLYRqCB7NOBWNapY5OND+gSOpz0iMFL/a3D0+CT1toUkIfhG4INRb4oM7tCf5uaopZ1i88PgktmM+XzoUPT9ryzElhQup8MzVMcZcIzBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2368,"cache_read_input_tokens":74211,"cache_creation":{"ephemeral_5m_input_tokens":2368,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f0330924-1c1d-4fc6-8e7e-765954ec0b8f"}} |
|
403 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzpKYNJ2DyoTeAGqokd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Scxjym6ymMKwdmfUhASXQN","name":"Grep","input":{"pattern":"external_links|social_links","path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","output_mode":"content","-n":true,"-C":6},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2368,"cache_read_input_tokens":74211,"cache_creation":{"ephemeral_5m_input_tokens":2368,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a6cf30a9-7bd2-4154-af19-943e3cfebf8d"}} |
|
404 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Scxjym6ymMKwdmfUhASXQN","type":"tool_result","content":"188- if url:\n189- links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n190- return {\n191- \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n192- \"joined_date\": _s(acv.get(\"joinedDateText\")),\n193- \"country\": _s(acv.get(\"country\")),\n194: \"external_links\": links[:10],\n195- }\n196-\n197-\n198-async def main() -> None:\n199- async with Actor:\n200- inp = await Actor.get_input() or {}\n--\n285- \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n286- \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n287- or [{}])[-1].get(\"url\")),\n288- \"banner\": _banner_of(data),\n289- \"total_views\": about.get(\"total_views\"),\n290- \"joined_date\": about.get(\"joined_date\"),\n291: \"external_links\": about.get(\"external_links\") or [],\n292- \"avg_views\": (round(sum(views) / len(views))\n293- if views else None),\n294- \"top_video\": (max(\n295- (v for v in vids if isinstance(v[\"views\"], int)),\n296- key=lambda v: v[\"views\"], default=None)),\n297- \"last_video_published\": (vids[0][\"published\"]"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4e0b5f3a-86f0-4353-abc4-fb391a60bf74","timestamp":"2026-08-21T01:07:04.030Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"188- if url:\n189- links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n190- return {\n191- \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n192- \"joined_date\": _s(acv.get(\"joinedDateText\")),\n193- \"country\": _s(acv.get(\"country\")),\n194: \"external_links\": links[:10],\n195- }\n196-\n197-\n198-async def main() -> None:\n199- async with Actor:\n200- inp = await Actor.get_input() or {}\n--\n285- \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n286- \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n287- or [{}])[-1].get(\"url\")),\n288- \"banner\": _banner_of(data),\n289- \"total_views\": about.get(\"total_views\"),\n290- \"joined_date\": about.get(\"joined_date\"),\n291: \"external_links\": about.get(\"external_links\") or [],\n292- \"avg_views\": (round(sum(views) / len(views))\n293- if views else None),\n294- \"top_video\": (max(\n295- (v for v in vids if isinstance(v[\"views\"], int)),\n296- key=lambda v: v[\"views\"], default=None)),\n297- \"last_video_published\": (vids[0][\"published\"]","numLines":27}}} |
|
405 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzpKYNJ2DyoTeAGqokd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S4WfbaBUuJbboh5r8hpdJ2","name":"Grep","input":{"pattern":"social_links","path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","output_mode":"content","-n":true,"-C":4},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2368,"cache_read_input_tokens":74211,"cache_creation":{"ephemeral_5m_input_tokens":2368,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1b0296bd-0b13-4d71-9551-84a126a60904"}} |
|
406 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01S4WfbaBUuJbboh5r8hpdJ2","type":"tool_result","content":"145- \"recent_games\": list(dict.fromkeys(games))[:5],\n146- \"recent_videos\": vids,\n147- # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →\n148- # cross_link fort côté crea-ka (§12.1)\n149: \"social_links\": [\n150- {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),\n151- \"url\": s.get(\"url\")}\n152- for s in (((user.get(\"channel\") or {})\n153- .get(\"socialMedias\")) or [])"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9edf3f4e-527e-4f38-b949-4ddeeedc0492","timestamp":"2026-08-21T01:07:05.046Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"145- \"recent_games\": list(dict.fromkeys(games))[:5],\n146- \"recent_videos\": vids,\n147- # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →\n148- # cross_link fort côté crea-ka (§12.1)\n149: \"social_links\": [\n150- {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),\n151- \"url\": s.get(\"url\")}\n152- for s in (((user.get(\"channel\") or {})\n153- .get(\"socialMedias\")) or [])","numLines":9}}} |
|
407 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"800a03df-88b5-47f7-8c4d-a64a6beabf1c","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
408 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzqJcqHBU1tkP94mE13","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS6QMKpAEIERgCKkCc6R/zHZ4KxyiKB7Xcu8A7504vE+ETy42nteklCICPac7hegWbgi+j7YC/6NB6jGgs3DNRut4rt4+ebMnKnY97Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG9yZ7UBhIMByIcrjVO/LzBgW1VGgwp1Xqm8QP2hjDVCCkiMPs11rPpCDCXMWodWJACR1dSH3tMx7gE2dGwvyomYr5kpz1QGoJ91Eia5DdFy787IirxARw5Ue5PtOf/mWdgUQcjrzGgwuFZ251o5iaB9D2lxzVZy/tEyZDOWBKlTFnLoTv8tc7s1dSUHixKOeKEWxv/b3Yho78HUZDVfqlm3g3khonNwM6EcYHxHjFP9pUaJhj6z9AE4Yl9LgWR7HSVzYAzyLmmyrSuxzbclyRM84/3yCE9nhHsTQ1lfo92iKDcmYr5Z/qYULrVvDFt2nw2TGIJFMCv1cNKUdxaqTR0JHp08/bXw0X/nYa49PcPO6eedN9JlP8GF5C014p2FjaAxounBdctYxCnUsQCate8hR3JFc5/i6gzXXJb1rOxi6vOT4aYob4YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1671,"cache_read_input_tokens":76579,"cache_creation":{"ephemeral_5m_input_tokens":1671,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"20aaa738-81ad-4459-a0ae-d93456cbff0c"}} |
|
409 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzqJcqHBU1tkP94mE13","type":"message","role":"assistant","content":[{"type":"text","text":"Formats confirmés. J'applique les extensions de metric_maps et bio_urls."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1671,"cache_read_input_tokens":76579,"cache_creation":{"ephemeral_5m_input_tokens":1671,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1daa6a14-dd01-4e15-89ef-58e3ae528700"}} |
|
410 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzqJcqHBU1tkP94mE13","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0158ghTbUYgBkfPVYe88MjjW","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]","new_string":" \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1671,"cache_read_input_tokens":76579,"cache_creation":{"ephemeral_5m_input_tokens":1671,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"535a6e33-c631-4a78-8a15-d44b9d5b3ed3"}} |
|
411 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0158ghTbUYgBkfPVYe88MjjW","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"583cc9d6-ac3e-405a-be92-d6ae1fe12fca","timestamp":"2026-08-21T01:07:13.310Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]","newString":" \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":332,"oldLines":6,"newStart":332,"newLines":9,"lines":[" \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\","," \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\","," \"engagement_rate_pct\": \"engagement_rate_pct\",","+ \"videos_per_week\": \"videos_per_week\",","+ \"last_video_at\": \"last_video_at\",","+ \"top_hashtags\": \"top_hashtags\", \"language\": \"language\","," \"top_video\": \"top_video\"}"," "," def bio_urls(self, it: dict) -> list[str]:"]}],"userModified":false,"replaceAll":false}}} |
|
412 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzqJcqHBU1tkP94mE13","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H61EFffE5RrJtPj1iCCYN1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}","new_string":" \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1671,"cache_read_input_tokens":76579,"cache_creation":{"ephemeral_5m_input_tokens":1671,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"22d109c5-c7e1-492a-8de1-d7274907f0b4"}} |
|
413 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H61EFffE5RrJtPj1iCCYN1","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0a3bea0d-f151-4bc7-9da1-0289d2dd5230","timestamp":"2026-08-21T01:07:16.106Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}","newString":" \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":356,"oldLines":6,"newStart":356,"newLines":9,"lines":[" \"tweets_per_week\": \"tweets_per_week\","," \"last_tweet_at\": \"last_tweet_at\","," \"media_share_pct\": \"media_share_pct\",","+ \"reply_share_pct\": \"reply_share_pct\",","+ \"is_blue_verified\": \"is_blue_verified\",","+ \"likes_given\": \"likes_given\", \"website\": \"website\","," \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}"," "," def extra_input(self) -> dict:"]}],"userModified":false,"replaceAll":false}}} |
|
414 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzqJcqHBU1tkP94mE13","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PqMYPBZFy5o9t3otvzPKRa","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}","new_string":" metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1671,"cache_read_input_tokens":76579,"cache_creation":{"ephemeral_5m_input_tokens":1671,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"82dc2aa2-d7d1-4694-9da1-974144ee6363"}} |
|
415 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PqMYPBZFy5o9t3otvzPKRa","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a2f57ba3-785f-4f7a-83b9-643b6229d658","timestamp":"2026-08-21T01:07:19.466Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}","newString":" metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"last_video_published\": \"last_video_published\"}\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":414,"oldLines":9,"newStart":414,"newLines":16,"lines":[" metric_map = {\"videos_count\": \"videos\", \"country\": \"country\","," \"keywords\": \"keywords\", \"channel_id\": \"channel_id\","," \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",","+ \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",","+ \"external_links\": \"external_links\","," \"last_video_published\": \"last_video_published\"}"," ","+ def bio_urls(self, it: dict) -> list[str]:","+ # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)","+ return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])","+ if isinstance(ln, dict)]"," ","+"," class ApifyTwitch(_ApifySocialEnrich):"," source_id = \"twitch-apify\""," platform = \"twitch\""]}],"userModified":false,"replaceAll":false}}} |
|
416 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"65625f97-02a4-4811-9276-42a042bdfa44","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
417 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzrP1H6dVqLHLXZ9wZs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018NwcW6i1VkY8MjUEJe2nWc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}","new_string":" \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"last_broadcast_title\": \"last_broadcast_title\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n if isinstance(ln, dict)]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1711,"cache_read_input_tokens":78250,"cache_creation":{"ephemeral_5m_input_tokens":1711,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f4431ebb-7bcd-496f-a214-fa6a2d3e52f6"}} |
|
418 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018NwcW6i1VkY8MjUEJe2nWc","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"00bf2a4d-0784-4740-a8c9-e476adb55ca6","timestamp":"2026-08-21T01:07:27.210Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}","newString":" \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"last_broadcast_title\": \"last_broadcast_title\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n if isinstance(ln, dict)]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"avg_video_views\": \"avg_video_views\",\n \"recent_games\": \"recent_games\"}\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":436,"oldLines":10,"newStart":436,"newLines":19,"lines":[" \"live_game\": \"live_game\","," \"last_broadcast_at\": \"last_broadcast_at\","," \"last_broadcast_game\": \"last_broadcast_game\",","+ \"last_broadcast_title\": \"last_broadcast_title\",","+ \"live_title\": \"live_title\",","+ \"live_thumbnail\": \"live_thumbnail\","," \"avg_video_views\": \"avg_video_views\",","+ \"social_links\": \"social_links\","," \"recent_games\": \"recent_games\"}"," ","+ def bio_urls(self, it: dict) -> list[str]:","+ # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)","+ return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])","+ if isinstance(ln, dict)]"," ","+"," class ApifyKick(_ApifySocialEnrich):"," source_id = \"kick-apify\""," platform = \"kick\""]}],"userModified":false,"replaceAll":false}}} |
|
419 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzrP1H6dVqLHLXZ9wZs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014bqtY5FpC1D1ZSZqzuVq6C","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}","new_string":" metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1711,"cache_read_input_tokens":78250,"cache_creation":{"ephemeral_5m_input_tokens":1711,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2910ad7b-15c2-4f32-a080-bda6cc021a27"}} |
|
420 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014bqtY5FpC1D1ZSZqzuVq6C","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5cfa8d94-46d3-4284-a970-b6b4b8d7ff11","timestamp":"2026-08-21T01:07:30.042Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}","newString":" metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"last_broadcast_title\": \"last_broadcast_title\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":456,"oldLines":6,"newStart":456,"newLines":10,"lines":[" cap = 600"," metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\","," \"subscription_enabled\": \"subscription_enabled\",","+ \"live_title\": \"live_title\",","+ \"live_thumbnail\": \"live_thumbnail\",","+ \"live_category\": \"live_category\",","+ \"vod_enabled\": \"vod_enabled\","," \"recent_categories\": \"recent_categories\"}"," "," def cross_links(self, cr: Creator, it: dict) -> None:"]}],"userModified":false,"replaceAll":false}}} |
|
421 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzrP1H6dVqLHLXZ9wZs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Lzc6cWj24wq2j8SXZKNJa1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}","new_string":" \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"story_previews\": \"story_previews\",\n \"spotlight_previews\": \"spotlight_previews\",\n \"website\": \"website\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1711,"cache_read_input_tokens":78250,"cache_creation":{"ephemeral_5m_input_tokens":1711,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fc6b7da2-03da-4607-a835-824ec387087e"}} |
|
422 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Lzc6cWj24wq2j8SXZKNJa1","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"274a31d0-fad3-47f6-9326-7629b622f04b","timestamp":"2026-08-21T01:07:32.152Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}","newString":" \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"story_previews\": \"story_previews\",\n \"spotlight_previews\": \"spotlight_previews\",\n \"website\": \"website\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"last_broadcast_title\": \"last_broadcast_title\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":399,"oldLines":6,"newStart":399,"newLines":9,"lines":[" \"publisher_type\": \"publisher_type\","," \"story_snaps_count\": \"story_snaps\","," \"lenses_count\": \"lenses\",","+ \"story_previews\": \"story_previews\",","+ \"spotlight_previews\": \"spotlight_previews\",","+ \"website\": \"website\","," \"spotlight_highlights_count\": \"spotlight_highlights\"}"," "," def bio_urls(self, it: dict) -> list[str]:"]}],"userModified":false,"replaceAll":false}}} |
|
423 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzrP1H6dVqLHLXZ9wZs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018VwbddaCb38GvYi1Zh3zsn","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}","new_string":" metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\",\n \"creation_name\": \"creation_name\",\n \"social_links\": \"social_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n return list((it.get(\"social_links\") or {}).values())"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1711,"cache_read_input_tokens":78250,"cache_creation":{"ephemeral_5m_input_tokens":1711,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"aa05ae42-8cc6-4df4-9f17-3fe70e3b4cea"}} |
|
424 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018VwbddaCb38GvYi1Zh3zsn","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"dae5cf47-b127-4a47-80a6-7d8abfc7868d","timestamp":"2026-08-21T01:07:34.979Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}","newString":" metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\",\n \"creation_name\": \"creation_name\",\n \"social_links\": \"social_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n return list((it.get(\"social_links\") or {}).values())","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"story_previews\": \"story_previews\",\n \"spotlight_previews\": \"spotlight_previews\",\n \"website\": \"website\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"last_broadcast_title\": \"last_broadcast_title\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\"}\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":521,"oldLines":9,"newStart":521,"newLines":15,"lines":[" cap = 400"," revisit_days = 7"," metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",","- \"is_monthly\": \"is_monthly\"}","+ \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\",","+ \"creation_name\": \"creation_name\",","+ \"social_links\": \"social_links\"}"," ","+ def bio_urls(self, it: dict) -> list[str]:","+ # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link","+ return list((it.get(\"social_links\") or {}).values())"," ","+"," class ApifyDiscord(_ApifySocialEnrich):"," source_id = \"discord-apify\""," platform = \"discord\""]}],"userModified":false,"replaceAll":false}}} |
|
425 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzrP1H6dVqLHLXZ9wZs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UruHpkenQEZqr1pVs13Skm","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}","new_string":" metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\",\n \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1711,"cache_read_input_tokens":78250,"cache_creation":{"ephemeral_5m_input_tokens":1711,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"19cd8ffd-e1f1-4e5d-bb82-1edb8d6b50d4"}} |
|
426 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UruHpkenQEZqr1pVs13Skm","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bf7d3b7a-17e7-4f0d-8958-061c8302a33f","timestamp":"2026-08-21T01:07:37.167Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}","newString":" metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\",\n \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/connectors/apify_social.py\n# Desc: Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,\n# compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy\n# RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,\n# contenu récent avec métriques, engagement) pour Instagram, TikTok,\n# X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).\n# Un passage = UN run d'acteur par plateforme (lot de handles), au lieu\n# d'une requête Scrapfly par profil — moins cher, plus fiable (IP\n# résidentielles + empreinte TLS Chrome côté acteur).\n# ==============================================================================\n\"\"\"Enrichissement multi-plateforme via les acteurs Apify KA.\n\nChaque connecteur d'enrichissement :\n1. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux\n que `revisit_days` (rotation douce = maîtrise du coût proxy) ;\n2. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles\n (proxy résidentiel Apify forcé) et attend le dataset ;\n3. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU\n récent (posts/vidéos/tweets avec likes, commentaires, vues) dans\n `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n\nNécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).\nUniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.\n\"\"\"\nfrom __future__ import annotations\n\nimport os\nimport random\nimport time\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\nfrom datetime import datetime, timedelta, timezone\n\nimport requests\n\nfrom ..identity import account, merge_accounts\nfrom ..normalize import parse_count, platform_from_url\nfrom ..schema import Creator, now_iso\nfrom .base import BaseConnector, SkipSource\nfrom .linkinbio import is_supported as is_linkinbio\n\nAPIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à\n# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de\n# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.\n# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),\n# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe\n# (large sous les 256 Go / 128 runs concurrents du plan).\nSHARD_SIZE = int(os.environ.get(\"APIFY_SHARD_SIZE\", \"60\")) # handles / run\nMAX_SHARDS = int(os.environ.get(\"APIFY_MAX_SHARDS\", \"32\")) # runs simultanés\nRUN_MEMORY_MB = int(os.environ.get(\"APIFY_RUN_MEMORY_MB\", \"2048\")) # /run\nRUN_CONCURRENCY = int(os.environ.get(\"APIFY_RUN_CONCURRENCY\", \"10\")) # interne (max 10)\n# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis\n# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.\nREVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))\n\n\ndef _token() -> str:\n token = os.environ.get(\"APIFY_TOKEN\")\n if not token:\n raise SkipSource(\"APIFY_TOKEN manquant (voir .env)\")\n return token\n\n\ndef _launch(name: str, run_input: dict, memory_mb: int,\n retries: int = 6) -> str:\n \"\"\"Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :\n elle se libère quand d'autres tranches terminent) avec backoff.\"\"\"\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n for attempt in range(retries):\n resp = requests.post(\n f\"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs\",\n json=run_input, headers=headers,\n params={\"memory\": memory_mb}, timeout=60)\n if resp.status_code in (400, 429, 402) or resp.status_code >= 500:\n if attempt < retries - 1:\n # backoff + jitter : désynchronise les tranches (évite le\n # lockstep où toutes redemandent la RAM au même instant)\n time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))\n continue\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n resp.raise_for_status()\n return resp.json()[\"data\"][\"id\"]\n\n\ndef _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:\n headers = {\"Authorization\": f\"Bearer {_token()}\"}\n t0 = time.time()\n while True:\n time.sleep(15)\n r = requests.get(f\"{APIFY_API}/actor-runs/{run_id}\",\n headers=headers, timeout=60)\n r.raise_for_status()\n status = r.json()[\"data\"]\n if status[\"status\"] in (\"SUCCEEDED\", \"FAILED\", \"ABORTED\", \"TIMED-OUT\"):\n break\n if time.time() - t0 > timeout_s:\n requests.post(f\"{APIFY_API}/actor-runs/{run_id}/abort\",\n headers=headers, timeout=60)\n raise RuntimeError(f\"acteur {name} : délai {timeout_s}s dépassé\")\n if status[\"status\"] != \"SUCCEEDED\":\n raise RuntimeError(f\"acteur {name} : run {status['status']}\")\n dataset = status[\"defaultDatasetId\"]\n items: list[dict] = []\n offset = 0\n while True:\n r = requests.get(f\"{APIFY_API}/datasets/{dataset}/items\",\n params={\"offset\": offset, \"limit\": 1000,\n \"clean\": \"true\"},\n headers=headers, timeout=120)\n r.raise_for_status()\n chunk = r.json()\n items.extend(chunk)\n if len(chunk) < 1000:\n return items\n offset += 1000\n\n\ndef run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Un seul run (recherche, petit lot).\"\"\"\n return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)\n\n\ndef run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,\n timeout_s: int = 2400,\n shard_size: int = SHARD_SIZE,\n max_shards: int = MAX_SHARDS,\n memory_mb: int = RUN_MEMORY_MB) -> list[dict]:\n \"\"\"Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.\n\n Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On\n plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :\n 12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue\n n'annule pas les autres.\n \"\"\"\n if not usernames:\n return []\n n = max(1, min(max_shards,\n (len(usernames) + shard_size - 1) // shard_size))\n shards = [usernames[i::n] for i in range(n)] # répartition équilibrée\n items: list[dict] = []\n with ThreadPoolExecutor(max_workers=n) as pool:\n futs = {pool.submit(run_actor, name,\n {**base_input, \"usernames\": shard},\n timeout_s=timeout_s, memory_mb=memory_mb): i\n for i, shard in enumerate(shards) if shard}\n for fut in as_completed(futs):\n try:\n items.extend(fut.result())\n except Exception as exc: # une tranche morte ne bloque pas le reste\n print(f\"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}\")\n return items\n\n\nclass _ApifySocialEnrich(BaseConnector):\n \"\"\"Base commune des enrichissements Apify (un acteur par plateforme).\"\"\"\n\n kind = \"enrichment\"\n platform = \"\" # plateforme canonique (§6.2)\n actor = \"\" # nom court de l'acteur (ka-instagram…)\n cap = 3000 # comptes max par passage (shardé en parallèle)\n revisit_days = REVISIT_DAYS # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)\n content_key = \"\" # clé du contenu récent dans l'item acteur\n # clés d'item copiées telles quelles dans account.metrics si non nulles\n metric_map: dict[str, str] = {}\n\n @property\n def _stamp_key(self) -> str:\n # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.\n # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte\n # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.\n return f\"{self.source_id}_at\"\n\n def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)\n if not stamp:\n return True\n try:\n seen = datetime.fromisoformat(str(stamp).replace(\"Z\", \"+00:00\"))\n except ValueError:\n return True\n return (datetime.now(timezone.utc) - seen\n > timedelta(days=self.revisit_days))\n\n def extra_input(self) -> dict:\n return {}\n\n def target_of(self, acc) -> str:\n \"\"\"Valeur envoyée à l'acteur pour ce compte (défaut : le handle).\n\n Certaines plateformes exigent autre chose que le handle minusculé —\n ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit\n être repris depuis l'URL. Le rapprochement se fait ensuite en\n minuscules des deux côtés.\n \"\"\"\n return acc.handle\n\n def enrich(self, creators: list[Creator]) -> list[Creator]:\n targets: dict[str, list] = {}\n for cr in creators:\n acc = next((a for a in cr.platforms\n if a.platform == self.platform and a.handle), None)\n if acc is None or not self._stale(acc):\n continue\n targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break\n if not targets:\n return []\n items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))\n by_handle = {str(it.get(\"username\") or \"\").lower(): it\n for it in items if it.get(\"kind\") == \"profile\"}\n enriched: list[Creator] = []\n self.errors = 0\n for handle, pairs in targets.items():\n it = by_handle.get(handle.lower())\n if it is None:\n continue\n if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue\n for cr, acc in pairs:\n self.apply(cr, acc, it)\n enriched.append(cr)\n return enriched\n\n # -- application aux fiches ------------------------------------------------\n def apply(self, cr: Creator, acc, it: dict) -> None:\n acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n if it.get(\"is_verified\") is not None:\n acc.verified = bool(it[\"is_verified\"])\n acc.last_checked = now_iso()\n metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n if it.get(src) is not None}\n if self.content_key and it.get(self.content_key):\n metrics[self.content_key] = it[self.content_key][:12]\n # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n # par plateforme + chaîne de repli côté frontend)\n for img_key in (\"avatar\", \"banner\"):\n if it.get(img_key):\n metrics[img_key] = it[img_key]\n metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)\n # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n # sinon on remplit seulement les manquants\n if it.get(\"avatar\") and (not cr.avatar_url\n or acc.platform == cr.primary_platform):\n cr.avatar_url = it[\"avatar\"]\n if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n or acc.platform == cr.primary_platform):\n cr.banner_url = it[\"banner\"]\n if not cr.bio and it.get(\"biography\"):\n cr.bio = it[\"biography\"]\n self.cross_links(cr, it)\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n for url in self.bio_urls(it):\n url = (url or \"\").strip()\n if url and not url.startswith(\"http\"):\n url = \"https://\" + url\n if not url:\n continue\n if is_linkinbio(url) and not cr.link_in_bio_url:\n cr.link_in_bio_url = url\n continue\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n def bio_urls(self, it: dict) -> list[str]:\n return []\n\n\nclass ApifyInstagram(_ApifySocialEnrich):\n source_id = \"instagram-apify\"\n platform = \"instagram\"\n actor = \"ka-instagram\"\n cap = 1800\n content_key = \"recent_posts\"\n metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n \"category\": \"category\", \"is_business\": \"is_business\",\n \"business_category\": \"business_category\",\n \"highlight_reels\": \"highlight_reels\",\n \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n \"avg_video_views\": \"avg_video_views\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"posts_per_week\": \"posts_per_week\",\n \"last_post_at\": \"last_post_at\",\n \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n or []) if r.get(\"username\")]\n if related: # matière première de découvertes futures (§12.1 mention)\n acc.metrics[\"related_profiles\"] = related[:10]\n\n\nclass ApifyTikTok(_ApifySocialEnrich):\n source_id = \"tiktok-apify\"\n platform = \"tiktok\"\n actor = \"ka-tiktok\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n \"videos_count\": \"videos\", \"region\": \"region\",\n \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n \"is_live_now\": \"is_live_now\",\n \"commerce_category\": \"commerce_category\",\n \"account_created_at\": \"account_created_at\",\n \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"videos_per_week\": \"videos_per_week\",\n \"last_video_at\": \"last_video_at\",\n \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n cap = 1800\n content_key = \"recent_tweets\"\n metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n \"location\": \"location\", \"created_at\": \"created_at\",\n \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n \"avg_replies\": \"avg_replies\",\n \"engagement_rate_pct\": \"engagement_rate_pct\",\n \"tweets_per_week\": \"tweets_per_week\",\n \"last_tweet_at\": \"last_tweet_at\",\n \"media_share_pct\": \"media_share_pct\",\n \"reply_share_pct\": \"reply_share_pct\",\n \"is_blue_verified\": \"is_blue_verified\",\n \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n\n def extra_input(self) -> dict:\n return {\"maxTweets\": 20}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFacebook(_ApifySocialEnrich):\n source_id = \"facebook-apify\"\n platform = \"facebook\"\n actor = \"ka-facebook\"\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n cap = 60\n revisit_days = 7\n content_key = \"recent_posts\"\n metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n\n\nclass ApifySnapchat(_ApifySocialEnrich):\n source_id = \"snapchat-apify\"\n platform = \"snapchat\"\n actor = \"ka-snapchat\"\n cap = 60\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n \"subcategory\": \"subcategory\",\n \"publisher_type\": \"publisher_type\",\n \"story_snaps_count\": \"story_snaps\",\n \"lenses_count\": \"lenses\",\n \"story_previews\": \"story_previews\",\n \"spotlight_previews\": \"spotlight_previews\",\n \"website\": \"website\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyYouTube(_ApifySocialEnrich):\n source_id = \"youtube-apify\"\n platform = \"youtube\"\n actor = \"ka-youtube\"\n cap = 1800\n content_key = \"recent_videos\"\n metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\n \"last_video_published\": \"last_video_published\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyTwitch(_ApifySocialEnrich):\n source_id = \"twitch-apify\"\n platform = \"twitch\"\n actor = \"ka-twitch\"\n cap = 1200\n content_key = \"recent_videos\"\n metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n \"team\": \"team\", \"created_at\": \"created_at\",\n \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"live_game\": \"live_game\",\n \"last_broadcast_at\": \"last_broadcast_at\",\n \"last_broadcast_game\": \"last_broadcast_game\",\n \"last_broadcast_title\": \"last_broadcast_title\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n if isinstance(ln, dict)]\n\n\nclass ApifyKick(_ApifySocialEnrich):\n source_id = \"kick-apify\"\n platform = \"kick\"\n actor = \"ka-kick\"\n cap = 600\n metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n \"subscription_enabled\": \"subscription_enabled\",\n \"live_title\": \"live_title\",\n \"live_thumbnail\": \"live_thumbnail\",\n \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}\n\n def cross_links(self, cr: Creator, it: dict) -> None:\n \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n socials = it.get(\"social_links\") or {}\n tmpl = {\"instagram\": \"https://instagram.com/{}\",\n \"twitter\": \"https://x.com/{}\",\n \"youtube\": \"https://youtube.com/{}\",\n \"tiktok\": \"https://www.tiktok.com/@{}\",\n \"facebook\": \"https://facebook.com/{}\",\n \"discord\": \"https://discord.gg/{}\"}\n for key, val in socials.items():\n val = (val or \"\").strip()\n if not val:\n continue\n url = val if val.startswith(\"http\") else \\\n tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n hit = platform_from_url(url)\n if hit and hit[0] != self.platform:\n cr.platforms = merge_accounts(cr.platforms, [\n account(hit[0], hit[1], \"cross_link\",\n url=url).finalize()])\n\n\nclass ApifyOnlyFans(_ApifySocialEnrich):\n source_id = \"onlyfans-apify\"\n platform = \"onlyfans\"\n actor = \"ka-onlyfans\"\n cap = 100\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"streams_count\": \"streams\",\n \"subscribe_price_usd\": \"subscribe_price_usd\",\n \"is_free\": \"is_free\", \"location\": \"location\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyFansly(_ApifySocialEnrich):\n source_id = \"fansly-apify\"\n platform = \"fansly\"\n actor = \"ka-fansly\"\n cap = 600\n revisit_days = 7\n metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}\n\n\nclass ApifyPatreon(_ApifySocialEnrich):\n source_id = \"patreon-apify\"\n platform = \"patreon\"\n actor = \"ka-patreon\"\n cap = 400\n revisit_days = 7\n metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\",\n \"creation_name\": \"creation_name\",\n \"social_links\": \"social_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n return list((it.get(\"social_links\") or {}).values())\n\n\nclass ApifyDiscord(_ApifySocialEnrich):\n source_id = \"discord-apify\"\n platform = \"discord\"\n actor = \"ka-discord\"\n cap = 400\n revisit_days = 7\n metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}\n\n def target_of(self, acc) -> str:\n \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>.\"\"\"\n url = acc.url or \"\"\n for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n if sep in url:\n return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n return acc.handle\n\n\n# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n\n# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n \"créateur de contenu\", \"créatrice de contenu\"]\n_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n\n\nclass InstagramDecouverteApify(BaseConnector):\n \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n\n L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n EST la source de la fiche.\n \"\"\"\n\n source_id = \"instagram-decouverte\"\n kind = \"discovery\"\n queries_per_pass = 40\n\n def build_queries(self) -> list[str]:\n combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n day = datetime.now(timezone.utc).timetuple().tm_yday\n start = (day * self.queries_per_pass) % len(combos)\n rotated = combos[start:] + combos[:start]\n return rotated[:self.queries_per_pass]\n\n def fetch(self) -> list[Creator]:\n # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n # une session authentifiée (401 « Server Error » depuis toute IP non\n # connectée). La découverte par mots-clés est donc suspendue proprement\n # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n # (instagram-apify) alimente `related_profiles` comme graine future.\n raise SkipSource(\"topsearch Instagram exige une session connectée \"\n \"(voie publique fermée le 2026-08-18)\")\n items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n \"usernames\": [],\n \"queries\": self.build_queries(),\n \"proxyConfiguration\": RESIDENTIAL,\n })\n creators: list[Creator] = []\n seen: set[str] = set()\n for it in items:\n if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n continue\n handle = str(it[\"username\"]).lower()\n if handle in seen:\n continue\n seen.add(handle)\n acc = account(\"instagram\", handle, \"profil_source\",\n followers=parse_count(it.get(\"followers\")),\n verified=it.get(\"is_verified\"),\n url=f\"https://www.instagram.com/{handle}/\")\n creators.append(Creator(\n source=self.source_id,\n external_id=handle,\n display_name=it.get(\"full_name\") or handle,\n avatar_url=it.get(\"avatar\") or None,\n platforms=[acc],\n notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n ))\n return creators\n","structuredPatch":[{"oldStart":537,"oldLines":7,"newStart":537,"newLines":9,"lines":[" cap = 400"," revisit_days = 7"," metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",","- \"partnered\": \"partnered\", \"guild_id\": \"guild_id\"}","+ \"partnered\": \"partnered\", \"guild_id\": \"guild_id\",","+ \"channel\": \"channel\", \"splash\": \"splash\",","+ \"vanity_url_code\": \"vanity_url_code\"}"," "," def target_of(self, acc) -> str:"," \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du"]}],"userModified":false,"replaceAll":false}}} |
|
427 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"411d60c6-ddc7-4508-b782-233e11db1236","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
428 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzsgQKLXHCU4tQZcZ3X","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStQsKpAEIERgCKkCG/yomu/w+NztVaD2zZKXG4ceVcHGOsyFA4lcll5PMc3dJSHWlzS4sJFqpwYJ+rxeTkR3XZ95hCOYiVSAKGIdYMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHkyZ7UBhIMGL6CyOIIIHqKPtuHGgyOPNin7uiHpari29YiMLksAX6YSXzeOM8ji4CU48QLmYxk3FNJpP3dUOKoEUPp2ALMBVw0Bzgq1EF6J1gV7iq9CbVXMtG1m7vuf6vN1AmbU7Nn2Ekifi9vbo0cKsLmAAEAJ9FJbcqCvNqvyAgvQ8Ibcnnpa5b3lgt8ClMmG5J31QTsspSn6zi2gzEmqec75Upe+Sh62t2B+Pm74i4yf2wRXH8Qj6H6zjywzUF3/XOhR+Q9osCRwoTYILbtpoYBMFV/WvVIN2h/k4kWvy6WBQvNTLJvELscQY4w9aHZ+7KlGeMAfv75+eHt0DqMx12MDgUxCRPYy9sHF50wVzHcRHevMaGRfj3yEhzEFREbrTI9T/O6xQBHN9FDOrIIVSqcYl8WSk+ysHpCEo03uEqHJoI3Q/rwSJOpQ4oF9iqlkT1U9UncLgR0j0yFp120ZmkzoXDdYYvv0F7She7ytYCLsq1B7DDkupoMB6ZxeBCO3qBXbkK2s6PfDTNjjQbkEpjbLCAcERgXCnVtCiLjM7Rehbc5Aa8xzymF/d++2xEFEZHdEl/99XEXqnZVUzz4PLvbP61M/WeW5ibhRD9GbX0V5xjGCd+WVUXDxlsmN0pL5XPWuSF+DVUu2hjGKgkDNYnlyz7/xg4QnNxw+mFjvrGyEUDuNtC0mu5NjgF19evGuU9TwQNywgiTjSW2f2nZtFQdB+xga65bZlTLqgKlR8Qn8UKaOLqHKOXeYH6dyrc7KIWOEqHzpVMBfLIT1f+5cYsVUAEjqT1v4Usu/nVcGRJveRbwwutrYX5dQshzdF9KyrWLCuksm8dEWJidHg3/eeatbKKbzi8HxJPnVDU91OmpWjiVkTCSGb3raydHvOlrEOIUaVfPq7lypoRKRXcbae/JM8EmoSqo8rCnsLboHyTe/Ji3VsHLuKFLQw+61om+tFks8VxRn8UEzu4gAR13MrmleAink+Y0vGnlr/XgDPhDX34vprVOHYn8GsEDH1lLmUM7gVs3WwN0MxuMXzbu3fpHoV4wCmlsyUB/HqYppvhOw6Jx/Jv0pAiRuYKTSerPORz9iZnbZbW6BjzfjanuuS0VfPyAHi4J3SEttQXUVvtRdilws3rBEDMVBuL82IPcNy2l4H//eNG0RbxFWc0CJ5qOr9m9iKMqVByX4CMV1O3K1jYbtu35kV9aoriQ0Tu3Y5h/3IiSRvpkU5vibK6+i3HM5qdvh65aW+I9H+fiknpWMBybiYWblVRdhIz0Um14tAHSCvexfHJzAWumTji0JH8JP5KlfCILp9xSre4Nunia0373EN1R4it46A32SikiIlgIcch3tzV4INBm27eWZayuO8QRnGamsWe02rq3DA8jQaJkPgfu+CUHPVfeu+h/JHA48FHRXGEUTfpRG9vs+gMMuQ/j2dLXi3snOXdfFtFYx2gFM17xN4atDUian89JSbuDplO5FrkEdh+ao2n2Q2/oIDJtlnradkto9MtxuGaewwd+TQMjnVuoFmfE97xBORZxoe8s3VpuFFLyHNi4mBGGMvPhjmthuTlWh2ZSu86L8w/92YthX/0sS+rXuIWrH5irroi/+j3l1apevI36Yx5R5zYdZJ13j2NuBo+r03qPV/SyrNMV/66fUvB0iCZbkL+UNJrJZJq+t6eZLs8QFdr55m6mh/+LuHz1xI1edTBv4EcSmcDtuUZqa0NgfIKwPKAYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2733,"cache_read_input_tokens":79961,"cache_creation":{"ephemeral_5m_input_tokens":2733,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9372eb70-a474-482a-91d7-82a8e10f1dc0"}} |
|
429 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzsgQKLXHCU4tQZcZ3X","type":"message","role":"assistant","content":[{"type":"text","text":"Metric_maps étendus pour les 9 plateformes. Je lance les runs de test de validation (ka-twitch GQL et ka-youtube /about) en arrière-plan pendant que j'attaque les snapshots DB."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2733,"cache_read_input_tokens":79961,"cache_creation":{"ephemeral_5m_input_tokens":2733,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d3927a5e-6839-4446-a0e2-2e8e87fd87c0"}} |
|
430 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzsgQKLXHCU4tQZcZ3X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017sRwYH9f71HWYjtPujJahm","name":"Bash","input":{"command":"curl -s -X POST \"https://api.apify.com/v2/acts/gorgeous_thistle~ka-twitch/run-sync-get-dataset-items?token=$APIFY_TOKEN&memory=1024&timeout=300\" -H \"Content-Type: application/json\" -d '{\"usernames\":[\"mikelenil\",\"lirik\"],\"proxyConfiguration\":{\"useApifyProxy\":true,\"apifyProxyGroups\":[\"RESIDENTIAL\"]},\"concurrency\":2}' -o /tmp/test-ka-twitch.json; echo \"exit=$?\"; head -c 300 /tmp/test-ka-twitch.json","description":"Test run ka-twitch (validate GQL socialMedias/thumbnails)","run_in_background":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2733,"cache_read_input_tokens":79961,"cache_creation":{"ephemeral_5m_input_tokens":2733,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a6e3ff7f-6b2a-4bc1-b575-ffb8d160c733"}} |
|
431 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bmbmioeas","tool_use_id":"toolu_017sRwYH9f71HWYjtPujJahm","description":"Test run ka-twitch (validate GQL socialMedias/thumbnails)","task_type":"local_bash","uuid":"d7b4359a-12c5-4436-a6ad-b96f1be48b95","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
432 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017sRwYH9f71HWYjtPujJahm","type":"tool_result","content":"Command running in background with ID: bmbmioeas. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/bmbmioeas.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b9245763-14f5-4add-aceb-6d6e10d0e261","timestamp":"2026-08-21T01:07:52.372Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bmbmioeas"}}} |
|
433 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzsgQKLXHCU4tQZcZ3X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019ik2tTWeHD1x1LiGxbvhRQ","name":"Bash","input":{"command":"curl -s -X POST \"https://api.apify.com/v2/acts/gorgeous_thistle~ka-youtube/run-sync-get-dataset-items?token=$APIFY_TOKEN&memory=1024&timeout=300\" -H \"Content-Type: application/json\" -d '{\"usernames\":[\"PLPodcastQc\",\"mrbeast\"],\"proxyConfiguration\":{\"useApifyProxy\":true,\"apifyProxyGroups\":[\"RESIDENTIAL\"]},\"concurrency\":2,\"fetchAbout\":true}' -o /tmp/test-ka-youtube.json; echo \"exit=$?\"; head -c 300 /tmp/test-ka-youtube.json","description":"Test run ka-youtube (validate /about + banner)","run_in_background":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2733,"cache_read_input_tokens":79961,"cache_creation":{"ephemeral_5m_input_tokens":2733,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a2a4c836-515e-49f3-97b6-acdadb82f422"}} |
|
434 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bsrz2rkm9","tool_use_id":"toolu_019ik2tTWeHD1x1LiGxbvhRQ","description":"Test run ka-youtube (validate /about + banner)","task_type":"local_bash","uuid":"a6d82273-fb79-40e3-bd77-d4649c140db0","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
435 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019ik2tTWeHD1x1LiGxbvhRQ","type":"tool_result","content":"Command running in background with ID: bsrz2rkm9. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/421167e4-872a-4d48-8f92-42b50bf5d5ca/tasks/bsrz2rkm9.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"32f88804-2f53-4388-9644-f9e1c451d0ac","timestamp":"2026-08-21T01:07:54.964Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bsrz2rkm9"}}} |
|
436 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzsgQKLXHCU4tQZcZ3X","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011aWaRvEuXAAcqcTX9urF72","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2733,"cache_read_input_tokens":79961,"cache_creation":{"ephemeral_5m_input_tokens":2733,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8f6c8bdf-38db-446e-ba3a-8fca5fa19a7f"}} |
|
437 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011aWaRvEuXAAcqcTX9urF72","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/db.py\n4\t# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n5\t# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n6\t# ==============================================================================\n7\t\"\"\"Base de données Créa-Ka (SQLite, WAL).\n8\t\n9\t- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n10\t indexées pour la recherche).\n11\t- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n12\t l'index qui permet la déduplication inter-sources (§12.2).\n13\t- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n14\t\n15\tPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\n16\thorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\n17\tpasse `opted_out` (masqué, jamais ré-agrégé).\n18\t\"\"\"\n19\tfrom __future__ import annotations\n20\t\n21\timport json\n22\timport sqlite3\n23\timport sys\n24\timport time\n25\tfrom dataclasses import asdict\n26\tfrom datetime import datetime, timedelta, timezone\n27\tfrom pathlib import Path\n28\t\n29\tfrom . import ethics\n30\tfrom .dedup import merge_creators\n31\tfrom .identity import needs_review\n32\tfrom .normalize import slugify\n33\tfrom .schema import Creator, PlatformAccount, now_iso\n34\t\n35\tROOT = Path(__file__).resolve().parent.parent\n36\tDB_PATH = ROOT / \"data\" / \"creaka.db\"\n37\t\n38\t_SCHEMA = \"\"\"\n39\tCREATE TABLE IF NOT EXISTS creators (\n40\t id TEXT PRIMARY KEY,\n41\t display_name TEXT NOT NULL,\n42\t status TEXT NOT NULL DEFAULT 'active',\n43\t is_minor INTEGER NOT NULL DEFAULT 0,\n44\t region TEXT, city TEXT,\n45\t niches TEXT, languages TEXT,\n46\t creator_type TEXT, primary_platform TEXT,\n47\t audience_tier TEXT, total_reach INTEGER,\n48\t bio TEXT,\n49\t first_seen TEXT, last_seen TEXT, updated_at TEXT,\n50\t doc TEXT NOT NULL\n51\t);\n52\tCREATE TABLE IF NOT EXISTS accounts (\n53\t platform TEXT NOT NULL,\n54\t handle TEXT NOT NULL,\n55\t creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n56\t url TEXT NOT NULL,\n57\t followers INTEGER,\n58\t verified INTEGER,\n59\t confidence REAL NOT NULL,\n60\t signal TEXT,\n61\t needs_review INTEGER NOT NULL DEFAULT 0,\n62\t last_checked TEXT,\n63\t metrics TEXT,\n64\t PRIMARY KEY (platform, handle)\n65\t);\n66\tCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\n67\tCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\n68\tCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\n69\tCREATE TABLE IF NOT EXISTS sync_log (\n70\t id INTEGER PRIMARY KEY AUTOINCREMENT,\n71\t ts TEXT NOT NULL,\n72\t source TEXT NOT NULL,\n73\t creators INTEGER, accounts INTEGER, avg_confidence REAL,\n74\t added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n75\t alert TEXT\n76\t);\n77\t\"\"\"\n78\t\n79\t\n80\tdef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n81\t Path(path).parent.mkdir(parents=True, exist_ok=True)\n82\t con = sqlite3.connect(path, check_same_thread=False)\n83\t con.row_factory = sqlite3.Row\n84\t con.execute(\"PRAGMA journal_mode=WAL\")\n85\t con.execute(\"PRAGMA foreign_keys=ON\")\n86\t con.executescript(_SCHEMA)\n87\t # migrations légères (bases existantes)\n88\t try:\n89\t con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n90\t except sqlite3.OperationalError:\n91\t pass\n92\t return con\n93\t\n94\t\n95\t# --- (dé)sérialisation ----------------------------------------------------------\n96\t\n97\tdef creator_to_doc(cr: Creator) -> str:\n98\t return json.dumps(asdict(cr), ensure_ascii=False)\n99\t\n100\t\n101\tdef creator_from_row(row: sqlite3.Row) -> Creator:\n102\t doc = json.loads(row[\"doc\"])\n103\t doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n104\t cr = Creator(**doc)\n105\t return cr\n106\t\n107\t\n108\tdef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n109\t \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n110\t doc = json.loads(row[\"doc\"])\n111\t doc[\"id\"] = row[\"id\"]\n112\t doc[\"first_seen\"] = row[\"first_seen\"]\n113\t doc[\"last_seen\"] = row[\"last_seen\"]\n114\t doc[\"updated_at\"] = row[\"updated_at\"]\n115\t doc[\"platforms\"] = [\n116\t {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n117\t \"followers\": a[\"followers\"],\n118\t \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n119\t \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n120\t \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n121\t for a in accounts if not a[\"needs_review\"]]\n122\t # champs internes / sensibles jamais exposés tels quels\n123\t doc.pop(\"source\", None)\n124\t doc.pop(\"external_id\", None)\n125\t doc.pop(\"legal_name\", None)\n126\t return doc\n127\t\n128\t\n129\t# --- écriture -------------------------------------------------------------------\n130\t\n131\tdef _unique_id(con: sqlite3.Connection, base: str) -> str:\n132\t cid, n = base, 2\n133\t while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n134\t cid = f\"{base}-{n}\"\n135\t n += 1\n136\t return cid\n137\t\n138\t\n139\tdef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n140\t first_seen: str, ts: str) -> None:\n141\t # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n142\t # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n143\t old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n144\t \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n145\t lost = old_keys - {a.key for a in cr.platforms}\n146\t if lost:\n147\t print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n148\t f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n149\t con.execute(\n150\t \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n151\t niches, languages, creator_type, primary_platform, audience_tier,\n152\t total_reach, bio, first_seen, last_seen, updated_at, doc)\n153\t VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n154\t ON CONFLICT(id) DO UPDATE SET\n155\t display_name=excluded.display_name, status=excluded.status,\n156\t is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n157\t niches=excluded.niches, languages=excluded.languages,\n158\t creator_type=excluded.creator_type,\n159\t primary_platform=excluded.primary_platform,\n160\t audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n161\t bio=excluded.bio, last_seen=excluded.last_seen,\n162\t updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n163\t (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n164\t \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n165\t cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n166\t first_seen, ts, ts, creator_to_doc(cr)))\n167\t con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n168\t for acc in cr.platforms:\n169\t con.execute(\n170\t \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n171\t verified, confidence, signal, needs_review, last_checked, metrics)\n172\t VALUES (?,?,?,?,?,?,?,?,?,?,?)\n173\t ON CONFLICT(platform, handle) DO UPDATE SET\n174\t creator_id=excluded.creator_id, url=excluded.url,\n175\t followers=excluded.followers, verified=excluded.verified,\n176\t confidence=excluded.confidence, signal=excluded.signal,\n177\t needs_review=excluded.needs_review,\n178\t last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n179\t (acc.platform, acc.handle, cid, acc.url, acc.followers,\n180\t None if acc.verified is None else int(acc.verified),\n181\t acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n182\t json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n183\t\n184\t\n185\tdef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n186\t \"\"\"Fiche existante possédant déjà un des comptes entrants.\n187\t\n188\t Même plateforme + même handle = MÊME compte : la table accounts n'admet\n189\t qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n190\t fait donc sur l'identité exacte du compte, sans condition de confiance —\n191\t exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n192\t 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n193\t snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n194\t comptes lui réassignait le compte, laissant l'originale orpheline\n195\t (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n196\t l'égalité stricte plateforme+handle identifie le compte lui-même.\n197\t \"\"\"\n198\t for acc in cr.platforms:\n199\t row = con.execute(\n200\t \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n201\t (acc.platform, acc.handle)).fetchone()\n202\t if row:\n203\t return row[\"creator_id\"]\n204\t return None\n205\t\n206\t\n207\tdef sync_source(con: sqlite3.Connection, source_id: str,\n208\t creators: list[Creator], *, started: float | None = None,\n209\t errors: int = 0) -> dict:\n210\t \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n211\t\n212\t `started` (time.time() du début du passage) → durée réelle persistée dans\n213\t sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n214\t connecteur (§16 logging structuré).\n215\t \"\"\"\n216\t ts = now_iso()\n217\t added = updated = skipped_optout = 0\n218\t confs: list[float] = []\n219\t for cr in creators:\n220\t cr = ethics.scrub(cr)\n221\t # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n222\t if ethics.is_opted_out(cr):\n223\t skipped_optout += 1\n224\t existing = _find_existing(con, cr)\n225\t if existing:\n226\t con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n227\t \"WHERE id=?\", (ts, existing))\n228\t continue\n229\t confs.extend(a.confidence or 0 for a in cr.platforms)\n230\t existing_id = _find_existing(con, cr)\n231\t if existing_id:\n232\t row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n233\t (existing_id,)).fetchone()\n234\t current = creator_from_row(row)\n235\t if current.status == \"opted_out\":\n236\t continue # masqué : on ne ré-agrège pas\n237\t # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n238\t # partagé : c'est le même compte, la fusion est toujours sûre —\n239\t # pas une fusion sur le nom (§12.2)\n240\t merged = merge_creators(current, cr)\n241\t _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n242\t updated += 1\n243\t continue\n244\t cid = _unique_id(con, slugify(cr.display_name))\n245\t _write_creator(con, cid, cr, ts, ts)\n246\t added += 1\n247\t con.commit()\n248\t n_acc = sum(len(c.platforms) for c in creators)\n249\t stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n250\t \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n251\t \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n252\t alert = None\n253\t if not creators:\n254\t alert = \"0 créateur retourné — source possiblement bloquée\"\n255\t stats[\"alert\"] = alert\n256\t seconds = round(time.time() - started, 1) if started else 0.0\n257\t stats[\"seconds\"] = seconds\n258\t if errors:\n259\t stats[\"errors\"] = errors\n260\t con.execute(\n261\t \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n262\t \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n263\t (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n264\t added, updated, errors, seconds, alert))\n265\t con.commit()\n266\t return stats\n267\t\n268\t\n269\tdef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n270\t \"\"\"Archivage des disparus (§13, politique de grâce).\n271\t\n272\t Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n273\t complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n274\t Réversible : dès qu'une source le revoit, la fusion (sync_source →\n275\t _write_creator) réécrit son statut actif et son `last_seen`.\n276\t Les opt-out ne sont JAMAIS touchés (status != 'active').\n277\t \"\"\"\n278\t cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n279\t .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n280\t ts = now_iso()\n281\t cur = con.execute(\n282\t \"UPDATE creators SET status='inactive', updated_at=? \"\n283\t \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n284\t (ts, cutoff))\n285\t con.commit()\n286\t if cur.rowcount:\n287\t print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n288\t f\"depuis {hours} h → inactive (réversible au retour)\")\n289\t return cur.rowcount\n290\t\n291\t\n292\tdef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n293\t con.execute(\n294\t \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n295\t \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n296\t con.commit()\n297\t\n298\t\n299\tdef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n300\t account: str | None = None) -> int:\n301\t \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n302\t ids: set[str] = set()\n303\t if account and \":\" in account:\n304\t platform, handle = account.lower().split(\":\", 1)\n305\t for row in con.execute(\n306\t \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n307\t (platform, handle.lstrip(\"@\"))):\n308\t ids.add(row[\"creator_id\"])\n309\t if name:\n310\t from .dedup import name_key\n311\t for row in con.execute(\"SELECT id, display_name FROM creators\"):\n312\t if name_key(row[\"display_name\"]) == name_key(name):\n313\t ids.add(row[\"id\"])\n314\t ts = now_iso()\n315\t for cid in ids:\n316\t con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n317\t (ts, cid))\n318\t con.commit()\n319\t return len(ids)\n320\t\n321\t\n322\t# --- lecture (API) ----------------------------------------------------------------\n323\t\n324\t_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n325\t\n326\t\n327\tdef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n328\t region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n329\t tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n330\t offset: int = 0) -> dict:\n331\t \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n332\t\n333\t Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n334\t exclues de l'annuaire public (prudence extrême, §15).\n335\t \"\"\"\n336\t where = [\"status='active'\", \"is_minor=0\"]\n337\t params: list = []\n338\t if q:\n339\t where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n340\t needle = f\"%{q.lower()}%\"\n341\t params += [needle, needle]\n342\t if niche:\n343\t where.append(\"(','||niches||',') LIKE ?\")\n344\t params.append(f\"%,{niche},%\")\n345\t if region:\n346\t where.append(\"region=?\")\n347\t params.append(region)\n348\t if langue:\n349\t where.append(\"(','||languages||',') LIKE ?\")\n350\t params.append(f\"%,{langue},%\")\n351\t if tier in _TIERS:\n352\t where.append(\"audience_tier=?\")\n353\t params.append(tier)\n354\t if plateforme:\n355\t where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n356\t \"AND needs_review=0)\")\n357\t params.append(plateforme)\n358\t order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n359\t \"nom\": \"display_name COLLATE NOCASE ASC\",\n360\t \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n361\t sql_where = \" AND \".join(where)\n362\t total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n363\t params).fetchone()[\"c\"]\n364\t rows = con.execute(\n365\t f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n366\t params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n367\t items = []\n368\t for row in rows:\n369\t accounts = con.execute(\n370\t \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n371\t (row[\"id\"],)).fetchall()\n372\t items.append(_public_dict(row, accounts))\n373\t return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n374\t\n375\t\n376\tdef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n377\t row = con.execute(\n378\t \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n379\t (cid,)).fetchone()\n380\t if not row:\n381\t return None\n382\t accounts = con.execute(\n383\t \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n384\t (row[\"id\"],)).fetchall()\n385\t return _public_dict(row, accounts)\n386\t\n387\t\n388\tdef stats(con: sqlite3.Connection) -> dict:\n389\t \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n390\t base = \"FROM creators WHERE status='active' AND is_minor=0\"\n391\t n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n392\t n_acc = con.execute(\n393\t \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n394\t \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n395\t ).fetchone()[\"c\"]\n396\t by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n397\t \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n398\t \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n399\t \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n400\t by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n401\t f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n402\t niches: dict[str, int] = {}\n403\t for row in con.execute(f\"SELECT niches {base}\"):\n404\t for nch in (row[\"niches\"] or \"\").split(\",\"):\n405\t if nch:\n406\t niches[nch] = niches.get(nch, 0) + 1\n407\t regions: dict[str, int] = {}\n408\t for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n409\t regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n410\t last_sync = con.execute(\n411\t \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n412\t return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n413\t \"by_tier\": by_tier,\n414\t \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n415\t \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n416\t \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n417\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5a7a290d-9d5c-4bc6-98a4-84543ba30502","timestamp":"2026-08-21T01:07:55.498Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/db.py\n# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n# ==============================================================================\n\"\"\"Base de données Créa-Ka (SQLite, WAL).\n\n- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n indexées pour la recherche).\n- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n l'index qui permet la déduplication inter-sources (§12.2).\n- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n\nPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\nhorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\npasse `opted_out` (masqué, jamais ré-agrégé).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport sys\nimport time\nfrom dataclasses import asdict\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom . import ethics\nfrom .dedup import merge_creators\nfrom .identity import needs_review\nfrom .normalize import slugify\nfrom .schema import Creator, PlatformAccount, now_iso\n\nROOT = Path(__file__).resolve().parent.parent\nDB_PATH = ROOT / \"data\" / \"creaka.db\"\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS creators (\n id TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'active',\n is_minor INTEGER NOT NULL DEFAULT 0,\n region TEXT, city TEXT,\n niches TEXT, languages TEXT,\n creator_type TEXT, primary_platform TEXT,\n audience_tier TEXT, total_reach INTEGER,\n bio TEXT,\n first_seen TEXT, last_seen TEXT, updated_at TEXT,\n doc TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS accounts (\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n url TEXT NOT NULL,\n followers INTEGER,\n verified INTEGER,\n confidence REAL NOT NULL,\n signal TEXT,\n needs_review INTEGER NOT NULL DEFAULT 0,\n last_checked TEXT,\n metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\nCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\nCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n source TEXT NOT NULL,\n creators INTEGER, accounts INTEGER, avg_confidence REAL,\n added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n alert TEXT\n);\n\"\"\"\n\n\ndef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(path, check_same_thread=False)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA journal_mode=WAL\")\n con.execute(\"PRAGMA foreign_keys=ON\")\n con.executescript(_SCHEMA)\n # migrations légères (bases existantes)\n try:\n con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n except sqlite3.OperationalError:\n pass\n return con\n\n\n# --- (dé)sérialisation ----------------------------------------------------------\n\ndef creator_to_doc(cr: Creator) -> str:\n return json.dumps(asdict(cr), ensure_ascii=False)\n\n\ndef creator_from_row(row: sqlite3.Row) -> Creator:\n doc = json.loads(row[\"doc\"])\n doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n cr = Creator(**doc)\n return cr\n\n\ndef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n doc = json.loads(row[\"doc\"])\n doc[\"id\"] = row[\"id\"]\n doc[\"first_seen\"] = row[\"first_seen\"]\n doc[\"last_seen\"] = row[\"last_seen\"]\n doc[\"updated_at\"] = row[\"updated_at\"]\n doc[\"platforms\"] = [\n {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n \"followers\": a[\"followers\"],\n \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n for a in accounts if not a[\"needs_review\"]]\n # champs internes / sensibles jamais exposés tels quels\n doc.pop(\"source\", None)\n doc.pop(\"external_id\", None)\n doc.pop(\"legal_name\", None)\n return doc\n\n\n# --- écriture -------------------------------------------------------------------\n\ndef _unique_id(con: sqlite3.Connection, base: str) -> str:\n cid, n = base, 2\n while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n cid = f\"{base}-{n}\"\n n += 1\n return cid\n\n\ndef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n first_seen: str, ts: str) -> None:\n # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n lost = old_keys - {a.key for a in cr.platforms}\n if lost:\n print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n con.execute(\n \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n niches, languages, creator_type, primary_platform, audience_tier,\n total_reach, bio, first_seen, last_seen, updated_at, doc)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(id) DO UPDATE SET\n display_name=excluded.display_name, status=excluded.status,\n is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n niches=excluded.niches, languages=excluded.languages,\n creator_type=excluded.creator_type,\n primary_platform=excluded.primary_platform,\n audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n bio=excluded.bio, last_seen=excluded.last_seen,\n updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n first_seen, ts, ts, creator_to_doc(cr)))\n con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n for acc in cr.platforms:\n con.execute(\n \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n verified, confidence, signal, needs_review, last_checked, metrics)\n VALUES (?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id, url=excluded.url,\n followers=excluded.followers, verified=excluded.verified,\n confidence=excluded.confidence, signal=excluded.signal,\n needs_review=excluded.needs_review,\n last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n\n\ndef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n \"\"\"Fiche existante possédant déjà un des comptes entrants.\n\n Même plateforme + même handle = MÊME compte : la table accounts n'admet\n qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n fait donc sur l'identité exacte du compte, sans condition de confiance —\n exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n comptes lui réassignait le compte, laissant l'originale orpheline\n (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n l'égalité stricte plateforme+handle identifie le compte lui-même.\n \"\"\"\n for acc in cr.platforms:\n row = con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (acc.platform, acc.handle)).fetchone()\n if row:\n return row[\"creator_id\"]\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source_id: str,\n creators: list[Creator], *, started: float | None = None,\n errors: int = 0) -> dict:\n \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n\n `started` (time.time() du début du passage) → durée réelle persistée dans\n sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n connecteur (§16 logging structuré).\n \"\"\"\n ts = now_iso()\n added = updated = skipped_optout = 0\n confs: list[float] = []\n for cr in creators:\n cr = ethics.scrub(cr)\n # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n if ethics.is_opted_out(cr):\n skipped_optout += 1\n existing = _find_existing(con, cr)\n if existing:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n \"WHERE id=?\", (ts, existing))\n continue\n confs.extend(a.confidence or 0 for a in cr.platforms)\n existing_id = _find_existing(con, cr)\n if existing_id:\n row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n (existing_id,)).fetchone()\n current = creator_from_row(row)\n if current.status == \"opted_out\":\n continue # masqué : on ne ré-agrège pas\n # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n # partagé : c'est le même compte, la fusion est toujours sûre —\n # pas une fusion sur le nom (§12.2)\n merged = merge_creators(current, cr)\n _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n updated += 1\n continue\n cid = _unique_id(con, slugify(cr.display_name))\n _write_creator(con, cid, cr, ts, ts)\n added += 1\n con.commit()\n n_acc = sum(len(c.platforms) for c in creators)\n stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n alert = None\n if not creators:\n alert = \"0 créateur retourné — source possiblement bloquée\"\n stats[\"alert\"] = alert\n seconds = round(time.time() - started, 1) if started else 0.0\n stats[\"seconds\"] = seconds\n if errors:\n stats[\"errors\"] = errors\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n added, updated, errors, seconds, alert))\n con.commit()\n return stats\n\n\ndef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n \"\"\"Archivage des disparus (§13, politique de grâce).\n\n Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n Réversible : dès qu'une source le revoit, la fusion (sync_source →\n _write_creator) réécrit son statut actif et son `last_seen`.\n Les opt-out ne sont JAMAIS touchés (status != 'active').\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n ts = now_iso()\n cur = con.execute(\n \"UPDATE creators SET status='inactive', updated_at=? \"\n \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n (ts, cutoff))\n con.commit()\n if cur.rowcount:\n print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n f\"depuis {hours} h → inactive (réversible au retour)\")\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n con.commit()\n\n\ndef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n account: str | None = None) -> int:\n \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n ids: set[str] = set()\n if account and \":\" in account:\n platform, handle = account.lower().split(\":\", 1)\n for row in con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (platform, handle.lstrip(\"@\"))):\n ids.add(row[\"creator_id\"])\n if name:\n from .dedup import name_key\n for row in con.execute(\"SELECT id, display_name FROM creators\"):\n if name_key(row[\"display_name\"]) == name_key(name):\n ids.add(row[\"id\"])\n ts = now_iso()\n for cid in ids:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n (ts, cid))\n con.commit()\n return len(ids)\n\n\n# --- lecture (API) ----------------------------------------------------------------\n\n_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n\n\ndef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n offset: int = 0) -> dict:\n \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n\n Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n exclues de l'annuaire public (prudence extrême, §15).\n \"\"\"\n where = [\"status='active'\", \"is_minor=0\"]\n params: list = []\n if q:\n where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n needle = f\"%{q.lower()}%\"\n params += [needle, needle]\n if niche:\n where.append(\"(','||niches||',') LIKE ?\")\n params.append(f\"%,{niche},%\")\n if region:\n where.append(\"region=?\")\n params.append(region)\n if langue:\n where.append(\"(','||languages||',') LIKE ?\")\n params.append(f\"%,{langue},%\")\n if tier in _TIERS:\n where.append(\"audience_tier=?\")\n params.append(tier)\n if plateforme:\n where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n \"AND needs_review=0)\")\n params.append(plateforme)\n order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n \"nom\": \"display_name COLLATE NOCASE ASC\",\n \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n sql_where = \" AND \".join(where)\n total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n params).fetchone()[\"c\"]\n rows = con.execute(\n f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n items = []\n for row in rows:\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n items.append(_public_dict(row, accounts))\n return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n row = con.execute(\n \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n (cid,)).fetchone()\n if not row:\n return None\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n return _public_dict(row, accounts)\n\n\ndef stats(con: sqlite3.Connection) -> dict:\n \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n base = \"FROM creators WHERE status='active' AND is_minor=0\"\n n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n n_acc = con.execute(\n \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n ).fetchone()[\"c\"]\n by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n niches: dict[str, int] = {}\n for row in con.execute(f\"SELECT niches {base}\"):\n for nch in (row[\"niches\"] or \"\").split(\",\"):\n if nch:\n niches[nch] = niches.get(nch, 0) + 1\n regions: dict[str, int] = {}\n for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n last_sync = con.execute(\n \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n \"by_tier\": by_tier,\n \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n","numLines":417,"startLine":1,"totalLines":417}}}} |
|
438 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"29779bc2-6534-4854-a12a-b02e7adead8a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
439 |
+{"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"bmbmioeas","patch":{"status":"completed","end_time":1787274479120},"uuid":"0ffb16ce-7442-4987-9407-e15106ebe326","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
440 |
+{"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"bsrz2rkm9","patch":{"status":"completed","end_time":1787274483165},"uuid":"3e0a9105-4ee4-43a3-aab4-e06089554489","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
441 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzu2gFDQKSB6hQNcZQ4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISggwKpAEIERgCKkDXNNP0bhRGehSAFCNrmI8LHrJlmnhUAsy0aZjla+4RoI39OZ+SyU6XLqx+vuQieODElwaH3ciJBMfzFldWIwezMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH1yZ7UBhIMy+PGwP69HNceRX4XGgxcHdZSqi6Pn6oB2ZciMAu9xyOvtarm+JpzdYVpU0/ZrUtjOWuXgej21pW/QfqiduT4ZA4z/lHmo3e/yIKlbyqKCh3kfaFOpDAAoECn+qquSzKOCR37K0ZyMtkvfoDAIEUHWtEy3bmoT4ISBV+2AxQTuQDs+IBMwj8ijhIRqcSUKtVMiaUFCNXATPSTHo0mC+dfVS+wElin8o04Fem02VGpxLbc9HpKQPZGVtdgOFHCeZZ7vzKXLommERYDGT44jI9qs9/vt+RPqe5XTZkmlH3rhH8OX7nwstvPLUVao5hHqpLfzhV3SLdifaXevmcDcRtAPQbDchJqFPltQGqWP72f78liqOt8TJCWbN6sYayk2spvoA1fAuzQu2vxv4DNIDsT8kmZYE/OAnXrHb7fFyR9OfLomtbz8gXzOlc5HLYxbuI9YZR8o1dThENjlJl+2YL8q64MxSdKAFtW743iQVBslbZ/ZVJF4TLkcAsfkMadvNi4Q32KAGIFXKa2xPJ8pMPQSHf1AJFFnoRROVcbpKMqqdcppaXciloWJfNOyIVnNhbY2P+7aX4ci7Ew3XjjHHhZnwxXA67vkw5nsmQGSV2KPOw+NRmL0hqvmz1ThWCG/6KWjpL8KZYkYC2tSMXtHk761J/300tdiFEnodjJspOdpw2LjKPjg7Seq5WUrI8OF3ksLHIhyu+8yqKnCK3DmvcwawuXvuZupIwLljnODqs+rSr3ggvqE1fpj8wCVgEXLeiS+3Hano7fhWjtr18wxaGpkWjBWmhZqFzxwgVtb8QHPq2Z+SpwTmu0Fa/QIdWINsss5NNhUhCnrejskb8jtfhy6ntmOZYXyjCmxNWYY2BVRT6/BZruZE6knMivf3i2iLI0/3/2C7GsliNUWF/eevS/CC4QBP3RKqkYkFLuIaUjJ9QLXTc3fBfG5TNuw30ANoPloUzFNWtVdZcJNSWYU00LTvjFN/+O41cOdCnApihzt3YeCaEPoWaTduYK/FfmOV2p5O3ztUaFS7gaNY0y8KP6hsAOSWdOnWXuBhw6z+nKbxgm028wluQSD6yNG/Gt9d0ugoZE3NIyxPbTOmEr/hHkbiWRKU/Kuvz2tjJO5CZVj7Xk7742s1zkseSikXo1bFomu3Gcw08aCX6eEsk9zx8usuTrJwol/R14xTp4M9UwJCLnFr5goUiW4jPkw6gHjzg8S+SvcokymdcE6gIofZ4xcy3J1OX+AZr5J/pAvTiwtZIcw0sBDNCX7A6i2wysymezUYh6Zm8pMUtZQepu70Prf92LGW2UGixpExRqumQCav86zbZvKIb62KuapqpabuUdgwWS52OF3WrNwFMXIRlU86V3rz+etXlGUzHRzkb86FhxE296LRWuO80BW/OA+tfnMlu+/jaJBHcqwp/EMGGR9W4qpNkrVo0Y/xrr9p0uGkHurGz7uF/5VL3/6hG8PFMMPgHgelL0SfuTWGXegYzg73YgtPewY6FzUYfpo0wOd+fuKwCIkntCV/julgTjBSW+aIjvT811/vtGdsyLRTjvi4ukYhrbSWF0D7kwtiqa3ZvtO7oFf7eEjCO48cTTBr3ESsIuXLiFz92HpszpZenUL7aKK2CZ5bsmhgAGf0MrRkWhQulW6QExq0qEXuR6M/E2UWFezN1Wc793jiDpYPSHj7xZbEO9H8E5S867VRN6L70ubW5VWaR4gpB4SwCu7pgaWE/1SJntl/Q6pik0QvMsHuTUI7crpKu6wE2tSNanSDVO8e1KoEI0W/Hzu69HshHictPUCbTijneZmpM2uN8aYH7VgbJb261c8xgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":10168,"cache_read_input_tokens":82694,"cache_creation":{"ephemeral_5m_input_tokens":10168,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"451c5e30-fee5-4853-9527-f35e7d8cad8c"}} |
|
442 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzu2gFDQKSB6hQNcZQ4","type":"message","role":"assistant","content":[{"type":"text","text":"J'ajoute la table `snapshots` et l'historisation dans db.py."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":10168,"cache_read_input_tokens":82694,"cache_creation":{"ephemeral_5m_input_tokens":10168,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d7ef0742-afac-4d39-b5bb-a5aa610b88ed"}} |
|
443 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzu2gFDQKSB6hQNcZQ4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JGnTS1PQhH6yA1PUoXveA5","name":"Bash","input":{"command":"ls ~/apps/crea-ka/creaka/ && grep -n \"get_creator\\|def api_\\|@app\" ~/apps/crea-ka/creaka/web.py | head -30","description":"List creaka modules and web.py endpoints"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":10168,"cache_read_input_tokens":82694,"cache_creation":{"ephemeral_5m_input_tokens":10168,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f3f0ac23-668c-458d-aeb1-a5e845f3b4ed"}} |
|
444 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JGnTS1PQhH6yA1PUoXveA5","type":"tool_result","content":"__init__.py\n__pycache__\nauth.py\nconnectors\ndb.py\ndedup.py\nethics.py\nidentity.py\ningest.py\nkapdf.py\nnormalize.py\nschema.py\nstats.py\nweb.py\n51:@app.get(\"/healthz\")\n56:@app.get(\"/api/health\")\n63:@app.get(\"/api/creators\")\n73:@app.get(\"/api/creators/{cid}\")\n74:def get_creator(cid: str):\n75: creator = db.get_creator(_db(), cid)\n81:@app.get(\"/api/stats\")\n89:@app.get(\"/api/stats/dashboard\")\n101:@app.get(\"/api/stats/report\")\n122:@app.get(\"/api/taxonomies\")\n130:@app.get(\"/api/sources\")\n146:@app.post(\"/api/optout\")\n166:@app.get(\"/\")\n174:@app.get(\"/{path:path}\")","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"dd5030e4-4fc6-44a9-b092-1e2e7b4b903f","timestamp":"2026-08-21T01:08:06.309Z","tool_use_result":{"stdout":"__init__.py\n__pycache__\nauth.py\nconnectors\ndb.py\ndedup.py\nethics.py\nidentity.py\ningest.py\nkapdf.py\nnormalize.py\nschema.py\nstats.py\nweb.py\n51:@app.get(\"/healthz\")\n56:@app.get(\"/api/health\")\n63:@app.get(\"/api/creators\")\n73:@app.get(\"/api/creators/{cid}\")\n74:def get_creator(cid: str):\n75: creator = db.get_creator(_db(), cid)\n81:@app.get(\"/api/stats\")\n89:@app.get(\"/api/stats/dashboard\")\n101:@app.get(\"/api/stats/report\")\n122:@app.get(\"/api/taxonomies\")\n130:@app.get(\"/api/sources\")\n146:@app.post(\"/api/optout\")\n166:@app.get(\"/\")\n174:@app.get(\"/{path:path}\")","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
445 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b1beac2c-ccbb-4cba-9400-d12d085500a3","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
446 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzupdUTPyzQWWwdhzEc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016neX8r6oTXAvptco9hLA7A","name":"Bash","input":{"command":"python3 -c \"\nimport json\nfor f in ('/tmp/test-ka-twitch.json','/tmp/test-ka-youtube.json'):\n print('===', f)\n try:\n items = json.load(open(f))\n except Exception as e:\n print('PARSE FAIL', e, open(f).read()[:300]); continue\n for it in items:\n if it.get('kind') != 'profile': continue\n print(it.get('username'), 'found=', it.get('found'), 'err=', it.get('error'))\n for k in ('followers','banner','total_views','joined_date','external_links','social_links','live_title','live_thumbnail','is_verified','avatar'):\n v = it.get(k)\n if v is not None:\n s = json.dumps(v, ensure_ascii=False)\n print(' ', k, '=', s[:180])\n rv = it.get('recent_videos') or []\n if rv:\n print(' recent_videos[0] keys:', sorted(rv[0].keys()))\n print(' thumb:', str(rv[0].get('thumbnail'))[:120])\n\"","description":"Inspect test run outputs for twitch and youtube"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1656,"cache_read_input_tokens":92862,"cache_creation":{"ephemeral_5m_input_tokens":1656,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"723e1d4d-f58b-421f-88ba-09791b27223d"}} |
|
447 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016neX8r6oTXAvptco9hLA7A","type":"tool_result","content":"=== /tmp/test-ka-twitch.json\nmikelenil found= False err= not_found\nlirik found= True err= None\n followers = 2997718\n banner = \"https://static-cdn.jtvnw.net/jtv_user_pictures/ee47a480-27e4-4b0a-ad7a-5a244aa34a8d-profile_banner-480.png\"\n social_links = [{\"name\": \"twitter\", \"title\": \"Twitter\", \"url\": \"https://twitter.com/lirik\"}, {\"name\": \"instagram\", \"title\": \"Instagram\", \"url\": \"https://instagram.com/lirik\"}, {\"name\": \"YouTube\",\n is_verified = true\n avatar = \"https://static-cdn.jtvnw.net/jtv_user_pictures/38e925fc-0b07-4e1e-82e2-6639e01344f3-profile_image-300x300.png\"\n recent_videos[0] keys: ['duration_s', 'game', 'id', 'published_at', 'thumbnail', 'title', 'url', 'views']\n thumb: https://static-cdn.jtvnw.net/cf_vods/d2vi6trrdongqn/133fe0bca805fcaf640d_lirik_319864707678_1787069072//thumb/thumb0-320\n=== /tmp/test-ka-youtube.json\nPLPodcastQc found= False err= http_404\nmrbeast found= True err= None\n followers = 514000000\n banner = \"https://yt3.googleusercontent.com/mHMO_eEMp0dPvh0ADwXhPXNYb_GnjSVsLI8biqF1CpxT8OPl7izhNQsDPD3JHhd5y5Mg9GrP=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\"\n total_views = 137639410858\n joined_date = \"Joined Feb 19, 2012\"\n external_links = [{\"title\": \"$1,000,000 Contest\", \"url\": \"themostdangerousgames.com\"}, {\"title\": \"Follow\", \"url\": \"instagram.com/mrbeast\"}, {\"title\": \"Twitter\", \"url\": \"twitter.com/MrBeast\"}, {\"tit\n avatar = \"https://yt3.googleusercontent.com/nxYrc_1_2f77DoBadyxMTmv7ZpRZapHR5jbuYe7PlPd5cIRJxtNNEYyOC0ZsxaDyJJzXrnJiuDE=s900-c-k-c0x00ffffff-no-rj\"\n recent_videos[0] keys: ['duration', 'id', 'published', 'thumbnail', 'title', 'url', 'views']\n thumb: https://i.ytimg.com/vi/Af6i6ChAVTw/hqdefault.jpg","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8cb29964-cfc8-43d7-bec0-c87b4e3fb525","timestamp":"2026-08-21T01:08:15.222Z","tool_use_result":{"stdout":"=== /tmp/test-ka-twitch.json\nmikelenil found= False err= not_found\nlirik found= True err= None\n followers = 2997718\n banner = \"https://static-cdn.jtvnw.net/jtv_user_pictures/ee47a480-27e4-4b0a-ad7a-5a244aa34a8d-profile_banner-480.png\"\n social_links = [{\"name\": \"twitter\", \"title\": \"Twitter\", \"url\": \"https://twitter.com/lirik\"}, {\"name\": \"instagram\", \"title\": \"Instagram\", \"url\": \"https://instagram.com/lirik\"}, {\"name\": \"YouTube\",\n is_verified = true\n avatar = \"https://static-cdn.jtvnw.net/jtv_user_pictures/38e925fc-0b07-4e1e-82e2-6639e01344f3-profile_image-300x300.png\"\n recent_videos[0] keys: ['duration_s', 'game', 'id', 'published_at', 'thumbnail', 'title', 'url', 'views']\n thumb: https://static-cdn.jtvnw.net/cf_vods/d2vi6trrdongqn/133fe0bca805fcaf640d_lirik_319864707678_1787069072//thumb/thumb0-320\n=== /tmp/test-ka-youtube.json\nPLPodcastQc found= False err= http_404\nmrbeast found= True err= None\n followers = 514000000\n banner = \"https://yt3.googleusercontent.com/mHMO_eEMp0dPvh0ADwXhPXNYb_GnjSVsLI8biqF1CpxT8OPl7izhNQsDPD3JHhd5y5Mg9GrP=w2560-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj\"\n total_views = 137639410858\n joined_date = \"Joined Feb 19, 2012\"\n external_links = [{\"title\": \"$1,000,000 Contest\", \"url\": \"themostdangerousgames.com\"}, {\"title\": \"Follow\", \"url\": \"instagram.com/mrbeast\"}, {\"title\": \"Twitter\", \"url\": \"twitter.com/MrBeast\"}, {\"tit\n avatar = \"https://yt3.googleusercontent.com/nxYrc_1_2f77DoBadyxMTmv7ZpRZapHR5jbuYe7PlPd5cIRJxtNNEYyOC0ZsxaDyJJzXrnJiuDE=s900-c-k-c0x00ffffff-no-rj\"\n recent_videos[0] keys: ['duration', 'id', 'published', 'thumbnail', 'title', 'url', 'views']\n thumb: https://i.ytimg.com/vi/Af6i6ChAVTw/hqdefault.jpg","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
448 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"04f4b999-c2d5-4ad1-a8ed-49dfb9a0c139","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
449 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzvUrNQMhAEJfKVBGBn","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9AYKpAEIERgCKkBPUk1gHMmuvgM3Qmt0ZMbAEh4gX4eIhr4NDBmHButwvSEfUS0WCs2TcSu8sgF+FKJgIH27AC1FT+FtEkVX7MboMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGHyp7UBhIMY73uo4/O76nNkMUEGgyBKxlv8wfa/HZ13CAiMCJsqzmPg2UtLujyQCJNuK/M3Wv6OoOguEZtfjkimlESYKDbmuAUcNGkExOBNCor6yr8BNesbRPxXwbcDVzj0A4IK1qWOe0s327ybnDC/4562C6Nma/niizTn8b4FdpC6E6h3J3oQkvH++SFPM42gF0sulwyalGIe96TJlsGVmclJqM2K/ytmLxgw9u+9mNC8o5CCOUADPOapsa4Ij1tWUFgmXWO5bNPiC0NKbPl/uTEZ0VjvcTjBFmdDCZNIgpFrMMxCWzg2CAEZ9OcF/2jWCvV3QuYQ3tifZwJzaOTM0uU50iFoLqMO5ylHQFiSrPDRFmmBRdb7kwL0jIcftSMhpGfIu1jrA2G6GEFM7KYnuGXCgHl89jOcyevanIZNVavNjHYwaRZFghBhffuIQjarN2hgOoHhy8wZ6olPsYF9rmcpP1vbLn5ul0Deyauk++EalIgw0ib5cPd3z92Pse/qRCluR7rn2eGgcAJAKFy7PCzTaBKnxWKcrxCrvDVbQzC7amNQaZwlBYPY+Gm7sZAGFSiI3FaTzBT3LnFREF7GZV0cg1A73SdQxR9fHKyH7OhKPvNFt/Llcu6zHqwREU1N6lHnaOC96AjZHDfL+jznFtFoVOuIsPRqtnm7PaM+yvyTcL2D5RTvaJJ442iieNEUAULDT8Lt5OkVQZbHKIgodys+naUqwLeK+dSLP0BchcRIMlc7xCfl15Z8kboDbUBnBLbS43RKaxHXQxDEsNmnpSECF6xciTpNFrZ4R0GbENk/91Arlk7vPxZ5R7onLZszJg1CZrFwWfmRhohX9jFN1GWitgQxzd6PtdJhr9tgPm960xz8jVG+JhEudUdLTGg+37MiVkLIo7X7vicFGzGLXCcMdj8Y8nNIpGW7HDPvVeBmYtP84RH1b9Bh+snhIMf9BgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1400,"cache_read_input_tokens":94518,"cache_creation":{"ephemeral_5m_input_tokens":1400,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"16671556-3fd8-48e0-9e32-45e838af4d15"}} |
|
450 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzvUrNQMhAEJfKVBGBn","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISswQKpQEIERgCKkCeLZsIParTijPAfRFLkYxfJNUG+cKNEyI7Lsh8MA1Y23enNO1RuIR+a1oJoX4vkcu6ckby8cbBgQrif1QxMh8hMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBh8qe1AYSDFB5FQZdAqJLEENfiRoMlO6GK66iAIrUDxB4IjBly3ahjveDd2dRO/5T6pevObmaJfexRm61UEwqnlf19ygO6Ykz8awLUqq+Y30o4w8qugIyTPkGWUnifM6mCstOLC06+NMMq9k/OBPZSjkRtVHC5BxJrj+2Je71m3+uK+wQyfC75tJM+LJuYxx2U8ymgsX8QAGusNAcawLk2fjEQ9PyQE2zCg8+UEarQiTzCavGYAVaSfGjriKpbSyLYfZgBFCloFXu5cEBm0VJGrwJOW8p6ELSuQ9Zj1WPTJOWBZHsVo3CmHWYTIBznHpP/X1vSkXxtKV6Qh9FanxBLsWcxCBQwBD/DgaO3n4J2HjUbf8rswClhyk6m2L6oiAZQMzLT3j2sY9hl4iZJhd7jg3jiJFhnGnUCXLSZ/Pvh5dFKLm+CXKPjBrRQyUOYMWYsN4TvJjsCnugfCy8QPuiqU8ylfeakNqNvlihVbRq1+thzTegcs+xv/CTiKKMOjNqYZY8qSl6uyhKAAiz7hUSYhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1400,"cache_read_input_tokens":94518,"cache_creation":{"ephemeral_5m_input_tokens":1400,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"71690633-6fe4-40a9-adf5-32f59ad96b25"}} |
|
451 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzvUrNQMhAEJfKVBGBn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NhoeB6iYSoqshiGdHVc19u","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"in_progress","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"pending","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1400,"cache_read_input_tokens":94518,"cache_creation":{"ephemeral_5m_input_tokens":1400,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bae1cee1-2524-4f28-a5b5-98e75443cdd7"}} |
|
452 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NhoeB6iYSoqshiGdHVc19u","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f3683ae2-d5cd-40bc-9ff2-b3dadb95060e","timestamp":"2026-08-21T01:08:29.658Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"pending","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"in_progress","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"pending","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"pending","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"in_progress","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"pending","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
453 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzvUrNQMhAEJfKVBGBn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VUe255mTLvgAwBCUAEKcw3","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py","limit":80},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1400,"cache_read_input_tokens":94518,"cache_creation":{"ephemeral_5m_input_tokens":1400,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4e744bbe-b9ea-492d-8be3-90162a4c2990"}} |
|
454 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VUe255mTLvgAwBCUAEKcw3","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/stats.py\n4\t# Desc: Tableau de bord analytique /api/stats/dashboard (contrat ka-stats\n5\t# SPEC.md v2) + rapport PDF Groupe-KA (/api/stats/report via kapdf.py,\n6\t# 5 modes). AUCUNE stat inventée : tout est calculé depuis creators/\n7\t# accounts/sync_log (first_seen converti en date locale, cache 5 min).\n8\t# v2 : sparklines KPI, jauges de complétude, multi-courbes par\n9\t# plateforme, ajouts empilés par source, distributions (audiences,\n10\t# comptes/créateur, confiance), heatmap horaire 7×24, 5 tableaux,\n11\t# records enrichis.\n12\t# ==============================================================================\n13\tfrom __future__ import annotations\n14\t\n15\timport json\n16\timport sqlite3\n17\timport time\n18\tfrom datetime import date, datetime, timedelta, timezone\n19\tfrom pathlib import Path\n20\tfrom zoneinfo import ZoneInfo\n21\t\n22\tTZ = ZoneInfo(\"America/Toronto\")\n23\tROOT = Path(__file__).resolve().parent.parent\n24\tECO_PATH = ROOT / \"frontend\" / \"src\" / \"ka\" / \"ecosystem.json\"\n25\t\n26\tCACHE_TTL = 300 # ≥ 5 min par période (SPEC §2)\n27\t_CACHE: dict[tuple, tuple[float, dict]] = {}\n28\t\n29\tPERIOD_DAYS = {\"auj\": 1, \"7j\": 7, \"30j\": 30, \"3m\": 91, \"6m\": 182, \"12m\": 365}\n30\tPERIOD_LABELS = {\n31\t \"auj\": \"aujourd'hui\", \"7j\": \"7 jours\", \"30j\": \"30 jours\", \"3m\": \"3 mois\",\n32\t \"6m\": \"6 mois\", \"12m\": \"12 mois\", \"annee\": \"année en cours\",\n33\t \"tout\": \"toute la période\",\n34\t}\n35\t\n36\tPLAT_LBL = {\n37\t \"instagram\": \"Instagram\", \"tiktok\": \"TikTok\", \"youtube\": \"YouTube\",\n38\t \"twitch\": \"Twitch\", \"kick\": \"Kick\", \"x\": \"X (Twitter)\",\n39\t \"facebook\": \"Facebook\", \"snapchat\": \"Snapchat\", \"substack\": \"Substack\",\n40\t \"patreon\": \"Patreon\", \"onlyfans\": \"OnlyFans\", \"linkedin\": \"LinkedIn\",\n41\t \"threads\": \"Threads\", \"podcast\": \"Balado\", \"site-web\": \"Site web\",\n42\t \"spotify\": \"Spotify\", \"discord\": \"Discord\", \"fansly\": \"Fansly\",\n43\t \"mym\": \"MYM\", \"autre\": \"Autre\",\n44\t}\n45\tNICHE_LBL = {\n46\t \"humour\": \"Humour\", \"mode\": \"Mode\", \"beaute\": \"Beauté\",\n47\t \"lifestyle\": \"Lifestyle\", \"famille-parentalite\": \"Famille & parentalité\",\n48\t \"cuisine\": \"Cuisine\", \"gaming\": \"Gaming\", \"tech\": \"Tech\",\n49\t \"sport-fitness\": \"Sport & fitness\", \"plein-air\": \"Plein air\",\n50\t \"voyage\": \"Voyage\", \"musique\": \"Musique\", \"arts\": \"Arts\", \"danse\": \"Danse\",\n51\t \"education\": \"Éducation\", \"finance-affaires\": \"Finance & affaires\",\n52\t \"sante-mieux-etre\": \"Santé & mieux-être\", \"bouffe-resto\": \"Bouffe & resto\",\n53\t \"actualite-opinion\": \"Actualité & opinion\", \"autre\": \"Autre\",\n54\t}\n55\tTYPE_LBL = {\n56\t \"youtubeur\": \"Youtubeur\", \"influenceur\": \"Influenceur\",\n57\t \"streamer\": \"Streamer\", \"podcasteur\": \"Podcasteur\",\n58\t \"humoriste\": \"Humoriste\", \"createur-tiktok\": \"Créateur TikTok\",\n59\t \"createur-ecrit\": \"Créateur écrit\", \"musicien\": \"Musicien\",\n60\t \"artiste\": \"Artiste\", \"autre\": \"Autre\",\n61\t}\n62\t# bornes réelles de normalize.audience_tier (§6.3)\n63\tTIER_LBL = [(\"nano\", \"Nano (< 10 k)\"), (\"micro\", \"Micro (10 k – 100 k)\"),\n64\t (\"macro\", \"Macro (100 k – 1 M)\"), (\"mega\", \"Méga (1 M et +)\")]\n65\t# tranches d'audience (distribution) — bornes en abonnés cumulés connus\n66\tREACH_BINS = [(0, 1_000, \"< 1 k\"), (1_000, 10_000, \"1 k – 10 k\"),\n67\t (10_000, 50_000, \"10 k – 50 k\"), (50_000, 100_000, \"50 k – 100 k\"),\n68\t (100_000, 500_000, \"100 k – 500 k\"),\n69\t (500_000, 1_000_000, \"500 k – 1 M\"),\n70\t (1_000_000, None, \"1 M et +\")]\n71\t\n72\t\n73\tdef site_info() -> dict:\n74\t \"\"\"Identité Créa-Ka pour le PDF, lue dans ka/ecosystem.json (source commune).\"\"\"\n75\t try:\n76\t eco = json.loads(ECO_PATH.read_text(encoding=\"utf-8\"))\n77\t s = next(x for x in eco[\"sites\"] if x[\"id\"] == \"crea-ka\")\n78\t return {\"wordmark\": s[\"wordmark\"], \"accent\": s[\"accent\"],\n79\t \"domain\": s[\"domain\"], \"tagline\": s.get(\"tagline\", \"\")}\n80\t except Exception:"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9f32b5f1-f873-4df9-b542-1b93267dca67","timestamp":"2026-08-21T01:08:30.789Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/stats.py\n# Desc: Tableau de bord analytique /api/stats/dashboard (contrat ka-stats\n# SPEC.md v2) + rapport PDF Groupe-KA (/api/stats/report via kapdf.py,\n# 5 modes). AUCUNE stat inventée : tout est calculé depuis creators/\n# accounts/sync_log (first_seen converti en date locale, cache 5 min).\n# v2 : sparklines KPI, jauges de complétude, multi-courbes par\n# plateforme, ajouts empilés par source, distributions (audiences,\n# comptes/créateur, confiance), heatmap horaire 7×24, 5 tableaux,\n# records enrichis.\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport time\nfrom datetime import date, datetime, timedelta, timezone\nfrom pathlib import Path\nfrom zoneinfo import ZoneInfo\n\nTZ = ZoneInfo(\"America/Toronto\")\nROOT = Path(__file__).resolve().parent.parent\nECO_PATH = ROOT / \"frontend\" / \"src\" / \"ka\" / \"ecosystem.json\"\n\nCACHE_TTL = 300 # ≥ 5 min par période (SPEC §2)\n_CACHE: dict[tuple, tuple[float, dict]] = {}\n\nPERIOD_DAYS = {\"auj\": 1, \"7j\": 7, \"30j\": 30, \"3m\": 91, \"6m\": 182, \"12m\": 365}\nPERIOD_LABELS = {\n \"auj\": \"aujourd'hui\", \"7j\": \"7 jours\", \"30j\": \"30 jours\", \"3m\": \"3 mois\",\n \"6m\": \"6 mois\", \"12m\": \"12 mois\", \"annee\": \"année en cours\",\n \"tout\": \"toute la période\",\n}\n\nPLAT_LBL = {\n \"instagram\": \"Instagram\", \"tiktok\": \"TikTok\", \"youtube\": \"YouTube\",\n \"twitch\": \"Twitch\", \"kick\": \"Kick\", \"x\": \"X (Twitter)\",\n \"facebook\": \"Facebook\", \"snapchat\": \"Snapchat\", \"substack\": \"Substack\",\n \"patreon\": \"Patreon\", \"onlyfans\": \"OnlyFans\", \"linkedin\": \"LinkedIn\",\n \"threads\": \"Threads\", \"podcast\": \"Balado\", \"site-web\": \"Site web\",\n \"spotify\": \"Spotify\", \"discord\": \"Discord\", \"fansly\": \"Fansly\",\n \"mym\": \"MYM\", \"autre\": \"Autre\",\n}\nNICHE_LBL = {\n \"humour\": \"Humour\", \"mode\": \"Mode\", \"beaute\": \"Beauté\",\n \"lifestyle\": \"Lifestyle\", \"famille-parentalite\": \"Famille & parentalité\",\n \"cuisine\": \"Cuisine\", \"gaming\": \"Gaming\", \"tech\": \"Tech\",\n \"sport-fitness\": \"Sport & fitness\", \"plein-air\": \"Plein air\",\n \"voyage\": \"Voyage\", \"musique\": \"Musique\", \"arts\": \"Arts\", \"danse\": \"Danse\",\n \"education\": \"Éducation\", \"finance-affaires\": \"Finance & affaires\",\n \"sante-mieux-etre\": \"Santé & mieux-être\", \"bouffe-resto\": \"Bouffe & resto\",\n \"actualite-opinion\": \"Actualité & opinion\", \"autre\": \"Autre\",\n}\nTYPE_LBL = {\n \"youtubeur\": \"Youtubeur\", \"influenceur\": \"Influenceur\",\n \"streamer\": \"Streamer\", \"podcasteur\": \"Podcasteur\",\n \"humoriste\": \"Humoriste\", \"createur-tiktok\": \"Créateur TikTok\",\n \"createur-ecrit\": \"Créateur écrit\", \"musicien\": \"Musicien\",\n \"artiste\": \"Artiste\", \"autre\": \"Autre\",\n}\n# bornes réelles de normalize.audience_tier (§6.3)\nTIER_LBL = [(\"nano\", \"Nano (< 10 k)\"), (\"micro\", \"Micro (10 k – 100 k)\"),\n (\"macro\", \"Macro (100 k – 1 M)\"), (\"mega\", \"Méga (1 M et +)\")]\n# tranches d'audience (distribution) — bornes en abonnés cumulés connus\nREACH_BINS = [(0, 1_000, \"< 1 k\"), (1_000, 10_000, \"1 k – 10 k\"),\n (10_000, 50_000, \"10 k – 50 k\"), (50_000, 100_000, \"50 k – 100 k\"),\n (100_000, 500_000, \"100 k – 500 k\"),\n (500_000, 1_000_000, \"500 k – 1 M\"),\n (1_000_000, None, \"1 M et +\")]\n\n\ndef site_info() -> dict:\n \"\"\"Identité Créa-Ka pour le PDF, lue dans ka/ecosystem.json (source commune).\"\"\"\n try:\n eco = json.loads(ECO_PATH.read_text(encoding=\"utf-8\"))\n s = next(x for x in eco[\"sites\"] if x[\"id\"] == \"crea-ka\")\n return {\"wordmark\": s[\"wordmark\"], \"accent\": s[\"accent\"],\n \"domain\": s[\"domain\"], \"tagline\": s.get(\"tagline\", \"\")}\n except Exception:","numLines":80,"startLine":1,"totalLines":631}}}} |
|
455 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"68949d11-a206-4e45-8db7-5700e88e559c","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
456 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzwdL8jyENtkhGJt9pz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/gMKpAEIERgCKkA5rdTfU6aZisWGCsI49qixxNowRJPMnH0OzF7wVKqnP/WGEzU7ONbPE5LJ4TOHEhEYr4b/iofmh9AH9dZ4TXWgMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGSyp7UBhIMqwt50XvoBPL+Cp/MGgxxgdKqMiKKLNUcNU4iMF/sY0Ai8MLYr0USgtLT9WYnC0Wy3AZ68nUokMCQyLuFzF+M2ox96GNh0QYYm9V9EiqGAnpMAMTRg1S6qJgfZHbk7CbxWTbRfyY7kZ14Skvv7E8lOyklyf7lCL/49M8lok4feYTGP8AQal5Dm22+VRJMtHMpeJgSCrGeNpvs4uJV1zG7+4SSrt/1L5PQMK+Yg0Q0JpY2Ia0JLBDC09kBDtfzxdT+MV5WlxOpLR4UX6AW9x8bQrXdhdlB6zoJ8iVQNJNxJDMK2v1LWf+sPitVcxOFh8pt112t2SYvxMTpOmEDnltcFYY9o7jJizpckO9G8z6NFzIei7tQ/i/xFZEw6hhHNPpyFE2cluynTqf5c5c/SGG4lxwm9FWe5tVNOcs7wdK8vNShI9agnObiEcfMEXTUK5VAdmMcjZwYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3315,"cache_read_input_tokens":95918,"cache_creation":{"ephemeral_5m_input_tokens":3315,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e0faf38f-aadb-4ace-8d09-9845c54d379a"}} |
|
457 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzwdL8jyENtkhGJt9pz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RR4FaBgYqF2AJwQHkxJcHS","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","old_string":"CREATE TABLE IF NOT EXISTS sync_log (","new_string":"CREATE TABLE IF NOT EXISTS snapshots (\n day TEXT NOT NULL,\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL,\n followers INTEGER,\n engagement REAL,\n PRIMARY KEY (day, platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);\nCREATE TABLE IF NOT EXISTS sync_log ("},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3315,"cache_read_input_tokens":95918,"cache_creation":{"ephemeral_5m_input_tokens":3315,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3b539513-153d-48e4-88c8-2742bc363ed4"}} |
|
458 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RR4FaBgYqF2AJwQHkxJcHS","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/db.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bbd38238-b789-4ed2-a84f-aec2f64b5a81","timestamp":"2026-08-21T01:08:38.308Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","oldString":"CREATE TABLE IF NOT EXISTS sync_log (","newString":"CREATE TABLE IF NOT EXISTS snapshots (\n day TEXT NOT NULL,\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL,\n followers INTEGER,\n engagement REAL,\n PRIMARY KEY (day, platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);\nCREATE TABLE IF NOT EXISTS sync_log (","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/db.py\n# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n# ==============================================================================\n\"\"\"Base de données Créa-Ka (SQLite, WAL).\n\n- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n indexées pour la recherche).\n- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n l'index qui permet la déduplication inter-sources (§12.2).\n- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n\nPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\nhorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\npasse `opted_out` (masqué, jamais ré-agrégé).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport sys\nimport time\nfrom dataclasses import asdict\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom . import ethics\nfrom .dedup import merge_creators\nfrom .identity import needs_review\nfrom .normalize import slugify\nfrom .schema import Creator, PlatformAccount, now_iso\n\nROOT = Path(__file__).resolve().parent.parent\nDB_PATH = ROOT / \"data\" / \"creaka.db\"\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS creators (\n id TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'active',\n is_minor INTEGER NOT NULL DEFAULT 0,\n region TEXT, city TEXT,\n niches TEXT, languages TEXT,\n creator_type TEXT, primary_platform TEXT,\n audience_tier TEXT, total_reach INTEGER,\n bio TEXT,\n first_seen TEXT, last_seen TEXT, updated_at TEXT,\n doc TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS accounts (\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n url TEXT NOT NULL,\n followers INTEGER,\n verified INTEGER,\n confidence REAL NOT NULL,\n signal TEXT,\n needs_review INTEGER NOT NULL DEFAULT 0,\n last_checked TEXT,\n metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\nCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\nCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n source TEXT NOT NULL,\n creators INTEGER, accounts INTEGER, avg_confidence REAL,\n added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n alert TEXT\n);\n\"\"\"\n\n\ndef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(path, check_same_thread=False)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA journal_mode=WAL\")\n con.execute(\"PRAGMA foreign_keys=ON\")\n con.executescript(_SCHEMA)\n # migrations légères (bases existantes)\n try:\n con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n except sqlite3.OperationalError:\n pass\n return con\n\n\n# --- (dé)sérialisation ----------------------------------------------------------\n\ndef creator_to_doc(cr: Creator) -> str:\n return json.dumps(asdict(cr), ensure_ascii=False)\n\n\ndef creator_from_row(row: sqlite3.Row) -> Creator:\n doc = json.loads(row[\"doc\"])\n doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n cr = Creator(**doc)\n return cr\n\n\ndef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n doc = json.loads(row[\"doc\"])\n doc[\"id\"] = row[\"id\"]\n doc[\"first_seen\"] = row[\"first_seen\"]\n doc[\"last_seen\"] = row[\"last_seen\"]\n doc[\"updated_at\"] = row[\"updated_at\"]\n doc[\"platforms\"] = [\n {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n \"followers\": a[\"followers\"],\n \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n for a in accounts if not a[\"needs_review\"]]\n # champs internes / sensibles jamais exposés tels quels\n doc.pop(\"source\", None)\n doc.pop(\"external_id\", None)\n doc.pop(\"legal_name\", None)\n return doc\n\n\n# --- écriture -------------------------------------------------------------------\n\ndef _unique_id(con: sqlite3.Connection, base: str) -> str:\n cid, n = base, 2\n while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n cid = f\"{base}-{n}\"\n n += 1\n return cid\n\n\ndef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n first_seen: str, ts: str) -> None:\n # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n lost = old_keys - {a.key for a in cr.platforms}\n if lost:\n print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n con.execute(\n \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n niches, languages, creator_type, primary_platform, audience_tier,\n total_reach, bio, first_seen, last_seen, updated_at, doc)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(id) DO UPDATE SET\n display_name=excluded.display_name, status=excluded.status,\n is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n niches=excluded.niches, languages=excluded.languages,\n creator_type=excluded.creator_type,\n primary_platform=excluded.primary_platform,\n audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n bio=excluded.bio, last_seen=excluded.last_seen,\n updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n first_seen, ts, ts, creator_to_doc(cr)))\n con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n for acc in cr.platforms:\n con.execute(\n \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n verified, confidence, signal, needs_review, last_checked, metrics)\n VALUES (?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id, url=excluded.url,\n followers=excluded.followers, verified=excluded.verified,\n confidence=excluded.confidence, signal=excluded.signal,\n needs_review=excluded.needs_review,\n last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n\n\ndef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n \"\"\"Fiche existante possédant déjà un des comptes entrants.\n\n Même plateforme + même handle = MÊME compte : la table accounts n'admet\n qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n fait donc sur l'identité exacte du compte, sans condition de confiance —\n exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n comptes lui réassignait le compte, laissant l'originale orpheline\n (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n l'égalité stricte plateforme+handle identifie le compte lui-même.\n \"\"\"\n for acc in cr.platforms:\n row = con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (acc.platform, acc.handle)).fetchone()\n if row:\n return row[\"creator_id\"]\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source_id: str,\n creators: list[Creator], *, started: float | None = None,\n errors: int = 0) -> dict:\n \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n\n `started` (time.time() du début du passage) → durée réelle persistée dans\n sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n connecteur (§16 logging structuré).\n \"\"\"\n ts = now_iso()\n added = updated = skipped_optout = 0\n confs: list[float] = []\n for cr in creators:\n cr = ethics.scrub(cr)\n # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n if ethics.is_opted_out(cr):\n skipped_optout += 1\n existing = _find_existing(con, cr)\n if existing:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n \"WHERE id=?\", (ts, existing))\n continue\n confs.extend(a.confidence or 0 for a in cr.platforms)\n existing_id = _find_existing(con, cr)\n if existing_id:\n row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n (existing_id,)).fetchone()\n current = creator_from_row(row)\n if current.status == \"opted_out\":\n continue # masqué : on ne ré-agrège pas\n # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n # partagé : c'est le même compte, la fusion est toujours sûre —\n # pas une fusion sur le nom (§12.2)\n merged = merge_creators(current, cr)\n _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n updated += 1\n continue\n cid = _unique_id(con, slugify(cr.display_name))\n _write_creator(con, cid, cr, ts, ts)\n added += 1\n con.commit()\n n_acc = sum(len(c.platforms) for c in creators)\n stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n alert = None\n if not creators:\n alert = \"0 créateur retourné — source possiblement bloquée\"\n stats[\"alert\"] = alert\n seconds = round(time.time() - started, 1) if started else 0.0\n stats[\"seconds\"] = seconds\n if errors:\n stats[\"errors\"] = errors\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n added, updated, errors, seconds, alert))\n con.commit()\n return stats\n\n\ndef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n \"\"\"Archivage des disparus (§13, politique de grâce).\n\n Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n Réversible : dès qu'une source le revoit, la fusion (sync_source →\n _write_creator) réécrit son statut actif et son `last_seen`.\n Les opt-out ne sont JAMAIS touchés (status != 'active').\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n ts = now_iso()\n cur = con.execute(\n \"UPDATE creators SET status='inactive', updated_at=? \"\n \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n (ts, cutoff))\n con.commit()\n if cur.rowcount:\n print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n f\"depuis {hours} h → inactive (réversible au retour)\")\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n con.commit()\n\n\ndef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n account: str | None = None) -> int:\n \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n ids: set[str] = set()\n if account and \":\" in account:\n platform, handle = account.lower().split(\":\", 1)\n for row in con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (platform, handle.lstrip(\"@\"))):\n ids.add(row[\"creator_id\"])\n if name:\n from .dedup import name_key\n for row in con.execute(\"SELECT id, display_name FROM creators\"):\n if name_key(row[\"display_name\"]) == name_key(name):\n ids.add(row[\"id\"])\n ts = now_iso()\n for cid in ids:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n (ts, cid))\n con.commit()\n return len(ids)\n\n\n# --- lecture (API) ----------------------------------------------------------------\n\n_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n\n\ndef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n offset: int = 0) -> dict:\n \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n\n Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n exclues de l'annuaire public (prudence extrême, §15).\n \"\"\"\n where = [\"status='active'\", \"is_minor=0\"]\n params: list = []\n if q:\n where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n needle = f\"%{q.lower()}%\"\n params += [needle, needle]\n if niche:\n where.append(\"(','||niches||',') LIKE ?\")\n params.append(f\"%,{niche},%\")\n if region:\n where.append(\"region=?\")\n params.append(region)\n if langue:\n where.append(\"(','||languages||',') LIKE ?\")\n params.append(f\"%,{langue},%\")\n if tier in _TIERS:\n where.append(\"audience_tier=?\")\n params.append(tier)\n if plateforme:\n where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n \"AND needs_review=0)\")\n params.append(plateforme)\n order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n \"nom\": \"display_name COLLATE NOCASE ASC\",\n \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n sql_where = \" AND \".join(where)\n total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n params).fetchone()[\"c\"]\n rows = con.execute(\n f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n items = []\n for row in rows:\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n items.append(_public_dict(row, accounts))\n return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n row = con.execute(\n \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n (cid,)).fetchone()\n if not row:\n return None\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n return _public_dict(row, accounts)\n\n\ndef stats(con: sqlite3.Connection) -> dict:\n \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n base = \"FROM creators WHERE status='active' AND is_minor=0\"\n n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n n_acc = con.execute(\n \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n ).fetchone()[\"c\"]\n by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n niches: dict[str, int] = {}\n for row in con.execute(f\"SELECT niches {base}\"):\n for nch in (row[\"niches\"] or \"\").split(\",\"):\n if nch:\n niches[nch] = niches.get(nch, 0) + 1\n regions: dict[str, int] = {}\n for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n last_sync = con.execute(\n \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n \"by_tier\": by_tier,\n \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n","structuredPatch":[{"oldStart":66,"oldLines":6,"newStart":66,"newLines":16,"lines":[" CREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);"," CREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);"," CREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);","+CREATE TABLE IF NOT EXISTS snapshots (","+ day TEXT NOT NULL,","+ platform TEXT NOT NULL,","+ handle TEXT NOT NULL,","+ creator_id TEXT NOT NULL,","+ followers INTEGER,","+ engagement REAL,","+ PRIMARY KEY (day, platform, handle)","+);","+CREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);"," CREATE TABLE IF NOT EXISTS sync_log ("," id INTEGER PRIMARY KEY AUTOINCREMENT,"," ts TEXT NOT NULL,"]}],"userModified":false,"replaceAll":false}}} |
|
459 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzwdL8jyENtkhGJt9pz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015RkVZTAst1mnYxkgDY6BNk","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","old_string":" (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))","new_string":" (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n # historisation (§13-14) : un point par jour et par compte — matière\n # première des courbes de croissance et des insights (7 j / 30 j)\n if acc.followers:\n con.execute(\n \"\"\"INSERT INTO snapshots (day, platform, handle, creator_id,\n followers, engagement)\n VALUES (?,?,?,?,?,?)\n ON CONFLICT(day, platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id,\n followers=excluded.followers,\n engagement=COALESCE(excluded.engagement,\n snapshots.engagement)\"\"\",\n (ts[:10], acc.platform, acc.handle, cid, acc.followers,\n (acc.metrics or {}).get(\"engagement_rate_pct\")))"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3315,"cache_read_input_tokens":95918,"cache_creation":{"ephemeral_5m_input_tokens":3315,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f63843d9-6664-4b48-be33-9d7bc192de53"}} |
|
460 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015RkVZTAst1mnYxkgDY6BNk","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/db.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e595c2f0-76aa-4967-bc34-a27a5ab26997","timestamp":"2026-08-21T01:08:43.665Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","oldString":" (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))","newString":" (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n # historisation (§13-14) : un point par jour et par compte — matière\n # première des courbes de croissance et des insights (7 j / 30 j)\n if acc.followers:\n con.execute(\n \"\"\"INSERT INTO snapshots (day, platform, handle, creator_id,\n followers, engagement)\n VALUES (?,?,?,?,?,?)\n ON CONFLICT(day, platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id,\n followers=excluded.followers,\n engagement=COALESCE(excluded.engagement,\n snapshots.engagement)\"\"\",\n (ts[:10], acc.platform, acc.handle, cid, acc.followers,\n (acc.metrics or {}).get(\"engagement_rate_pct\")))","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/db.py\n# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n# ==============================================================================\n\"\"\"Base de données Créa-Ka (SQLite, WAL).\n\n- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n indexées pour la recherche).\n- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n l'index qui permet la déduplication inter-sources (§12.2).\n- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n\nPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\nhorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\npasse `opted_out` (masqué, jamais ré-agrégé).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport sys\nimport time\nfrom dataclasses import asdict\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom . import ethics\nfrom .dedup import merge_creators\nfrom .identity import needs_review\nfrom .normalize import slugify\nfrom .schema import Creator, PlatformAccount, now_iso\n\nROOT = Path(__file__).resolve().parent.parent\nDB_PATH = ROOT / \"data\" / \"creaka.db\"\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS creators (\n id TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'active',\n is_minor INTEGER NOT NULL DEFAULT 0,\n region TEXT, city TEXT,\n niches TEXT, languages TEXT,\n creator_type TEXT, primary_platform TEXT,\n audience_tier TEXT, total_reach INTEGER,\n bio TEXT,\n first_seen TEXT, last_seen TEXT, updated_at TEXT,\n doc TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS accounts (\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n url TEXT NOT NULL,\n followers INTEGER,\n verified INTEGER,\n confidence REAL NOT NULL,\n signal TEXT,\n needs_review INTEGER NOT NULL DEFAULT 0,\n last_checked TEXT,\n metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\nCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\nCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\nCREATE TABLE IF NOT EXISTS snapshots (\n day TEXT NOT NULL,\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL,\n followers INTEGER,\n engagement REAL,\n PRIMARY KEY (day, platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n source TEXT NOT NULL,\n creators INTEGER, accounts INTEGER, avg_confidence REAL,\n added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n alert TEXT\n);\n\"\"\"\n\n\ndef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(path, check_same_thread=False)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA journal_mode=WAL\")\n con.execute(\"PRAGMA foreign_keys=ON\")\n con.executescript(_SCHEMA)\n # migrations légères (bases existantes)\n try:\n con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n except sqlite3.OperationalError:\n pass\n return con\n\n\n# --- (dé)sérialisation ----------------------------------------------------------\n\ndef creator_to_doc(cr: Creator) -> str:\n return json.dumps(asdict(cr), ensure_ascii=False)\n\n\ndef creator_from_row(row: sqlite3.Row) -> Creator:\n doc = json.loads(row[\"doc\"])\n doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n cr = Creator(**doc)\n return cr\n\n\ndef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n doc = json.loads(row[\"doc\"])\n doc[\"id\"] = row[\"id\"]\n doc[\"first_seen\"] = row[\"first_seen\"]\n doc[\"last_seen\"] = row[\"last_seen\"]\n doc[\"updated_at\"] = row[\"updated_at\"]\n doc[\"platforms\"] = [\n {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n \"followers\": a[\"followers\"],\n \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n for a in accounts if not a[\"needs_review\"]]\n # champs internes / sensibles jamais exposés tels quels\n doc.pop(\"source\", None)\n doc.pop(\"external_id\", None)\n doc.pop(\"legal_name\", None)\n return doc\n\n\n# --- écriture -------------------------------------------------------------------\n\ndef _unique_id(con: sqlite3.Connection, base: str) -> str:\n cid, n = base, 2\n while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n cid = f\"{base}-{n}\"\n n += 1\n return cid\n\n\ndef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n first_seen: str, ts: str) -> None:\n # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n lost = old_keys - {a.key for a in cr.platforms}\n if lost:\n print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n con.execute(\n \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n niches, languages, creator_type, primary_platform, audience_tier,\n total_reach, bio, first_seen, last_seen, updated_at, doc)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(id) DO UPDATE SET\n display_name=excluded.display_name, status=excluded.status,\n is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n niches=excluded.niches, languages=excluded.languages,\n creator_type=excluded.creator_type,\n primary_platform=excluded.primary_platform,\n audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n bio=excluded.bio, last_seen=excluded.last_seen,\n updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n first_seen, ts, ts, creator_to_doc(cr)))\n con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n for acc in cr.platforms:\n con.execute(\n \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n verified, confidence, signal, needs_review, last_checked, metrics)\n VALUES (?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id, url=excluded.url,\n followers=excluded.followers, verified=excluded.verified,\n confidence=excluded.confidence, signal=excluded.signal,\n needs_review=excluded.needs_review,\n last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n\n\ndef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n \"\"\"Fiche existante possédant déjà un des comptes entrants.\n\n Même plateforme + même handle = MÊME compte : la table accounts n'admet\n qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n fait donc sur l'identité exacte du compte, sans condition de confiance —\n exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n comptes lui réassignait le compte, laissant l'originale orpheline\n (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n l'égalité stricte plateforme+handle identifie le compte lui-même.\n \"\"\"\n for acc in cr.platforms:\n row = con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (acc.platform, acc.handle)).fetchone()\n if row:\n return row[\"creator_id\"]\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source_id: str,\n creators: list[Creator], *, started: float | None = None,\n errors: int = 0) -> dict:\n \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n\n `started` (time.time() du début du passage) → durée réelle persistée dans\n sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n connecteur (§16 logging structuré).\n \"\"\"\n ts = now_iso()\n added = updated = skipped_optout = 0\n confs: list[float] = []\n for cr in creators:\n cr = ethics.scrub(cr)\n # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n if ethics.is_opted_out(cr):\n skipped_optout += 1\n existing = _find_existing(con, cr)\n if existing:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n \"WHERE id=?\", (ts, existing))\n continue\n confs.extend(a.confidence or 0 for a in cr.platforms)\n existing_id = _find_existing(con, cr)\n if existing_id:\n row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n (existing_id,)).fetchone()\n current = creator_from_row(row)\n if current.status == \"opted_out\":\n continue # masqué : on ne ré-agrège pas\n # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n # partagé : c'est le même compte, la fusion est toujours sûre —\n # pas une fusion sur le nom (§12.2)\n merged = merge_creators(current, cr)\n _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n updated += 1\n continue\n cid = _unique_id(con, slugify(cr.display_name))\n _write_creator(con, cid, cr, ts, ts)\n added += 1\n con.commit()\n n_acc = sum(len(c.platforms) for c in creators)\n stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n alert = None\n if not creators:\n alert = \"0 créateur retourné — source possiblement bloquée\"\n stats[\"alert\"] = alert\n seconds = round(time.time() - started, 1) if started else 0.0\n stats[\"seconds\"] = seconds\n if errors:\n stats[\"errors\"] = errors\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n added, updated, errors, seconds, alert))\n con.commit()\n return stats\n\n\ndef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n \"\"\"Archivage des disparus (§13, politique de grâce).\n\n Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n Réversible : dès qu'une source le revoit, la fusion (sync_source →\n _write_creator) réécrit son statut actif et son `last_seen`.\n Les opt-out ne sont JAMAIS touchés (status != 'active').\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n ts = now_iso()\n cur = con.execute(\n \"UPDATE creators SET status='inactive', updated_at=? \"\n \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n (ts, cutoff))\n con.commit()\n if cur.rowcount:\n print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n f\"depuis {hours} h → inactive (réversible au retour)\")\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n con.commit()\n\n\ndef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n account: str | None = None) -> int:\n \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n ids: set[str] = set()\n if account and \":\" in account:\n platform, handle = account.lower().split(\":\", 1)\n for row in con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (platform, handle.lstrip(\"@\"))):\n ids.add(row[\"creator_id\"])\n if name:\n from .dedup import name_key\n for row in con.execute(\"SELECT id, display_name FROM creators\"):\n if name_key(row[\"display_name\"]) == name_key(name):\n ids.add(row[\"id\"])\n ts = now_iso()\n for cid in ids:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n (ts, cid))\n con.commit()\n return len(ids)\n\n\n# --- lecture (API) ----------------------------------------------------------------\n\n_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n\n\ndef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n offset: int = 0) -> dict:\n \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n\n Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n exclues de l'annuaire public (prudence extrême, §15).\n \"\"\"\n where = [\"status='active'\", \"is_minor=0\"]\n params: list = []\n if q:\n where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n needle = f\"%{q.lower()}%\"\n params += [needle, needle]\n if niche:\n where.append(\"(','||niches||',') LIKE ?\")\n params.append(f\"%,{niche},%\")\n if region:\n where.append(\"region=?\")\n params.append(region)\n if langue:\n where.append(\"(','||languages||',') LIKE ?\")\n params.append(f\"%,{langue},%\")\n if tier in _TIERS:\n where.append(\"audience_tier=?\")\n params.append(tier)\n if plateforme:\n where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n \"AND needs_review=0)\")\n params.append(plateforme)\n order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n \"nom\": \"display_name COLLATE NOCASE ASC\",\n \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n sql_where = \" AND \".join(where)\n total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n params).fetchone()[\"c\"]\n rows = con.execute(\n f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n items = []\n for row in rows:\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n items.append(_public_dict(row, accounts))\n return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n row = con.execute(\n \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n (cid,)).fetchone()\n if not row:\n return None\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n return _public_dict(row, accounts)\n\n\ndef stats(con: sqlite3.Connection) -> dict:\n \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n base = \"FROM creators WHERE status='active' AND is_minor=0\"\n n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n n_acc = con.execute(\n \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n ).fetchone()[\"c\"]\n by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n niches: dict[str, int] = {}\n for row in con.execute(f\"SELECT niches {base}\"):\n for nch in (row[\"niches\"] or \"\").split(\",\"):\n if nch:\n niches[nch] = niches.get(nch, 0) + 1\n regions: dict[str, int] = {}\n for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n last_sync = con.execute(\n \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n \"by_tier\": by_tier,\n \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n","structuredPatch":[{"oldStart":190,"oldLines":6,"newStart":190,"newLines":20,"lines":[" None if acc.verified is None else int(acc.verified),"," acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,"," json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))","+ # historisation (§13-14) : un point par jour et par compte — matière","+ # première des courbes de croissance et des insights (7 j / 30 j)","+ if acc.followers:","+ con.execute(","+ \"\"\"INSERT INTO snapshots (day, platform, handle, creator_id,","+ followers, engagement)","+ VALUES (?,?,?,?,?,?)","+ ON CONFLICT(day, platform, handle) DO UPDATE SET","+ creator_id=excluded.creator_id,","+ followers=excluded.followers,","+ engagement=COALESCE(excluded.engagement,","+ snapshots.engagement)\"\"\",","+ (ts[:10], acc.platform, acc.handle, cid, acc.followers,","+ (acc.metrics or {}).get(\"engagement_rate_pct\")))"," "," "," def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:"]}],"userModified":false,"replaceAll":false}}} |
|
461 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d78c18bd-7110-4463-98e5-289c4d948278","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
462 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzxazGdGmTEzQpkRxrJ","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant `follower_history` dans db.py, puis `creator_insights` (Ka Score) dans stats.py."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1252,"cache_read_input_tokens":99233,"cache_creation":{"ephemeral_5m_input_tokens":1252,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e82cfae2-484d-4b40-a74b-583d8df53408"}} |
|
463 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzxazGdGmTEzQpkRxrJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015S18vycbe6TfN91UNLM9Ep","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","old_string":"def get_creator(con: sqlite3.Connection, cid: str) -> dict | None:","new_string":"def follower_history(con: sqlite3.Connection, cid: str,\n days: int = 90) -> dict:\n \"\"\"Historique d'audience d'une fiche (snapshots quotidiens, §13-14).\n\n Retourne les séries par plateforme (jour → abonnés) + la série totale\n (somme des plateformes connues ce jour-là), prêtes pour une sparkline.\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(days=days)) \\\n .strftime(\"%Y-%m-%d\")\n rows = con.execute(\n \"SELECT day, platform, handle, followers FROM snapshots \"\n \"WHERE creator_id=? AND day>=? ORDER BY day\", (cid, cutoff)).fetchall()\n by_platform: dict[str, dict[str, int]] = {}\n for r in rows:\n by_platform.setdefault(r[\"platform\"], {})[r[\"day\"]] = r[\"followers\"]\n total: dict[str, int] = {}\n for series in by_platform.values():\n # report de la dernière valeur connue pour que la somme quotidienne\n # ne s'effondre pas quand une plateforme n'a pas de point ce jour-là\n last = None\n for day in sorted({d for s in by_platform.values() for d in s}):\n last = series.get(day, last)\n if last is not None:\n total[day] = total.get(day, 0) + last\n return {\n \"platforms\": {p: [{\"day\": d, \"followers\": f}\n for d, f in sorted(s.items())]\n for p, s in by_platform.items()},\n \"total\": [{\"day\": d, \"followers\": f} for d, f in sorted(total.items())],\n }\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1252,"cache_read_input_tokens":99233,"cache_creation":{"ephemeral_5m_input_tokens":1252,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"06aa994c-7026-4a8b-8034-ef072b73b6e1"}} |
|
464 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015S18vycbe6TfN91UNLM9Ep","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/db.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d20d454e-1129-46e9-966e-ee7bee989110","timestamp":"2026-08-21T01:08:54.603Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py","oldString":"def get_creator(con: sqlite3.Connection, cid: str) -> dict | None:","newString":"def follower_history(con: sqlite3.Connection, cid: str,\n days: int = 90) -> dict:\n \"\"\"Historique d'audience d'une fiche (snapshots quotidiens, §13-14).\n\n Retourne les séries par plateforme (jour → abonnés) + la série totale\n (somme des plateformes connues ce jour-là), prêtes pour une sparkline.\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(days=days)) \\\n .strftime(\"%Y-%m-%d\")\n rows = con.execute(\n \"SELECT day, platform, handle, followers FROM snapshots \"\n \"WHERE creator_id=? AND day>=? ORDER BY day\", (cid, cutoff)).fetchall()\n by_platform: dict[str, dict[str, int]] = {}\n for r in rows:\n by_platform.setdefault(r[\"platform\"], {})[r[\"day\"]] = r[\"followers\"]\n total: dict[str, int] = {}\n for series in by_platform.values():\n # report de la dernière valeur connue pour que la somme quotidienne\n # ne s'effondre pas quand une plateforme n'a pas de point ce jour-là\n last = None\n for day in sorted({d for s in by_platform.values() for d in s}):\n last = series.get(day, last)\n if last is not None:\n total[day] = total.get(day, 0) + last\n return {\n \"platforms\": {p: [{\"day\": d, \"followers\": f}\n for d, f in sorted(s.items())]\n for p, s in by_platform.items()},\n \"total\": [{\"day\": d, \"followers\": f} for d, f in sorted(total.items())],\n }\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/db.py\n# Desc: Stockage SQLite — fiches créateurs canoniques, comptes rattachés,\n# journal des synchronisations (fraîcheur §13-14, opt-out §15)\n# ==============================================================================\n\"\"\"Base de données Créa-Ka (SQLite, WAL).\n\n- `creators` : une ligne = une fiche canonique (document JSON + colonnes\n indexées pour la recherche).\n- `accounts` : un compte (plateforme+handle) pointe vers SA fiche — c'est\n l'index qui permet la déduplication inter-sources (§12.2).\n- `sync_log` : statistiques par passage de connecteur (§16 logging structuré).\n\nPolitique de grâce (§13) : on ne supprime jamais brutalement — `last_seen`\nhorodate chaque passage ; un créateur disparu passe `inactive`, un opt-out\npasse `opted_out` (masqué, jamais ré-agrégé).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport sqlite3\nimport sys\nimport time\nfrom dataclasses import asdict\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom . import ethics\nfrom .dedup import merge_creators\nfrom .identity import needs_review\nfrom .normalize import slugify\nfrom .schema import Creator, PlatformAccount, now_iso\n\nROOT = Path(__file__).resolve().parent.parent\nDB_PATH = ROOT / \"data\" / \"creaka.db\"\n\n_SCHEMA = \"\"\"\nCREATE TABLE IF NOT EXISTS creators (\n id TEXT PRIMARY KEY,\n display_name TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'active',\n is_minor INTEGER NOT NULL DEFAULT 0,\n region TEXT, city TEXT,\n niches TEXT, languages TEXT,\n creator_type TEXT, primary_platform TEXT,\n audience_tier TEXT, total_reach INTEGER,\n bio TEXT,\n first_seen TEXT, last_seen TEXT, updated_at TEXT,\n doc TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS accounts (\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL REFERENCES creators(id) ON DELETE CASCADE,\n url TEXT NOT NULL,\n followers INTEGER,\n verified INTEGER,\n confidence REAL NOT NULL,\n signal TEXT,\n needs_review INTEGER NOT NULL DEFAULT 0,\n last_checked TEXT,\n metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);\nCREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);\nCREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);\nCREATE TABLE IF NOT EXISTS snapshots (\n day TEXT NOT NULL,\n platform TEXT NOT NULL,\n handle TEXT NOT NULL,\n creator_id TEXT NOT NULL,\n followers INTEGER,\n engagement REAL,\n PRIMARY KEY (day, platform, handle)\n);\nCREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);\nCREATE TABLE IF NOT EXISTS sync_log (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n source TEXT NOT NULL,\n creators INTEGER, accounts INTEGER, avg_confidence REAL,\n added INTEGER, updated INTEGER, errors INTEGER, seconds REAL,\n alert TEXT\n);\n\"\"\"\n\n\ndef connect(path: Path | str = DB_PATH) -> sqlite3.Connection:\n Path(path).parent.mkdir(parents=True, exist_ok=True)\n con = sqlite3.connect(path, check_same_thread=False)\n con.row_factory = sqlite3.Row\n con.execute(\"PRAGMA journal_mode=WAL\")\n con.execute(\"PRAGMA foreign_keys=ON\")\n con.executescript(_SCHEMA)\n # migrations légères (bases existantes)\n try:\n con.execute(\"ALTER TABLE accounts ADD COLUMN metrics TEXT\")\n except sqlite3.OperationalError:\n pass\n return con\n\n\n# --- (dé)sérialisation ----------------------------------------------------------\n\ndef creator_to_doc(cr: Creator) -> str:\n return json.dumps(asdict(cr), ensure_ascii=False)\n\n\ndef creator_from_row(row: sqlite3.Row) -> Creator:\n doc = json.loads(row[\"doc\"])\n doc[\"platforms\"] = [PlatformAccount(**a) for a in doc.get(\"platforms\", [])]\n cr = Creator(**doc)\n return cr\n\n\ndef _public_dict(row: sqlite3.Row, accounts: list[sqlite3.Row]) -> dict:\n \"\"\"Fiche publique (API) : comptes « à vérifier » EXCLUS (§12.1).\"\"\"\n doc = json.loads(row[\"doc\"])\n doc[\"id\"] = row[\"id\"]\n doc[\"first_seen\"] = row[\"first_seen\"]\n doc[\"last_seen\"] = row[\"last_seen\"]\n doc[\"updated_at\"] = row[\"updated_at\"]\n doc[\"platforms\"] = [\n {\"platform\": a[\"platform\"], \"handle\": a[\"handle\"], \"url\": a[\"url\"],\n \"followers\": a[\"followers\"],\n \"verified\": bool(a[\"verified\"]) if a[\"verified\"] is not None else None,\n \"confidence\": a[\"confidence\"], \"last_checked\": a[\"last_checked\"],\n \"metrics\": json.loads(a[\"metrics\"]) if a[\"metrics\"] else {}}\n for a in accounts if not a[\"needs_review\"]]\n # champs internes / sensibles jamais exposés tels quels\n doc.pop(\"source\", None)\n doc.pop(\"external_id\", None)\n doc.pop(\"legal_name\", None)\n return doc\n\n\n# --- écriture -------------------------------------------------------------------\n\ndef _unique_id(con: sqlite3.Connection, base: str) -> str:\n cid, n = base, 2\n while con.execute(\"SELECT 1 FROM creators WHERE id=?\", (cid,)).fetchone():\n cid = f\"{base}-{n}\"\n n += 1\n return cid\n\n\ndef _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,\n first_seen: str, ts: str) -> None:\n # comptes retirés de la fiche : jamais en silence (§13 — historique) ;\n # merge_accounts fait l'union, donc une perte ici est anormale → journalisée\n old_keys = {f\"{r['platform']}:{r['handle']}\" for r in con.execute(\n \"SELECT platform, handle FROM accounts WHERE creator_id=?\", (cid,))}\n lost = old_keys - {a.key for a in cr.platforms}\n if lost:\n print(f\"[crea-ka] ⚠ {cid} : compte(s) retiré(s) de la fiche : \"\n f\"{', '.join(sorted(lost))}\", file=sys.stderr)\n con.execute(\n \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n niches, languages, creator_type, primary_platform, audience_tier,\n total_reach, bio, first_seen, last_seen, updated_at, doc)\n VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(id) DO UPDATE SET\n display_name=excluded.display_name, status=excluded.status,\n is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n niches=excluded.niches, languages=excluded.languages,\n creator_type=excluded.creator_type,\n primary_platform=excluded.primary_platform,\n audience_tier=excluded.audience_tier, total_reach=excluded.total_reach,\n bio=excluded.bio, last_seen=excluded.last_seen,\n updated_at=excluded.updated_at, doc=excluded.doc\"\"\",\n (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n \",\".join(cr.niches), \",\".join(cr.languages), cr.creator_type,\n cr.primary_platform, cr.audience_tier, cr.total_reach, cr.bio,\n first_seen, ts, ts, creator_to_doc(cr)))\n con.execute(\"DELETE FROM accounts WHERE creator_id=?\", (cid,))\n for acc in cr.platforms:\n con.execute(\n \"\"\"INSERT INTO accounts (platform, handle, creator_id, url, followers,\n verified, confidence, signal, needs_review, last_checked, metrics)\n VALUES (?,?,?,?,?,?,?,?,?,?,?)\n ON CONFLICT(platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id, url=excluded.url,\n followers=excluded.followers, verified=excluded.verified,\n confidence=excluded.confidence, signal=excluded.signal,\n needs_review=excluded.needs_review,\n last_checked=excluded.last_checked, metrics=excluded.metrics\"\"\",\n (acc.platform, acc.handle, cid, acc.url, acc.followers,\n None if acc.verified is None else int(acc.verified),\n acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,\n json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))\n # historisation (§13-14) : un point par jour et par compte — matière\n # première des courbes de croissance et des insights (7 j / 30 j)\n if acc.followers:\n con.execute(\n \"\"\"INSERT INTO snapshots (day, platform, handle, creator_id,\n followers, engagement)\n VALUES (?,?,?,?,?,?)\n ON CONFLICT(day, platform, handle) DO UPDATE SET\n creator_id=excluded.creator_id,\n followers=excluded.followers,\n engagement=COALESCE(excluded.engagement,\n snapshots.engagement)\"\"\",\n (ts[:10], acc.platform, acc.handle, cid, acc.followers,\n (acc.metrics or {}).get(\"engagement_rate_pct\")))\n\n\ndef _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:\n \"\"\"Fiche existante possédant déjà un des comptes entrants.\n\n Même plateforme + même handle = MÊME compte : la table accounts n'admet\n qu'un propriétaire par compte (UNIQUE platform,handle). Le rapprochement se\n fait donc sur l'identité exacte du compte, sans condition de confiance —\n exiger `confidence >= STRONG_THRESHOLD` des deux côtés (version d'avant le\n 2026-08-20) faisait rater les fiches à signal faible (mention 0.60, ex.\n snowball-ig) : sync_source créait alors une SECONDE fiche et l'upsert des\n comptes lui réassignait le compte, laissant l'originale orpheline\n (691 doublons constatés). Ce n'est PAS une fusion sur le nom (§12.2) :\n l'égalité stricte plateforme+handle identifie le compte lui-même.\n \"\"\"\n for acc in cr.platforms:\n row = con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (acc.platform, acc.handle)).fetchone()\n if row:\n return row[\"creator_id\"]\n return None\n\n\ndef sync_source(con: sqlite3.Connection, source_id: str,\n creators: list[Creator], *, started: float | None = None,\n errors: int = 0) -> dict:\n \"\"\"Synchronise le lot d'une source : ajouts, fusions, mises à jour, opt-out.\n\n `started` (time.time() du début du passage) → durée réelle persistée dans\n sync_log.seconds ; `errors` → erreurs non bloquantes signalées par le\n connecteur (§16 logging structuré).\n \"\"\"\n ts = now_iso()\n added = updated = skipped_optout = 0\n confs: list[float] = []\n for cr in creators:\n cr = ethics.scrub(cr)\n # opt-out : jamais ré-agrégé (§15) ; si une fiche existe, la masquer\n if ethics.is_opted_out(cr):\n skipped_optout += 1\n existing = _find_existing(con, cr)\n if existing:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? \"\n \"WHERE id=?\", (ts, existing))\n continue\n confs.extend(a.confidence or 0 for a in cr.platforms)\n existing_id = _find_existing(con, cr)\n if existing_id:\n row = con.execute(\"SELECT * FROM creators WHERE id=?\",\n (existing_id,)).fetchone()\n current = creator_from_row(row)\n if current.status == \"opted_out\":\n continue # masqué : on ne ré-agrège pas\n # _find_existing garantit un compte IDENTIQUE (plateforme+handle)\n # partagé : c'est le même compte, la fusion est toujours sûre —\n # pas une fusion sur le nom (§12.2)\n merged = merge_creators(current, cr)\n _write_creator(con, existing_id, merged, row[\"first_seen\"], ts)\n updated += 1\n continue\n cid = _unique_id(con, slugify(cr.display_name))\n _write_creator(con, cid, cr, ts, ts)\n added += 1\n con.commit()\n n_acc = sum(len(c.platforms) for c in creators)\n stats = {\"source\": source_id, \"creators\": len(creators), \"accounts\": n_acc,\n \"added\": added, \"updated\": updated, \"optout_skipped\": skipped_optout,\n \"avg_confidence\": round(sum(confs) / len(confs), 3) if confs else None}\n alert = None\n if not creators:\n alert = \"0 créateur retourné — source possiblement bloquée\"\n stats[\"alert\"] = alert\n seconds = round(time.time() - started, 1) if started else 0.0\n stats[\"seconds\"] = seconds\n if errors:\n stats[\"errors\"] = errors\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, avg_confidence,\"\n \" added, updated, errors, seconds, alert) VALUES (?,?,?,?,?,?,?,?,?,?)\",\n (ts, source_id, len(creators), n_acc, stats[\"avg_confidence\"],\n added, updated, errors, seconds, alert))\n con.commit()\n return stats\n\n\ndef archive_missing(con: sqlite3.Connection, *, hours: int = 72) -> int:\n \"\"\"Archivage des disparus (§13, politique de grâce).\n\n Un créateur qu'AUCUNE source de découverte n'a revu depuis ~3 passages\n complets (cadence quotidienne §14 → 72 h) passe `status='inactive'`.\n Réversible : dès qu'une source le revoit, la fusion (sync_source →\n _write_creator) réécrit son statut actif et son `last_seen`.\n Les opt-out ne sont JAMAIS touchés (status != 'active').\n \"\"\"\n cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)) \\\n .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n ts = now_iso()\n cur = con.execute(\n \"UPDATE creators SET status='inactive', updated_at=? \"\n \"WHERE status='active' AND last_seen IS NOT NULL AND last_seen<?\",\n (ts, cutoff))\n con.commit()\n if cur.rowcount:\n print(f\"[crea-ka] archivage §13 : {cur.rowcount} fiche(s) non revue(s) \"\n f\"depuis {hours} h → inactive (réversible au retour)\")\n return cur.rowcount\n\n\ndef log_failure(con: sqlite3.Connection, source_id: str, message: str) -> None:\n con.execute(\n \"INSERT INTO sync_log (ts, source, creators, accounts, errors, alert)\"\n \" VALUES (?,?,0,0,1,?)\", (now_iso(), source_id, message[:500]))\n con.commit()\n\n\ndef apply_optout(con: sqlite3.Connection, *, name: str | None = None,\n account: str | None = None) -> int:\n \"\"\"Masque immédiatement les fiches correspondant à une demande de retrait.\"\"\"\n ids: set[str] = set()\n if account and \":\" in account:\n platform, handle = account.lower().split(\":\", 1)\n for row in con.execute(\n \"SELECT creator_id FROM accounts WHERE platform=? AND handle=?\",\n (platform, handle.lstrip(\"@\"))):\n ids.add(row[\"creator_id\"])\n if name:\n from .dedup import name_key\n for row in con.execute(\"SELECT id, display_name FROM creators\"):\n if name_key(row[\"display_name\"]) == name_key(name):\n ids.add(row[\"id\"])\n ts = now_iso()\n for cid in ids:\n con.execute(\"UPDATE creators SET status='opted_out', updated_at=? WHERE id=?\",\n (ts, cid))\n con.commit()\n return len(ids)\n\n\n# --- lecture (API) ----------------------------------------------------------------\n\n_TIERS = (\"nano\", \"micro\", \"macro\", \"mega\")\n\n\ndef search(con: sqlite3.Connection, *, q: str = \"\", niche: str = \"\",\n region: str = \"\", langue: str = \"\", plateforme: str = \"\",\n tier: str = \"\", sort: str = \"reach\", limit: int = 60,\n offset: int = 0) -> dict:\n \"\"\"Recherche annuaire : filtres niche/région/langue/plateforme/taille + texte.\n\n Les fiches `opted_out` sont TOUJOURS exclues ; les fiches de mineurs sont\n exclues de l'annuaire public (prudence extrême, §15).\n \"\"\"\n where = [\"status='active'\", \"is_minor=0\"]\n params: list = []\n if q:\n where.append(\"(lower(display_name) LIKE ? OR lower(bio) LIKE ?)\")\n needle = f\"%{q.lower()}%\"\n params += [needle, needle]\n if niche:\n where.append(\"(','||niches||',') LIKE ?\")\n params.append(f\"%,{niche},%\")\n if region:\n where.append(\"region=?\")\n params.append(region)\n if langue:\n where.append(\"(','||languages||',') LIKE ?\")\n params.append(f\"%,{langue},%\")\n if tier in _TIERS:\n where.append(\"audience_tier=?\")\n params.append(tier)\n if plateforme:\n where.append(\"id IN (SELECT creator_id FROM accounts WHERE platform=? \"\n \"AND needs_review=0)\")\n params.append(plateforme)\n order = {\"reach\": \"total_reach IS NULL, total_reach DESC\",\n \"nom\": \"display_name COLLATE NOCASE ASC\",\n \"recent\": \"updated_at DESC\"}.get(sort, \"total_reach IS NULL, total_reach DESC\")\n sql_where = \" AND \".join(where)\n total = con.execute(f\"SELECT COUNT(*) c FROM creators WHERE {sql_where}\",\n params).fetchone()[\"c\"]\n rows = con.execute(\n f\"SELECT * FROM creators WHERE {sql_where} ORDER BY {order} LIMIT ? OFFSET ?\",\n params + [max(1, min(200, limit)), max(0, offset)]).fetchall()\n items = []\n for row in rows:\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n items.append(_public_dict(row, accounts))\n return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}\n\n\ndef get_creator(con: sqlite3.Connection, cid: str) -> dict | None:\n row = con.execute(\n \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n (cid,)).fetchone()\n if not row:\n return None\n accounts = con.execute(\n \"SELECT * FROM accounts WHERE creator_id=? ORDER BY followers DESC\",\n (row[\"id\"],)).fetchall()\n return _public_dict(row, accounts)\n\n\ndef stats(con: sqlite3.Connection) -> dict:\n \"\"\"Statistiques d'ensemble pour l'accueil de l'annuaire.\"\"\"\n base = \"FROM creators WHERE status='active' AND is_minor=0\"\n n = con.execute(f\"SELECT COUNT(*) c {base}\").fetchone()[\"c\"]\n n_acc = con.execute(\n \"SELECT COUNT(*) c FROM accounts a JOIN creators c2 ON c2.id=a.creator_id\"\n \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n ).fetchone()[\"c\"]\n by_platform = {r[\"platform\"]: r[\"c\"] for r in con.execute(\n \"SELECT a.platform, COUNT(*) c FROM accounts a JOIN creators c2 \"\n \"ON c2.id=a.creator_id WHERE a.needs_review=0 AND c2.status='active' \"\n \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n by_tier = {r[\"audience_tier\"]: r[\"c\"] for r in con.execute(\n f\"SELECT audience_tier, COUNT(*) c {base} GROUP BY audience_tier\")}\n niches: dict[str, int] = {}\n for row in con.execute(f\"SELECT niches {base}\"):\n for nch in (row[\"niches\"] or \"\").split(\",\"):\n if nch:\n niches[nch] = niches.get(nch, 0) + 1\n regions: dict[str, int] = {}\n for row in con.execute(f\"SELECT region {base} AND region IS NOT NULL\"):\n regions[row[\"region\"]] = regions.get(row[\"region\"], 0) + 1\n last_sync = con.execute(\n \"SELECT ts FROM sync_log ORDER BY id DESC LIMIT 1\").fetchone()\n return {\"creators\": n, \"accounts\": n_acc, \"by_platform\": by_platform,\n \"by_tier\": by_tier,\n \"by_niche\": dict(sorted(niches.items(), key=lambda x: -x[1])),\n \"by_region\": dict(sorted(regions.items(), key=lambda x: -x[1])),\n \"last_sync\": last_sync[\"ts\"] if last_sync else None}\n","structuredPatch":[{"oldStart":397,"oldLines":6,"newStart":397,"newLines":38,"lines":[" return {\"total\": total, \"count\": len(items), \"offset\": offset, \"items\": items}"," "," ","+def follower_history(con: sqlite3.Connection, cid: str,","+ days: int = 90) -> dict:","+ \"\"\"Historique d'audience d'une fiche (snapshots quotidiens, §13-14).","+","+ Retourne les séries par plateforme (jour → abonnés) + la série totale","+ (somme des plateformes connues ce jour-là), prêtes pour une sparkline.","+ \"\"\"","+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)) \\","+ .strftime(\"%Y-%m-%d\")","+ rows = con.execute(","+ \"SELECT day, platform, handle, followers FROM snapshots \"","+ \"WHERE creator_id=? AND day>=? ORDER BY day\", (cid, cutoff)).fetchall()","+ by_platform: dict[str, dict[str, int]] = {}","+ for r in rows:","+ by_platform.setdefault(r[\"platform\"], {})[r[\"day\"]] = r[\"followers\"]","+ total: dict[str, int] = {}","+ for series in by_platform.values():","+ # report de la dernière valeur connue pour que la somme quotidienne","+ # ne s'effondre pas quand une plateforme n'a pas de point ce jour-là","+ last = None","+ for day in sorted({d for s in by_platform.values() for d in s}):","+ last = series.get(day, last)","+ if last is not None:","+ total[day] = total.get(day, 0) + last","+ return {","+ \"platforms\": {p: [{\"day\": d, \"followers\": f}","+ for d, f in sorted(s.items())]","+ for p, s in by_platform.items()},","+ \"total\": [{\"day\": d, \"followers\": f} for d, f in sorted(total.items())],","+ }","+","+"," def get_creator(con: sqlite3.Connection, cid: str) -> dict | None:"," row = con.execute("," \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\","]}],"userModified":false,"replaceAll":false}}} |
|
465 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"cf167ad8-8035-4c43-b2a8-b8c06c409317","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
466 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzyPB9XwM29khKC88q7","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmwkKpAEIERgCKkDRm8o7pJuab+rBOjrDYlNZPZi1RdLwGS5oobQ08uCuLr05HAGowSYVWDXpsgUItVrAj560znA0RNNMAsXPLy3lMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGxyp7UBhIMJCu3PIeS8rWb2fZ/GgxBjnvI7/7dGI07n3UiMNOFvE/FuLH2z8wN6E83ppKF1kP0Vd22k7rs7mshwn7YoUNJlcOB7CDzeO68TiW0OyqjB3k+EQd9Z36uGhYJDcwoVaFUmThcrJEa0xchzhW77lpHZVWr8A9HjeaPloO0C5OzLAEkkT2Z2lFV6OIPLWmI6Rc4fG7pEQ1Sfl8UM+3pU/Gags19uKIXi3M7ileHaCCJHTqRodVbkdIryVG0q5zCaWwHCSDkPTsujQIQ47doCllVLNrQEhcHSVfhQlOSvWctUYUQkz3RzrhBE+CBlKxZLfcLcwAZyo27vmSreR6aETVzd1JCtsKviT7KCtCTrKEBrtRiNRtM+UOi/ktJKQ3yVRUL4EMEqlbm3YhSyJ4eOJTgRappLQAloxCcQ03POYsOVFOVbvqxDjagi6VBRf7cAjymui9spN50MEzhMsmBpCULNFR+HbgLuZZeherePDtfSYId2e0J40UC9lopqL9c9Q4HJbj+MvUxz7X67sYI7GBlNJ9snlNqkE9SRV3vonZIL5EpAKJpYGpbmc3+NqUO4a5bqaUxakox4xpyxBz+VnsQ+tcijS/3GkO2JuaXnp+No6bUp+6ei/pK1qvA0Aw1R3eFfikSo1iOjQrnBAaHJBLNoSEFwphM7aMj28eUQnd70hR/6uoW9lh6Liswql/2AQZw5gzc8eDn/CS6PxxNNi15R50vXx2nF7aoIsRIaT+EWXWt1B0qApA5hGofCfvxJoeWGrIDCwZo67okIsKf3PMeQkTCHNVKUsu/TuLs+upUyLSq22zWID3Rjtx7CC+vwz5tyKOai4mNEpEChVlNtAVo4mlLpmivYQvdKJEJb9I4cJHyxljHhqZNurX21otvwBFKEK15bjm2Oj8Yq7HoU6UQv+UeudiETRSW62K7OPPc3WLof7mBAGge4SjDK5BtwgaKK4Xbmu29vCAwfir5+na6KcSoKVDV9OwIzRYPw39YDNOZ4s/ygXNIEvBPDm96Hh0e/1hs/3hGcjgWR96LtpCtjsM67VZamN9dSD/9uUEzr9bMt6YNuHYYBTeYnw1J0DIzDRW9w/AEOBywhG3yTdCW2+RRVSe9xc32Q6B28f4ydIwcSj76IeC35sdpb7JBLrjCZwFiulDMrRlM4jeTmPdWJhv0z4oWlcYlaMHruuIzPa4BPNhyv4GCVNhZHkgYObKKChe/k2hWeQPFkzcUIoToG+95LwC9HJxYIY7PcN71mIevSgMRsZdZRpgBFKrYTX8pC1X3ZmymmohiIh9o015wEyvEYqso8J68U3ZWdNZJfMletUvHtxrsUN2Z/zrVnwWeQDIYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":864,"cache_read_input_tokens":100485,"cache_creation":{"ephemeral_5m_input_tokens":864,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"562efd35-0f45-4903-be85-5b05afdb36ea"}} |
|
467 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzyPB9XwM29khKC88q7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ug9FHjofzD3MkKymdYsqi2","name":"Bash","input":{"command":"tail -30 ~/apps/crea-ka/creaka/stats.py && wc -l ~/apps/crea-ka/creaka/stats.py && sed -n 40,90p ~/apps/crea-ka/creaka/web.py","description":"View end of stats.py and web.py creator endpoint"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":864,"cache_read_input_tokens":100485,"cache_creation":{"ephemeral_5m_input_tokens":864,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"08373351-e732-4db3-9ab7-c91097242d58"}} |
|
468 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ug9FHjofzD3MkKymdYsqi2","type":"tool_result","content":" \"heatmap\": heatmap,\n \"tables\": tables,\n \"records\": records[:12],\n }\n if multiseries:\n out[\"multiseries\"] = multiseries\n if stacked:\n out[\"stacked\"] = stacked\n if distributions:\n out[\"distributions\"] = distributions\n if geo:\n out[\"geo\"] = geo\n if hourly:\n out[\"hourly\"] = hourly\n return out\n\n\ndef dashboard(con: sqlite3.Connection, period: str = \"30j\",\n date_from: str = \"\", date_to: str = \"\") -> dict:\n \"\"\"Point d'entrée avec cache mémoire (TTL 5 min par combinaison de période).\"\"\"\n key = (period, date_from, date_to)\n now = time.time()\n hit = _CACHE.get(key)\n if hit and now - hit[0] < CACHE_TTL:\n return hit[1]\n data = _build(con, period, date_from, date_to)\n if len(_CACHE) > 64: # borne dure (plages personnalisées illimitées)\n _CACHE.clear()\n _CACHE[key] = (now, data)\n return data\n 630 /Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py\n\ndef _db():\n con = getattr(_local, \"con\", None)\n if con is None:\n con = _local.con = db.connect()\n return con\n\n# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py\napp.include_router(auth.router)\n\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"ok\": True}\n\n\n@app.get(\"/api/health\")\ndef health():\n s = db.stats(_db())\n return {\"ok\": True, \"creators\": s[\"creators\"], \"accounts\": s[\"accounts\"],\n \"last_sync\": s[\"last_sync\"]}\n\n\n@app.get(\"/api/creators\")\ndef list_creators(q: str = \"\", niche: str = \"\", region: str = \"\",\n langue: str = \"\", plateforme: str = \"\", tier: str = \"\",\n sort: str = \"reach\",\n limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):\n return db.search(_db(), q=q.strip(), niche=niche, region=region,\n langue=langue, plateforme=plateforme, tier=tier,\n sort=sort, limit=limit, offset=offset)\n\n\n@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n return creator\n\n\n@app.get(\"/api/stats\")\ndef get_stats():\n return db.stats(_db())\n\n\n_PERIODS_OK = {\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4920c28d-147d-4fdb-8c7c-edbce41d9c1f","timestamp":"2026-08-21T01:09:06.486Z","tool_use_result":{"stdout":" \"heatmap\": heatmap,\n \"tables\": tables,\n \"records\": records[:12],\n }\n if multiseries:\n out[\"multiseries\"] = multiseries\n if stacked:\n out[\"stacked\"] = stacked\n if distributions:\n out[\"distributions\"] = distributions\n if geo:\n out[\"geo\"] = geo\n if hourly:\n out[\"hourly\"] = hourly\n return out\n\n\ndef dashboard(con: sqlite3.Connection, period: str = \"30j\",\n date_from: str = \"\", date_to: str = \"\") -> dict:\n \"\"\"Point d'entrée avec cache mémoire (TTL 5 min par combinaison de période).\"\"\"\n key = (period, date_from, date_to)\n now = time.time()\n hit = _CACHE.get(key)\n if hit and now - hit[0] < CACHE_TTL:\n return hit[1]\n data = _build(con, period, date_from, date_to)\n if len(_CACHE) > 64: # borne dure (plages personnalisées illimitées)\n _CACHE.clear()\n _CACHE[key] = (now, data)\n return data\n 630 /Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py\n\ndef _db():\n con = getattr(_local, \"con\", None)\n if con is None:\n con = _local.con = db.connect()\n return con\n\n# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py\napp.include_router(auth.router)\n\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"ok\": True}\n\n\n@app.get(\"/api/health\")\ndef health():\n s = db.stats(_db())\n return {\"ok\": True, \"creators\": s[\"creators\"], \"accounts\": s[\"accounts\"],\n \"last_sync\": s[\"last_sync\"]}\n\n\n@app.get(\"/api/creators\")\ndef list_creators(q: str = \"\", niche: str = \"\", region: str = \"\",\n langue: str = \"\", plateforme: str = \"\", tier: str = \"\",\n sort: str = \"reach\",\n limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):\n return db.search(_db(), q=q.strip(), niche=niche, region=region,\n langue=langue, plateforme=plateforme, tier=tier,\n sort=sort, limit=limit, offset=offset)\n\n\n@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n return creator\n\n\n@app.get(\"/api/stats\")\ndef get_stats():\n return db.stats(_db())\n\n\n_PERIODS_OK = {\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
469 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"17b24f5f-0f39-4ecc-b55e-3e2481d684d5","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
470 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzzFvcHqFV1dKRfUDea","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvREKpAEIERgCKkA/O2H/ZfFkYd2yIPNqYhD32QT0T0HW7rrwqq6e7mUPxJQN54DF1LDs23V3MGIqm1GhvRsycd1iRvxdTaIKJ8geMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHCyp7UBhIMF8Gv7OkiGerdCPkgGgxxVdCZlhCJTC/CpK8iMP6V3RuiAQ91nofRs0qbaOkx67ZA5HDSi46WZjdhL8LiAGSz9TausAFHClCp1gwMXSrFD1BUTjcaUNtVX2OrWHqJLGUkgeCuGylru7+Q7v0Inq3jU38oVhnTiXqEF92QQu1ywRANw/rWpghW3v7jDs5lzpSZDAoN9nUavJDFfG6rbPU/uXyKsrrKXhSUnMwSFRbfkFhx/aeQGoqgmxwuHge+puKVoyOS66FMHXfNvfTF0TGVvSl+E+W5mUSeG2vqD9vkzX2ilfkUBhiRUKseFZEE2vVPeZoFZqRX+CSYGG3wen5+s1FwvIMGGjxtmDowLzV8k0fdF0gNoGNeFXDd+0irPDDqTk1Lb51eddoVMha6rHZNT6GAFX3wjwLJpK2cMVCmLFry45n8jPrvC3JXMU/7G1pI+aiCQ4sIaNaFdNggfPOWjKTpWreWV2KqLfLvzzEKGZJa1f/A6nbyuF48wkmvpZFKj8TYekKokMgaZYbR+aTwTmtNn4b+h1hUEbWa6FKOdQ9rZdBkX6wDfdGTQvuIxHS+WWByNOiLmkWZu3VHz8xg3qWc/Q/LDhwd2ruEZt8l+7sPzw5JbaKF3sOPQLbDDUuWlZl4hmlAhSEfgGWJspD/86+DT4Kbel+kRerFM8buNbudWacIaJ56Enc8JXkmbRi94PsR56+LyOLd1dKf9qZvxCwSsbwUKmCDn78Kh3mt9A7WJckobIWd/Lfmm5kvVH3CeCfnF7x9JQVfWrNmLnfsS08sAAlbikJvTH5gNlBLfGJHVIUGbZnhx2ne3A8VYz9HHwrLw2uyPdMIh2PQlOVl6Fqg5O+WAYFlkX6DqKapvZvd+AIFYahNUxVkpRZJYDoXtB2nx7osDiAbCi3XiiuqiVpoHNBbrm04Kcraabmsd9amo6R+mEJ2nCrsYkrFsgZrSUTxSm35hZeO6kUCaSkkQ35/ZaRzOiBreOoGDiARocSczLCs/JURqDGZDx9RACl5OQw7osIKBInjWXYbpvjuqeKf8VwmGu1PXUoPajVQEoY/J1EpfEVOTo5M+u5JPjj7H15WfCznROGvgKrAZIKjNeDCHnIXldMJeF8Xvii/be+V1nqfuZ0g00Yxy2/o9b1SImKnzTPSki8d3B4NEJmsquYHrlONNgYhxmbF85Im+0Ujj9w5fVgYY5Vr/tM7t4McVMrRdepmnO3aKLWcU7NPPHdGJ1jFqCgFXxFrlOHCw9ZuhPwaLDKWH1wVOyd+WwQj9o46U2IHsSxGxIzpnQAEipoWhCx6knomIH2CqcQ8UOSFE6JNv/3UNPxd7Jio7A0Nx5Nt8k6kpBjsv0Dwn4rbJ1gYO+ZR8ZKl05KE8eAsEGtQbGEFXun0xM8ewvpMZf8ebIRDKmMgxtUSnXPbo/6atWwoupYW1JTrD1NgEv8Bare48AiXe5W58utJAdslnqMmDClc999lPV2U2GvaUp1hP1APXOGp1WjZ97FibhHidjfFd3QWr+1jaowb6i7s0iq6yMklNpLr099qTQUirCEUFZ1UVygDsYd9pFsMhFTW+9GQBPq+zmbxjUZ/jVoAXxV2+Wfr/kCf3OnCocrVOC/R6REEkLLRVZ0US0Yuzt4giw/RNWNCaZQohVI67GZsAqGk0M0YQYLteQqTHY58Ll8rKg8caSrTwbmmX27IpNtmVolUDfcnEX13YygnewFIIBgoc3yhmpTcRd1bQhH+Mm9/SXcIHkMIpN8dZuAQbYFet+XYio6rbnB5cZNkaD+vQxzc9A0iusIKOe7teT5tglkz6XQWdaYWbhtaLSKI6AvQYssMHWAjZrpx0uJTCgs73Z7TqyyhyfI71mEdGzB5YBICmePhxac9vsFz8ShTSA8n32TGHUe1UBkYODJtIA5jGrutOOHTtHrXo5cOzelXkTazFG6uZw5CZcC0S6ynWuzkzFcyTXnsiRXo2PzDofLN/T4YiFiCJa8lPD5hPzWJCUpSvGOnYNnuX6HEXb6EdhVloy2Tm/iXKiZYk+wSDjOrxRhSTy+BqR+H7TLuQLy6i7avYMXWO1fMRliLFRCIz/4MQ/oa5qKqK++EsePOjFMQZdt73gS9H7gRsKFk7+mwpU/IvWxWTxLV4uReOIENoMDf9bzOT2qvvmTkLn3or7CiT1uUA+3gtuokPmNEatvdbHu5vESKi+Exg19EJEESCUxtT0mBqEAdDpzCrR2tAFxyk8IXTWCFH0Fbxz6QxCM6UiW8hlA6MYHq5LRwlwiC/DYjRUenBRsitr9DP3NMz2qUk57DC+y+3/habaW9zsJw9UJ7U/CPwILbnmibJhqmMK89unJBxo+9/AtnQ9xjB+coJwKIfhg5+4ViGx82Ia+eplveH9F/dp2Yw6G1xzzPjGtBoHXeUgPAXr8XkQ+YwXbP9bSDJXthzMqT7Afb3CbnFVw6DS52VPJvSHMSwyEESGeSpwG65wBv+EfmFnBk3wSOflGl+5dTMJzTX05KycgTCFBlk9R0l8Q/TNCv9YRBSy5CjvewqpL3bXiu349jGzj1/XZeFFhEsOUSmxd6geq/LYyuHcwYtVOaG4E3/5p9kikVEjx2P91xsFbUPIQylsEpTSJyoJwNNNdoi6GXPlQiV8oGoWgNyE8SrkZ80sBUThwuq55coJ6Vayggpw0WV3kW+iWaXv3zM0yYuhuPk/mwpz3Do6R++2BN9m/ARxJoVBAAJ4kzBAeNUImr34YEQQCZVrM67SiWxBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1529,"cache_read_input_tokens":101349,"cache_creation":{"ephemeral_5m_input_tokens":1529,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3700656a-dc97-4e47-bd26-9c20e4caf7cf"}} |
|
471 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeEzzFvcHqFV1dKRfUDea","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HGXLFsy6Bn2u8yL7yYw2Eo","name":"Bash","input":{"command":"cat >> ~/apps/crea-ka/creaka/stats.py <<'EOF'\n\n\n# --- insights par créateur (fiche « légendaire ») ------------------------------\n\ndef creator_insights(con: sqlite3.Connection, doc: dict) -> dict:\n \"\"\"Insights d'une fiche créateur : croissance (snapshots §13-14),\n engagement pondéré, rythme de publication et Ka Score composite /100.\n\n AUCUNE stat inventée : tout provient des comptes rattachés (accounts.metrics\n remplis par les acteurs Apify) et des snapshots quotidiens. Un champ absent\n reste absent — pas d'estimation.\n \"\"\"\n import math\n\n from .db import follower_history\n\n platforms = doc.get(\"platforms\") or []\n reach = doc.get(\"total_reach\") or sum(\n p.get(\"followers\") or 0 for p in platforms) or 0\n\n # croissance : série totale quotidienne (report dernière valeur connue)\n hist = follower_history(con, doc[\"id\"], days=95)\n total = hist[\"total\"]\n\n def growth(days: int) -> dict | None:\n if len(total) < 2:\n return None\n last = total[-1]\n cutoff = (date.today() - timedelta(days=days)).isoformat()\n base = next((p for p in total if p[\"day\"] >= cutoff), None)\n if base is None or base[\"day\"] == last[\"day\"] or not base[\"followers\"]:\n return None\n delta = last[\"followers\"] - base[\"followers\"]\n return {\"since\": base[\"day\"], \"delta\": delta,\n \"pct\": round(100 * delta / base[\"followers\"], 2)}\n\n # engagement moyen pondéré par l'audience de chaque plateforme\n weighted = [(p[\"metrics\"].get(\"engagement_rate_pct\"), p.get(\"followers\") or 1)\n for p in platforms\n if isinstance(p.get(\"metrics\"), dict)\n and p[\"metrics\"].get(\"engagement_rate_pct\") is not None]\n engagement = (round(sum(e * w for e, w in weighted)\n / sum(w for _, w in weighted), 2)\n if weighted else None)\n\n # rythme de publication : somme des cadences hebdo déclarées par plateforme\n rates = [p[\"metrics\"].get(k) for p in platforms\n if isinstance(p.get(\"metrics\"), dict)\n for k in (\"posts_per_week\", \"videos_per_week\", \"tweets_per_week\")\n if isinstance(p[\"metrics\"].get(k), (int, float))]\n pubs_week = round(sum(rates), 1) if rates else None\n\n # dernière activité publique connue, toutes plateformes confondues\n last_dates = [str(p[\"metrics\"].get(k)) for p in platforms\n if isinstance(p.get(\"metrics\"), dict)\n for k in (\"last_post_at\", \"last_video_at\", \"last_tweet_at\",\n \"last_broadcast_at\", \"last_video_published\")\n if p[\"metrics\"].get(k)]\n last_activity = max(last_dates) if last_dates else None\n\n is_verified = any(p.get(\"verified\") for p in platforms)\n live_now = any(isinstance(p.get(\"metrics\"), dict)\n and p[\"metrics\"].get(\"is_live_now\") for p in platforms)\n\n # Ka Score /100 : audience 40 (log), engagement 25, présence 20, rythme 10,\n # vérification 5 — comparable d'un créateur à l'autre, jamais inventé :\n # une composante inconnue vaut simplement 0.\n parts = {\n \"audience\": round(40 * min(1.0, math.log10(max(reach, 1)) / 7), 1),\n \"engagement\": round(25 * min(1.0, (engagement or 0) / 10), 1),\n \"presence\": round(20 * min(1.0, len(platforms) / 5), 1),\n \"rythme\": round(10 * min(1.0, (pubs_week or 0) / 3), 1),\n \"verification\": 5.0 if is_verified else 0.0,\n }\n top = max(platforms, key=lambda p: p.get(\"followers\") or 0, default=None)\n return {\n \"ka_score\": round(sum(parts.values()), 1),\n \"ka_score_parts\": parts,\n \"total_reach\": reach or None,\n \"growth_7d\": growth(7),\n \"growth_30d\": growth(30),\n \"avg_engagement_pct\": engagement,\n \"publications_per_week\": pubs_week,\n \"last_activity\": last_activity,\n \"platforms_count\": len(platforms),\n \"is_verified_somewhere\": is_verified,\n \"is_live_now\": live_now,\n \"top_platform\": (top or {}).get(\"platform\"),\n \"history\": hist,\n }\nEOF\npython3 -c \"import ast; ast.parse(open('/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py').read())\" && echo SYNTAX_OK","description":"Append creator_insights to stats.py and check syntax"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1529,"cache_read_input_tokens":101349,"cache_creation":{"ephemeral_5m_input_tokens":1529,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ef8fc708-48e1-4342-ad3d-f0b03ad43f37"}} |
|
472 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HGXLFsy6Bn2u8yL7yYw2Eo","type":"tool_result","content":"SYNTAX_OK","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a0b83afc-49c3-4908-9f41-1f1d771cd650","timestamp":"2026-08-21T01:09:39.108Z","tool_use_result":{"stdout":"SYNTAX_OK","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
473 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f403ed04-bc35-4c46-beb0-d004956605fb","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
474 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF12fQY6n2VGnyUi7f8p","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Fwsjhptc5Rci2hqpRRYv93","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py","offset":1,"limit":40},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2549,"cache_read_input_tokens":102878,"cache_creation":{"ephemeral_5m_input_tokens":2549,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"468447e8-55bb-4979-b765-6e6ef0f4b2ca"}} |
|
475 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Fwsjhptc5Rci2hqpRRYv93","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n3\t# File: creaka/web.py\n4\t# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —\n5\t# annuaire cherchable par niche, région, langue, plateforme, taille\n6\t# ==============================================================================\n7\tfrom __future__ import annotations\n8\t\n9\timport json\n10\timport threading\n11\tfrom pathlib import Path\n12\t\n13\tfrom fastapi import FastAPI, HTTPException, Query\n14\tfrom fastapi.middleware.cors import CORSMiddleware\n15\tfrom fastapi.middleware.gzip import GZipMiddleware\n16\tfrom fastapi.responses import FileResponse, Response\n17\tfrom pydantic import BaseModel, Field\n18\t\n19\tfrom . import auth, db, ethics, kapdf\n20\tfrom . import stats as stats_mod\n21\tfrom .normalize import NICHES, PLATFORMS, REGIONS\n22\t\n23\tROOT = Path(__file__).resolve().parent.parent\n24\tFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\n25\tSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n26\t\n27\tapp = FastAPI(title=\"Créa-Ka API\", version=\"1.0\",\n28\t description=\"Annuaire public des créateurs de contenu québécois \"\n29\t \"— tous leurs comptes, toutes leurs plateformes.\")\n30\tapp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n31\t allow_methods=[\"*\"], allow_headers=[\"*\"])\n32\tapp.add_middleware(GZipMiddleware, minimum_size=1000)\n33\t\n34\t# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans\n35\t# un pool de threads — une connexion partagée (check_same_thread=False) fait\n36\t# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'\n37\t# object is not subscriptable »). threading.local = une connexion par thread.\n38\t_local = threading.local()\n39\t\n40\t"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d3997a56-2a4d-41c7-b5c2-5434abde1bd2","timestamp":"2026-08-21T01:09:42.504Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/web.py\n# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —\n# annuaire cherchable par niche, région, langue, plateforme, taille\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, ethics, kapdf\nfrom . import stats as stats_mod\nfrom .normalize import NICHES, PLATFORMS, REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n\napp = FastAPI(title=\"Créa-Ka API\", version=\"1.0\",\n description=\"Annuaire public des créateurs de contenu québécois \"\n \"— tous leurs comptes, toutes leurs plateformes.\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans\n# un pool de threads — une connexion partagée (check_same_thread=False) fait\n# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'\n# object is not subscriptable »). threading.local = une connexion par thread.\n_local = threading.local()\n\n","numLines":40,"startLine":1,"totalLines":183}}}} |
|
476 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"46c8d425-5b82-4207-bc15-778ec53f1a0c","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
477 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF12v2rz4t7FbyhcDPUs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PY8Ld8WMdfSW9A9FDWgNye","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py","old_string":"@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n return creator","new_string":"@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n # insights « légendaires » : croissance (snapshots), engagement pondéré,\n # rythme de publication, Ka Score /100 + historique pour la sparkline\n creator[\"insights\"] = stats_mod.creator_insights(_db(), creator)\n return creator"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":876,"cache_read_input_tokens":105427,"cache_creation":{"ephemeral_5m_input_tokens":876,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e55abba3-5449-49c5-ac44-a5697c5c4ad4"}} |
|
478 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PY8Ld8WMdfSW9A9FDWgNye","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/creaka/web.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2322b084-a8a8-45f7-a87d-ea970351e586","timestamp":"2026-08-21T01:09:49.691Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/web.py","oldString":"@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n return creator","newString":"@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n # insights « légendaires » : croissance (snapshots), engagement pondéré,\n # rythme de publication, Ka Score /100 + historique pour la sparkline\n creator[\"insights\"] = stats_mod.creator_insights(_db(), creator)\n return creator","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\n# File: creaka/web.py\n# Desc: API FastAPI (JSON) + service du frontend statique (frontend/dist) —\n# annuaire cherchable par niche, région, langue, plateforme, taille\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport threading\nfrom pathlib import Path\n\nfrom fastapi import FastAPI, HTTPException, Query\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.middleware.gzip import GZipMiddleware\nfrom fastapi.responses import FileResponse, Response\nfrom pydantic import BaseModel, Field\n\nfrom . import auth, db, ethics, kapdf\nfrom . import stats as stats_mod\nfrom .normalize import NICHES, PLATFORMS, REGIONS\n\nROOT = Path(__file__).resolve().parent.parent\nFRONTEND_DIST = ROOT / \"frontend\" / \"dist\"\nSOURCES_PATH = ROOT / \"data\" / \"sources.json\"\n\napp = FastAPI(title=\"Créa-Ka API\", version=\"1.0\",\n description=\"Annuaire public des créateurs de contenu québécois \"\n \"— tous leurs comptes, toutes leurs plateformes.\")\napp.add_middleware(CORSMiddleware, allow_origins=[\"*\"],\n allow_methods=[\"*\"], allow_headers=[\"*\"])\napp.add_middleware(GZipMiddleware, minimum_size=1000)\n\n# Connexion SQLite PAR THREAD (§18) : FastAPI exécute les endpoints sync dans\n# un pool de threads — une connexion partagée (check_same_thread=False) fait\n# s'entrelacer les curseurs et produit des 500 intermittents (« 'NoneType'\n# object is not subscriptable »). threading.local = une connexion par thread.\n_local = threading.local()\n\n\ndef _db():\n con = getattr(_local, \"con\", None)\n if con is None:\n con = _local.con = db.connect()\n return con\n\n# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py\napp.include_router(auth.router)\n\n\n@app.get(\"/healthz\")\ndef healthz():\n return {\"ok\": True}\n\n\n@app.get(\"/api/health\")\ndef health():\n s = db.stats(_db())\n return {\"ok\": True, \"creators\": s[\"creators\"], \"accounts\": s[\"accounts\"],\n \"last_sync\": s[\"last_sync\"]}\n\n\n@app.get(\"/api/creators\")\ndef list_creators(q: str = \"\", niche: str = \"\", region: str = \"\",\n langue: str = \"\", plateforme: str = \"\", tier: str = \"\",\n sort: str = \"reach\",\n limit: int = Query(60, le=200), offset: int = Query(0, ge=0)):\n return db.search(_db(), q=q.strip(), niche=niche, region=region,\n langue=langue, plateforme=plateforme, tier=tier,\n sort=sort, limit=limit, offset=offset)\n\n\n@app.get(\"/api/creators/{cid}\")\ndef get_creator(cid: str):\n creator = db.get_creator(_db(), cid)\n if creator is None:\n raise HTTPException(404, \"créateur introuvable\")\n return creator\n\n\n@app.get(\"/api/stats\")\ndef get_stats():\n return db.stats(_db())\n\n\n_PERIODS_OK = {\"auj\", \"7j\", \"30j\", \"3m\", \"6m\", \"12m\", \"annee\", \"tout\"}\n\n\n@app.get(\"/api/stats/dashboard\")\ndef stats_dashboard(period: str = \"30j\",\n date_from: str = Query(\"\", alias=\"from\"),\n date_to: str = Query(\"\", alias=\"to\")):\n \"\"\"Tableau de bord analytique (contrat ka-stats SPEC.md) — cache 5 min.\"\"\"\n if period not in _PERIODS_OK and not (date_from and date_to):\n raise HTTPException(400, \"période inconnue \"\n \"(auj|7j|30j|3m|6m|12m|annee|tout ou from/to)\")\n return stats_mod.dashboard(_db(), period=period,\n date_from=date_from, date_to=date_to)\n\n\n@app.get(\"/api/stats/report\")\ndef stats_report(period: str = \"30j\", mode: str = \"complet\",\n date_from: str = Query(\"\", alias=\"from\"),\n date_to: str = Query(\"\", alias=\"to\")):\n \"\"\"Rapport PDF estampillé Groupe-KA — 5 modes (contrat ka-stats SPEC §3) :\n complet | synthese | tendances | repartitions | donnees.\n Un mode inconnu retombe sur `complet` (rétrocompatible v1).\"\"\"\n if mode not in kapdf.REPORT_MODES:\n mode = \"complet\"\n if period not in _PERIODS_OK and not (date_from and date_to):\n raise HTTPException(400, \"période inconnue\")\n dash = stats_mod.dashboard(_db(), period=period,\n date_from=date_from, date_to=date_to)\n pdf = kapdf.GroupeKAReport(site=stats_mod.site_info(),\n dashboard=dash, mode=mode).build()\n fname = kapdf.filename(\"crea-ka\", period, mode)\n return Response(content=pdf, media_type=\"application/pdf\",\n headers={\"Content-Disposition\":\n f'attachment; filename=\"{fname}\"'})\n\n\n@app.get(\"/api/taxonomies\")\ndef taxonomies():\n return {\"niches\": sorted(NICHES), \"plateformes\": sorted(PLATFORMS),\n \"regions\": sorted(REGIONS),\n \"tiers\": [\"nano\", \"micro\", \"macro\", \"mega\"],\n \"langues\": [\"fr\", \"en\", \"bilingue\"]}\n\n\n@app.get(\"/api/sources\")\ndef sources():\n if SOURCES_PATH.exists():\n return json.loads(SOURCES_PATH.read_text(encoding=\"utf-8\"))\n return {\"sources\": []}\n\n\nclass OptOutRequest(BaseModel):\n \"\"\"Demande de retrait (§15 — opt-out obligatoire et respecté).\"\"\"\n name: str = Field(\"\", max_length=200)\n account: str = Field(\"\", max_length=200,\n description=\"plateforme:handle, ex. instagram:monnom\")\n reason: str = Field(\"\", max_length=500)\n contact: str = Field(\"\", max_length=200)\n\n\n@app.post(\"/api/optout\")\ndef optout(req: OptOutRequest):\n \"\"\"Retrait immédiat : la fiche est masquée et exclue des ré-agrégations.\n\n La vie privée prime sur la complétude (§15) : on masque d'abord, la\n demande est journalisée pour vérification humaine ensuite.\n \"\"\"\n name = req.name.strip() or None\n acc = req.account.strip().lower() or None\n if not name and not acc:\n raise HTTPException(400, \"préciser un nom ou un compte (plateforme:handle)\")\n ethics.add_optout(name=name, account=acc, reason=req.reason,\n contact=req.contact)\n masked = db.apply_optout(_db(), name=name, account=acc)\n return {\"ok\": True, \"masked\": masked,\n \"message\": \"Fiche masquée. La demande sera vérifiée ; merci.\"}\n\n\n# --- frontend statique -----------------------------------------------------------\n\n@app.get(\"/\")\ndef index():\n idx = FRONTEND_DIST / \"index.html\"\n if idx.exists():\n return FileResponse(idx)\n raise HTTPException(404, \"frontend non construit\")\n\n\n@app.get(\"/{path:path}\")\ndef static_files(path: str):\n target = (FRONTEND_DIST / path).resolve()\n if target.is_file() and str(target).startswith(str(FRONTEND_DIST.resolve())):\n return FileResponse(target)\n idx = FRONTEND_DIST / \"index.html\"\n if idx.exists():\n return FileResponse(idx) # SPA fallback\n raise HTTPException(404, \"introuvable\")\n","structuredPatch":[{"oldStart":75,"oldLines":6,"newStart":75,"newLines":9,"lines":[" creator = db.get_creator(_db(), cid)"," if creator is None:"," raise HTTPException(404, \"créateur introuvable\")","+ # insights « légendaires » : croissance (snapshots), engagement pondéré,","+ # rythme de publication, Ka Score /100 + historique pour la sparkline","+ creator[\"insights\"] = stats_mod.creator_insights(_db(), creator)"," return creator"," "," "]}],"userModified":false,"replaceAll":false}}} |
|
479 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"93170757-5cda-4a9f-87bb-aecf50cbd7ad","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
480 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF13TKJExuhNFpSUF18f","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISngMKpAEIERgCKkBsEM+T4mRXXRfrVInCChoZkRgIt2NbNgkvhQ/Q3Mz9jV8neYgS/rgkZJYWz/uJSjPlV58DHs6/JRiOE4R+L1k4Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHkyp7UBhIMqMJgm/t/yAY2pSvQGgzN2m2Vs3VD+psEewEiMKY/6ClYpJ7s5Gdpv73yOfdZ2sTJtGnsEZ8SMTNiHnd83muBzrtM0992EJDyMTC/1SqmAS94SOAAADtMwQ1xX+NS4hiGS/Tfm1LAIdkh0B+MnfAtnwxl52HJlITAVwZD5IqjBcLzgnsE1LMDK1AkB2f5iu11+ZkW/qASFljkLT7GnA2II9dwqIRk6RjpNLAqiB6SzdUgbc7j+Fo8nQ+v+MRTPB6lCBi0wTlCw8CT06KV5hiq0GwA3LB43+a9Z9gaOWlOp8vjKR4rBvKhamjcrXOBVADYjfp35/AYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":439,"cache_read_input_tokens":106303,"cache_creation":{"ephemeral_5m_input_tokens":439,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8a8799da-977d-49ab-abe4-8c6475770ce5"}} |
|
481 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF13TKJExuhNFpSUF18f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FHLfD5umKqGe2ZRBaUgxwX","name":"Bash","input":{"command":"cd ~/apps/crea-ka && python3 -c \"\nfrom creaka import db, stats\ncon = db.connect()\nrow = con.execute(\\\"SELECT id FROM creators WHERE status='active' AND is_minor=0 AND total_reach>10000 LIMIT 3\\\").fetchall()\nimport json\nfor r in row:\n doc = db.get_creator(con, r['id'])\n ins = stats.creator_insights(con, doc)\n print(r['id'], '→ ka_score', ins['ka_score'], ins['ka_score_parts'], 'eng', ins['avg_engagement_pct'], 'pubs/sem', ins['publications_per_week'], 'hist pts', len(ins['history']['total']))\n\"","description":"Smoke-test creator_insights on live DB"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":439,"cache_read_input_tokens":106303,"cache_creation":{"ephemeral_5m_input_tokens":439,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"20144e55-f84b-4912-bc56-c655c3dbef27"}} |
|
482 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FHLfD5umKqGe2ZRBaUgxwX","type":"tool_result","content":"enola-bedard → ka_score 70.1 {'audience': 40.0, 'engagement': 7.4, 'presence': 16.0, 'rythme': 1.7, 'verification': 5.0} eng 2.96 pubs/sem 0.5 hist pts 0\nceline-dion → ka_score 53.0 {'audience': 40.0, 'engagement': 0.0, 'presence': 8.0, 'rythme': 0.0, 'verification': 5.0} eng None pubs/sem None hist pts 0\nsarah-jade-bleau → ka_score 86.7 {'audience': 40.0, 'engagement': 25.0, 'presence': 16.0, 'rythme': 0.7, 'verification': 5.0} eng 30.17 pubs/sem 0.2 hist pts 0","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f2dfe50c-8476-409f-beca-9d2d3c20b8d6","timestamp":"2026-08-21T01:09:59.225Z","tool_use_result":{"stdout":"enola-bedard → ka_score 70.1 {'audience': 40.0, 'engagement': 7.4, 'presence': 16.0, 'rythme': 1.7, 'verification': 5.0} eng 2.96 pubs/sem 0.5 hist pts 0\nceline-dion → ka_score 53.0 {'audience': 40.0, 'engagement': 0.0, 'presence': 8.0, 'rythme': 0.0, 'verification': 5.0} eng None pubs/sem None hist pts 0\nsarah-jade-bleau → ka_score 86.7 {'audience': 40.0, 'engagement': 25.0, 'presence': 16.0, 'rythme': 0.7, 'verification': 5.0} eng 30.17 pubs/sem 0.2 hist pts 0","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
483 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ed2dcbbc-8177-477c-acf8-2fd0f2ed65c9","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
484 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF149X1G4XR7fFyp8j1F","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvgUKpAEIERgCKkBGwzDo/Rf5LohY6I/qjwRkp3oWnyEvfvAR3lk7QgzMcUHSAs3VTZfhjQ7MuVB5mSFnHhCyBeGl1n/PgK1IVx+nMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHtyp7UBhIMxy/Vdm32Lhror1waGgz6IjXuA8RESHN1NbEiMCZ6Wnl3Sz2BK34bgyQA2vCRFpVIoVGtKHiMALD/ZABYBsC2k7Prfygb3rKkRPudDyrGAz0H6H+MSAC8jrFDY59ZZrXJkbfzTBgr5hONSX88KH+I4dj9Lz2EJgqcXYHgmp46YcJpX7iREQXueHWudfMfBZwYT4Z9aQ4/VtpC07LYofQNmid0Ywn7EPWT3YYYbBuL0Fkmpy8GV/3VCCeYTj48/+PGiG5OAT6fvJsDozriMIkAbFuCKCO1rhO//lBZaI4Ephl3G0PTZk0j6gyf3fp+1nwoYWm2y0raCV+QH3JNQro9o4zB7QLT3A9HOIh4kplvxUmQxqAGAktEDLiCRsiRIB3ur9Kl4VMRlvMzrdCZ2VE7JKHrzi0HNGADRAgMY7IP2ky5nXxGxyeU7CJeuLkw1WsOs9QlBgYUaZ4lOYnSrWS2v/dDgpI4OkYSAtIrKuohEWjCkYPVTxXL9/XgUoQkLFPQfENzQIS9tWuwttITcNrk2M89e0iNjiK7JIaFmthWyqTaeGw8f7Flk12fLuSkCOq3uz5LA2jhQD9ZgC4UaCwn97tFmuys1+c+tzD8OBLz5CMsu0vizRNGAEX0vYE6bF+3+UpTf/PoZTWAychTFxYMQDL+JGBSsX8rYpUCxQnSctsd7GMOgd3574nMwdX+APEwUYHjv60YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1187,"cache_read_input_tokens":106742,"cache_creation":{"ephemeral_5m_input_tokens":1187,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b0a6d1b7-d242-4655-85ae-d79986d049b3"}} |
|
485 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF149X1G4XR7fFyp8j1F","type":"message","role":"assistant","content":[{"type":"text","text":"Insights fonctionnels sur la base live (Ka Score calculé correctement). J'amorce les snapshots avec les valeurs actuelles pour que l'historique commence dès aujourd'hui."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1187,"cache_read_input_tokens":106742,"cache_creation":{"ephemeral_5m_input_tokens":1187,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2f9d0559-b8b7-4456-99a6-db2d5d7eebbb"}} |
|
486 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF149X1G4XR7fFyp8j1F","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AT5Lm1FtPL3MGJnGWsGZkK","name":"Bash","input":{"command":"cd ~/apps/crea-ka && python3 -c \"\nimport json\nfrom creaka import db\ncon = db.connect()\nn = 0\nfrom datetime import date\nday = date.today().isoformat()\nfor r in con.execute('SELECT platform, handle, creator_id, followers, metrics FROM accounts WHERE followers IS NOT NULL AND followers > 0'):\n m = json.loads(r['metrics']) if r['metrics'] else {}\n con.execute('INSERT OR REPLACE INTO snapshots (day, platform, handle, creator_id, followers, engagement) VALUES (?,?,?,?,?,?)',\n (day, r['platform'], r['handle'], r['creator_id'], r['followers'], m.get('engagement_rate_pct')))\n n += 1\ncon.commit()\nprint('snapshots amorcés :', n)\n\"","description":"Backfill today's snapshots from current account data"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1187,"cache_read_input_tokens":106742,"cache_creation":{"ephemeral_5m_input_tokens":1187,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"3ecaa84e-2577-4969-a225-062a1fe1d3aa"}} |
|
487 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AT5Lm1FtPL3MGJnGWsGZkK","type":"tool_result","content":"snapshots amorcés : 10311","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bad2b52d-2b85-45ae-96a4-99ee6b07185c","timestamp":"2026-08-21T01:10:09.316Z","tool_use_result":{"stdout":"snapshots amorcés : 10311","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
488 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"17a931a6-7468-46a1-b9a1-0a803a3cdcdf","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
489 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF14tgZ7UzRtRS2wTKnB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01691dAGhsFcptNHxN8eYzng","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"in_progress","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":610,"cache_read_input_tokens":107929,"cache_creation":{"ephemeral_5m_input_tokens":610,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"55c61f57-9796-465c-b324-153b5ee516af"}} |
|
490 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01691dAGhsFcptNHxN8eYzng","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9dcc8fed-1e78-4ff1-84e2-7d301171546a","timestamp":"2026-08-21T01:10:21.166Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"in_progress","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"pending","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"in_progress","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
491 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF14tgZ7UzRtRS2wTKnB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01URomeFgdeG3opkVwGXxC2Z","name":"Grep","input":{"pattern":"renderCreator|function openCreator|fiche|#creator|creator-page|detail","path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","output_mode":"content","-n":true,"-i":true,"head_limit":40},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":610,"cache_read_input_tokens":107929,"cache_creation":{"ephemeral_5m_input_tokens":610,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"12b32f1f-a1e1-4222-8ec8-b7c89ed46b8c"}} |
|
492 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01URomeFgdeG3opkVwGXxC2Z","type":"tool_result","content":"805: `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n936:async function renderCreator(id){\n993: Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n1459: <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n1566: La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n1573: <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n1583: $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n1605: if(m)r=renderCreator(decodeURIComponent(m[1]));"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ab2ff9e0-8946-4d1b-8573-8d863d4bc4bf","timestamp":"2026-08-21T01:10:22.431Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"805: `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n936:async function renderCreator(id){\n993: Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n1459: <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n1566: La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n1573: <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n1583: $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n1605: if(m)r=renderCreator(decodeURIComponent(m[1]));","numLines":8}}} |
|
493 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0bb689df-fd9b-499f-b00b-4fd2d3235214","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
494 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF15rkW58rmBdQ2EGcwg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016R9UoawMiUu3acwWtrxidk","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","offset":700,"limit":300},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1443,"cache_read_input_tokens":108539,"cache_creation":{"ephemeral_5m_input_tokens":1443,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"59d8ead4-6aaf-459e-b677-82f67f7550f2"}} |
|
495 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016R9UoawMiUu3acwWtrxidk","type":"tool_result","content":"700\t <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n701\t <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n702\t <ul class=\"sites\">\n703\t ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n704\t ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n705\t </ul>\n706\t <div class=\"contacts\">\n707\t ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n708\t </div>\n709\t <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n710\t ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n711\t ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n712\t </p>\n713\t </div>\n714\t </footer>`;\n715\t}\n716\t\n717\t/* ---------- annuaire ---------- */\n718\tfunction platIcon(a){\n719\t const p=PLAT[a.platform]||PLAT.autre;\n720\t return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n721\t title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n722\t onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n723\t}\n724\tfunction cardHtml(c){\n725\t const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n726\t const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n727\t const verified=(c.platforms||[]).some(a=>a.verified);\n728\t const np=(c.platforms||[]).length;\n729\t return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n730\t <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n731\t <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n732\t <span class=\"nplat\">${np} plat.</span></div>\n733\t <div class=\"card-body\">\n734\t <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n735\t <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n736\t <div class=\"chips\">${niches}</div>\n737\t ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n738\t <div class=\"platrow\">${plats}</div></div></div>`;\n739\t}\n740\tfunction selectHtml(id,label,opts,val,lbls){\n741\t const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n742\t return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n743\t}\n744\tasync function renderHome(){\n745\t document.title=\"Créa-Ka — Un service Groupe KA\";\n746\t if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n747\t if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n748\t const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n749\t app.innerHTML=header()+`\n750\t <section class=\"hero\"><div class=\"container\">\n751\t <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n752\t <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n753\t <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n754\t podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n755\t <div class=\"stats-row\">\n756\t <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n757\t <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n758\t <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n759\t </div>\n760\t <div class=\"advantages\">\n761\t <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n762\t <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n763\t <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n764\t <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n765\t </div>\n766\t </div></section>\n767\t <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n768\t <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n769\t <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n770\t <div class=\"filters\">\n771\t ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n772\t ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n773\t ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n774\t ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n775\t ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n776\t ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n777\t </div>\n778\t </div></div>\n779\t <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n780\t <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n781\t <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n782\t bindNav();\n783\t $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n784\t [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n785\t [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n786\t $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n787\t e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n788\t if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n789\t await reload();\n790\t}\n791\tfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\n792\tasync function reload(more){\n793\t if(!more){state.offset=0;state.items=[]}\n794\t const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n795\t langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n796\t limit:\"60\",offset:String(state.offset)});\n797\t const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n798\t state.items=state.items.concat(data.items);state.total=data.total;\n799\t const grid=$(\"#grid\");if(!grid)return;\n800\t $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n801\t grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n802\t `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n803\t const moreDiv=$(\"#more\");\n804\t moreDiv.innerHTML=state.items.length<state.total?\n805\t `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n806\t const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n807\t bindNav(grid);\n808\t}\n809\t\n810\t/* ---------- page créateur ---------- */\n811\t/* --- bande de contenu récent par compte (enrichissement Apify) --- */\n812\tconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n813\t const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n814\t return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\n815\tconst _relTimeC=ts=>_relTime(ts);\n816\t/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\n817\twindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n818\t w.innerHTML=w.dataset.icon||\"\";}};\n819\tfunction _mediaItems(a){const m=a.metrics||{};\n820\t return m.recent_posts||m.recent_videos||m.recent_tweets||[];}\n821\t\n822\t/* grille de contenu récent, images/vidéos bien affichées */\n823\tfunction contentGrid(a){\n824\t const items=_mediaItems(a);\n825\t if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n826\t const ico=icon(a.platform);\n827\t const cards=items.slice(0,12).map(it=>{\n828\t const img=it.display_url||it.thumbnail_url||\"\";\n829\t const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n830\t const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n831\t const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n832\t const stats=[\n833\t it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n834\t it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n835\t it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n836\t it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n837\t it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n838\t ].filter(Boolean).join(\" · \");\n839\t const thumb=img\n840\t ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n841\t ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n842\t ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n843\t : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n844\t return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n845\t ${thumb}\n846\t <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n847\t <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n848\t }).join(\"\");\n849\t return `<div class=\"cg-grid\">${cards}</div>`;\n850\t}\n851\t\n852\t/* définitions de métriques calculées, par plateforme */\n853\tfunction platformKPIs(a){\n854\t const m=a.metrics||{}, out=[];\n855\t const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n856\t push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n857\t push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n858\t push(m.avg_likes,\"j'aime / pub (moy.)\");\n859\t push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n860\t push(m.avg_comments,\"comm. / pub (moy.)\");\n861\t push(m.avg_retweets,\"RT / pub (moy.)\");\n862\t push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n863\t push(m.posts,\"publications\");\n864\t push(m.videos,\"vidéos\");\n865\t push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n866\t push(m.following,\"abonnements\");\n867\t push(m.online,\"en ligne\");\n868\t push(m.boosts,\"boosts\");\n869\t push(m.patrons,\"patrons\");\n870\t push(m.live_viewers,\"spectateurs (live)\");\n871\t return out;\n872\t}\n873\tfunction platformBadges(a){\n874\t const m=a.metrics||{};\n875\t return [\n876\t a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n877\t m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n878\t m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n879\t m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n880\t m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n881\t m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n882\t m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n883\t m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n884\t m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n885\t m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n886\t m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n887\t (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n888\t (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n889\t ].filter(Boolean).join(\"\");\n890\t}\n891\t\n892\t/* panneau d'un compte (onglet plateforme) */\n893\tfunction platformPanel(a,idx,active){\n894\t const p=PLAT[a.platform]||PLAT.autre;\n895\t const conf=Math.round((a.confidence||0)*100);\n896\t const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n897\t const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n898\t const badges=platformBadges(a);\n899\t const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n900\t const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n901\t return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n902\t <div class=\"ppanel-head\">\n903\t <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n904\t <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n905\t <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n906\t <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n907\t </div>\n908\t ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n909\t <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n910\t ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n911\t <h3 class=\"cg-title\">Contenu récent</h3>\n912\t ${contentGrid(a)}\n913\t <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n914\t </section>`;\n915\t}\n916\t\n917\twindow.showPtab=(i,el)=>{\n918\t document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n919\t document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n920\t if(el)el.classList.add(\"on\");\n921\t const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n922\t};\n923\t\n924\tfunction computeAgg(c){\n925\t const P=(c.platforms||[]);\n926\t const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n927\t const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n928\t const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n929\t const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n930\t const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n931\t const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n932\t return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n933\t nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n934\t}\n935\t\n936\tasync function renderCreator(id){\n937\t app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n938\t let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n939\t catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n940\t <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n941\t document.title=`${c.display_name} — Créa-Ka`;\n942\t const verified=(c.platforms||[]).some(a=>a.verified);\n943\t const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n944\t const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n945\t const g=computeAgg(c);\n946\t // bandeau de métriques CALCULÉES (multi-plateforme)\n947\t const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n948\t const heroKpis=[\n949\t kpi(g.reach,\"abonnés cumulés\"),\n950\t kpi(g.nPlat,\"plateformes\"),\n951\t kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n952\t kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n953\t kpi(g.content||null,\"contenus analysés\"),\n954\t kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n955\t ].filter(Boolean).join(\"\");\n956\t // barres comparatives de portée par plateforme (vue d'ensemble)\n957\t const maxF=Math.max(1,...P.map(a=>a.followers||0));\n958\t const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n959\t return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n960\t <span class=\"obar-lab\">${p.label}</span>\n961\t <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n962\t <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n963\t // onglets + panneaux\n964\t const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n965\t P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n966\t return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n967\t <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n968\t ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n969\t const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n970\t <h3 class=\"cg-title\">Portée par plateforme</h3>\n971\t <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n972\t ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n973\t </section>`;\n974\t const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n975\t const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n976\t app.innerHTML=header()+`\n977\t <div class=\"container cpage\">\n978\t <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n979\t <div class=\"cpage-head\">\n980\t <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n981\t <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n982\t <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n983\t ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n984\t ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n985\t ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n986\t </div>\n987\t <div class=\"hkpi-row\">${heroKpis}</div>\n988\t ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n989\t <div class=\"ptabs\">${tabs}</div>\n990\t <div class=\"ppanels\">${panels}</div>\n991\t <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n992\t Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n993\t Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n994\t </div>`+footer()+tabbar(\"\");\n995\t bindNav();window.scrollTo(0,0);\n996\t}\n997\t\n998\t/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\n999\tconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6ddd7496-384a-4f5e-8c16-c6cdafa09368","timestamp":"2026-08-21T01:10:27.749Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","content":" <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n return m.recent_posts||m.recent_videos||m.recent_tweets||[];}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};","numLines":300,"startLine":700,"totalLines":1620}}}} |
|
496 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e5df4341-a8fb-4485-8284-90a3253003dd","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
497 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF16Fw1yANr6w8mrsPxM","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStg4KpAEIERgCKkCk84pmwFh14h3CpIZEF3uxEvM2OQaXH2X12U158B9pUl2N3MCc+G5gcyt9KLPKONgwcEOUwxyhmJFqrCNIDJkZMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGSy57UBhIMvUBj2o0pRecXrVIGGgzP14x/mBaPaWOaozsiMFZls/i7RPoI6hFEMXr6PPCvDtZzz1wLbtrG+XStho+l06rNeNCF1czk6aluz548qCq+DDcmYODFgKFiWbQxU0UauXm987pDMGCtjrkdcTt61NUo16hvVNXF7l3dGDJD+dfGNho7jf7nZ5P+T9GSMg3DYwIelhKNtLc5JerOjXwoFpQwXl3O1hoClygAbSpXRU5gWX0fZ9um2Nda7jQMNqO9g+0r+ZivarBBhF0rs+1H39fT21P2IO2Y3AIc15efF8uTBpj4uV4DACco1+0FNBqeMJFdzRkldHic0KxEkLcbvDsXbDvLNOwe1AWMUhjkSSHywVRZA3MHX9RD+TfbqSxoPV6vvKZ+C89GdTS0z3i6N0hCnxmju/3z20/GXQex02BVDPHilkv49GGNuJKpYejkzyvYRtdtZ6meEgHhgQTw2UW3gHtjdz5ZnK2WeLYNyoSwIN8jCmj657pz5EWK0bNesEURi485eJ0cS/m5CDVBRDtnmMCGyFkFWCeHGbzMBw+V0u+DN2S8+jgq2VeNhetTd8Ojs9J8Wx8PR/aymLBVbbnTHv/aoXudLwNGMZQHPlVaGl1Rr0ezpVwnPA0W/pOXglZlw0fnOq3fudSZCi9IjPeXDqFkZvlRPQuSl4ARG4sBUBcqS9rFugTBSriIDfY6b1MFHvUcccNrk45MUE0QkMHLzFYG30BfaE4bk6EWX9eT9au2R6WrNMpBiY4WmCoYZcQ5EAROJyi9GJpJjnEamiFPNoQWMWM4ETbbHD1Xvorn/xCfnGAK6EmIrhy3Yjo9HwoCbEmx1frCJ1pIfjhmnz8/fRcXdEL55BKsvyqgq2uLbS5vuCo6LfVImE9Rm0T5qFDXA6+MHz7K6u2FJBG9xJ3r7TQXR5qQaCikVKxjZjJTNvnqjFtmx41AhmyN+5ucEGPi5zGZOeV23eeZhk+3LSB/N+r03HBCsoNC8FtQZ8lFJ75jI+xiTc/8NLSg2ytRM9NXo3V7tpqJeliKu2o0WyVrIAEAeFyBZ5pDpPkHAblCnkihWsYitdWJVZR40sN+CMS+Om2uUnQqI5gfotLGH6ESPTqL4gUk824CK8tLXRsYby6kfeei5tIabz0OOOiZG+XpBqbJnQxQMdhIjouU9XBA9gwJ4HxiWx25kJb/d905VOIEv3bgFKpU7+lBb5k2Mu+UBUJg9yF+dV7ZIqMLPBPKl3w+OIX3QWn3mGrkedqK3dkPpMnAcmLhPl9O6toGHuZK7jfS2c2+cSvKa7ZcKZz2FrFMVsbnoN+2u71JFwhZjQsZWIoelYsNACiqYJR5vy2NxADcQw64Goaq3jWn5zl7dCHb2Qgf3ebkLhjQ+E1bu0JOtHnmjfmZFp8ypqhv3ZaiWPN/IRr8PpJIX5Accf/QrLx7ysl+S3UM5UnU1Bs+vu41wm0SGQhHPF3CMnLFzF6njMyDOkQxYoc/IuSlk4QqraG6EAHc5vB0cEqXlvcWd7WZ/28QUgONeOJqytwUCZL9QfBFazkUImySyU2CdBy8tm0BqbBg2kRp5ylBVvHzDm5UbiopMvgHtKi1mk9lqtRmvGlBBXimFkItioaKMHcje2z06og1m/DvClqilc8DRuz2Hm4yGYppJePuMfBKXCBRp6qZxdGIbLelLy+cPrXmgW9F2+GuVP/xleA2VcCrIMjfmj1npGzyDeA0HspxEg6kLEE+NiZhGrOiEkTcqN/UKsMYGiSPi7Y1xRs6stF/ZeEctEXzkzF7E3ha1ipajrsmZJ3rPIIiVIoi9rxaaqwVj59OQUbFKRCqRTysZjEeMdc8XR9yBSSErqIbebIJzCQg/UTNRFNBwlZT2n3KtjZvLwy4lGdZavgN0h672ypo6Q0lI/voyQBuNuG2GEaW96A2SnAMhoZRl0pZZJDou5+6mnhU18Fmqd+jVKCWiSdcVnIa2QGNqe8SPGclDPEi82A7fQcLIdYagHLMELpVo7Y152kNJ7f+yimYKe3iej1ECbxGk7VEScTc3MDgc+leyAUsgVLKQRrbsBNHkBE9i0eDHFCi3+mmx3799VGpAHaERR6mumeaEdNxVPGhPUdV52V+yKZHQmqk0h6q5dSG7CGgA1sJFLd7ecV3dYXxzCpENSlGklNNfDfPpuRFhwjSXGqnHX0RD4rqE/5WSb/HjnjodddESUAPEfN1HFKBObB9qKjoXY4mrQdi/ZUiJ7WhGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":10262,"cache_read_input_tokens":109982,"cache_creation":{"ephemeral_5m_input_tokens":10262,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"292a777b-6213-442e-99cb-74a10a87edda"}} |
|
498 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF16Fw1yANr6w8mrsPxM","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9gMKpQEIERgCKkCNe7zhWG3xyCyEvrYXvI2jQRyW7sjW+sawNc59pGdIQCa1K7qYrNAklVnoBMmf5Qms2FuEJQCrQ3plgeFUMazdMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBksue1AYSDDBD8/xuIdiCo9ZxRhoMyuR1dqTYmfnIn/5PIjCYfJLvqv5E6KOs2ofPcuqObBIrz+5AGTKRrKS7jfP+4kWKpz845+ZRZDBXdQ6miLEq/QHhQKp2+iU8q84lHGaBQnqhQo8XRq52hi+a0xyucmNdz9VWzyrvdXuHOoWUmLQnV70ZG7CSC1ESNwe/mauAHRmZc7YMANvf8YYjS4DpHT1suMCPwZ7sCoHxV2gunh2O05QJiyhGy0eLYkAg9sWVaQIWh2FmtNGZNcOgBn2c1GjP5SmhPHchBNz/iGjStGaSg0UHLCSQGgEQN7RJYTQrIA0KYDTKWKp/VxLGzlZRcojobtoCEbvXl7UFKhVhnAFNRVRJtlxBg1lPAoUIg26GZGcveKuE/sBDZQJ47TL4DPI0oDQbgLrzxAxZ9F7hfRCKC9Jy6mwhH/1SXU2xJNemGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":10262,"cache_read_input_tokens":109982,"cache_creation":{"ephemeral_5m_input_tokens":10262,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"952f4d05-63fb-4c73-b133-8f17f283e12f"}} |
|
499 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF16Fw1yANr6w8mrsPxM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PR98kkRW7fYJxqEHjoWRux","name":"Grep","input":{"pattern":"\\.cpage-head|\\.cavatar|\\.hkpi|\\.obar\\{|\\.ppanel\\{","path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","output_mode":"content","-n":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":10262,"cache_read_input_tokens":109982,"cache_creation":{"ephemeral_5m_input_tokens":10262,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fd5757f5-9d96-42da-ab7f-9acc5ec99c07"}} |
|
500 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PR98kkRW7fYJxqEHjoWRux","type":"tool_result","content":"238:.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n241:@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n242:.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n247:@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n248:.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n249:.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n251:.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n252:.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n269:.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n270:.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n272:.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n273:.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n289:.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n344:.obar{display:flex;align-items:center;gap:11px}"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"65265208-0346-436e-bd22-631acc13d551","timestamp":"2026-08-21T01:10:43.881Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"238:.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n241:@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n242:.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n247:@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n248:.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n249:.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n251:.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n252:.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n269:.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n270:.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n272:.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n273:.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n289:.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n344:.obar{display:flex;align-items:center;gap:11px}","numLines":14}}} |
|
501 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"320780c6-51f8-4ed9-851c-974f936cb61e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
502 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF17SWHkEm76j8qDdFdP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RsFhxBcPJnwd1QnN4gPcxj","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","offset":232,"limit":130},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1520,"cache_read_input_tokens":120244,"cache_creation":{"ephemeral_5m_input_tokens":1520,"ephemeral_1h_input_tokens":0},"output_tokens":14,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a087840a-8df5-43b4-8c41-06685222d25a"}} |
|
503 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RsFhxBcPJnwd1QnN4gPcxj","type":"tool_result","content":"232\t/* ===== page créateur ===== */\n233\t.cpage{padding:30px 0 60px}\n234\t@media(max-width:640px){.cpage{padding:20px 0 40px}}\n235\t.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n236\t font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n237\t color:var(--green);margin-bottom:20px}\n238\t.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n239\t border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n240\t color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n241\t@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n242\t.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n243\t background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n244\t color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n245\t font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n246\t position:relative;border:2px solid var(--accent)}\n247\t@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n248\t.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n249\t.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n250\t line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n251\t.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n252\t.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n253\t.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n254\t border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n255\t font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n256\t.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n257\t.cpage-reach{margin-left:auto;text-align:right}\n258\t.cpage-reach b{display:block;font-family:var(--font-display);\n259\t font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n260\t.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n261\t text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n262\t@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n263\t.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n264\t.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n265\t background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n266\t padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n267\t.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n268\t/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n269\t.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n270\t.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n271\t box-shadow:var(--shadow-off-soft);padding:13px 15px}\n272\t.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n273\t.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n274\t text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n275\t/* barre d'onglets */\n276\t.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n277\t.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n278\t.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n279\t background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n280\t border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n281\t font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n282\t.ptab:hover{background:var(--surface-2)}\n283\t.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n284\t.ptab.on .ptab-ico{color:var(--on-accent)!important}\n285\t.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n286\t.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n287\t.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n288\t background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n289\t.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n290\t@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n291\t.ppanel-head{display:flex;align-items:center;gap:13px}\n292\t.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n293\t.ppanel-head .plat svg{width:22px;height:22px}\n294\t.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n295\t.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n296\t.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n297\t.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n298\t font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n299\t.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n300\t.conf.mid{background:var(--amber-soft);color:#8a5a12}\n301\t.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n302\t.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n303\t border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n304\t font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n305\t.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n306\t.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n307\t/* grille KPI par plateforme */\n308\t.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n309\t.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n310\t.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n311\t.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n312\t text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n313\t.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n314\t border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n315\t.ppanel-top:hover{color:var(--green)}\n316\t.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n317\t/* grille de contenu récent (images/vidéos) */\n318\t.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n319\t.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n320\t.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n321\t border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n322\t background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n323\t.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n324\t.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n325\t.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n326\t.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n327\t.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n328\t.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n329\t font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n330\t.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n331\t font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n332\t.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n333\t.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n334\t.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n335\t -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n336\t.cg-cap.dim{color:var(--ink-3)}\n337\t.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n338\t justify-content:space-between;gap:6px;flex-wrap:wrap}\n339\t.cg-when{color:var(--ink-3);opacity:.8}\n340\t.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n341\t.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n342\t/* vue d'ensemble : barres de portée */\n343\t.obars{display:flex;flex-direction:column;gap:11px}\n344\t.obar{display:flex;align-items:center;gap:11px}\n345\t.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n346\t.obar-ico svg{width:16px;height:16px}\n347\t.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n348\t.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n349\t.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n350\t.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n351\t@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n352\t .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n353\t.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n354\t background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n355\t border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n356\t font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n357\t.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n358\t.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n359\t.linkinbio svg{width:16px;height:16px;fill:currentColor}\n360\t.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n361\t border-top:1.5px solid var(--ink);padding-top:16px}"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"04a87824-ed59-40e1-93df-c9ce766b335e","timestamp":"2026-08-21T01:10:49.443Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","content":"/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}","numLines":130,"startLine":232,"totalLines":1620}}}} |
|
504 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"aa68dac1-e947-4970-b70f-c4cc9e460d59","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
505 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF17rCKaRBSYgcDcCUvZ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmBIKpAEIERgCKkBwiCShJUpc7HxlduIZ5fbSFvxGcOTH7+6ciiAhipFxl6xCPHxwNb029ShZW+Ji4o+4Iekz4tyMELzfqyGPtSyQMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGqy57UBhIM6Uo0NyyigDSCgfVcGgwPhWMxEhnma/pI2TYiMJnzYSONJAwG600T6AiqWnOGSWNLfwWq+rBWP8Wvcr1reJYknSlShlXUEhaq48EFHSqgEFTL7DgmsjETUMKfNU5idv0CtcgDMn8LpivYHOBZzEF3XyKivkIdFUD8ukuw1drHnZTyjvi++cR//pz2AWW5yFslb1yWlE70TKeg/5u7ar8naJQVxVMjknMXUXzQcETGrqLWoowR67DOCheAdejMM5fjDQcpfkngxO89EtrbEn/MOGRXub6cjdFJpvM5kD8IBbPa/LdO0lIZYmcmSmGpDqalcpntCEt5rjg+c62VYaQQLkOhqsW5Ola3JEujptJyKxVncj+G2Q0lJexIjot/e+dxY/SUISfyzGSE8+0NNE0lpr7MB/xJpZ51YZB5/VoS2p1KWHLMQyTAeK78Wfa5WEp2Wx6GxHBhBHZGcBGWbnfRaciGZf0Yo5QRHgeOiZRqpV1uwA1ty2PniOcdAM+hH4NdbAgp0UPbYaPzts5MzodCJ2nP7vKDhDqgXdBZE8jXllMnmyq5QJ8tbsBy/f3WrmZhEwV6iMei0Z3ectwBC8yNPbizamL8+A56KPq7jtZoRKlWDg37uuI5B2p/UDazAh75mLhFMfZtV5Nbm6BCBuEJZLELAJwvig28r9ubSoCDUvw0u3R/BjSr4cm0tgCPukvEwE2kmcOjuFdh2cYdUCga2YWg8P8t7HWSz5oFjaTK7vk/0z84+LsAzSINpATXbYHf1rJVmRcrPE1j5Cleb0Ky5oa1STnDjPrZOpn/EKOOtML06WM1O0UkybJwuIq49toB5OzJ2NkTTEQ29szqcoGokxVEkwAHjCSkER3WHxherJ5bTJovUawapFZtbclXS5LaB2sxChEsm5Ot3CV+CdmyD/RRxJwAYuIYPkVt0dxMaDEPv2oJ5G4Va3evrjoh/zU4FJ2tuEij26Us4/VcYeyW9Ux6l/QpIk48yueoWo+AUCGYxGpkOni/opzEd9chfB5wV58bOtYaIrkV4O3M/0Rl58uajLL4MQ+OZMpmrzxZlZ+Uyl9cTehUpgIGGvJ9s1yMcB/AMAv6wJdFYZ7SBhSLIO9YReCXcuYz3TOhHcsTryriZjJDZbn1krm4Iy/06nTrCv0hzriF7swjo6ylm39IG2ZbtznXkgw3S3vWtPlIg9l4ldmmr5eNkYL53iNyBaEMYvze1X0gTsq6Xt4sMc8Dr9bvA3ODPU4jZV+e4Cy6yAsIutp/p6kFwbgRKV4uW/U47aGVm2DDFpjzDseV6m/+kggt51MsTGZln6n56lMCiq45tacCndnQ3LfyTG47Rx1cvN2j61as7aUOxEGpRqtwDA/Ksb3sOSaMphk0y7S3VtaQxpZckHJ1DvwIo/cTySYQJC97Az/Iln9QeG2a8DyHRIh/lD8bFePltY+wXVfQWFHOFIAHmpgcUV1b55JaarXhSdh4ACZCk+49JXdOeE5jzvcGmYAKCzGnyqbktt2TbnyRoohknlULX2RKe0aw4e2tKsvGpMDE4+aN8D91nHDj5YwR1+kTFI3W+0Qv55/fZB24CjmirjGLW3kECq/xbKOMU5zcGoE20qELo/3l9RNfqosX7Mw5QMKqJmdDtbNIfm2fFnxGwwpj+ZZmw/JXmeo3cfvUhFrA56WQqgtktXAAu6gUlm9R1d8WFOhEz1CCicGgiXsSdcvDzJWeA63u0+VD2/pp7QqBbO5roLJuZjAKnUwgXE+fAyDkxXxEIzjgSSfd4vaYF/ZyLsXtgQSMcbL2ulpF71T23gS5wlkorXum92Wp/fO3lLOoE8/QkO1UIB0RLLyLy5Dd16vlq0L0VeLCOuCtqbL2QxDR3+oHo1PkCkT84iqeJMl+k9nXvw1qgMytiCvGTUrVLLLSlWGpetOi6XEvH8cCkxrABAMberFq9d1wr+9yl7fRxumq3TQde/dLHMIQuqiaBCgTXRDNMf8pslVwhvVI/nX5NlZpAMBe/8JrGQ4+9GTmhPMw44hcBkuOJ1+NsDoMJ6ijx4pz8NRd0n1Z2qnPX12XoxhNjNaQJPycjqBF1rILwXGLH3Th/bw/c8HZEySZR7Bv9vo9wC0Bb+0w7fzOQotrMUmwlGBGwtZVfEshzDEg3pNBeWF4mbUB6J/b/3gTUGClENfkLOLn1/1e3uz/HoVItzX+9lckN6ZakFiL11DaZcy71PWEHmpIk2AthLuYYTcqnegNjMh7tSXf5rQrYMKqcjA6ZkU/Uf99zBqeP3dZaxclIuP55LoVUqIUfx6qoa3f69makGkbQWxLqm38G9BmkHs2Z/LdkBn8SmVJBiXO1SVk6QvkX387jlCbZfgq+EMDVYWJyn+uprEpS+nV1doFGdiPobyrUIlZ+TtHVP499PujB7R+4xBI+wWnyjduCbzY5X7u29R0JmlXxlcxOB9ymwdRvpPuob2ti2m7yb6T9qH74+Dk5NDeENiRAXnz9a9dm4ZsqWDVn36Xut1DpsK4GxKKhRoXbYXfQpXGwGiNHW2B33uqJbjcdQH/RVvYUfxdZcKyq/eLBY2XtMpPB/b+eKHUa0Hy6OHi20MAEUA/QIr0bpblo8MtzrICN2XKBi05ZnrFuiX0Hph0fTlHdsbrU6jqPV4wqq5FvYgB6Blzb291umigkNKt39uvx/MY6bk5+5ZLYCFjCrq3a4YnCmpL+8DBajLAkgTU2sKoEn4MCUnUxcJ7RrPsW18MPqxQSOa2IpWi5OpoCVNECUFms1TMrNF7QH7SAg37FOvdZVlD5kPxaQRmAjJoQ80cyyJxnBDc+4EG5P9DXtvrr1KuvJp3vphO5RIZmyXw1XwoGen+Ny24lgJq6H2pNlc92MRc2w4x+tywu1MYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5228,"cache_read_input_tokens":121764,"cache_creation":{"ephemeral_5m_input_tokens":5228,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5572b0d4-3208-43ea-ae48-e72d763905f7"}} |
|
506 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF17rCKaRBSYgcDcCUvZ","type":"message","role":"assistant","content":[{"type":"text","text":"J'implémente les ajouts frontend : bannière héro, carte Ka Score, sparkline, images de contenu élargies, bloc live."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5228,"cache_read_input_tokens":121764,"cache_creation":{"ephemeral_5m_input_tokens":5228,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e7c5729f-4c48-4e4c-a4ca-01ba11214abb"}} |
|
507 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF17rCKaRBSYgcDcCUvZ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SGoyN8mRGB3BU8P2ww8vXy","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":".cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}","new_string":".cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5228,"cache_read_input_tokens":121764,"cache_creation":{"ephemeral_5m_input_tokens":5228,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"65636db0-b6a6-4c64-b10a-e86959b30946"}} |
|
508 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SGoyN8mRGB3BU8P2ww8vXy","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"da975839-c043-4539-a439-d6c46ccabcf1","timestamp":"2026-08-21T01:11:21.735Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":".cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}","newString":".cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n return m.recent_posts||m.recent_videos||m.recent_tweets||[];}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":359,"oldLines":6,"newStart":359,"newLines":50,"lines":[" .linkinbio svg{width:16px;height:16px;fill:currentColor}"," .cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);"," border-top:1.5px solid var(--ink);padding-top:16px}","+/* bannière héro (banner_url capté par les acteurs Apify) */","+.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;","+ border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);","+ background:var(--ink)}","+.cbanner img{width:100%;height:100%;object-fit:cover;display:block}","+.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}","+/* Ka Score */","+.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}","+.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;","+ flex-direction:column;align-items:center;justify-content:center;","+ border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);","+ box-shadow:var(--shadow-off-soft)}","+.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}","+.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;","+ text-transform:uppercase;letter-spacing:.06em;margin-top:3px}","+.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}","+.kspart{display:flex;align-items:center;gap:10px}","+.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;","+ font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}","+.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);","+ border-radius:5px;overflow:hidden}","+.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}","+.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);","+ font-size:11px;font-weight:700;color:var(--ink)}","+@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}","+/* sparkline évolution d'audience */","+.spark-wrap{margin-top:8px}","+.spark-svg{width:100%;height:88px;display:block}","+.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}","+.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}","+.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);","+ font-size:10px;color:var(--ink-3);margin-top:4px}","+.growth-up{color:var(--green-deep);font-weight:700}","+.growth-down{color:#b0201a;font-weight:700}","+/* bloc EN DIRECT (miniature live Twitch/Kick) */","+.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;","+ border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;","+ background:var(--surface);text-decoration:none;color:inherit}","+.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}","+.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}","+.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}","+.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}","+.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}","+@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}"," .cpage-foot a{color:var(--green);font-weight:600}"," "," /* ===== page stats ===== */"]}],"userModified":false,"replaceAll":false}}} |
|
509 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"64816c70-02bf-475a-8408-7fc76b913029","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
510 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1AEUZyS2fQ2nJUmpzB","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant les fonctions JS : images élargies dans `contentGrid`, previews Snapchat, KPIs additionnels, bloc live, sparkline, Ka Score et bannière dans `renderCreator`."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2651,"cache_read_input_tokens":126992,"cache_creation":{"ephemeral_5m_input_tokens":2651,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"dac975d4-8b6f-4e3a-b62f-e8fd7a5a3cb9"}} |
|
511 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1AEUZyS2fQ2nJUmpzB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PvrhAmV8SJz2rLE4deRYoW","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":"function _mediaItems(a){const m=a.metrics||{};\n return m.recent_posts||m.recent_videos||m.recent_tweets||[];}","new_string":"function _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2651,"cache_read_input_tokens":126992,"cache_creation":{"ephemeral_5m_input_tokens":2651,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"741b0b47-dcc6-4b0a-8b68-559f90bd8d7d"}} |
|
512 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PvrhAmV8SJz2rLE4deRYoW","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d0355c6c-7dfa-4481-bf02-0542fbf6f570","timestamp":"2026-08-21T01:11:30.215Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":"function _mediaItems(a){const m=a.metrics||{};\n return m.recent_posts||m.recent_videos||m.recent_tweets||[];}","newString":"function _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n return m.recent_posts||m.recent_videos||m.recent_tweets||[];}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":861,"oldLines":7,"newStart":861,"newLines":11,"lines":[" window.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");"," w.innerHTML=w.dataset.icon||\"\";}};"," function _mediaItems(a){const m=a.metrics||{};","- return m.recent_posts||m.recent_videos||m.recent_tweets||[];}","+ const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];","+ if(base.length)return base;","+ // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images","+ const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];","+ return prev.map(u=>({display_url:u,type:\"story\"}));}"," "," /* grille de contenu récent, images/vidéos bien affichées */"," function contentGrid(a){"]}],"userModified":false,"replaceAll":false}}} |
|
513 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1AEUZyS2fQ2nJUmpzB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019oVsT64ZwebtQeuQQgtbbx","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" const img=it.display_url||it.thumbnail_url||\"\";","new_string":" const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2651,"cache_read_input_tokens":126992,"cache_creation":{"ephemeral_5m_input_tokens":2651,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b82bcca8-0a4c-4e23-9b54-3fb9f67d5345"}} |
|
514 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019oVsT64ZwebtQeuQQgtbbx","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"856449a1-614a-442c-91e7-576407bd0405","timestamp":"2026-08-21T01:11:31.580Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" const img=it.display_url||it.thumbnail_url||\"\";","newString":" const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":873,"oldLines":7,"newStart":873,"newLines":7,"lines":[" if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;"," const ico=icon(a.platform);"," const cards=items.slice(0,12).map(it=>{","- const img=it.display_url||it.thumbnail_url||\"\";","+ const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";"," const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;"," const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));"," const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);"]}],"userModified":false,"replaceAll":false}}} |
|
515 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1AEUZyS2fQ2nJUmpzB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016m9aYKdvpoF4tav3rUTFLG","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");","new_string":" push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2651,"cache_read_input_tokens":126992,"cache_creation":{"ephemeral_5m_input_tokens":2651,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6882c3a4-c25b-429c-bf62-f8e7bb3752df"}} |
|
516 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016m9aYKdvpoF4tav3rUTFLG","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5bfabd96-688f-468f-8f77-852b5330730e","timestamp":"2026-08-21T01:11:34.412Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");","newString":" push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":907,"oldLines":7,"newStart":907,"newLines":8,"lines":[" push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");"," push(m.avg_comments,\"comm. / pub (moy.)\");"," push(m.avg_retweets,\"RT / pub (moy.)\");","- push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,\"pubs / semaine\");","+ push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");","+ push(m.total_views,\"vues totales\");"," push(m.posts,\"publications\");"," push(m.videos,\"vidéos\");"," push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");"]}],"userModified":false,"replaceAll":false}}} |
|
517 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1AEUZyS2fQ2nJUmpzB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01C75WS38TuVsaTo8KoF925T","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",","new_string":" m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\","},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2651,"cache_read_input_tokens":126992,"cache_creation":{"ephemeral_5m_input_tokens":2651,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"bfe9ce28-8cec-4e2b-b42d-2b46a8138a12"}} |
|
518 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01C75WS38TuVsaTo8KoF925T","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"d6781a19-f8c6-44e3-a15e-4467f00451e6","timestamp":"2026-08-21T01:11:38.587Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",","newString":" m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":932,"oldLines":6,"newStart":932,"newLines":10,"lines":[" m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\","," m.has_story?'<span class=\"abadge\">Story active</span>':\"\","," m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",","+ m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",","+ m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",","+ m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",","+ m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\","," m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\","," (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),"," (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),"]}],"userModified":false,"replaceAll":false}}} |
|
519 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3e22ef07-e94b-4ac4-98a1-9ce0176b12c6","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
520 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1BUfsfugET8G289335","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8wIKpAEIERgCKkC+VMQr7nyYQmysWD2sNxEIXFctI4EjaDIc85tG1x3tN4aZH/N3VRdzr2QTCNKq+w4Lr6ZGpQp0mlTNGbcqHvOkMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHOy57UBhIMi5TFgTyrnPhkdt/VGgwKMEgl7DUrfNTU0d8iMPWKGx6LIjNP6Ffoyczd1y09SxN27TBgfRALzoS9223vkOcSYfIyRhKSJeTSAjt6sSp8Vssmh9kSB2+5qpPhH42YHupaQCr8EfYEPbQNmGE/Fw4Yfmaq1ajCSgbURttedVM6gkHROmCUJCSiC2Zm4X3PN2wuuvuXKa8DLBrUjEpaD/gJ7RQBialOcV98GPU4WCtWjtIMMuhqK3J5OmuQV3CLeMgxujZ6jZFzYxauORgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2263,"cache_read_input_tokens":129643,"cache_creation":{"ephemeral_5m_input_tokens":2263,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"c9f86d05-f426-477f-a6a8-e583d02df1a1"}} |
|
521 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1BUfsfugET8G289335","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AbDkiSoCZ3hqbfzdXPoRrJ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>","new_string":" ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`\n <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">\n <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">\n <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>\n ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}\n <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>\n </span></a>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2263,"cache_read_input_tokens":129643,"cache_creation":{"ephemeral_5m_input_tokens":2263,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"181588cc-d5d8-406e-8ac1-f5910c6a1350"}} |
|
522 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AbDkiSoCZ3hqbfzdXPoRrJ","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"a05c19bb-5dd7-46eb-ab64-9ca5d457ef53","timestamp":"2026-08-21T01:11:48.755Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>","newString":" ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`\n <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">\n <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">\n <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>\n ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}\n <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>\n </span></a>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":959,"oldLines":6,"newStart":959,"newLines":13,"lines":[" <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>"," </div>"," ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}","+ ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`","+ <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">","+ <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">","+ <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>","+ ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}","+ <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>","+ </span></a>`:\"\"}"," <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>"," ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}"," <h3 class=\"cg-title\">Contenu récent</h3>"]}],"userModified":false,"replaceAll":false}}} |
|
523 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"56cec0e1-d3de-464e-a5d8-07862a09073c","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
524 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1CDueQpYgPh7DD4iyA","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/BYKpAEIERgCKkD8oyG6Oz8Up/zCqixLoW04KfcdqS047qVkFSq/3Q7lLHH+rp5jabMY9lR7wXTepiwsfagHAWAOQONDhUeeGxH7Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHly57UBhIMW5IHwHW4QfMWEKc4Ggxwm/oqdbh4Ab6iQ7YiMNL+IClqqKUUx5kaL8pnrLxwkV1gTdM4H/qZCKbb+G5WtOqjVb0kHL6Tc2//8oAbrSqEFf6ePrkr4tm3mNfLxE6dpULTvzxW4J/XLERTzCfY+SdaEQ+FHY4OT8awv7FWq8lnrgVSuqDCT4T+503OqZDFfolAFX0ob/cF9YECXbQ3L8JvMvCRAexyAHwRnivXAD5AQd6UZBTUMXk8r/fSWwkEEIopLLhMimzGwnmgm9cAJDAMUL/xAH1GjJzpgFso3Q3dA8byWghjZHcj28nMzuQKtJ1m/93dCSWsk5aFG4gPdPbaEmgazJn/sPzSO/h/wm6K/X6AIjSSFAPCOyL4nGMxegjqzD7lepGcOHWzn+vjbZLTroC76XOPU27oSV2NAETAUZS2ChkOdUs5L1o1Cw5dzBLaQ2ogBf7Nzg5krtnsFD7HtkP9RbYWWmLGJLxyZggnKjEK231Qugj5ym1vKX50nO9QmENe2ebjbxKeO/mqstogccVv5VkltlAyUeqy19X9hBQeYpxfHHaM/vfiOGJV2A9A4QxSbOAc5B8UNmG5oVBTL5XFpI/R5A9D4ixB03U7tlMFMuuQTmXMA0Fx8vDqYkw7a2xHGHNKKymohHEw5G+EC6w1iEmujdqU/DKGawReWrwrcMUNyj4aE3yzH05eMEQFrkMPHAz6USqctaPwe4jmnnEcqUUC7/5sD51+onlhFP8LANs9eFx1QwBx3TPQIIMSbt+w2NC8zUqWck/jmhcs9zBG9BF+aT9u9IYOSW7yrW6VsCaZx/RAXMSGKJVj/TIVYAi5tNBEjlFWhIuC3FmqxST/oG8N6r9VZNNgzcHCUYGHAqCMsel6xrQAHKFdq5K4UhKSZSLxFaMFGXg7VlEpVE4cxtlGS7tlMNWtSgZ0gWbDsiLRssmxI+KLBob/2a9dY+0VMiRccZLTBnFqhReEwcBeB56nTTPuYeY2ZbaMXquyvC9dz9++MwZAMEhaA9cSo1woP+/yR9JPnMI/3m/SZpD2V+sKpiDLKD8hp4Q2vAeqQB3wBkJ6Til6s/5y63yXCnb76Vdi0/NOMKYp/fKXtqOnz7MADSKeFAFhvfhgFUGk8ktIns/UEqVwLSdE79Voi7SqGOSW3cZrzG/V+ZbYrjTL12PcFmd6AummwyawYPUWdbWUmepEvrSEbIBkouvsgLsLWJT1jqexstA7g86N5xnxzVtk5mEQyEGWAg6l8KN5Xb0xFi6F59POYAHWDLVTIKXmdabLETRiMoXm557CL3K/VI94XcgPCmF6mMRL/u2xI2BgrgkLqut90bGBjmOeDigsnCXtpnxbe4v0LxwCFUdSs9DrlS/LByDauRcX1j1wpHGS7oUQ9DuYiivP4cqi5AP3HHeo9TfWouRKqWr56Kw5M59zQcq9uvkQmBkLrfvIXDNEFfF0qAEfpXZK2dxVdIQ1jm0Y+LNe+Ta9K9t4QxLIm91zzlfA7OXb+/dQiEdk+stmFNd0AjgySSDlZoQ4QhIAXdxP8JeA0cV2GcdI12FNh7082OUdugUmQW9+9nTfn4TRM7VsmYW1962wVRamkzIK5H2jn9asXCwm0bl62zphLxmdlOunZ3lP8cUXCSoalh7mKXCqOFA5iR3wOXNP+XTDYCgYKiuliiu2AyT7RVFGg2p0SJxXodMO+PWA5AC5SiyxTau0O+k/w9rJ208Drbhes9AkMJUANnMonq6Sbhp/KInSemxMC8cVmn9+T7/80smXqoX9eHJNNTFwhk39ZdzeaR2nfjZEpAzeST+keHDjZQKTGTBgVbX4PMa9LUZuhKiaXJOh/XJsppy0ajVFtwXw6pQz/NpOYY82kbLZLaHxr8P7En0aT/nP/hGgyFtVw3JdYzmY7GdPc0qXvBRPDoVgvxaMhvULvK9NfVrrWMnlPFvffmO7qbpBYCqT8qS1MYMenRsgZ2OXiqGL+E6xTi/1fs+dI8OVY/eaZmDp4Aa/mtMKVHDSQJUuT6H0w+UAfm5CpZRY1FtIi1DvKSP0oCxDJNPSMDlxuiJ03ml81K2awg1n7abJlLdEbWKsxckHGXqU3rWIG8+XfTIMJRuj1TOoupXh+23wK+VorewZzOeGVOj1+5uxl6ixB5i47YLGGPN3YV+hXnVEIrZiviGChIaxUnuleI5pjpF5R82XZoKU4VwRSxTeY/LKzt/yllcTLuvU/RLZaWyByBPwOQdVN5vlmqh6LzCSnx6FyOEGqVJ0MLTTsQdI1o/6QEaIyi8b1YG1y2OWxqej7Y3NnBmQzCSUs4UVCoFeK6Ons6E9Xj462fSZKd3Dt/BQjSEFz9wy/I/ZK26B36GAmh0p0aPxfDLOrQQD39dikstq5Xba7k9sloJf8J6DAe2MZ+4wTqeAt4Z4nP9TFklaXQ7Nbt8kL4pp1l1+1xGBYwQjy0qgGLk+A1Crqa0GPPQHoLMPa0M9BwcSlXshl74R5rNHX/1loGQrWwb4PuYkYOSVQzHFW8/xb7k5i1RbSdLEDLm+ZGZxA85yPr0ybHulcaEKcUHKXb5Upj4U/svHlKX8z0/iYmLf2Eo3bgUxy+K0moIo6tTKq6W65JdqR/63XSM3e17kCmvdLTghOBIVMjDo0q5tl4vIDsddL+SoozVDC/Bc3x4yjQynyYFjKftBbnkPJABuvIjwAof8VPEXHlDyPEe8nlOYEzntOuzKhVhz+W+U0OOnTDhnjSDTyfCzxiG5B7XJnDby4MRJj4/6ixB4qpsvbEOX13FYpsO0fL/nsxsiWJo7HriissVVxFEPNwpuLcpj8YQ/Yqzzeh8ZrV3mfOv0aDXXHjAvsCtPuyak5A//VFAyaWjvi78SluD+ty+OS2h3sCT28WNSjTMOpG6vwMBziPRzxH84qZd+KWYPJTq2RvBiBtDMcZ1PR/0TyoCB9dYr9KD7Royj856obaH1jbPAaLuDcTVvgjDm6o8e4jx4tg7DCJ15kXT1MiWh4s5nUnI5bFPMFb1kSySGHPDbTwyt0UXy+b4094W96DkoOtRIlGCxni1kxT7f9DZDROI3KNTPt5o+czqQkKNW2PcdwZ1SEmdzXwZRywQmU/ENAnyKu3MESaTHXJOqJsThlNKDc18GVI3hc8obJoQC/imPFMiNUH7Qe+bCVTUknsb4oSZVAzYKgrlCjsu19pSZRSzAd80j3NJtKRz/PSwqAFImB+5+yB7FzwHrg2ubpoaGuZ0Gn3Ua5bVffau1dHe1fM3Nvqcuu3qF61xt3I8QhMR/wq5rGYrITxFlMTK0m0pfJz/RBFlkXw9OMP1T4FwWYw0D4GB6JT7+B547wDEoROZoZ57CzLCti6MInrAUo1BSTKKpa+2O7JyGUmUGy2oLSn4rWxszJ2HOr52AUEKgQr3Oy/ulOUu4k7s7p6XzMT+RN7LaROED9T1O5oMF5/FjC4vrjAuXvDcHFVD4QmpA1FgjhXsJ5S0umPyyoDtnpE1zsG96XZtWsre+Cfy/b1wq5z2JGUz16lTpHIzlJIBn/xO3gbzkCRbcx+965QAViAxAI5Sow0jdCIbKa699jKRXPqMHdi59hSPpXybirWGRPKPDPukZmwHPciOcQt+V6uXJJ4GjIb6VIy0k82hINvO/jF2H39VBDdILOwf1M0MN8J77T/cpOg7blcJfBz8BasvRNBL8np+ZZMsYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":783,"cache_read_input_tokens":131906,"cache_creation":{"ephemeral_5m_input_tokens":783,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"832efa21-92d9-479e-94e0-ed5311aa0d01"}} |
|
525 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1CDueQpYgPh7DD4iyA","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QphxkFy5by79t3Dp7ibHjX","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":"async function renderCreator(id){","new_string":"/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */\nfunction sparkSvg(pts){\n if(!pts||pts.length<2)return\"\";\n const w=600,h=88,pad=4;\n const vals=pts.map(p=>p.followers);\n const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);\n const x=i=>pad+i*(w-2*pad)/(pts.length-1);\n const y=v=>h-pad-((v-min)/span)*(h-2*pad);\n const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(\" \");\n const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;\n return `<div class=\"spark-wrap\"><svg class=\"spark-svg\" viewBox=\"0 0 ${w} ${h}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polygon class=\"spark-fill\" points=\"${area}\"/><polyline class=\"spark-line\" points=\"${line}\"/></svg>\n <div class=\"spark-meta\"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;\n}\n\nasync function renderCreator(id){"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":783,"cache_read_input_tokens":131906,"cache_creation":{"ephemeral_5m_input_tokens":783,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"80481474-64f5-40a5-8491-651a7f0f3b22"}} |
|
526 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QphxkFy5by79t3Dp7ibHjX","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f22eb9b5-8620-4e60-8d28-6bed23c2f288","timestamp":"2026-08-21T01:12:10.540Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":"async function renderCreator(id){","newString":"/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */\nfunction sparkSvg(pts){\n if(!pts||pts.length<2)return\"\";\n const w=600,h=88,pad=4;\n const vals=pts.map(p=>p.followers);\n const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);\n const x=i=>pad+i*(w-2*pad)/(pts.length-1);\n const y=v=>h-pad-((v-min)/span)*(h-2*pad);\n const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(\" \");\n const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;\n return `<div class=\"spark-wrap\"><svg class=\"spark-svg\" viewBox=\"0 0 ${w} ${h}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polygon class=\"spark-fill\" points=\"${area}\"/><polyline class=\"spark-line\" points=\"${line}\"/></svg>\n <div class=\"spark-meta\"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;\n}\n\nasync function renderCreator(id){","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`\n <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">\n <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">\n <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>\n ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}\n <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>\n </span></a>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":993,"oldLines":6,"newStart":993,"newLines":21,"lines":[" nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};"," }"," ","+/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */","+function sparkSvg(pts){","+ if(!pts||pts.length<2)return\"\";","+ const w=600,h=88,pad=4;","+ const vals=pts.map(p=>p.followers);","+ const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);","+ const x=i=>pad+i*(w-2*pad)/(pts.length-1);","+ const y=v=>h-pad-((v-min)/span)*(h-2*pad);","+ const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(\" \");","+ const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;","+ return `<div class=\"spark-wrap\"><svg class=\"spark-svg\" viewBox=\"0 0 ${w} ${h}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">","+ <polygon class=\"spark-fill\" points=\"${area}\"/><polyline class=\"spark-line\" points=\"${line}\"/></svg>","+ <div class=\"spark-meta\"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;","+}","+"," async function renderCreator(id){"," app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();"," let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}"]}],"userModified":false,"replaceAll":false}}} |
|
527 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ce61db33-6552-4fed-8787-c33ebbfda71e","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
528 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1Dpv6Ht2tYTJvbzMGp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WUzfpVJqPuzZCAtCsAehMg","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");","new_string":" const g=computeAgg(c);\n const ins=c.insights||{};\n const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const gkpi=(d,l)=>!d||!d.delta?\"\":`<div class=\"hkpi\"><b class=\"${d.delta>=0?\"growth-up\":\"growth-down\"}\">${d.delta>=0?\"+\":\"\"}${fmt.format(d.delta)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n gkpi(ins.growth_7d,\"abonnés / 7 j\"),\n gkpi(ins.growth_30d,\"abonnés / 30 j\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+\" %\":(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null),\"engagement moyen\"),\n kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)\n const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};\n const KS_LBL={audience:\"Audience\",engagement:\"Engagement\",presence:\"Multi-plateforme\",\n rythme:\"Rythme de pub.\",verification:\"Vérification\"};\n const ksParts=ins.ka_score_parts||{};\n const ksHtml=ins.ka_score!=null?`<h3 class=\"cg-title\">Ka Score</h3><div class=\"kascore\">\n <div class=\"kascore-num\"><b>${ins.ka_score}</b><span>/ 100</span></div>\n <div class=\"kascore-parts\">${Object.keys(KS_MAX).map(k=>`<div class=\"kspart\">\n <span class=\"kspart-lab\">${KS_LBL[k]}</span>\n <span class=\"kspart-track\"><span class=\"kspart-fill\" style=\"width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%\"></span></span>\n <span class=\"kspart-val\">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join(\"\")}</div>\n </div>`:\"\";\n const hist=((ins.history||{}).total)||[];\n const sparkHtml=hist.length>1?`<h3 class=\"cg-title\">Évolution de l'audience</h3>${sparkSvg(hist)}`:\"\";"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2048,"cache_read_input_tokens":132689,"cache_creation":{"ephemeral_5m_input_tokens":2048,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"01a190f6-5fdc-4055-aa0a-0c3318342145"}} |
|
529 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2e3c3ab0-f7f6-4211-a034-e91e2e27b1a1","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
530 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WUzfpVJqPuzZCAtCsAehMg","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"69c3b8d5-5565-4a2f-bfdf-656eb0c6789b","timestamp":"2026-08-21T01:12:28.704Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");","newString":" const g=computeAgg(c);\n const ins=c.insights||{};\n const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const gkpi=(d,l)=>!d||!d.delta?\"\":`<div class=\"hkpi\"><b class=\"${d.delta>=0?\"growth-up\":\"growth-down\"}\">${d.delta>=0?\"+\":\"\"}${fmt.format(d.delta)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n gkpi(ins.growth_7d,\"abonnés / 7 j\"),\n gkpi(ins.growth_30d,\"abonnés / 30 j\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+\" %\":(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null),\"engagement moyen\"),\n kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)\n const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};\n const KS_LBL={audience:\"Audience\",engagement:\"Engagement\",presence:\"Multi-plateforme\",\n rythme:\"Rythme de pub.\",verification:\"Vérification\"};\n const ksParts=ins.ka_score_parts||{};\n const ksHtml=ins.ka_score!=null?`<h3 class=\"cg-title\">Ka Score</h3><div class=\"kascore\">\n <div class=\"kascore-num\"><b>${ins.ka_score}</b><span>/ 100</span></div>\n <div class=\"kascore-parts\">${Object.keys(KS_MAX).map(k=>`<div class=\"kspart\">\n <span class=\"kspart-lab\">${KS_LBL[k]}</span>\n <span class=\"kspart-track\"><span class=\"kspart-fill\" style=\"width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%\"></span></span>\n <span class=\"kspart-val\">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join(\"\")}</div>\n </div>`:\"\";\n const hist=((ins.history||{}).total)||[];\n const sparkHtml=hist.length>1?`<h3 class=\"cg-title\">Évolution de l'audience</h3>${sparkSvg(hist)}`:\"\";","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`\n <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">\n <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">\n <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>\n ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}\n <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>\n </span></a>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\n/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */\nfunction sparkSvg(pts){\n if(!pts||pts.length<2)return\"\";\n const w=600,h=88,pad=4;\n const vals=pts.map(p=>p.followers);\n const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);\n const x=i=>pad+i*(w-2*pad)/(pts.length-1);\n const y=v=>h-pad-((v-min)/span)*(h-2*pad);\n const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(\" \");\n const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;\n return `<div class=\"spark-wrap\"><svg class=\"spark-svg\" viewBox=\"0 0 ${w} ${h}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polygon class=\"spark-fill\" points=\"${area}\"/><polyline class=\"spark-line\" points=\"${line}\"/></svg>\n <div class=\"spark-meta\"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),\n kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":1018,"oldLines":16,"newStart":1018,"newLines":35,"lines":[" const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");"," const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));"," const g=computeAgg(c);","+ const ins=c.insights||{};","+ const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);"," // bandeau de métriques CALCULÉES (multi-plateforme)"," const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;","+ const gkpi=(d,l)=>!d||!d.delta?\"\":`<div class=\"hkpi\"><b class=\"${d.delta>=0?\"growth-up\":\"growth-down\"}\">${d.delta>=0?\"+\":\"\"}${fmt.format(d.delta)}</b><span>${l}</span></div>`;"," const heroKpis=["," kpi(g.reach,\"abonnés cumulés\"),","+ gkpi(ins.growth_7d,\"abonnés / 7 j\"),","+ gkpi(ins.growth_30d,\"abonnés / 30 j\"),"," kpi(g.nPlat,\"plateformes\"),","- kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null,\"engagement moyen\"),","- kpi(g.maxCad!=null?g.maxCad:null,\"pubs / semaine\"),","+ kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+\" %\":(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null),\"engagement moyen\"),","+ kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,\"pubs / semaine\"),"," kpi(g.content||null,\"contenus analysés\"),"," kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),"," ].filter(Boolean).join(\"\");","+ // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)","+ const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};","+ const KS_LBL={audience:\"Audience\",engagement:\"Engagement\",presence:\"Multi-plateforme\",","+ rythme:\"Rythme de pub.\",verification:\"Vérification\"};","+ const ksParts=ins.ka_score_parts||{};","+ const ksHtml=ins.ka_score!=null?`<h3 class=\"cg-title\">Ka Score</h3><div class=\"kascore\">","+ <div class=\"kascore-num\"><b>${ins.ka_score}</b><span>/ 100</span></div>","+ <div class=\"kascore-parts\">${Object.keys(KS_MAX).map(k=>`<div class=\"kspart\">","+ <span class=\"kspart-lab\">${KS_LBL[k]}</span>","+ <span class=\"kspart-track\"><span class=\"kspart-fill\" style=\"width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%\"></span></span>","+ <span class=\"kspart-val\">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join(\"\")}</div>","+ </div>`:\"\";","+ const hist=((ins.history||{}).total)||[];","+ const sparkHtml=hist.length>1?`<h3 class=\"cg-title\">Évolution de l'audience</h3>${sparkSvg(hist)}`:\"\";"," // barres comparatives de portée par plateforme (vue d'ensemble)"," const maxF=Math.max(1,...P.map(a=>a.followers||0));"," const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);"]}],"userModified":false,"replaceAll":false}}} |
|
531 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1FCEmmnVtF2QWiB9Z5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01U4wnzVULmJ742cpa3R1FXV","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;","new_string":" const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n ${ksHtml}\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${sparkHtml}\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1643,"cache_read_input_tokens":134737,"cache_creation":{"ephemeral_5m_input_tokens":1643,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"0f6f8b06-6095-4993-bc98-6bc9b054bab7"}} |
|
532 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01U4wnzVULmJ742cpa3R1FXV","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"184b0e62-42a5-4a2b-8311-69b581066252","timestamp":"2026-08-21T01:12:39.755Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;","newString":" const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n ${ksHtml}\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${sparkHtml}\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`\n <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">\n <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">\n <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>\n ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}\n <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>\n </span></a>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\n/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */\nfunction sparkSvg(pts){\n if(!pts||pts.length<2)return\"\";\n const w=600,h=88,pad=4;\n const vals=pts.map(p=>p.followers);\n const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);\n const x=i=>pad+i*(w-2*pad)/(pts.length-1);\n const y=v=>h-pad-((v-min)/span)*(h-2*pad);\n const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(\" \");\n const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;\n return `<div class=\"spark-wrap\"><svg class=\"spark-svg\" viewBox=\"0 0 ${w} ${h}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polygon class=\"spark-fill\" points=\"${area}\"/><polyline class=\"spark-line\" points=\"${line}\"/></svg>\n <div class=\"spark-meta\"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n const ins=c.insights||{};\n const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const gkpi=(d,l)=>!d||!d.delta?\"\":`<div class=\"hkpi\"><b class=\"${d.delta>=0?\"growth-up\":\"growth-down\"}\">${d.delta>=0?\"+\":\"\"}${fmt.format(d.delta)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n gkpi(ins.growth_7d,\"abonnés / 7 j\"),\n gkpi(ins.growth_30d,\"abonnés / 30 j\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+\" %\":(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null),\"engagement moyen\"),\n kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)\n const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};\n const KS_LBL={audience:\"Audience\",engagement:\"Engagement\",presence:\"Multi-plateforme\",\n rythme:\"Rythme de pub.\",verification:\"Vérification\"};\n const ksParts=ins.ka_score_parts||{};\n const ksHtml=ins.ka_score!=null?`<h3 class=\"cg-title\">Ka Score</h3><div class=\"kascore\">\n <div class=\"kascore-num\"><b>${ins.ka_score}</b><span>/ 100</span></div>\n <div class=\"kascore-parts\">${Object.keys(KS_MAX).map(k=>`<div class=\"kspart\">\n <span class=\"kspart-lab\">${KS_LBL[k]}</span>\n <span class=\"kspart-track\"><span class=\"kspart-fill\" style=\"width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%\"></span></span>\n <span class=\"kspart-val\">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join(\"\")}</div>\n </div>`:\"\";\n const hist=((ins.history||{}).total)||[];\n const sparkHtml=hist.length>1?`<h3 class=\"cg-title\">Évolution de l'audience</h3>${sparkSvg(hist)}`:\"\";\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":1061,"oldLines":8,"newStart":1061,"newLines":10,"lines":[" <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}"," ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");"," const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">","+ ${ksHtml}"," <h3 class=\"cg-title\">Portée par plateforme</h3>"," <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>","+ ${sparkHtml}"," ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}"," </section>`;"," const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");"]}],"userModified":false,"replaceAll":false}}} |
|
533 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1FCEmmnVtF2QWiB9Z5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KVfmmoL6xH2uvgkY9nMQ3V","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","old_string":" <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">","new_string":" <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n ${banner?`<div class=\"cbanner\"><img src=\"${esc(banner)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.parentElement.remove()\"></div>`:\"\"}\n <div class=\"cpage-head\">"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1643,"cache_read_input_tokens":134737,"cache_creation":{"ephemeral_5m_input_tokens":1643,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"8c9cbc13-e5ec-4ce1-ae60-e6b567ddde06"}} |
|
534 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KVfmmoL6xH2uvgkY9nMQ3V","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"fe1f28c7-d2e7-4602-ad48-d934b3e8e03b","timestamp":"2026-08-21T01:12:42.259Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/frontend/src/index.template.html","oldString":" <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">","newString":" <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n ${banner?`<div class=\"cbanner\"><img src=\"${esc(banner)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.parentElement.remove()\"></div>`:\"\"}\n <div class=\"cpage-head\">","originalFile":"<!--\n Author: Simon-Pierre Boucher <contact@spboucher.ai>\n File: frontend/src/index.template.html\n Desc: Gabarit du frontend Créa-Ka — marqueurs remplacés par\n scripts/build_frontend.py : __ICONS__ (tracés SVG simple-icons, CC0),\n __KA_TOKENS__ (ka/tokens.css — design system commun Groupe KA,\n importé EN PREMIER) et __KA_ECO__ (ka/ecosystem.json — contacts,\n sites, mentions légales du groupe : footer commun + page /contact).\n Système « éditorial sharp » du Groupe KA (papier grainé #f5f3ee,\n encre #141814, bordures encre + ombres décalées dures, Space Grotesk\n / Inter / JetBrains Mono) — accent attribué à Créa-Ka : VIOLET\n (--accent:#7048e8). Patrons mobiles calqués sur Lou-Ka (tab bar\n 4 onglets, filtres défilants, anti-zoom iOS).\n Routes : / · /createur/{id} · /stats · /retrait · /compte · /contact\n-->\n<!DOCTYPE html>\n<html lang=\"fr\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Créa-Ka — Un service Groupe KA</title>\n<meta name=\"description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta name=\"theme-color\" content=\"#141814\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"preconnect\" href=\"https://unavatar.io\">\n<link href=\"https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap\" rel=\"stylesheet\">\n<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<meta property=\"og:title\" content=\"Créa-Ka — Un service Groupe KA\">\n<meta property=\"og:description\" content=\"Annuaire public des créateurs de contenu et influenceurs québécois : tous leurs comptes (Instagram, TikTok, YouTube, Twitch…) regroupés, cherchables par niche, région, langue et taille d'audience.\">\n<meta property=\"og:image\" content=\"https://www.crea-ka.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:image\" content=\"https://www.crea-ka.com/og.png\">\n<style>\n/* ka/tokens.css — design system commun Groupe KA, importé EN PREMIER\n (injecté au build par scripts/build_frontend.py) */\n__KA_TOKENS__\n</style>\n<style>\n/* Surcharges Créa-Ka par-dessus ka/tokens.css. Convention du groupe : les\n NOMS de variables sont partagés (--accent/--lime), seules les VALEURS\n changent — accent attribué à Créa-Ka : VIOLET. */\n:root{\n --accent:#7048e8; --accent-soft:#ece5fc; --accent-deep:#5433b8; --on-accent:#ffffff;\n /* alias rétro-compatibles (le CSS historique du site utilise --lime) */\n --lime:var(--accent); --lime-soft:var(--accent-soft);\n --green:var(--accent-deep); /* primaire du site = violet profond */\n --green-deep:#3d2178;\n}\n/* le layout du site est calibré sur des titres sans marge basse */\nh1,h2,h3,h4{margin:0}\na{text-decoration:none} button{font-family:inherit;cursor:pointer}\n.icn{width:1em;height:1em;display:block;fill:currentColor}\n.container{max-width:1240px;margin:0 auto;padding:0 24px}\n@media(max-width:640px){.container{padding:0 16px}}\n\n/* ---------- cartes & boutons (langage du groupe) ---------- */\n.gk-card{background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);box-shadow:var(--shadow-off-soft)}\n.gk-hover{transition:transform .18s ease,box-shadow .18s ease}\n.gk-hover:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.btn:active{transform:translate(2px,2px);box-shadow:none!important}\n.btn-primary{box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n\n/* ===== header ===== */\n.header{position:sticky;top:0;z-index:50;background:rgba(245,243,238,.9);\n backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border-bottom:1.5px solid var(--ink)}\n.header-inner{display:flex;align-items:center;gap:16px;height:64px}\n.brand{font-family:var(--font-display);font-weight:700;font-size:23px;letter-spacing:-.03em;\n display:flex;align-items:center;line-height:1;color:var(--ink)}\n.brand svg{margin-right:11px;flex:none}\n@media(pointer:coarse){.brand{position:relative}.brand::after{content:\"\";position:absolute;inset:-8px}}\n.brand .ka{background:var(--accent);color:var(--on-accent);border-radius:6px;padding:0 6px 1px;margin-left:2px;transform:rotate(-1deg)}\n.brand-tag{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);\n line-height:1.5;text-transform:uppercase;letter-spacing:.08em}\n@media(max-width:1040px){.brand-tag{display:none}}\n.header-spacer{flex:1}\n.hbtn{border:1.5px solid var(--ink);background:var(--surface);color:var(--ink);\n border-radius:var(--r-ctl);padding:8px 16px;min-height:44px;font-family:var(--font-display);\n font-size:13px;font-weight:700;transition:.15s;display:inline-flex;align-items:center;\n gap:8px;white-space:nowrap;box-shadow:3px 3px 0 rgba(20,24,20,.12)}\n.hbtn:hover{background:var(--accent);color:var(--on-accent)}\n.hbtn:active{transform:translate(2px,2px);box-shadow:none}\n.hbtn.primary{background:var(--ink);color:var(--accent)}\n.hbtn.primary:hover{background:var(--accent-deep);color:var(--on-accent)}\n@media(max-width:940px){.header .hbtn:not(.acct){display:none}}\n/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */\n.hbtn.acct{padding:7px 14px}\n.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;\n border:1.5px solid var(--ink);margin-left:-4px}\n.hbtn.acct .ka-badge{background:var(--accent);color:var(--on-accent);border-radius:4px;padding:0 5px;\n font-family:var(--font-mono);font-size:10px;font-weight:700}\n.hbtn:hover .ka-badge{background:var(--on-accent);color:var(--accent)}\n/* badge « Un service Groupe KA » (ka/GroupeKaBadge — styles dans tokens.css) */\n.header .gk-badge{flex:none}\n.hbtn .acct-short{display:none}\n@media(max-width:560px){\n .header-inner{gap:10px}\n .header .gk-badge .pfx{display:none}\n .hbtn .acct-long{display:none}.hbtn .acct-short{display:inline}}\n@media(max-width:400px){.brand svg{width:26px;height:26px;margin-right:8px}.brand{font-size:20px}.header .gk-badge{display:none}}\n\n/* ===== page compte (carte de membre Groupe KA) ===== */\n.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}\n.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;\n padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;\n position:relative;overflow:hidden}\n.membercard::after{content:\"\";position:absolute;right:-40px;top:-40px;width:160px;\n height:160px;border-radius:50%;background:rgba(112,72,232,.18)}\n.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.14em;color:var(--accent-soft)}\n.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}\n.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--accent);\n background:var(--green-deep);overflow:hidden;flex:none;display:flex;\n align-items:center;justify-content:center;font-family:var(--font-display);\n font-weight:700;font-size:26px;color:var(--accent-soft)}\n.mc-avatar img{width:100%;height:100%;object-fit:cover}\n.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}\n.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--accent-soft);margin-top:2px;letter-spacing:.04em}\n.mc-role{display:inline-flex;background:var(--accent);color:var(--on-accent);border-radius:5px;\n padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:8px}\n.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}\n.mc-meta b{color:var(--paper);font-weight:600}\n.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n\n/* ===== héros ===== */\n.hero{padding:54px 0 26px}\n@media(max-width:640px){.hero{padding:30px 0 12px}}\n.hero h1{font-size:clamp(30px,5.4vw,54px);font-weight:700;max-width:820px;line-height:1.06;\n text-transform:uppercase}\n.hero p.lead{color:var(--ink-2);font-size:clamp(14.5px,2vw,16.5px);max-width:640px;margin:18px 0 0}\n.stats-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}\n.stat-pill{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:8px 16px;font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;color:var(--ink-2);\n display:inline-flex;gap:8px;align-items:center;box-shadow:3px 3px 0 rgba(20,24,20,.1)}\n.stat-pill b{font-family:var(--font-display);color:var(--ink);font-size:16px;letter-spacing:0}\n.advantages{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:32px 0 8px}\n@media(max-width:900px){.advantages{grid-template-columns:repeat(2,1fr);gap:10px}}\n.adv{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px;box-shadow:var(--shadow-off-soft)}\n.adv .ico{width:36px;height:36px;border-radius:8px;border:1.5px solid var(--ink);\n background:var(--accent);color:var(--on-accent);display:flex;align-items:center;\n justify-content:center;margin-bottom:10px;font-size:15px}\n.adv:nth-child(2n) .ico{color:var(--ink)}\n.adv:nth-child(2n) .ico{background:var(--amber-soft)}\n.adv h3{font-size:14px;margin-bottom:3px}\n.adv p{margin:0;font-size:12px;color:var(--ink-3);line-height:1.45}\n\n/* ===== recherche + filtres ===== */\n.searchbar{position:sticky;top:64px;z-index:40;background:rgba(245,243,238,.94);\n backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);\n padding:12px 0;border-bottom:1.5px solid var(--ink)}\n.search-wrap{display:flex;gap:10px;align-items:center}\n.search-input{flex:1 1 auto;display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 15px;\n transition:.15s;min-width:0;box-shadow:3px 3px 0 rgba(20,24,20,.08)}\n.search-input:focus-within{box-shadow:3px 3px 0 rgba(112,72,232,.35)}\n.search-input input{border:0;background:none;outline:none;font:inherit;flex:1;color:var(--ink);min-width:0}\n.search-input svg{flex:none;color:var(--ink-3)}\n.filters{display:flex;gap:8px;flex-wrap:wrap}\nselect.f{appearance:none;-webkit-appearance:none;border:1.5px solid var(--ink);\n background:var(--surface) url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23141814' stroke-width='1.8' fill='none' stroke-linecap='round'/%3E%3C/svg%3E\") no-repeat right 12px center;\n border-radius:var(--r-ctl);padding:9px 30px 9px 14px;font-family:var(--font-display);\n font-size:13px;font-weight:700;color:var(--ink-2);max-width:170px;\n box-shadow:3px 3px 0 rgba(20,24,20,.08)}\nselect.f:focus{outline:2px solid var(--green);outline-offset:1px}\nselect.f.active{background-color:var(--accent-soft);color:var(--ink);border-color:var(--accent-deep)}\n@media(max-width:760px){\n .search-wrap{flex-wrap:wrap}\n .search-input{flex-basis:100%}\n .search-input input{font-size:16px}\n .filters{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n margin:0 -16px;padding:4px 16px 6px;scrollbar-width:none;width:calc(100% + 32px)}\n .filters::-webkit-scrollbar{display:none}\n select.f{flex:none;font-size:16px;padding:8px 30px 8px 13px}\n}\n\n/* ===== grille créateurs ===== */\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px;padding:24px 0 10px}\n@media(max-width:640px){.grid{grid-template-columns:repeat(2,1fr);gap:12px}}\n@media(max-width:420px){.grid{grid-template-columns:1fr;gap:14px}}\n.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n overflow:hidden;box-shadow:var(--shadow-off-soft);transition:transform .18s ease,box-shadow .18s ease;\n display:flex;flex-direction:column}\n.card:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}\n.card-photo{position:relative;aspect-ratio:1/1;background:\n repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n display:flex;align-items:center;justify-content:center;overflow:hidden;\n border-bottom:1.5px solid var(--ink)}\n.card-photo .init{color:var(--accent-soft);font-family:var(--font-display);font-weight:700;\n font-size:46px;letter-spacing:.02em}\n.card-photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.card-photo .tier{position:absolute;top:10px;left:10px;z-index:2}\n.card-photo .nplat{position:absolute;top:10px;right:10px;z-index:2;background:var(--ink);\n color:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:2px 8px;font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.05em}\n.tier{display:inline-flex;padding:2px 9px;border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);font-family:var(--font-mono);font-size:9.5px;\n font-weight:700;text-transform:uppercase;letter-spacing:.06em;background:var(--surface);color:var(--ink)}\n.tier.mega{background:var(--accent);color:var(--on-accent)}\n.tier.macro{background:var(--amber-soft)}\n.card-body{padding:14px 15px 15px;display:flex;flex-direction:column;gap:9px;flex:1}\n.card-name{font-family:var(--font-display);font-size:16px;font-weight:700;\n letter-spacing:-.01em;line-height:1.25;display:flex;align-items:center;gap:6px}\n.vbadge{color:var(--green);flex:none;width:15px;height:15px}\n.card-sub{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:-4px}\n.chips{display:flex;gap:6px;flex-wrap:wrap}\n.chip{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);\n padding:2px 10px;font-family:var(--font-body);font-size:11px;font-weight:600;color:var(--ink-2);\n white-space:nowrap;text-transform:none;letter-spacing:0}\n.reach{font-size:12px;color:var(--ink-2)}\n.reach b{font-family:var(--font-display);color:var(--ink);font-size:15.5px;font-weight:700}\n.platrow{display:flex;gap:6px;flex-wrap:wrap;margin-top:auto;padding-top:2px}\n.plat{width:32px;height:32px;border-radius:8px;border:1.5px solid var(--ink);\n display:flex;align-items:center;justify-content:center;color:#fff;transition:.15s;\n box-shadow:2px 2px 0 rgba(20,24,20,.25)}\n.plat svg{width:15px;height:15px;fill:currentColor}\n.plat:hover{transform:translate(-1px,-1px);box-shadow:3px 3px 0 var(--ink)}\n.more-wrap{display:flex;justify-content:center;padding:16px 0 44px}\n.empty{padding:60px 0;text-align:center;color:var(--ink-3);grid-column:1/-1}\n.count-line{font-family:var(--font-mono);font-size:11px;text-transform:uppercase;\n letter-spacing:.08em;color:var(--ink-3);padding-top:18px}\n.count-line b{color:var(--ink)}\n\n/* ===== page créateur ===== */\n.cpage{padding:30px 0 60px}\n@media(max-width:640px){.cpage{padding:20px 0 40px}}\n.backlink{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);\n font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;\n color:var(--green);margin-bottom:20px}\n.cpage-head{display:flex;gap:26px;align-items:center;background:var(--ink);\n border:1.5px solid var(--ink);border-radius:var(--r-card);padding:32px;\n color:var(--paper);box-shadow:var(--shadow-off);flex-wrap:wrap}\n@media(max-width:640px){.cpage-head{padding:22px 18px;gap:16px}}\n.cavatar{width:104px;height:104px;border-radius:var(--r-card);\n background:repeating-linear-gradient(-45deg,var(--green-deep) 0 14px,#33195f 14px 28px);\n color:var(--accent-soft);flex:none;display:flex;align-items:center;justify-content:center;\n font-family:var(--font-display);font-weight:700;font-size:36px;overflow:hidden;\n position:relative;border:2px solid var(--accent)}\n@media(max-width:640px){.cavatar{width:76px;height:76px;font-size:26px}}\n.cavatar img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}\n.cpage-head h1{font-size:clamp(24px,4vw,40px);font-weight:700;color:var(--paper);\n line-height:1.1;display:flex;align-items:center;gap:10px;flex-wrap:wrap;text-transform:uppercase}\n.cpage-head h1 .vbadge{width:22px;height:22px;color:var(--accent-soft)}\n.cpage-head .meta{display:flex;gap:7px;flex-wrap:wrap;margin-top:12px}\n.mchip{background:rgba(245,243,238,.12);border:1px solid rgba(245,243,238,.3);\n border-radius:var(--r-ctl);padding:3px 11px;font-family:var(--font-mono);\n font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--paper)}\n.mchip.o{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}\n.cpage-reach{margin-left:auto;text-align:right}\n.cpage-reach b{display:block;font-family:var(--font-display);\n font-size:clamp(28px,3.6vw,40px);font-weight:700;color:var(--accent-soft);letter-spacing:-.02em}\n.cpage-reach span{font-family:var(--font-mono);font-size:10px;color:rgba(245,243,238,.6);\n text-transform:uppercase;letter-spacing:.08em;font-weight:700}\n@media(max-width:760px){.cpage-reach{margin-left:0;text-align:left;flex-basis:100%}}\n.cpage-bio{margin:24px 0 0;max-width:820px;color:var(--ink-2);font-size:15px}\n.cpage-notes{margin:14px 0 0;max-width:820px;color:var(--ink-2);font-size:12.5px;\n background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 17px;box-shadow:var(--shadow-off-soft)}\n.sect-title{font-size:19px;margin:36px 0 15px;display:flex;align-items:center;gap:10px;text-transform:uppercase}\n/* ===== page créateur : métriques calculées + onglets par plateforme ===== */\n.hkpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:22px 0 6px}\n.hkpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:13px 15px}\n.hkpi b{display:block;font-family:var(--font-display);font-size:22px;font-weight:800;color:var(--ink);line-height:1}\n.hkpi span{display:block;margin-top:5px;font-family:var(--font-mono);font-size:9.5px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.05em;font-weight:700}\n/* barre d'onglets */\n.ptabs{display:flex;gap:8px;overflow-x:auto;margin:26px 0 0;padding-bottom:2px}\n.ptabs::-webkit-scrollbar{height:5px}.ptabs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}\n.ptab{flex:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;\n background:var(--surface);border:1.5px solid var(--ink);border-bottom:none;\n border-radius:12px 12px 0 0;padding:10px 15px;font-family:var(--font-display);\n font-weight:700;font-size:13px;color:var(--ink-2);transition:.14s;margin-bottom:-1.5px}\n.ptab:hover{background:var(--surface-2)}\n.ptab.on{background:var(--accent);color:var(--on-accent);border-color:var(--ink)}\n.ptab.on .ptab-ico{color:var(--on-accent)!important}\n.ptab-ico{width:16px;height:16px;display:inline-flex}.ptab-ico svg{width:16px;height:16px}\n.ptab-n{font-family:var(--font-mono);font-size:10px;opacity:.75;font-weight:700}\n.ppanels{border:1.5px solid var(--ink);border-radius:0 12px 12px 12px;\n background:var(--surface);box-shadow:var(--shadow-off-soft);padding:20px 18px 22px}\n.ppanel{display:none}.ppanel.on{display:block;animation:fade .2s ease}\n@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}\n.ppanel-head{display:flex;align-items:center;gap:13px}\n.ppanel-head .plat{width:46px;height:46px;border-radius:10px;flex:none;display:flex;align-items:center;justify-content:center}\n.ppanel-head .plat svg{width:22px;height:22px}\n.ppanel-id{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}\n.ppanel-name{font-family:var(--font-display);font-weight:800;font-size:17px;display:flex;align-items:center;gap:6px}\n.ppanel-handle{font-size:13px;color:var(--green);font-weight:600}\n.conf{display:inline-flex;padding:2px 9px;border-radius:var(--r-ctl);\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.03em;flex:none}\n.conf.hi{background:var(--lime-soft);color:var(--green-deep)}\n.conf.mid{background:var(--amber-soft);color:#8a5a12}\n.abadge-row{display:flex;flex-wrap:wrap;gap:6px;margin:12px 0 4px}\n.abadge{display:inline-flex;background:var(--lime-soft);color:var(--green-deep);\n border:1px solid var(--line);border-radius:var(--r-ctl);padding:3px 9px;\n font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.02em}\n.abadge.ok{background:var(--surface-2);color:var(--ink-2)}\n.abadge.live{background:#ffe3e0;color:#b0201a;border-color:#b0201a}\n/* grille KPI par plateforme */\n.kpi-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin:16px 0 4px}\n.kpi{background:var(--surface-2);border:1px solid var(--line);border-radius:var(--r-ctl);padding:11px 13px}\n.kpi b{display:block;font-family:var(--font-display);font-size:19px;font-weight:800;color:var(--ink);line-height:1}\n.kpi span{display:block;margin-top:4px;font-family:var(--font-mono);font-size:9px;color:var(--ink-3);\n text-transform:uppercase;letter-spacing:.04em;font-weight:700}\n.ppanel-top{display:inline-flex;margin:12px 0 2px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:var(--r-ctl);padding:8px 13px;font-size:12.5px;font-weight:700;color:var(--ink-2)}\n.ppanel-top:hover{color:var(--green)}\n.ppanel-foot{margin-top:14px;font-family:var(--font-mono);font-size:10px;color:var(--ink-3)}\n/* grille de contenu récent (images/vidéos) */\n.cg-title{font-family:var(--font-display);font-size:14px;font-weight:800;margin:20px 0 12px;color:var(--ink)}\n.cg-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:13px}\n.cg-card{display:flex;flex-direction:column;text-decoration:none;color:inherit;\n border:1.5px solid var(--ink);border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);box-shadow:var(--shadow-off-soft);transition:transform .15s,box-shadow .15s}\n.cg-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.cg-thumb{position:relative;display:block;width:100%;aspect-ratio:1/1;background:var(--surface-2);overflow:hidden}\n.cg-thumb img{width:100%;height:100%;object-fit:cover;display:block}\n.cg-thumb.noimg{display:flex;align-items:center;justify-content:center}\n.cg-thumb.noimg svg{width:44px;height:44px;opacity:.3}\n.cg-play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;\n font-size:26px;text-shadow:0 2px 6px rgba(0,0,0,.7);pointer-events:none}\n.cg-dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.8);color:#fff;\n font-family:var(--font-mono);font-size:10px;padding:2px 6px;border-radius:5px}\n.cg-pin{position:absolute;left:6px;top:6px;font-size:13px}\n.cg-body{padding:9px 11px 11px;display:flex;flex-direction:column;gap:5px}\n.cg-cap{font-size:12px;line-height:1.3;color:var(--ink-2);display:-webkit-box;\n -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;min-height:31px}\n.cg-cap.dim{color:var(--ink-3)}\n.cg-meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);display:flex;\n justify-content:space-between;gap:6px;flex-wrap:wrap}\n.cg-when{color:var(--ink-3);opacity:.8}\n.cg-empty{padding:26px;text-align:center;color:var(--ink-3);font-size:13px}\n.cg-empty svg{width:20px;height:20px;vertical-align:-4px;opacity:.4;margin-right:6px}\n/* vue d'ensemble : barres de portée */\n.obars{display:flex;flex-direction:column;gap:11px}\n.obar{display:flex;align-items:center;gap:11px}\n.obar-ico{width:32px;height:32px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center}\n.obar-ico svg{width:16px;height:16px}\n.obar-lab{width:96px;flex:none;font-family:var(--font-display);font-weight:700;font-size:13px}\n.obar-track{flex:1;height:12px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;overflow:hidden}\n.obar-fill{display:block;height:100%;border-radius:6px 0 0 6px;min-width:3px}\n.obar-val{width:78px;flex:none;text-align:right;font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.obar-lab{width:64px;font-size:11px}.obar-val{width:60px;font-size:11px}\n .cg-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}\n.linkinbio{display:inline-flex;align-items:center;gap:10px;margin-top:22px;\n background:var(--accent);color:var(--on-accent);border:1.5px solid var(--ink);\n border-radius:var(--r-ctl);padding:11px 20px;font-family:var(--font-display);\n font-weight:700;font-size:13.5px;transition:.15s;box-shadow:4px 4px 0 rgba(20,24,20,.25)}\n.linkinbio:hover{background:var(--accent-soft);color:var(--ink)}\n.linkinbio:active{transform:translate(2px,2px);box-shadow:none}\n.linkinbio svg{width:16px;height:16px;fill:currentColor}\n.cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);\n border-top:1.5px solid var(--ink);padding-top:16px}\n/* bannière héro (banner_url capté par les acteurs Apify) */\n.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;\n border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);\n background:var(--ink)}\n.cbanner img{width:100%;height:100%;object-fit:cover;display:block}\n.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}\n/* Ka Score */\n.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}\n.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;\n flex-direction:column;align-items:center;justify-content:center;\n border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);\n box-shadow:var(--shadow-off-soft)}\n.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}\n.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;\n text-transform:uppercase;letter-spacing:.06em;margin-top:3px}\n.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}\n.kspart{display:flex;align-items:center;gap:10px}\n.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;\n font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}\n.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);\n border-radius:5px;overflow:hidden}\n.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}\n.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);\n font-size:11px;font-weight:700;color:var(--ink)}\n@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}\n/* sparkline évolution d'audience */\n.spark-wrap{margin-top:8px}\n.spark-svg{width:100%;height:88px;display:block}\n.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}\n.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}\n.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);\n font-size:10px;color:var(--ink-3);margin-top:4px}\n.growth-up{color:var(--green-deep);font-weight:700}\n.growth-down{color:#b0201a;font-weight:700}\n/* bloc EN DIRECT (miniature live Twitch/Kick) */\n.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;\n border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;\n background:var(--surface);text-decoration:none;color:inherit}\n.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}\n.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}\n.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}\n.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}\n.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}\n@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}\n.cpage-foot a{color:var(--green);font-weight:600}\n\n/* ===== page stats ===== */\n.stats-page{padding-top:40px;padding-bottom:70px}\n.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin:26px 0}\n.tile{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:18px 20px;box-shadow:var(--shadow-off-soft)}\n.tile-v{font-family:var(--font-display);font-weight:700;font-size:clamp(23px,3vw,32px);\n letter-spacing:-.03em;line-height:1.05;color:var(--ink)}\n.tile-k{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;\n letter-spacing:.1em;color:var(--ink-3);margin-top:7px}\n.tile.hero-tile{background:var(--ink)}\n.tile.hero-tile .tile-v{color:var(--accent-soft)}\n.tile.hero-tile .tile-k{color:rgba(245,243,238,.55)}\n.viz-card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n box-shadow:var(--shadow-off-soft);padding:24px 26px 18px;margin-bottom:22px}\n.viz-card h2{font-size:18px;text-transform:uppercase}\n.viz-sub{font-family:var(--font-mono);font-size:10px;font-weight:700;letter-spacing:.08em;\n text-transform:uppercase;color:var(--ink-3);margin:4px 0 18px}\n.hrow{display:flex;align-items:center;gap:12px;margin-bottom:10px}\n.hrow .plat{width:28px;height:28px;border-radius:7px;flex:none}\n.hrow .plat svg{width:13px;height:13px}\n.hrow-label{flex:0 0 130px;font-size:12.5px;font-weight:600;color:var(--ink-2);\n overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n@media(max-width:640px){.hrow-label{flex-basis:96px;font-size:11.5px}}\n.hbar{flex:1;height:12px;border:1.5px solid var(--ink);border-radius:4px;\n background:var(--surface-2);overflow:hidden}\n.hbar i{display:block;height:100%;background:var(--lime);border-right:1.5px solid var(--ink)}\n.hrow .n{flex:none;font-family:var(--font-display);font-size:12.5px;font-weight:700;\n color:var(--ink);min-width:44px;text-align:right}\n\n/* ===== page stats : tableau de bord ka-stats (SPEC) ===== */\n.stats-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start;justify-content:space-between}\n.pdf-actions{display:flex;gap:8px;flex-wrap:wrap}\n.fresh-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:14px;\n font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.06em}\n.pchips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:22px 0 2px}\n.pchip{border:1.5px solid var(--ink);background:var(--surface);border-radius:var(--r-ctl);\n padding:8px 14px;min-height:44px;font-family:var(--font-display);font-size:12.5px;\n font-weight:700;color:var(--ink-2);box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:.15s}\n.pchip[aria-pressed=\"true\"]{background:var(--accent);color:var(--on-accent)}\n.pchip:active{transform:translate(2px,2px);box-shadow:none}\n.pdates{display:inline-flex;gap:6px;align-items:center;flex-wrap:wrap}\n.pdates input{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:8px 10px;\n font:inherit;font-size:13px;background:var(--surface);min-height:44px;max-width:160px}\n@media(max-width:760px){.pchips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;\n padding:4px 0 8px;scrollbar-width:none}\n .pchips::-webkit-scrollbar{display:none}\n .pchip{flex:none}.pdates{flex:none}.pdates input{font-size:16px}}\n@media(max-width:640px){.pchips{margin-left:-16px;margin-right:-16px;\n padding-left:16px;padding-right:16px}}\n.tile-d{font-family:var(--font-mono);font-size:10.5px;font-weight:700;margin-top:8px}\n.tile-d.up{color:var(--green)}.tile-d.down{color:var(--danger)}\n.tile.hero-tile .tile-d.up{color:var(--accent-soft)}\n.viz-grid{display:grid;gap:22px;grid-template-columns:1fr;margin-bottom:22px}\n@media(min-width:960px){.viz-grid.two{grid-template-columns:1fr 1fr}}\n.viz-grid .viz-card{margin-bottom:0}\n.viz-grid>*,.dt,.lc{min-width:0}\n.dt .viz-card,.lc .viz-card{margin-bottom:0}\n.lc svg{width:100%;height:auto;display:block;touch-action:pan-y}\n.lc-legend{display:flex;gap:14px;flex-wrap:wrap;margin:2px 0 0}\n.leg-btn{display:inline-flex;align-items:center;gap:6px;border:0;background:none;\n font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;\n letter-spacing:.06em;color:var(--ink-2);min-height:44px}\n.leg-btn[aria-pressed=\"false\"]{opacity:.35}\n.leg-swatch{width:18px;height:0;border-top:3px solid var(--accent)}\n.leg-swatch.cmp{border-top-style:dashed;border-top-color:var(--ink-3)}\n.lc-tip{display:inline-flex;margin-top:6px;visibility:hidden}\n.donut-wrap{display:flex;flex-wrap:wrap;gap:22px;align-items:center;margin-top:14px}\n.donut-legend{list-style:none;margin:0;padding:0;display:grid;gap:7px;flex:1;min-width:200px}\n.donut-legend li{display:flex;align-items:center;gap:8px;font-size:12.5px;min-width:0}\n.donut-legend .dsw{width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);\n background:var(--accent);flex:none}\n.donut-legend .dlbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.donut-legend b{font-family:var(--font-mono);font-size:11px}\n.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%}\n.dtable table{width:100%;border-collapse:collapse;font-size:13px}\n.dtable th{cursor:pointer;text-align:left;padding:8px 10px;background:var(--ink);\n color:var(--paper);font-family:var(--font-mono);font-size:10px;text-transform:uppercase;\n letter-spacing:.06em;white-space:nowrap;user-select:none}\n.dtable td{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}\n.dtable tbody tr:nth-child(even) td{background:var(--surface-2)}\n.dtable .dt-q{border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:9px 13px;\n font:inherit;font-size:13px;background:var(--surface);max-width:230px;min-height:44px}\n@media(max-width:760px){.dtable .dt-q{font-size:16px;max-width:100%;flex:1}}\n.dtable-foot{display:flex;justify-content:space-between;align-items:center;margin-top:12px;\n flex-wrap:wrap;gap:8px}\n.records-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:14px}\n.record{background:var(--surface-2);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:13px 16px;display:flex;justify-content:space-between;gap:12px;align-items:baseline;\n box-shadow:var(--shadow-off-soft)}\n.record .r-lbl{font-size:12.5px;color:var(--ink-2)}\n.record .r-val{text-align:right;font-family:var(--font-display);font-size:14.5px;font-weight:700}\n.hm svg{min-width:520px;width:100%;height:auto}\n.empty-block{border:1.5px dashed var(--ink);border-radius:var(--r-card);\n background:var(--surface-2);padding:20px}\n.stats-sect{font-size:17px;text-transform:uppercase;margin:34px 0 14px}\n\n/* ===== page stats v2 : sparklines, jauges, histogrammes, heatmap horaire, menu PDF ===== */\n.tile svg.spark{display:block;margin-top:9px;width:100%;height:28px}\n.gauges{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:8px}\n.gauge{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 14px 13px;box-shadow:var(--shadow-off-soft);text-align:center}\n.gauge svg{width:124px;max-width:100%;height:auto;margin:0 auto;display:block}\n.gauge .g-v{font-family:var(--font-display);font-weight:700;font-size:21px;color:var(--ink);margin-top:-30px}\n.gauge .g-l{font-family:var(--font-mono);font-size:9px;font-weight:700;text-transform:uppercase;\n letter-spacing:.05em;color:var(--ink-3);margin-top:10px;line-height:1.55}\n.statsum{display:flex;gap:7px;flex-wrap:wrap;margin-top:10px}\n.statsum .chip b{font-family:var(--font-mono);margin-left:4px}\n.leg-swatch.ml1{border-top:3px solid var(--ink)}\n.leg-swatch.ml2{border-top:3px dashed var(--accent)}\n.leg-swatch.ml3{border-top:3px dotted var(--ink-3)}\n.hrow .d{flex:none;font-family:var(--font-mono);font-size:10px;font-weight:700;min-width:54px;text-align:right}\n.hrow .d.up{color:var(--green)}.hrow .d.down{color:var(--danger)}\n.hh svg{min-width:560px;width:100%;height:auto}\n.pdf-menu-wrap{position:relative;display:inline-flex;gap:8px;flex-wrap:wrap}\n.pdf-menu{position:absolute;right:0;top:calc(100% + 8px);z-index:70;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off);\n display:none;min-width:270px;padding:6px}\n.pdf-menu.open{display:block}\n.pdf-menu button{display:block;width:100%;text-align:left;border:0;background:none;\n padding:11px 13px;border-radius:9px;font-family:var(--font-display);font-weight:700;\n font-size:13px;color:var(--ink);min-height:44px}\n.pdf-menu button:hover{background:var(--accent-soft)}\n.pdf-menu small{display:block;font-family:var(--font-body);font-weight:400;font-size:11px;\n color:var(--ink-3);margin-top:2px}\n@media(max-width:760px){.pdf-menu{right:auto;left:0}}\n\n/* ===== retrait ===== */\n.optout-page{max-width:620px;margin:0 auto;padding:44px 0 70px}\n.optout-page h1{font-size:clamp(26px,4vw,32px);margin:12px 0;text-transform:uppercase}\n.optout-page p{color:var(--ink-2)}\n.field{margin-top:16px}\n.field label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;margin-bottom:7px;color:var(--ink-2)}\n.field input,.field textarea{width:100%;border:1.5px solid var(--ink);border-radius:var(--r-ctl);\n padding:11px 14px;font:inherit;background:var(--surface);color:var(--ink);\n box-shadow:3px 3px 0 rgba(20,24,20,.08);transition:box-shadow .15s ease}\n.field input:focus,.field textarea:focus{outline:2px solid var(--green);outline-offset:1px;\n box-shadow:3px 3px 0 rgba(112,72,232,.25)}\n@media(max-width:900px){.field input,.field textarea{font-size:16px}}\n.optout-ok{background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 20px;font-weight:600;margin-top:20px;\n box-shadow:var(--shadow-off-soft)}\n.optout-ok.warn{background:var(--amber-soft);color:#8a5a12}\n\n/* ===== footer : nav locale + footer commun Groupe KA (ka/KaFooter) ===== */\n.footer-local{margin-top:56px;border-top:1.5px solid var(--ink);background:var(--surface);padding:16px 0}\n.footer-local nav{display:flex;gap:10px 26px;flex-wrap:wrap;align-items:center;\n font-family:var(--font-mono);font-size:11px;font-weight:700;\n text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2)}\n.footer-local .klabel{color:var(--ink-3)}\n.footer-local a{display:inline-flex;align-items:center;min-height:44px}\n.footer-local a:hover{color:var(--accent-deep);text-decoration:underline;text-underline-offset:4px}\n.footer-local+.ka-footer{margin-top:0}\n.ka-footer .brand{font-size:19px;color:var(--paper)}\n\n/* ===== contact ===== */\n.contact-page{max-width:880px;margin:0 auto;padding:44px 0 70px}\n.contact-page h1{font-size:clamp(26px,4vw,38px);margin:12px 0;text-transform:uppercase}\n.contact-page .lead{color:var(--ink-2);max-width:640px}\n.contact-notice{background:var(--accent-soft);border:1.5px solid var(--ink);border-radius:var(--r-card);\n padding:16px 20px;margin:22px 0;box-shadow:var(--shadow-off-soft);font-weight:600;color:var(--ink)}\n.contact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px;margin:8px 0 4px}\n.contact-card{display:block;background:var(--surface);border:1.5px solid var(--ink);\n border-radius:var(--r-card);padding:16px 18px;box-shadow:var(--shadow-off-soft);\n transition:transform .16s ease,box-shadow .16s ease;min-height:44px}\n.contact-card:hover{transform:translate(-2px,-2px);box-shadow:var(--shadow-off)}\n.contact-card b{display:block;font-family:var(--font-mono);font-size:13px;font-weight:700;\n color:var(--accent-deep);word-break:break-all}\n.contact-card span{display:block;margin-top:5px;font-size:12px;color:var(--ink-2)}\n.site-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px;margin:14px 0 0}\n.site-item{display:flex;align-items:center;gap:10px;background:var(--surface);\n border:1.5px solid var(--ink);border-radius:var(--r-ctl);padding:10px 13px;min-height:44px;\n transition:transform .15s ease,box-shadow .15s ease}\n.site-item:hover{transform:translate(-2px,-2px);box-shadow:3px 3px 0 var(--ink)}\n.site-item i{width:10px;height:10px;border-radius:3px;border:1px solid var(--ink);flex:none}\n.site-item b{font-family:var(--font-display);font-size:13.5px;font-weight:700}\n.site-item span{font-size:11px;color:var(--ink-3);margin-left:auto;font-family:var(--font-mono)}\n@media(max-width:480px){.site-item span{display:none}}\n.tabbar{display:none}\n@media(max-width:940px){\n .tabbar{display:grid;grid-template-columns:repeat(4,1fr);\n position:fixed;left:0;right:0;bottom:0;z-index:85;\n background:rgba(245,243,238,.94);\n backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);\n border-top:1.5px solid var(--ink);\n padding:6px 4px calc(6px + env(safe-area-inset-bottom))}\n .tabbar a{display:flex;flex-direction:column;align-items:center;gap:3px;\n padding:5px 2px;min-height:48px;justify-content:center;\n font-family:var(--font-mono);font-size:9.5px;font-weight:700;\n text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3);\n border-radius:var(--r-ctl);transition:color .12s ease}\n .tabbar a.active{color:var(--green)}\n .tabbar a svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;\n stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}\n body{padding-bottom:calc(64px + env(safe-area-inset-bottom))}\n .ka-footer{padding-bottom:calc(76px + env(safe-area-inset-bottom))}\n}\n.spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--green);\n border-radius:50%;margin:50px auto;animation:sp .8s linear infinite}\n@keyframes sp{to{transform:rotate(360deg)}}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<script>\n\"use strict\";\n/* Créa-Ka SPA — routes : / · /createur/{id} · /stats · /retrait · /compte · /contact */\nconst ICONS=__ICONS__;\n/* écosystème Groupe KA (ka/ecosystem.json, injecté au build) — footer commun + /contact */\nconst KA_ECO=__KA_ECO__;\nconst KA_SITE=KA_ECO.sites.find(s=>s.id===\"crea-ka\");\nconst $=s=>document.querySelector(s);\nconst app=$(\"#app\");\nconst fmt=new Intl.NumberFormat(\"fr-CA\",{notation:\"compact\",maximumFractionDigits:1});\nconst fmtFull=new Intl.NumberFormat(\"fr-CA\");\nconst PLAT={\n instagram:{label:\"Instagram\",color:\"#c13584\",av:\"instagram\"},\n tiktok:{label:\"TikTok\",color:\"#141814\",av:\"tiktok\"},\n youtube:{label:\"YouTube\",color:\"#e02424\",av:\"youtube\"},\n twitch:{label:\"Twitch\",color:\"#9146ff\",av:\"twitch\"},\n kick:{label:\"Kick\",color:\"#141814\"},\n x:{label:\"X (Twitter)\",color:\"#141814\",av:\"twitter\"},\n facebook:{label:\"Facebook\",color:\"#1877f2\"},\n snapchat:{label:\"Snapchat\",color:\"#d4a900\"},\n substack:{label:\"Substack\",color:\"#ff6719\"},\n patreon:{label:\"Patreon\",color:\"#f1465a\"},\n onlyfans:{label:\"OnlyFans\",color:\"#00aff0\"},\n mym:{label:\"MYM\",color:\"#141814\"},\n fansly:{label:\"Fansly\",color:\"#2699f7\"},\n linkedin:{label:\"LinkedIn\",color:\"#0a66c2\"},\n threads:{label:\"Threads\",color:\"#141814\"},\n spotify:{label:\"Spotify\",color:\"#1db954\"},\n discord:{label:\"Discord\",color:\"#5865f2\"},\n podcast:{label:\"Balado\",color:\"#8940fa\"},\n \"site-web\":{label:\"Site web\",color:\"#7048e8\"},\n autre:{label:\"Autre\",color:\"#8b928c\"}};\nconst NICHE_LBL={\"humour\":\"Humour\",\"mode\":\"Mode\",\"beaute\":\"Beauté\",\"lifestyle\":\"Lifestyle\",\n \"famille-parentalite\":\"Famille\",\"cuisine\":\"Cuisine\",\"gaming\":\"Gaming\",\"tech\":\"Tech\",\n \"sport-fitness\":\"Sport & fitness\",\"plein-air\":\"Plein air\",\"voyage\":\"Voyage\",\"musique\":\"Musique\",\n \"arts\":\"Arts\",\"danse\":\"Danse\",\"education\":\"Éducation\",\"finance-affaires\":\"Finance & affaires\",\n \"sante-mieux-etre\":\"Santé & mieux-être\",\"bouffe-resto\":\"Bouffe & resto\",\n \"actualite-opinion\":\"Actualité & opinion\",\"autre\":\"Autre\"};\nconst TIER_LBL={nano:\"Nano\",micro:\"Micro\",macro:\"Macro\",mega:\"Méga\"};\nconst state={q:\"\",niche:\"\",region:\"\",langue:\"\",plateforme:\"\",tier:\"\",sort:\"reach\",\n offset:0,items:[],total:0,stats:null,tax:null};\n\nconst esc=s=>String(s??\"\").replace(/[&<>\"']/g,c=>({\"&\":\"&\",\"<\":\"<\",\">\":\">\",'\"':\""\",\"'\":\"'\"}[c]));\nconst initials=n=>n.split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\nasync function api(p){const r=await fetch(p);if(!r.ok)throw new Error(r.status);return r.json()}\nfunction icon(p,extra){return `<svg class=\"icn\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"${extra||\"\"}><path d=\"${ICONS[p]||ICONS.autre}\"/></svg>`}\nconst checkSvg=`<svg class=\"vbadge\" viewBox=\"0 0 24 24\" aria-label=\"vérifié\"><path fill=\"currentColor\" d=\"M12 0l2.8 2.5 3.7-.7.9 3.7 3.4 1.7-1.5 3.5 1.5 3.5-3.4 1.7-.9 3.7-3.7-.7L12 24l-2.8-2.5-3.7.7-.9-3.7-3.4-1.7 1.5-3.5-1.5-3.5 3.4-1.7.9-3.7 3.7.7z\"/><path fill=\"#f5f3ee\" d=\"M10.6 16.2l-3.3-3.3 1.4-1.4 1.9 1.9 4.7-4.7 1.4 1.4z\"/></svg>`;\n\n/* cascade de photos : avatar capté à la source, puis unavatar par plateforme,\n repli final = initiales sur motif encre/violet (déjà en dessous) */\nfunction avatarSources(c){\n const order=[\"instagram\",\"tiktok\",\"youtube\",\"twitch\",\"x\"];\n const srcs=[];\n if(c.avatar_url)srcs.push(c.avatar_url);\n for(const p of order){\n const acc=(c.platforms||[]).find(a=>a.platform===p&&PLAT[p]&&PLAT[p].av);\n if(acc)srcs.push(`https://unavatar.io/${PLAT[p].av}/${encodeURIComponent(acc.handle)}?fallback=false`);\n }\n return srcs;\n}\nwindow.nextSrc=el=>{\n const srcs=JSON.parse(el.dataset.srcs||\"[]\");const i=+(el.dataset.i||0)+1;\n if(i<srcs.length){el.dataset.i=i;el.src=srcs[i]}else el.remove();\n};\nfunction photoImg(c){\n const srcs=avatarSources(c);\n if(!srcs.length)return\"\";\n return `<img loading=\"lazy\" alt=\"${esc(c.display_name)}\" src=\"${srcs[0]}\"\n data-srcs='${esc(JSON.stringify(srcs))}' data-i=\"0\" onerror=\"nextSrc(this)\">`;\n}\nconst brandSvg=`<svg width=\"30\" height=\"30\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" rx=\"10\" fill=\"#141814\"/><circle cx=\"32\" cy=\"23\" r=\"9\" fill=\"#7048e8\"/><path d=\"M14 52c3-10 9-15 18-15s15 5 18 15\" stroke=\"#7048e8\" stroke-width=\"6\" fill=\"none\" stroke-linecap=\"round\"/></svg>`;\n\nfunction header(){\n return `<header class=\"header\"><div class=\"container header-inner\">\n <a href=\"/\" data-nav class=\"brand\">${brandSvg}Créa<span class=\"ka\">Ka</span></a>\n <a class=\"gk-badge\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Un service Groupe KA — visiter le portail de l'écosystème\"><span class=\"pfx\">Un service</span>\n <b>Groupe<span class=\"ka\">KA</span></b></a>\n <span class=\"brand-tag\">Tous les créateurs québécois.<br>Tous leurs liens.</span>\n <span class=\"header-spacer\"></span>\n <a class=\"hbtn\" href=\"/stats\" data-nav>Statistiques</a>\n <a class=\"hbtn\" href=\"/contact\" data-nav>Contact</a>\n <a class=\"hbtn primary\" href=\"/#annuaire\" data-nav>Explorer l'annuaire</a>\n <span id=\"acct-slot\"></span>\n </div></header>`;\n}\n/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */\nasync function loadAccount(){\n const slot=$(\"#acct-slot\");if(!slot)return;\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(me){\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/compte\" data-nav>\n ${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:\"\"}${esc((me.name||\"\").split(\" \")[0])}\n <span class=\"ka-badge\">KA</span></a>`;\n }else{\n slot.innerHTML=`<a class=\"hbtn acct\" href=\"/api/auth/ka/login\"\n title=\"Se connecter avec KA ID\"><span class=\"acct-long\">Se connecter avec</span><span class=\"acct-short\">Connexion</span> <span class=\"ka-badge\">KA</span></a>`;\n }\n bindNav(slot);\n}\nfunction tabbar(active){\n const t=(href,label,svg,key)=>`<a href=\"${href}\" data-nav class=\"${active===key?\"active\":\"\"}\">${svg}<span>${label}</span></a>`;\n return `<nav class=\"tabbar\">\n ${t(\"/\",\"Annuaire\",`<svg viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>`,\"home\")}\n ${t(\"/#annuaire\",\"Recherche\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\"/></svg>`,\"search\")}\n ${t(\"/stats\",\"Stats\",`<svg viewBox=\"0 0 24 24\"><path d=\"M4 20V10M10 20V4M16 20v-7M22 20H2\"/></svg>`,\"stats\")}\n ${t(\"/retrait\",\"Retrait\",`<svg viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/></svg>`,\"optout\")}\n </nav>`;\n}\n/* footer commun Groupe KA (équivalent vanilla de ka/KaFooter.tsx, siteId\n \"crea-ka\", localLegal = /retrait) — nav locale conservée au-dessus */\nfunction footer(){\n const year=new Date().getFullYear();\n const others=KA_ECO.sites.filter(s=>s.id!==\"crea-ka\");\n const localLegal=[{label:\"Retrait (opt-out)\",href:\"/retrait\",nav:true}];\n return `<div class=\"footer-local\"><div class=\"container\"><nav>\n <span class=\"klabel\">Créa·Ka</span>\n <a href=\"/compte\" data-nav>Mon compte KA ID</a>\n <a href=\"/stats\" data-nav>Statistiques</a>\n <a href=\"/retrait\" data-nav>Demander un retrait</a>\n <a href=\"/contact\" data-nav>Contact</a>\n <a href=\"/api/stats\" target=\"_blank\" rel=\"noopener\">API publique</a>\n </nav></div></div>\n <footer class=\"ka-footer\" id=\"contact-footer\">\n <div class=\"container\">\n <a class=\"wordmark\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\"\n aria-label=\"Groupe KA — le portail de l'écosystème\">Groupe <span class=\"ka\">KA</span></a>\n <p class=\"desc\">${esc(KA_ECO.org.tagline)} Des centaines de connecteurs lisent les sites à la\n source, normalisent la donnée et se resynchronisent seuls.\n <b style=\"color:var(--paper)\">${esc(KA_SITE.wordmark)}</b> — ${esc(KA_SITE.tagline)} — est un service Groupe KA.</p>\n <p class=\"notice\"><b>${esc(KA_ECO.org.disclaimer)}</b></p>\n <ul class=\"sites\">\n ${others.map(s=>`<li><a href=\"https://${esc(s.domain)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(s.wordmark)}</a></li>`).join(\"\")}\n ${(KA_ECO.extraFooterLinks||[]).map(l=>`<li><a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a></li>`).join(\"\")}\n </ul>\n <div class=\"contacts\">\n ${KA_ECO.contacts.map(c=>`<p style=\"margin:0\"><a href=\"mailto:${esc(c.email)}\">${esc(c.email)}</a><span>${esc(c.role)}</span></p>`).join(\"\")}\n </div>\n <p class=\"legal\">© ${year} ${esc(KA_ECO.org.copyrightHolder)}\n ${localLegal.map(l=>` · <a href=\"${esc(l.href)}\"${l.nav?\" data-nav\":\"\"}>${esc(l.label)}</a>`).join(\"\")}\n ${KA_ECO.legal.map(l=>` · <a href=\"${esc(l.href)}\" target=\"_blank\" rel=\"noopener noreferrer\">${esc(l.label)}</a>`).join(\"\")}\n </p>\n </div>\n </footer>`;\n}\n\n/* ---------- annuaire ---------- */\nfunction platIcon(a){\n const p=PLAT[a.platform]||PLAT.autre;\n return `<a class=\"plat\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\"\n title=\"${p.label} — @${esc(a.handle)}\" style=\"background:${p.color}\"\n onclick=\"event.stopPropagation()\">${icon(a.platform)}</a>`;\n}\nfunction cardHtml(c){\n const plats=(c.platforms||[]).slice(0,7).map(platIcon).join(\"\");\n const niches=(c.niches||[]).slice(0,2).map(n=>`<span class=\"chip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const verified=(c.platforms||[]).some(a=>a.verified);\n const np=(c.platforms||[]).length;\n return `<div class=\"card\" data-href=\"/createur/${esc(c.id)}\" role=\"link\" tabindex=\"0\" style=\"cursor:pointer\">\n <div class=\"card-photo\"><span class=\"init\">${esc(initials(c.display_name))}</span>${photoImg(c)}\n <span class=\"tier ${esc(c.audience_tier)}\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n <span class=\"nplat\">${np} plat.</span></div>\n <div class=\"card-body\">\n <div class=\"card-name\">${esc(c.display_name)}${verified?checkSvg:\"\"}</div>\n <div class=\"card-sub\">${c.region?`${esc(c.region)} · `:\"\"}${(c.languages||[]).map(l=>({fr:\"FR\",en:\"EN\",bilingue:\"FR/EN\"}[l]||l)).join(\", \")}</div>\n <div class=\"chips\">${niches}</div>\n ${c.total_reach?`<div class=\"reach\"><b>${fmt.format(c.total_reach)}</b> abonnés cumulés</div>`:\"\"}\n <div class=\"platrow\">${plats}</div></div></div>`;\n}\nfunction selectHtml(id,label,opts,val,lbls){\n const o=opts.map(v=>`<option value=\"${esc(v)}\"${v===val?\" selected\":\"\"}>${esc(lbls?(lbls[v]||v):v)}</option>`).join(\"\");\n return `<select class=\"f${val?\" active\":\"\"}\" id=\"${id}\"><option value=\"\">${label}</option>${o}</select>`;\n}\nasync function renderHome(){\n document.title=\"Créa-Ka — Un service Groupe KA\";\n if(!state.tax)state.tax=await api(\"/api/taxonomies\");\n if(!state.stats)state.stats=await api(\"/api/stats\").catch(()=>null);\n const s=state.stats||{creators:0,accounts:0,by_platform:{}};\n app.innerHTML=header()+`\n <section class=\"hero\"><div class=\"container\">\n <span class=\"kicker\">Annuaire des créateurs du Québec</span>\n <h1 style=\"margin-top:16px\">Chaque créateur<br>québécois. <span class=\"outline-txt\">Tous</span> ses <span class=\"hl\">liens.</span></h1>\n <p class=\"lead\">Un « Linktree collectif » du Québec : influenceurs, youtubeurs, streamers,\n podcasteurs — leurs comptes regroupés et découvrables par niche, région, langue et taille d'audience.</p>\n <div class=\"stats-row\">\n <span class=\"stat-pill\"><b>${fmtFull.format(s.creators)}</b> créateurs</span>\n <span class=\"stat-pill\"><b>${fmtFull.format(s.accounts)}</b> comptes reliés</span>\n <span class=\"stat-pill\"><b>${Object.keys(s.by_platform||{}).length}</b> plateformes</span>\n </div>\n <div class=\"advantages\">\n <div class=\"adv\"><div class=\"ico\">${icon(\"autre\")}</div><h3>Cross-plateforme</h3><p>Instagram, TikTok, YouTube, Twitch, X… tous les comptes d'une même personne, au même endroit.</p></div>\n <div class=\"adv\"><div class=\"ico\"><svg class=\"icn\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"M20 20l-3.5-3.5\" stroke-linecap=\"round\"/></svg></div><h3>Découvrable</h3><p>Filtres par niche, région, langue, plateforme et taille d'audience.</p></div>\n <div class=\"adv\"><div class=\"ico\">✓</div><h3>Liens vérifiés</h3><p>Chaque rattachement porte un score de confiance ; mieux vaut un lien de moins qu'un lien erroné.</p></div>\n <div class=\"adv\"><div class=\"ico\">🛡</div><h3>Respectueux</h3><p>Profils publics seulement, retrait (opt-out) simple et respecté. Loi 25.</p></div>\n </div>\n </div></section>\n <div class=\"searchbar\" id=\"annuaire\"><div class=\"container search-wrap\">\n <label class=\"search-input\"><svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"none\"><circle cx=\"11\" cy=\"11\" r=\"7\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M20 20l-3.5-3.5\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\"/></svg>\n <input id=\"q\" placeholder=\"Chercher un créateur…\" value=\"${esc(state.q)}\"></label>\n <div class=\"filters\">\n ${selectHtml(\"f-niche\",\"Niche\",state.tax.niches,state.niche,NICHE_LBL)}\n ${selectHtml(\"f-region\",\"Région\",state.tax.regions,state.region)}\n ${selectHtml(\"f-plat\",\"Plateforme\",state.tax.plateformes,state.plateforme,Object.fromEntries(Object.entries(PLAT).map(([k,v])=>[k,v.label])))}\n ${selectHtml(\"f-tier\",\"Taille\",state.tax.tiers,state.tier,TIER_LBL)}\n ${selectHtml(\"f-langue\",\"Langue\",state.tax.langues,state.langue,{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"})}\n ${selectHtml(\"f-sort\",\"Tri\",[\"reach\",\"nom\",\"recent\"],state.sort===\"reach\"?\"\":state.sort,{reach:\"Audience\",nom:\"Nom A-Z\",recent:\"Récents\"})}\n </div>\n </div></div>\n <main class=\"container\"><div class=\"count-line\" id=\"countline\"></div>\n <div class=\"grid\" id=\"grid\"><div class=\"spin\"></div></div>\n <div class=\"more-wrap\" id=\"more\"></div></main>`+footer()+tabbar(location.hash===\"#annuaire\"?\"search\":\"home\");\n bindNav();\n $(\"#q\").addEventListener(\"input\",deb(()=>{state.q=$(\"#q\").value;reload()},300));\n [[\"f-niche\",\"niche\"],[\"f-region\",\"region\"],[\"f-plat\",\"plateforme\"],\n [\"f-tier\",\"tier\"],[\"f-langue\",\"langue\"],[\"f-sort\",\"sort\"]].forEach(([id,key])=>{\n $(\"#\"+id).addEventListener(\"change\",e=>{state[key]=e.target.value||(key===\"sort\"?\"reach\":\"\");\n e.target.classList.toggle(\"active\",!!e.target.value);reload()})});\n if(location.hash===\"#annuaire\"){const el=$(\"#annuaire\");if(el)el.scrollIntoView()}\n await reload();\n}\nfunction deb(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}\nasync function reload(more){\n if(!more){state.offset=0;state.items=[]}\n const p=new URLSearchParams({q:state.q,niche:state.niche,region:state.region,\n langue:state.langue,plateforme:state.plateforme,tier:state.tier,sort:state.sort,\n limit:\"60\",offset:String(state.offset)});\n const data=await api(\"/api/creators?\"+p).catch(()=>({items:[],total:0}));\n state.items=state.items.concat(data.items);state.total=data.total;\n const grid=$(\"#grid\");if(!grid)return;\n $(\"#countline\").innerHTML=`<b>${fmtFull.format(state.total)}</b> créateur${state.total>1?\"s\":\"\"} trouvé${state.total>1?\"s\":\"\"}`;\n grid.innerHTML=state.items.length?state.items.map(cardHtml).join(\"\"):\n `<div class=\"empty\">Aucun créateur ne correspond à ces filtres.</div>`;\n const moreDiv=$(\"#more\");\n moreDiv.innerHTML=state.items.length<state.total?\n `<button class=\"btn btn-primary\" id=\"loadmore\">Afficher plus (${fmtFull.format(state.total-state.items.length)} restants)</button>`:\"\";\n const lm=$(\"#loadmore\");if(lm)lm.onclick=()=>{state.offset+=60;reload(true)};\n bindNav(grid);\n}\n\n/* ---------- page créateur ---------- */\n/* --- bande de contenu récent par compte (enrichissement Apify) --- */\nconst _relTime=ts=>{if(!ts)return\"\";if(typeof ts===\"string\"&&!/^\\d+$/.test(ts))return esc(ts.slice(0,10));\n const d=Math.floor((Date.now()/1000-Number(ts))/86400);\n return d<=0?\"aujourd'hui\":d===1?\"hier\":d<30?d+\" j\":d<365?Math.floor(d/30)+\" mois\":Math.floor(d/365)+\" ans\";};\nconst _relTimeC=ts=>_relTime(ts);\n/* image de contenu : IG/CDN expirent souvent → onerror bascule sur repli icône */\nwindow.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add(\"noimg\");\n w.innerHTML=w.dataset.icon||\"\";}};\nfunction _mediaItems(a){const m=a.metrics||{};\n const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];\n if(base.length)return base;\n // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images\n const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];\n return prev.map(u=>({display_url:u,type:\"story\"}));}\n\n/* grille de contenu récent, images/vidéos bien affichées */\nfunction contentGrid(a){\n const items=_mediaItems(a);\n if(!items.length)return `<div class=\"cg-empty\">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;\n const ico=icon(a.platform);\n const cards=items.slice(0,12).map(it=>{\n const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||\"\";\n const isVid=it.type===\"video\"||it.type===\"reel\"||!!it.duration||it.video_views!=null;\n const cap=esc((it.caption||it.title||it.text||\"\").slice(0,140));\n const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);\n const stats=[\n it.views!=null?fmt.format(it.views)+\" vues\":(it.video_views!=null?fmt.format(it.video_views)+\" vues\":null),\n it.likes!=null?fmt.format(it.likes)+\" ❤\":null,\n it.comments!=null?fmt.format(it.comments)+\" 💬\":null,\n it.retweets!=null?fmt.format(it.retweets)+\" ↻\":null,\n it.shares!=null?fmt.format(it.shares)+\" ↗\":null,\n ].filter(Boolean).join(\" · \");\n const thumb=img\n ? `<span class=\"cg-thumb\" data-icon='${esc(ico)}'><img loading=\"lazy\" src=\"${esc(img)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"thumbFail(this)\">\n ${isVid?'<span class=\"cg-play\">▶</span>':\"\"}${it.duration?`<span class=\"cg-dur\">${esc(it.duration)}</span>`:\"\"}\n ${it.is_pinned?'<span class=\"cg-pin\">📌</span>':\"\"}</span>`\n : `<span class=\"cg-thumb noimg\" data-icon='${esc(ico)}'>${ico}${isVid?'<span class=\"cg-play\">▶</span>':\"\"}</span>`;\n return `<a class=\"cg-card\" href=\"${esc(it.url||a.url)}\" target=\"_blank\" rel=\"noopener\">\n ${thumb}\n <span class=\"cg-body\">${cap?`<span class=\"cg-cap\">${cap}</span>`:`<span class=\"cg-cap dim\">Publication ${a.platform}</span>`}\n <span class=\"cg-meta\">${stats||\"—\"}${when?`<span class=\"cg-when\">${when}</span>`:\"\"}</span></span></a>`;\n }).join(\"\");\n return `<div class=\"cg-grid\">${cards}</div>`;\n}\n\n/* définitions de métriques calculées, par plateforme */\nfunction platformKPIs(a){\n const m=a.metrics||{}, out=[];\n const push=(v,l,suf)=>{if(v!=null&&v!==\"\")out.push([typeof v===\"number\"?fmt.format(v)+(suf||\"\"):esc(v)+(suf||\"\"),l]);};\n push(a.followers,a.platform===\"discord\"?\"membres\":a.platform===\"patreon\"?\"patrons\":\"abonnés\");\n push(m.engagement_rate_pct!=null?m.engagement_rate_pct:null,\"engagement\",\"%\");\n push(m.avg_likes,\"j'aime / pub (moy.)\");\n push(m.avg_views!=null?m.avg_views:m.avg_video_views,\"vues / pub (moy.)\");\n push(m.avg_comments,\"comm. / pub (moy.)\");\n push(m.avg_retweets,\"RT / pub (moy.)\");\n push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),\"pubs / semaine\");\n push(m.total_views,\"vues totales\");\n push(m.posts,\"publications\");\n push(m.videos,\"vidéos\");\n push(m.likes,a.platform===\"tiktok\"?\"j'aime cumulés\":\"j'aime\");\n push(m.following,\"abonnements\");\n push(m.online,\"en ligne\");\n push(m.boosts,\"boosts\");\n push(m.patrons,\"patrons\");\n push(m.live_viewers,\"spectateurs (live)\");\n return out;\n}\nfunction platformBadges(a){\n const m=a.metrics||{};\n return [\n a.verified?'<span class=\"abadge ok\">✓ Vérifié</span>':\"\",\n m.is_partner?'<span class=\"abadge\">Partenaire</span>':\"\",\n m.is_affiliate&&!m.is_partner?'<span class=\"abadge\">Affilié</span>':\"\",\n m.partnered?'<span class=\"abadge\">Serveur partenaire</span>':\"\",\n m.is_live_now?'<span class=\"abadge live\">● En direct</span>':\"\",\n m.is_seller?'<span class=\"abadge\">Boutique</span>':\"\",\n m.is_free===true?'<span class=\"abadge\">Gratuit</span>':\"\",\n m.is_monthly?'<span class=\"abadge\">Mensuel</span>':\"\",\n m.has_story?'<span class=\"abadge\">Story active</span>':\"\",\n m.has_spotlight?'<span class=\"abadge\">Spotlight</span>':\"\",\n m.is_nsfw?'<span class=\"abadge live\">18+</span>':\"\",\n m.creation_name?`<span class=\"abadge\">${esc(String(m.creation_name).slice(0,60))}</span>`:\"\",\n m.joined_date?`<span class=\"abadge\">${esc(m.joined_date)}</span>`:\"\",\n m.is_blue_verified?'<span class=\"abadge ok\">✓ Bleu</span>':\"\",\n m.country?`<span class=\"abadge\">${esc(m.country)}</span>`:\"\",\n (m.recent_games||[]).slice(0,2).map(g=>`<span class=\"abadge\">🎮 ${esc(g)}</span>`).join(\"\"),\n (m.top_hashtags||[]).slice(0,3).map(h=>`<span class=\"abadge\">#${esc(h)}</span>`).join(\"\"),\n ].filter(Boolean).join(\"\");\n}\n\n/* panneau d'un compte (onglet plateforme) */\nfunction platformPanel(a,idx,active){\n const p=PLAT[a.platform]||PLAT.autre;\n const conf=Math.round((a.confidence||0)*100);\n const confCls=(a.confidence||0)>=0.75?\"hi\":\"mid\";\n const kpis=platformKPIs(a).map(([v,l])=>`<div class=\"kpi\"><b>${v}</b><span>${l}</span></div>`).join(\"\");\n const badges=platformBadges(a);\n const stamp=(a.metrics||{})[a.platform+\"_apify_at\"]||a.last_checked;\n const top=(a.metrics||{}).top_post||(a.metrics||{}).top_video||(a.metrics||{}).top_tweet;\n return `<section class=\"ppanel${active?\" on\":\"\"}\" data-panel=\"${idx}\">\n <div class=\"ppanel-head\">\n <span class=\"plat\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <div class=\"ppanel-id\"><span class=\"ppanel-name\">${p.label}${a.verified?checkSvg:\"\"}</span>\n <a class=\"ppanel-handle\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">@${esc(a.handle)} ↗</a></div>\n <span class=\"conf ${confCls}\" title=\"Score de rattachement\">${conf}%</span>\n </div>\n ${badges?`<div class=\"abadge-row\">${badges}</div>`:\"\"}\n ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`\n <a class=\"live-card\" href=\"${esc(a.url)}\" target=\"_blank\" rel=\"noopener\">\n <img loading=\"lazy\" src=\"${esc((a.metrics||{}).live_thumbnail)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.remove()\">\n <span class=\"live-card-body\"><span class=\"live-card-tag\">● EN DIRECT</span>\n ${(a.metrics||{}).live_title?`<span class=\"live-card-title\">${esc((a.metrics||{}).live_title)}</span>`:\"\"}\n <span class=\"live-card-meta\">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+\" spectateurs\":\"\"}${(a.metrics||{}).live_game?\" · \"+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?\" · \"+esc((a.metrics||{}).live_category):\"\")}</span>\n </span></a>`:\"\"}\n <div class=\"kpi-grid\">${kpis||'<div class=\"kpi\"><b>—</b><span>pas encore de métriques</span></div>'}</div>\n ${top&&top.url?`<a class=\"ppanel-top\" href=\"${esc(top.url)}\" target=\"_blank\" rel=\"noopener\">★ Top contenu — ${top.views!=null?fmt.format(top.views)+\" vues\":\"\"}${top.likes!=null?\" · \"+fmt.format(top.likes)+\" ❤\":\"\"} ↗</a>`:\"\"}\n <h3 class=\"cg-title\">Contenu récent</h3>\n ${contentGrid(a)}\n <div class=\"ppanel-foot\">${stamp?\"Dernière vérification \"+esc(String(stamp).slice(0,10)):\"\"}</div>\n </section>`;\n}\n\nwindow.showPtab=(i,el)=>{\n document.querySelectorAll(\".ppanel\").forEach(s=>s.classList.toggle(\"on\",s.dataset.panel==String(i)));\n document.querySelectorAll(\".ptab\").forEach(t=>t.classList.remove(\"on\"));\n if(el)el.classList.add(\"on\");\n const ov=document.getElementById(\"ov-panel\");if(ov)ov.classList.toggle(\"on\",i===\"ov\");\n};\n\nfunction computeAgg(c){\n const P=(c.platforms||[]);\n const reach=c.total_reach||P.reduce((s,a)=>s+(a.followers||0),0);\n const eng=P.map(a=>(a.metrics||{}).engagement_rate_pct).filter(v=>v!=null);\n const avgEng=eng.length?(eng.reduce((s,v)=>s+v,0)/eng.length):null;\n const content=P.reduce((s,a)=>s+_mediaItems(a).length,0);\n const cad=P.map(a=>{const m=a.metrics||{};return m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week;}).filter(v=>v!=null);\n const main=P.slice().sort((x,y)=>(y.followers||0)-(x.followers||0))[0];\n return {reach,avgEng,content,maxCad:cad.length?Math.max(...cad):null,\n nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};\n}\n\n/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */\nfunction sparkSvg(pts){\n if(!pts||pts.length<2)return\"\";\n const w=600,h=88,pad=4;\n const vals=pts.map(p=>p.followers);\n const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);\n const x=i=>pad+i*(w-2*pad)/(pts.length-1);\n const y=v=>h-pad-((v-min)/span)*(h-2*pad);\n const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(\" \");\n const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;\n return `<div class=\"spark-wrap\"><svg class=\"spark-svg\" viewBox=\"0 0 ${w} ${h}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polygon class=\"spark-fill\" points=\"${area}\"/><polyline class=\"spark-line\" points=\"${line}\"/></svg>\n <div class=\"spark-meta\"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;\n}\n\nasync function renderCreator(id){\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n let c;try{c=await api(\"/api/creators/\"+encodeURIComponent(id))}\n catch(e){app.innerHTML=header()+`<div class=\"container\"><div class=\"empty\"><h2>Créateur introuvable</h2>\n <p style=\"margin-top:10px\"><a href=\"/\" data-nav style=\"color:var(--green);font-weight:600\">← Retour à l'annuaire</a></p></div></div>`+footer()+tabbar(\"\");bindNav();return}\n document.title=`${c.display_name} — Créa-Ka`;\n const verified=(c.platforms||[]).some(a=>a.verified);\n const niches=(c.niches||[]).map(n=>`<span class=\"mchip\">${NICHE_LBL[n]||n}</span>`).join(\"\");\n const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));\n const g=computeAgg(c);\n const ins=c.insights||{};\n const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);\n // bandeau de métriques CALCULÉES (multi-plateforme)\n const kpi=(v,l)=>v==null?\"\":`<div class=\"hkpi\"><b>${typeof v===\"number\"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;\n const gkpi=(d,l)=>!d||!d.delta?\"\":`<div class=\"hkpi\"><b class=\"${d.delta>=0?\"growth-up\":\"growth-down\"}\">${d.delta>=0?\"+\":\"\"}${fmt.format(d.delta)}</b><span>${l}</span></div>`;\n const heroKpis=[\n kpi(g.reach,\"abonnés cumulés\"),\n gkpi(ins.growth_7d,\"abonnés / 7 j\"),\n gkpi(ins.growth_30d,\"abonnés / 30 j\"),\n kpi(g.nPlat,\"plateformes\"),\n kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+\" %\":(g.avgEng!=null?Math.round(g.avgEng*100)/100+\" %\":null),\"engagement moyen\"),\n kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,\"pubs / semaine\"),\n kpi(g.content||null,\"contenus analysés\"),\n kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,\"plateforme nº 1\"),\n ].filter(Boolean).join(\"\");\n // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)\n const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};\n const KS_LBL={audience:\"Audience\",engagement:\"Engagement\",presence:\"Multi-plateforme\",\n rythme:\"Rythme de pub.\",verification:\"Vérification\"};\n const ksParts=ins.ka_score_parts||{};\n const ksHtml=ins.ka_score!=null?`<h3 class=\"cg-title\">Ka Score</h3><div class=\"kascore\">\n <div class=\"kascore-num\"><b>${ins.ka_score}</b><span>/ 100</span></div>\n <div class=\"kascore-parts\">${Object.keys(KS_MAX).map(k=>`<div class=\"kspart\">\n <span class=\"kspart-lab\">${KS_LBL[k]}</span>\n <span class=\"kspart-track\"><span class=\"kspart-fill\" style=\"width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%\"></span></span>\n <span class=\"kspart-val\">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join(\"\")}</div>\n </div>`:\"\";\n const hist=((ins.history||{}).total)||[];\n const sparkHtml=hist.length>1?`<h3 class=\"cg-title\">Évolution de l'audience</h3>${sparkSvg(hist)}`:\"\";\n // barres comparatives de portée par plateforme (vue d'ensemble)\n const maxF=Math.max(1,...P.map(a=>a.followers||0));\n const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);\n return `<div class=\"obar\"><span class=\"obar-ico\" style=\"background:${p.color}\">${icon(a.platform)}</span>\n <span class=\"obar-lab\">${p.label}</span>\n <span class=\"obar-track\"><span class=\"obar-fill\" style=\"width:${w}%;background:${p.color}\"></span></span>\n <span class=\"obar-val\">${a.followers!=null?fmt.format(a.followers):\"—\"}</span></div>`;}).join(\"\");\n // onglets + panneaux\n const tabs=`<button class=\"ptab on\" onclick=\"showPtab('ov',this)\">Vue d'ensemble</button>`+\n P.map((a,i)=>{const p=PLAT[a.platform]||PLAT.autre;\n return `<button class=\"ptab\" onclick=\"showPtab(${i},this)\">\n <span class=\"ptab-ico\" style=\"color:${p.color}\">${icon(a.platform)}</span>${p.label}\n ${a.followers!=null?`<span class=\"ptab-n\">${fmt.format(a.followers)}</span>`:\"\"}</button>`;}).join(\"\");\n const ovPanel=`<section class=\"ppanel on\" id=\"ov-panel\" data-panel=\"ov\">\n ${ksHtml}\n <h3 class=\"cg-title\">Portée par plateforme</h3>\n <div class=\"obars\">${bars||\"<p class=klabel>Aucune plateforme.</p>\"}</div>\n ${sparkHtml}\n ${c.link_in_bio_url?`<a class=\"linkinbio\" href=\"${esc(c.link_in_bio_url)}\" target=\"_blank\" rel=\"noopener\">${icon(\"linktree\")} Sa page de liens officielle ↗</a>`:\"\"}\n </section>`;\n const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(\"\");\n const checked=(c.platforms||[]).map(a=>{const m=a.metrics||{};return m[a.platform+\"_apify_at\"]||a.last_checked;}).filter(Boolean).sort().pop();\n app.innerHTML=header()+`\n <div class=\"container cpage\">\n <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>\n <div class=\"cpage-head\">\n <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>\n <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>\n <div class=\"meta\"><span class=\"mchip o\">${TIER_LBL[c.audience_tier]||c.audience_tier}</span>\n ${niches}${c.region?`<span class=\"mchip\">${esc(c.region)}</span>`:\"\"}\n ${(c.languages||[]).map(l=>`<span class=\"mchip\">${{fr:\"Français\",en:\"Anglais\",bilingue:\"Bilingue\"}[l]||l}</span>`).join(\"\")}</div>\n ${c.bio?`<p class=\"cpage-bio\">${esc(c.bio)}</p>`:\"\"}</div>\n </div>\n <div class=\"hkpi-row\">${heroKpis}</div>\n ${c.notes?`<div class=\"cpage-notes\"><span class=\"klabel\">Contexte public</span><br>${esc(c.notes)}</div>`:\"\"}\n <div class=\"ptabs\">${tabs}</div>\n <div class=\"ppanels\">${panels}</div>\n <div class=\"cpage-foot\">Sources : listes médias publiques, Wikidata, annuaires publics et profils publics des plateformes (enrichissement Apify).\n Audiences et métriques approximatives${checked?`, dernière vérification ${esc(String(checked).slice(0,10))}`:\"\"}.\n Une erreur dans cette fiche ? <a href=\"/retrait\" data-nav>Signaler / demander un retrait</a>.</div>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- page stats : tableau de bord analytique (contrat ka-stats) ---------- */\nconst ST={period:\"30j\",from:\"\",to:\"\",data:null,hide:{},tbl:{}};\nconst PERIODS_UI=[[\"auj\",\"Aujourd'hui\"],[\"7j\",\"7 jours\"],[\"30j\",\"30 jours\"],[\"3m\",\"3 mois\"],\n [\"6m\",\"6 mois\"],[\"12m\",\"12 mois\"],[\"annee\",\"Année en cours\"],[\"tout\",\"Tout\"]];\nconst fmtPct=v=>(v>=0?\"+\":\"\")+v.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})+\" %\";\nconst emptyBlock=t=>`<div class=\"empty-block\"><b style=\"font-family:var(--font-display);font-size:14px\">${esc(t)}</b>\n <p class=\"klabel\" style=\"margin:6px 0 0\">Pas encore mesuré — aucune donnée disponible pour cette période.</p></div>`;\n\n/* mini-tendance dans une carte KPI (série spark du contrat v2) */\nfunction sparkSvg(spark,hero){\n if(!spark||spark.length<2)return\"\";\n const W=120,H=28,vs=spark.map(p=>p.v);\n const mn=Math.min(...vs),mx=Math.max(...vs),rng=(mx-mn)||1;\n const pts=spark.map((p,i)=>`${(i/(spark.length-1)*W).toFixed(1)},${(H-2-(H-4)*((p.v-mn)/rng)).toFixed(1)}`).join(\" \");\n return `<svg class=\"spark\" viewBox=\"0 0 ${W} ${H}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <polyline points=\"${pts}\" fill=\"none\" stroke=\"${hero?\"var(--accent-soft)\":\"var(--accent)\"}\" stroke-width=\"2\"/></svg>`;\n}\nfunction kpiHtml(k,i){\n const v=k.value,hero=i===0;\n const num=typeof v===\"number\"?(v>=1e6?fmt.format(v):fmtFull.format(v)):esc(v);\n let delta=\"\";\n if(k.delta_pct!==undefined&&k.delta_pct!==null){\n const up=(k.direction||(k.delta_pct>=0?\"up\":\"down\"))===\"up\";\n delta=`<div class=\"tile-d ${up?\"up\":\"down\"}\">${up?\"▲\":\"▼\"} ${fmtPct(k.delta_pct)} <span style=\"opacity:.6\">vs période préc.</span></div>`;}\n return `<div class=\"tile${hero?\" hero-tile\":\"\"}\"${typeof v===\"number\"?` title=\"${fmtFull.format(v)}\"`:\"\"}>\n <div class=\"tile-v\">${num}${k.unit?`<span style=\"font-size:.42em;opacity:.6\"> ${esc(k.unit)}</span>`:\"\"}</div>\n <div class=\"tile-k\">${esc(k.label)}</div>${delta}${sparkSvg(k.spark,hero)}</div>`;\n}\n\n/* --- jauge demi-arc (taux, couvertures, complétude) --- */\nfunction gaugeHtml(g){\n if(typeof g.value!==\"number\"||!g.max)return\"\";\n const frac=Math.max(0,Math.min(1,g.value/g.max));\n const R=52,C=Math.PI*R;\n return `<div class=\"gauge\" title=\"${esc(g.label)} — ${g.value.toLocaleString(\"fr-CA\")}${g.unit?\" \"+esc(g.unit):\"\"} (${Math.round(frac*100)} % de ${fmtFull.format(g.max)})\">\n <svg viewBox=\"0 0 120 68\" role=\"img\" aria-label=\"${esc(g.label)}\">\n <path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--line)\" stroke-width=\"11\" stroke-linecap=\"round\"/>\n ${frac>0?`<path d=\"M8 60 A${R} ${R} 0 0 1 112 60\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"11\" stroke-linecap=\"round\"\n stroke-dasharray=\"${(frac*C).toFixed(1)} ${(C+20).toFixed(1)}\"/>`:\"\"}\n </svg>\n <div class=\"g-v\">${g.value.toLocaleString(\"fr-CA\",{maximumFractionDigits:1})}${g.unit?`<span style=\"font-size:.58em;opacity:.65\"> ${esc(g.unit)}</span>`:\"\"}</div>\n <div class=\"g-l\">${esc(g.label)}</div></div>`;\n}\n\n/* --- statistiques d'une série : min / max / moyenne / médiane / écart-type --- */\nfunction statSummary(s){\n const vs=(s.points||[]).map(p=>p.v).filter(v=>typeof v===\"number\");\n if(vs.length<2)return\"\";\n const sv=[...vs].sort((a,b)=>a-b),mean=vs.reduce((a,b)=>a+b,0)/vs.length;\n const med=sv[Math.floor(sv.length/2)];\n const sd=Math.sqrt(vs.reduce((a,v)=>a+(v-mean)*(v-mean),0)/vs.length);\n const f=v=>fmtFull.format(Math.round(v*100)/100);\n return `<div class=\"statsum\"><span class=\"chip\">min<b>${f(sv[0])}</b></span>\n <span class=\"chip\">max<b>${f(sv[sv.length-1])}</b></span>\n <span class=\"chip\">moyenne<b>${f(mean)}</b></span>\n <span class=\"chip\">médiane<b>${f(med)}</b></span>\n <span class=\"chip\">écart-type σ<b>${f(sd)}</b></span></div>`;\n}\n\n/* --- barres verticales : série kind=bar (volumes/jour) & histogrammes (bins) --- */\nfunction vbarsHtml(spec){\n const pts=(spec.points||(spec.bins||[]).map(b=>({t:b.label,v:b.value})))\n .filter(p=>typeof p.v===\"number\");\n if(!pts.length||!pts.some(p=>p.v>0))return emptyBlock(spec.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=40,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...pts.map(p=>p.v),1);\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{const bh=ch*p.v/vmax,x=PL+cw*i/n+1.5;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${(PT+ch-bh).toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh,p.v>0?1:0).toFixed(1)}\"\n fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"${n>40?0.4:1}\"><title>${esc(p.t)} — ${fmtFull.format(p.v)}${spec.unit?\" \"+esc(spec.unit):\"\"}</title></rect>`;}).join(\"\");\n const lblIdx=n<=10?pts.map((_,i)=>i):[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\"\n font-size=\"${n<=10?10:10}\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(spec.title)}</h2>\n ${spec.unit?`<div class=\"viz-sub\">${esc(spec.unit)}</div>`:'<div style=\"height:10px\"></div>'}\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(spec.title)}\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- multi-courbes (≤ 4) : identité par MOTIF de trait en plus de la couleur --- */\nconst ML_STYLES=[\n {stroke:\"var(--accent)\",w:2.6,dash:\"\",cls:\"\",name:\"trait plein accent\"},\n {stroke:\"var(--ink)\",w:2,dash:\"\",cls:\"ml1\",name:\"trait plein encre\"},\n {stroke:\"var(--accent)\",w:2.2,dash:\"7 4\",cls:\"ml2\",name:\"tirets accent\"},\n {stroke:\"var(--ink-3)\",w:2,dash:\"2 3\",cls:\"ml3\",name:\"pointillé gris\"}];\nfunction multiFigure(ms,hide){\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n if(!series.length)return emptyBlock(ms.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const shown=series.filter((_,i)=>!hide[i]);\n const all=(shown.length?shown:series).flatMap(s=>s.points.map(p=>p.v));\n const vmax=Math.max(...all,1),vmin=Math.min(0,...all),rng=(vmax-vmin)||1;\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const X=(i,n)=>PL+cw*i/((n-1)||1),Y=v=>PT+ch*(1-(v-vmin)/rng);\n const paths=series.map((s,si)=>{if(hide[si])return\"\";\n const st=ML_STYLES[si];\n const d=s.points.map((p,i)=>`${i?\"L\":\"M\"}${X(i,s.points.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n return `<path d=\"${d}\" fill=\"none\" stroke=\"${st.stroke}\" stroke-width=\"${st.w}\"${st.dash?` stroke-dasharray=\"${st.dash}\"`:\"\"}/>`;}).join(\"\");\n const ref=series[0].points;\n const xl=[0,Math.floor(ref.length/2),ref.length-1].map(i=>\n `<text x=\"${X(i,ref.length).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(ref[i].t)}</text>`).join(\"\");\n const leg=series.map((s,si)=>`<button type=\"button\" class=\"leg-btn\" data-mls=\"${si}\" aria-pressed=\"${!hide[si]}\"\n title=\"${ML_STYLES[si].name}\"><span class=\"leg-swatch ${ML_STYLES[si].cls}\"></span>${esc(s.label)}</button>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(ms.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(ms.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}${paths}\n <g class=\"lc-cursor\" style=\"display:none\"><line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/></g>\n </svg><span class=\"chip lc-tip\"> </span></div>`;\n}\nfunction mountMulti(el,ms){\n const hide=ST.hide[\"ml:\"+ms.id]||(ST.hide[\"ml:\"+ms.id]={});\n const series=(ms.series||[]).filter(s=>(s.points||[]).length>1).slice(0,4);\n const render=()=>{\n el.innerHTML=multiFigure(ms,hide);\n el.querySelectorAll(\"[data-mls]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.mls]=!hide[b.dataset.mls];render();}));\n const svg=el.querySelector(\"svg\");if(!svg||!series.length)return;\n const W=720,PL=54,PR=12,n=series[0].points.length;\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor?cursor.querySelector(\"line\"):null;\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*W;\n let i=Math.round((fx-PL)/(W-PL-PR)*(n-1));i=Math.max(0,Math.min(n-1,i));\n const x=(PL+(W-PL-PR)*i/((n-1)||1)).toFixed(1);\n if(cl){cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);cursor.style.display=\"\";}\n const vals=series.map((s,si)=>hide[si]?null:`${esc(s.label)} : <b> ${fmtFull.format((s.points[i]||{}).v??0)}</b>`).filter(Boolean).join(\" · \");\n tip.innerHTML=`${esc(series[0].points[i].t)} — ${vals}${ms.unit?` <span style=\"color:var(--ink-3)\">${esc(ms.unit)}</span>`:\"\"}`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{if(cursor)cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- barres empilées (composition dans le temps, nuances d'accent) --- */\nfunction stackedHtml(st){\n const keys=(st.keys||[]).slice(0,6),pts=st.points||[];\n if(!keys.length||!pts.length)return emptyBlock(st.title);\n const totals=pts.map(p=>(p.values||[]).slice(0,keys.length).reduce((a,b)=>a+(b||0),0));\n if(!totals.some(t=>t>0))return emptyBlock(st.title);\n const W=720,H=250,PL=54,PR=12,PT=14,PB=26,cw=W-PL-PR,ch=H-PT-PB;\n const vmax=Math.max(...totals,1),shades=[1,.78,.58,.42,.3,.22];\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+ch*k/4,v=vmax*(4-k)/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const n=pts.length,bw=Math.max(2,cw/n-3);\n const bars=pts.map((p,i)=>{let y=PT+ch;const x=PL+cw*i/n+1.5;\n return keys.map((k,j)=>{const v=(p.values||[])[j]||0;if(!v)return\"\";\n const bh=ch*v/vmax;y-=bh;\n return `<rect x=\"${x.toFixed(1)}\" y=\"${y.toFixed(1)}\" width=\"${bw.toFixed(1)}\" height=\"${Math.max(bh-.4,.5).toFixed(1)}\"\n fill=\"var(--accent)\" fill-opacity=\"${shades[j%6]}\" stroke=\"var(--paper)\" stroke-width=\"0.4\">\n <title>${esc(p.t)} · ${esc(k)} — ${fmtFull.format(v)}${st.unit?\" \"+esc(st.unit):\"\"} (total ${fmtFull.format(totals[i])})</title></rect>`;}).join(\"\");}).join(\"\");\n const lblIdx=[0,Math.floor(n/2),n-1];\n const xl=lblIdx.map(i=>`<text x=\"${(PL+cw*i/n+bw/2).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(String(pts[i].t))}</text>`).join(\"\");\n const leg=keys.map((k,j)=>`<span class=\"leg-btn\" style=\"min-height:auto\"><span class=\"dsw\" style=\"width:11px;height:11px;border-radius:3px;border:1px solid var(--ink);background:var(--accent);opacity:${shades[j%6]};display:inline-block\"></span> ${esc(k)}</span>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(st.title)}</h2><span class=\"lc-legend\">${leg}</span></div>\n <svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-label=\"${esc(st.title)}\" style=\"margin-top:10px\">${grid}${bars}${xl}</svg></div>`;\n}\n\n/* --- heatmap horaire 7 × 24 (dow 0=lun … 6=dim) --- */\nfunction hourHeatHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité horaire\");\n const grid=new Map(cells.map(c=>[c.dow+\":\"+c.hour,c.value||0]));\n const max=Math.max(...cells.map(c=>c.value||0),1);\n const CS=22,LX=42,LY=18,DOWS=[\"Lun\",\"Mar\",\"Mer\",\"Jeu\",\"Ven\",\"Sam\",\"Dim\"];\n let out=\"\";\n [0,6,12,18,23].forEach(hh=>{out+=`<text x=\"${LX+hh*CS+CS/2}\" y=\"12\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${hh} h</text>`;});\n for(let d=0;d<7;d++){\n out+=`<text x=\"${LX-6}\" y=\"${LY+d*CS+CS/2+3.5}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${DOWS[d]}</text>`;\n for(let hh=0;hh<24;hh++){const v=grid.get(d+\":\"+hh)||0;\n out+=`<rect x=\"${LX+hh*CS}\" y=\"${LY+d*CS}\" width=\"${CS-2.5}\" height=\"${CS-2.5}\" rx=\"3.5\"\n fill=\"${v?\"var(--accent)\":\"rgba(20,24,20,0.06)\"}\" fill-opacity=\"${v?(0.2+0.8*v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.14)\" stroke-width=\"0.5\"><title>${DOWS[d]} ${hh} h — ${fmtFull.format(v)}</title></rect>`;}}\n return `<div class=\"viz-card hh\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">7 jours × 24 heures (heure de l'Est)</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${LX+24*CS} ${LY+7*CS}\" role=\"img\" aria-label=\"${esc(h.title)}\">${out}</svg></div></div>`;\n}\n\n/* --- courbe SVG : infobulle (pointeur), légende cliquable, comparaison N-1 --- */\nfunction lineGeom(s,hide){\n const W=720,H=240,PL=54,PR=12,PT=14,PB=26,pts=s.points||[];\n const all=[...(hide.cur?[]:pts),...(!hide.cmp&&s.compare?s.compare:[])];\n const vmax=Math.max(...all.map(p=>p.v),1),vmin=Math.min(0,...all.map(p=>p.v));\n const X=(i,n)=>PL+(W-PL-PR)*i/((n||pts.length)-1);\n const Y=v=>PT+(H-PT-PB)*(1-(v-vmin)/((vmax-vmin)||1));\n return{W,H,PL,PR,PT,PB,vmax,vmin,X,Y};\n}\nfunction lineFigure(s,hide){\n const pts=s.points||[];\n if(pts.length<2)return emptyBlock(s.title);\n const g=lineGeom(s,hide),{W,H,PL,PR,PT,PB,vmax,vmin,X,Y}=g;\n const path=arr=>arr.map((p,i)=>`${i?\"L\":\"M\"}${X(i,arr.length).toFixed(1)},${Y(p.v).toFixed(1)}`).join(\"\");\n let grid=\"\";\n for(let k=0;k<5;k++){const y=PT+(H-PT-PB)*k/4,v=vmax-(vmax-vmin)*k/4;\n grid+=`<line x1=\"${PL}\" x2=\"${W-PR}\" y1=\"${y.toFixed(1)}\" y2=\"${y.toFixed(1)}\" stroke=\"var(--line)\"/>\n <text x=\"${PL-6}\" y=\"${(y+3).toFixed(1)}\" text-anchor=\"end\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${fmtFull.format(Math.round(v))}</text>`;}\n const xl=[0,Math.floor(pts.length/2),pts.length-1].map(i=>\n `<text x=\"${X(i).toFixed(1)}\" y=\"${H-8}\" text-anchor=\"middle\" font-size=\"10\" fill=\"var(--ink-3)\" font-family=\"var(--font-mono)\">${esc(pts[i].t)}</text>`).join(\"\");\n return `<div class=\"viz-card\">\n <div style=\"display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;align-items:center\">\n <h2>${esc(s.title)}</h2>\n <span class=\"lc-legend\">\n <button type=\"button\" class=\"leg-btn\" data-leg=\"cur\" aria-pressed=\"${!hide.cur}\"><span class=\"leg-swatch\"></span>Période courante</button>\n ${s.compare?`<button type=\"button\" class=\"leg-btn\" data-leg=\"cmp\" aria-pressed=\"${!hide.cmp}\"><span class=\"leg-swatch cmp\"></span>Période comparée</button>`:\"\"}\n </span></div>\n <svg viewBox=\"0 0 ${g.W} ${g.H}\" role=\"img\" aria-label=\"${esc(s.title)}\" style=\"margin-top:10px\">\n ${grid}${xl}\n ${!hide.cmp&&s.compare&&s.compare.length>1?`<path d=\"${path(s.compare)}\" fill=\"none\" stroke=\"var(--ink-3)\" stroke-width=\"1.4\" stroke-dasharray=\"4 4\"/>`:\"\"}\n ${!hide.cur&&s.kind===\"area\"?`<path d=\"${path(pts)} L${X(pts.length-1).toFixed(1)},${(H-PB).toFixed(1)} L${X(0).toFixed(1)},${(H-PB).toFixed(1)} Z\" fill=\"var(--accent)\" fill-opacity=\".12\" stroke=\"none\"/>`:\"\"}\n ${!hide.cur?`<path d=\"${path(pts)}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2.4\"/>`:\"\"}\n <g class=\"lc-cursor\" style=\"display:none\">\n <line y1=\"${PT}\" y2=\"${H-PB}\" stroke=\"var(--ink)\" stroke-dasharray=\"2 3\"/>\n <circle r=\"4\" fill=\"var(--accent)\" stroke=\"var(--ink)\" stroke-width=\"1.5\"/></g>\n </svg><span class=\"chip lc-tip\"> </span>`+statSummary(s)+\n `</div>`;\n}\nfunction mountLine(el,s){\n if(s.kind===\"bar\"){el.innerHTML=vbarsHtml(s);return;}\n const hide=ST.hide[s.id]||(ST.hide[s.id]={cur:false,cmp:false});\n const render=()=>{\n el.innerHTML=lineFigure(s,hide);\n el.querySelectorAll(\"[data-leg]\").forEach(b=>b.addEventListener(\"click\",()=>{\n hide[b.dataset.leg]=!hide[b.dataset.leg];render();}));\n const svg=el.querySelector(\"svg\");if(!svg)return;\n const pts=s.points,g=lineGeom(s,hide);\n const cursor=el.querySelector(\".lc-cursor\"),tip=el.querySelector(\".lc-tip\");\n const cl=cursor.querySelector(\"line\"),cc=cursor.querySelector(\"circle\");\n svg.addEventListener(\"pointermove\",e=>{\n const r=svg.getBoundingClientRect();\n const fx=(e.clientX-r.left)/r.width*g.W;\n let i=Math.round((fx-g.PL)/(g.W-g.PL-g.PR)*(pts.length-1));\n i=Math.max(0,Math.min(pts.length-1,i));\n const x=g.X(i).toFixed(1);\n cl.setAttribute(\"x1\",x);cl.setAttribute(\"x2\",x);\n cc.setAttribute(\"cx\",x);cc.setAttribute(\"cy\",g.Y(pts[i].v).toFixed(1));\n cursor.style.display=\"\";\n const cmp=s.compare&&!hide.cmp&&s.compare[i]?` · N-1 : ${fmtFull.format(s.compare[i].v)}`:\"\";\n tip.innerHTML=`${esc(pts[i].t)} — <b> ${fmtFull.format(pts[i].v)}${s.unit?\" \"+esc(s.unit):\"\"}</b><span style=\"color:var(--ink-3)\">${cmp}</span>`;\n tip.style.visibility=\"visible\";});\n svg.addEventListener(\"pointerleave\",()=>{cursor.style.display=\"none\";tip.style.visibility=\"hidden\"});\n };\n render();\n}\n\n/* --- anneau (top 7 + « Autres ») --- */\nfunction donutHtml(b){\n let rows=(b.items||[]).filter(i=>i.value>0);\n if(!rows.length)return emptyBlock(b.title);\n if(rows.length>8){const rest=rows.slice(7).reduce((s,r)=>s+r.value,0);\n rows=rows.slice(0,7);rows.push({label:\"Autres\",value:rest});}\n const total=rows.reduce((s,r)=>s+r.value,0);\n const R=74,C=2*Math.PI*R,shades=[1,.78,.58,.42,.3,.22,.15,.1];let acc=0;\n const segs=rows.map((r,i)=>{const frac=r.value/total,off=acc;acc+=frac;\n return `<circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--accent)\" stroke-opacity=\"${shades[i%8]}\"\n stroke-width=\"30\" stroke-dasharray=\"${(frac*C).toFixed(2)} ${C.toFixed(2)}\" stroke-dashoffset=\"${(-off*C).toFixed(2)}\"\n transform=\"rotate(-90 100 100)\"><title>${esc(r.label)} — ${fmtFull.format(r.value)}</title></circle>`;}).join(\"\");\n const leg=rows.map((r,i)=>`<li><span class=\"dsw\" style=\"opacity:${shades[i%8]}\"></span>\n <span class=\"dlbl\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}\">${esc(r.label)}</span>\n <b>${(100*r.value/total).toFixed(1).replace(\".\",\",\")} %</b></li>`).join(\"\");\n return `<div class=\"viz-card\"><h2>${esc(b.title)}</h2><div class=\"donut-wrap\">\n <svg viewBox=\"0 0 200 200\" role=\"img\" aria-label=\"${esc(b.title)}\" style=\"width:180px;max-width:100%\">${segs}\n <circle cx=\"100\" cy=\"100\" r=\"${R}\" fill=\"none\" stroke=\"var(--ink)\" opacity=\".5\"/></svg>\n <ul class=\"donut-legend\">${leg}</ul></div></div>`;\n}\n\n/* --- barres horizontales (répartitions, géo) --- */\nfunction barsHtml(title,items,sub){\n const rows=(items||[]).slice(0,14);\n if(!rows.length)return emptyBlock(title);\n const max=Math.max(...rows.map(r=>r.value),1);\n return `<div class=\"viz-card\"><h2>${esc(title)}</h2>${sub?`<div class=\"viz-sub\">${esc(sub)}</div>`:`<div style=\"height:14px\"></div>`}\n ${rows.map(r=>`<div class=\"hrow\" title=\"${esc(r.label)} — ${fmtFull.format(r.value)}${r.delta_pct!=null?` (${fmtPct(r.delta_pct)} sur la période)`:\"\"}\">\n <span class=\"hrow-label\">${esc(r.label)}</span>\n <span class=\"hbar\"><i style=\"width:${Math.max(2,Math.round(r.value/max*100))}%\"></i></span>\n <span class=\"n\">${fmtFull.format(r.value)}</span>${r.delta_pct!=null?`<span class=\"d ${r.delta_pct>=0?\"up\":\"down\"}\">${r.delta_pct>=0?\"▲\":\"▼\"} ${fmtPct(r.delta_pct)}</span>`:\"\"}</div>`).join(\"\")}</div>`;\n}\n\n/* --- calendrier de chaleur (26 dernières semaines) --- */\nfunction heatmapHtml(h){\n const cells=h&&h.cells||[];\n if(!cells.length)return emptyBlock(h?h.title:\"Activité\");\n const byDate=new Map(cells.map(c=>[c.date,c.value]));\n const dates=cells.map(c=>c.date).sort();\n const end=new Date(dates[dates.length-1]+\"T12:00:00\");\n const max=Math.max(...cells.map(c=>c.value),1);\n const weeks=26,cols=[];const cur=new Date(end);\n cur.setDate(cur.getDate()-(weeks*7-1));\n for(let w=0;w<weeks;w++){const col=[];\n for(let d=0;d<7;d++){const iso=cur.toISOString().slice(0,10);\n col.push({date:iso,v:byDate.get(iso)||0});cur.setDate(cur.getDate()+1);}\n cols.push(col);}\n const rects=cols.map((col,w)=>col.map((c,d)=>\n `<rect x=\"${w*14}\" y=\"${d*14}\" width=\"12\" height=\"12\" rx=\"2.5\"\n fill=\"${c.v?\"var(--accent)\":\"rgba(20,24,20,0.07)\"}\" fill-opacity=\"${c.v?(0.25+0.75*c.v/max).toFixed(2):1}\"\n stroke=\"rgba(20,24,20,0.15)\" stroke-width=\"0.5\"><title>${c.date} — ${fmtFull.format(c.v)}</title></rect>`).join(\"\")).join(\"\");\n return `<div class=\"viz-card hm\"><h2>${esc(h.title)}</h2><div class=\"viz-sub\">26 dernières semaines</div>\n <div class=\"tbl-wrap\"><svg viewBox=\"0 0 ${weeks*14} ${7*14}\" role=\"img\" aria-label=\"${esc(h.title)}\">${rects}</svg></div></div>`;\n}\n\n/* --- tableau : tri par colonne, recherche, pagination 25/pg --- */\nfunction mountTable(el,spec){\n const st=ST.tbl[spec.id]||(ST.tbl[spec.id]={q:\"\",sort:null,page:0});\n el.innerHTML=`<div class=\"viz-card dtable\">\n <div style=\"display:flex;flex-wrap:wrap;gap:10px;justify-content:space-between;align-items:center\">\n <h2>${esc(spec.title)}</h2>\n <input class=\"dt-q\" placeholder=\"Rechercher…\" value=\"${esc(st.q)}\" aria-label=\"Rechercher dans ${esc(spec.title)}\">\n </div><div class=\"dt-body\"></div></div>`;\n const body=el.querySelector(\".dt-body\"),qi=el.querySelector(\".dt-q\");\n const num=x=>typeof x===\"number\"?x:parseFloat(String(x).replace(/[^\\d.,-]/g,\"\").replace(\",\",\".\"));\n const render=()=>{\n let rows=spec.rows||[];\n if(st.q){const q=st.q.toLowerCase();\n rows=rows.filter(r=>r.some(c=>String(c).toLowerCase().includes(q)));}\n if(st.sort){const{col,dir}=st.sort;\n rows=[...rows].sort((a,b)=>{const nx=num(a[col]),ny=num(b[col]);\n if(!Number.isNaN(nx)&&!Number.isNaN(ny))return(nx-ny)*dir;\n return String(a[col]).localeCompare(String(b[col]),\"fr\")*dir;});}\n const pages=Math.max(1,Math.ceil(rows.length/25));\n st.page=Math.min(st.page,pages-1);const cur=st.page;\n body.innerHTML=`<div class=\"tbl-wrap\" style=\"margin-top:12px\"><table><thead><tr>\n ${spec.columns.map((c,i)=>`<th data-col=\"${i}\" aria-sort=\"${st.sort&&st.sort.col===i?(st.sort.dir===1?\"ascending\":\"descending\"):\"none\"}\">${esc(c)} ${st.sort&&st.sort.col===i?(st.sort.dir===1?\"▲\":\"▼\"):\"↕\"}</th>`).join(\"\")}\n </tr></thead><tbody>\n ${rows.slice(cur*25,(cur+1)*25).map(r=>`<tr>${r.map(c=>`<td>${typeof c===\"number\"?fmtFull.format(c):esc(c)}</td>`).join(\"\")}</tr>`).join(\"\")||`<tr><td colspan=\"${spec.columns.length}\" style=\"color:var(--ink-3)\">Aucun résultat.</td></tr>`}\n </tbody></table></div>\n <div class=\"dtable-foot\"><span class=\"klabel\">${fmtFull.format(rows.length)} ligne${rows.length>1?\"s\":\"\"}</span>\n <span style=\"display:flex;gap:6px;align-items:center\">\n <button type=\"button\" class=\"btn btn-ghost dt-prev\"${cur===0?\" disabled\":\"\"}>←</button>\n <span class=\"chip\">${cur+1} / ${pages}</span>\n <button type=\"button\" class=\"btn btn-ghost dt-next\"${cur>=pages-1?\" disabled\":\"\"}>→</button></span></div>`;\n body.querySelectorAll(\"th\").forEach(th=>th.addEventListener(\"click\",()=>{\n const col=+th.dataset.col;\n st.sort={col,dir:st.sort&&st.sort.col===col&&st.sort.dir===1?-1:1};render();}));\n const prev=body.querySelector(\".dt-prev\"),next=body.querySelector(\".dt-next\");\n prev.addEventListener(\"click\",()=>{st.page=Math.max(0,cur-1);render()});\n next.addEventListener(\"click\",()=>{st.page=Math.min(pages-1,cur+1);render()});\n };\n qi.addEventListener(\"input\",deb(()=>{st.q=qi.value;st.page=0;render()},250));\n render();\n}\n\n/* --- barre de période + boutons PDF --- */\nfunction periodBar(){\n const customOn=!!(ST.from&&ST.to);\n return `<div class=\"pchips\" role=\"group\" aria-label=\"Période d'analyse\">\n ${PERIODS_UI.map(([id,l])=>`<button type=\"button\" class=\"pchip\" data-period=\"${id}\" aria-pressed=\"${!customOn&&ST.period===id}\">${l}</button>`).join(\"\")}\n <span class=\"pdates\"><input type=\"date\" id=\"pfrom\" value=\"${esc(ST.from)}\" aria-label=\"Du\">\n <span class=\"klabel\">au</span><input type=\"date\" id=\"pto\" value=\"${esc(ST.to)}\" aria-label=\"Au\"></span></div>`;\n}\nfunction bindPeriod(){\n document.querySelectorAll(\".pchip\").forEach(b=>b.addEventListener(\"click\",()=>{\n ST.period=b.dataset.period;ST.from=\"\";ST.to=\"\";loadStatsData();}));\n const pf=$(\"#pfrom\"),pt=$(\"#pto\");\n const go=()=>{if(pf.value&&pt.value){ST.from=pf.value;ST.to=pt.value;loadStatsData();}};\n pf.addEventListener(\"change\",go);pt.addEventListener(\"change\",go);\n}\n/* --- menu PDF : 5 rapports (équivalent vanilla du PdfButton v2) --- */\nconst PDF_MODES=[\n [\"synthese\",\"Synthèse exécutive\",\"Couverture, KPI, jauges & records (2-3 p.)\"],\n [\"tendances\",\"Tendances & évolution\",\"Toutes les séries + min/max/moy/méd/σ\"],\n [\"repartitions\",\"Répartitions & géographie\",\"Anneaux, distributions, géo, activité horaire\"],\n [\"donnees\",\"Données détaillées\",\"Tous les tableaux en version longue (400 lignes)\"]];\nfunction pdfActionsHtml(){\n return `<span class=\"pdf-actions pdf-menu-wrap\">\n <button type=\"button\" class=\"btn btn-primary\" data-pdf=\"complet\">⬇ Rapport PDF complet</button>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"pdf-more\" aria-haspopup=\"true\" aria-expanded=\"false\">Autres rapports ▾</button>\n <div class=\"pdf-menu\" id=\"pdf-menu\" role=\"menu\">\n ${PDF_MODES.map(([m,l,d])=>`<button type=\"button\" role=\"menuitem\" data-pdf=\"${m}\">${l}<small>${d}</small></button>`).join(\"\")}\n </div></span>`;\n}\nfunction bindPdf(){\n const url=m=>{const p=new URLSearchParams({period:ST.period,mode:m});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n return \"/api/stats/report?\"+p;};\n const menu=document.getElementById(\"pdf-menu\"),more=document.getElementById(\"pdf-more\");\n if(more&&menu){\n more.addEventListener(\"click\",e=>{e.stopPropagation();\n const open=menu.classList.toggle(\"open\");more.setAttribute(\"aria-expanded\",open);\n if(open)document.addEventListener(\"click\",()=>{menu.classList.remove(\"open\");\n more.setAttribute(\"aria-expanded\",\"false\");},{once:true});});}\n document.querySelectorAll(\"[data-pdf]\").forEach(b=>{\n b.addEventListener(\"click\",async()=>{\n if(menu)menu.classList.remove(\"open\");\n const t=b.innerHTML;b.disabled=true;\n b.innerHTML=b.closest(\".pdf-menu\")?\"Génération…\":\"⏳ Génération…\";\n try{const r=await fetch(url(b.dataset.pdf));if(!r.ok)throw new Error(r.status);\n const blob=await r.blob();\n const fn=((r.headers.get(\"Content-Disposition\")||\"\").match(/filename=\"?([^\";]+)/)||[])[1]||\"rapport-crea-ka.pdf\";\n const a=document.createElement(\"a\");a.href=URL.createObjectURL(blob);a.download=fn;\n document.body.appendChild(a);a.click();a.remove();\n setTimeout(()=>URL.revokeObjectURL(a.href),4000);}\n catch(e){alert(\"Échec de la génération du PDF — réessayez.\");}\n b.disabled=false;b.innerHTML=t;});});\n}\n\nasync function renderStats(){\n document.title=\"Statistiques — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"stats\");bindNav();\n await loadStatsData(true);\n window.scrollTo(0,0);\n}\nasync function loadStatsData(first){\n const main=$(\"#stats-main\");if(main)main.style.opacity=\".45\";\n let d;\n try{const p=new URLSearchParams({period:ST.period});\n if(ST.from&&ST.to){p.set(\"from\",ST.from);p.set(\"to\",ST.to);}\n d=await api(\"/api/stats/dashboard?\"+p);}\n catch(e){app.innerHTML=header()+`<div class=\"container empty\">Statistiques indisponibles pour le moment.</div>`+footer()+tabbar(\"stats\");bindNav();return}\n ST.data=d;renderStatsPage();\n}\nfunction renderStatsPage(){\n const d=ST.data;\n const upd=new Date(d.updated).toLocaleString(\"fr-CA\",{dateStyle:\"medium\",timeStyle:\"short\"});\n const donuts=(d.breakdowns||[]).filter(b=>b.kind===\"donut\");\n const barsB=(d.breakdowns||[]).filter(b=>b.kind!==\"donut\");\n const gauges=(d.gauges||[]).filter(g=>typeof g.value===\"number\"&&g.max);\n const hasEvo=(d.series||[]).length||(d.multiseries||[]).length||(d.stacked||[]).length;\n app.innerHTML=header()+`\n <div class=\"container stats-page\" id=\"stats-main\">\n <div class=\"stats-head\">\n <div><span class=\"kicker\">Tableau de bord — ${esc(d.period.label)}</span>\n <h1 style=\"font-size:clamp(28px,4.6vw,46px);margin:12px 0 0;text-transform:uppercase\">Les créateurs québécois <span class=\"hl\">en chiffres.</span></h1>\n <div class=\"fresh-row\"><span>Mis à jour le ${esc(upd)}</span>\n <button type=\"button\" class=\"btn btn-ghost\" id=\"st-refresh\">↻ Rafraîchir</button></div></div>\n ${pdfActionsHtml()}\n </div>\n <div class=\"tiles\">${(d.kpis||[]).map(kpiHtml).join(\"\")||emptyBlock(\"Indicateurs\")}</div>\n ${periodBar()}\n <p class=\"klabel\" style=\"margin:6px 0 0\">Période analysée : ${esc(d.period.from)} → ${esc(d.period.to)}</p>\n ${gauges.length?`<h2 class=\"stats-sect\">Taux & couvertures</h2>\n <div class=\"gauges\">${gauges.map(gaugeHtml).join(\"\")}</div>`:\"\"}\n <h2 class=\"stats-sect\">Évolution</h2>\n ${hasEvo?`<div class=\"viz-grid\">\n ${(d.series||[]).map(s=>`<div class=\"lc\" data-serie=\"${esc(s.id)}\"></div>`).join(\"\")}\n ${(d.multiseries||[]).map(m=>`<div class=\"lc\" data-multi=\"${esc(m.id)}\"></div>`).join(\"\")}\n ${(d.stacked||[]).map(st=>stackedHtml(st)).join(\"\")}\n </div>`:emptyBlock(\"Évolution\")}\n <h2 class=\"stats-sect\">Répartitions & distributions</h2>\n <div class=\"viz-grid two\">\n ${donuts.map(donutHtml).join(\"\")}\n ${barsB.map(b=>barsHtml(b.title,b.items)).join(\"\")}\n ${(d.distributions||[]).map(vbarsHtml).join(\"\")}\n </div>\n ${d.geo?`<h2 class=\"stats-sect\">Répartition géographique</h2>\n <div class=\"viz-grid two\">${barsHtml(d.geo.title,d.geo.items)}</div>`:\"\"}\n ${d.heatmap||d.hourly?`<h2 class=\"stats-sect\">Activité</h2>\n ${d.heatmap?heatmapHtml(d.heatmap):\"\"}${d.hourly?hourHeatHtml(d.hourly):\"\"}`:\"\"}\n ${(d.tables||[]).length?`<h2 class=\"stats-sect\">Détails</h2><div style=\"display:grid;gap:22px\">${(d.tables||[]).map(t=>`<div class=\"dt\" data-table=\"${esc(t.id)}\"></div>`).join(\"\")}</div>`:\"\"}\n ${(d.records||[]).length?`<h2 class=\"stats-sect\">Records & faits marquants</h2>\n <div class=\"records-grid\">${d.records.map(r=>`<div class=\"record\"><span class=\"r-lbl\">${esc(r.label)}</span>\n <span style=\"text-align:right\"><span class=\"r-val\">${esc(r.value)}</span>${r.date?`<span class=\"klabel\" style=\"display:block\">${esc(r.date)}</span>`:\"\"}</span></div>`).join(\"\")}</div>`:\"\"}\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:30px\">Données réelles de l'annuaire (fiches actives, comptes vérifiés) — aucune statistique estimée ou inventée.\n Rapports PDF estampillés Groupe-KA (5 formats) disponibles en haut de page.</p>\n </div>`+footer()+tabbar(\"stats\");\n bindNav();bindPeriod();bindPdf();\n $(\"#st-refresh\").addEventListener(\"click\",()=>loadStatsData());\n (ST.data.series||[]).forEach(s=>{const el=document.querySelector(`.lc[data-serie=\"${s.id}\"]`);if(el)mountLine(el,s);});\n (ST.data.multiseries||[]).forEach(m=>{const el=document.querySelector(`.lc[data-multi=\"${m.id}\"]`);if(el)mountMulti(el,m);});\n (ST.data.tables||[]).forEach(t=>{const el=document.querySelector(`.dt[data-table=\"${t.id}\"]`);if(el)mountTable(el,t);});\n}\n\n/* ---------- page compte (KA ID) ---------- */\nasync function renderCompte(){\n document.title=\"Mon compte KA — Créa-Ka\";\n app.innerHTML=header()+`<div class=\"container\"><div class=\"spin\"></div></div>`+footer()+tabbar(\"\");bindNav();\n if(state.me===undefined){\n try{const r=await fetch(\"/api/me\");state.me=r.ok?await r.json():null}\n catch(e){state.me=null}}\n const me=state.me;\n if(!me){\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">KA ID — un seul compte pour tout le groupe</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Connectez-vous avec votre <span class=\"hl\">KA ID.</span></h1>\n <p style=\"color:var(--ink-2)\">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et\n les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>\n <div class=\"compte-actions\">\n <a class=\"btn btn-primary\" href=\"/api/auth/ka/login\">Se connecter avec KA ID</a>\n <a class=\"btn btn-accent\" href=\"https://www.groupe-ka.com/connexion\" target=\"_blank\" rel=\"noopener\">Créer un compte KA ID ↗</a>\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com\" target=\"_blank\" rel=\"noopener\">Qu'est-ce que KA ID ?</a>\n </div>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();return;\n }\n const init=(me.name||\"?\").split(/\\s+/).slice(0,2).map(w=>w[0]||\"\").join(\"\").toUpperCase();\n app.innerHTML=header()+`\n <div class=\"container compte-page\">\n <span class=\"kicker\">Mon compte</span>\n <h1 style=\"font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase\">Carte de <span class=\"hl\">membre.</span></h1>\n <div class=\"membercard\">\n <div class=\"mc-brand\">Groupe KA · carte de membre</div>\n <div class=\"mc-row\">\n <div class=\"mc-avatar\">${me.picture?`<img src=\"${esc(me.picture)}\" alt=\"\">`:esc(init)}</div>\n <div><div class=\"mc-name\">${esc(me.name||\"Membre\")}</div>\n <div class=\"mc-kaid\">${esc(me.ka_id||\"\")}</div>\n ${me.role_label||me.role?`<span class=\"mc-role\">${esc(me.role_label||me.role)}</span>`:\"\"}</div>\n </div>\n <div class=\"mc-meta\">\n ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:\"\"}\n ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:\"\"}\n ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(\" · \"))}</span>`:\"\"}\n ${me.bio?`<span>${esc(me.bio)}</span>`:\"\"}\n </div>\n </div>\n <div class=\"compte-actions\">\n <a class=\"btn btn-ghost\" href=\"https://www.groupe-ka.com/compte\" target=\"_blank\" rel=\"noopener\">Modifier sur groupe-ka.com ↗</a>\n ${me.public_url?`<a class=\"btn btn-ghost\" href=\"${esc(me.public_url)}\" target=\"_blank\" rel=\"noopener\">Profil public ↗</a>`:\"\"}\n <button class=\"btn btn-primary\" id=\"logout\">Se déconnecter</button>\n </div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:18px\">Le profil est dicté par le hub\n groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();loadAccount();\n $(\"#logout\").addEventListener(\"click\",async()=>{\n await fetch(\"/api/auth/logout\",{method:\"POST\"}).catch(()=>{});\n state.me=null;history.pushState(null,\"\",\"/\");route();});\n}\n\n/* ---------- page contact (écosystème Groupe KA — ka/ecosystem.json) ---------- */\nfunction renderContact(){\n document.title=\"Contact — Créa-Ka, un service Groupe KA\";\n const sites=KA_ECO.sites.map(s=>{\n const cur=s.id===\"crea-ka\";\n return `<a class=\"site-item\" href=\"https://${esc(s.domain)}\"${cur?\"\":' target=\"_blank\" rel=\"noopener noreferrer\"'}\n title=\"${esc(s.tagline)}\"><i style=\"background:${esc(s.accent)}\"></i><b>${esc(s.wordmark)}${cur?\" (ici)\":\"\"}</b><span>${esc(s.domain)}</span></a>`;\n }).join(\"\");\n app.innerHTML=header()+`\n <div class=\"container contact-page\">\n <span class=\"kicker\">Contact — Groupe KA</span>\n <h1>Nous <span class=\"hl\">joindre.</span></h1>\n <p class=\"lead\">${esc(KA_SITE.wordmark)} — ${esc(KA_SITE.tagline)} — est un service du\n <b>Groupe KA</b>. ${esc(KA_ECO.org.tagline)} Les contacts ci-dessous valent pour\n l'ensemble des plateformes du groupe.</p>\n <div class=\"contact-notice\">${esc(KA_ECO.org.disclaimer)}</div>\n <h2 class=\"sect-title\" style=\"margin-top:26px\">Courriels</h2>\n <div class=\"contact-grid\">\n ${KA_ECO.contacts.map(c=>`<a class=\"contact-card\" href=\"mailto:${esc(c.email)}\">\n <b>${esc(c.email)}</b><span>${esc(c.role)}</span></a>`).join(\"\")}\n </div>\n <div class=\"compte-actions\" style=\"margin-top:22px\">\n <a class=\"btn btn-primary\" href=\"${esc(KA_ECO.hub.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">Visiter le hub groupe-ka.com ↗</a>\n <a class=\"btn btn-ghost\" href=\"/retrait\" data-nav>Demander un retrait (Loi 25)</a>\n </div>\n <h2 class=\"sect-title\">Les plateformes du groupe</h2>\n <div class=\"site-list\">${sites}</div>\n <p style=\"font-size:12px;color:var(--ink-3);margin-top:20px\">${esc(KA_ECO.hub.signupNote)}</p>\n </div>`+footer()+tabbar(\"\");\n bindNav();window.scrollTo(0,0);\n}\n\n/* ---------- retrait / opt-out ---------- */\nfunction renderOptout(){\n document.title=\"Retrait (opt-out) — Créa-Ka\";\n app.innerHTML=header()+`\n <div class=\"container optout-page\">\n <span class=\"kicker\">Vie privée d'abord</span>\n <h1>Demander un <span class=\"hl\">retrait</span></h1>\n <p>Vous êtes créateur ou créatrice et souhaitez ne plus figurer dans l'annuaire ?\n La fiche est <b>masquée immédiatement</b> et exclue de toute ré-agrégation future.\n Une correction d'erreur (mauvais compte rattaché) passe par le même formulaire.</p>\n <form id=\"of\">\n <div class=\"field\"><label>Nom public / nom de scène</label><input id=\"o-name\" maxlength=\"200\" placeholder=\"ex. Prénom Nom\"></div>\n <div class=\"field\"><label>Un de vos comptes (plateforme:handle)</label><input id=\"o-acc\" maxlength=\"200\" placeholder=\"ex. instagram:monhandle\"></div>\n <div class=\"field\"><label>Raison (facultatif)</label><textarea id=\"o-reason\" rows=\"3\" maxlength=\"500\"></textarea></div>\n <div class=\"field\"><label>Courriel de contact (facultatif, pour confirmation)</label><input id=\"o-contact\" maxlength=\"200\" type=\"email\"></div>\n <div class=\"field\"><button class=\"btn btn-primary\" type=\"submit\">Masquer ma fiche</button></div>\n </form><div id=\"o-result\"></div>\n </div>`+footer()+tabbar(\"optout\");\n bindNav();\n $(\"#of\").addEventListener(\"submit\",async e=>{e.preventDefault();\n const body={name:$(\"#o-name\").value.trim(),account:$(\"#o-acc\").value.trim(),\n reason:$(\"#o-reason\").value.trim(),contact:$(\"#o-contact\").value.trim()};\n if(!body.name&&!body.account){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Indiquez au moins un nom ou un compte.</div>`;return}\n try{const r=await fetch(\"/api/optout\",{method:\"POST\",headers:{\"Content-Type\":\"application/json\"},body:JSON.stringify(body)});\n const j=await r.json();\n $(\"#o-result\").innerHTML=`<div class=\"optout-ok\">✓ ${esc(j.message||\"Demande enregistrée.\")} ${j.masked?`(${j.masked} fiche(s) masquée(s))`:\"\"}</div>`;\n e.target.reset();}\n catch(err){$(\"#o-result\").innerHTML=`<div class=\"optout-ok warn\">Erreur — réessayez ou écrivez à contact@spboucher.ai.</div>`}});\n}\n\n/* ---------- routeur ---------- */\nfunction bindNav(root){\n (root||document).querySelectorAll(\"[data-nav]\").forEach(a=>{\n if(a._nav)return;a._nav=1;\n a.addEventListener(\"click\",e=>{const href=a.getAttribute(\"href\");\n e.preventDefault();history.pushState(null,\"\",href);route();\n if(href.endsWith(\"#annuaire\")){const el=$(\"#annuaire\");if(el){el.scrollIntoView();const q=$(\"#q\");if(q)q.focus()}}})});\n (root||document).querySelectorAll(\"[data-href]\").forEach(card=>{\n if(card._nav)return;card._nav=1;\n const go=()=>{history.pushState(null,\"\",card.dataset.href);route()};\n card.addEventListener(\"click\",e=>{if(e.target.closest(\"a\"))return;go()});\n card.addEventListener(\"keydown\",e=>{if(e.key===\"Enter\")go()});\n });}\nfunction route(){\n const p=location.pathname;\n const m=p.match(/^\\/createur\\/([^/]+)$/);\n let r;\n if(m)r=renderCreator(decodeURIComponent(m[1]));\n else if(p===\"/retrait\")r=renderOptout();\n else if(p===\"/stats\")r=renderStats();\n else if(p===\"/compte\")r=renderCompte();\n else if(p===\"/contact\")r=renderContact();\n else r=renderHome();\n Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});\n}\nwindow.addEventListener(\"popstate\",route);\nroute();\n</script>\n<script>window.KA_AGENT={site:\"crea-ka\"}</script>\n<script src=\"/ka-agent.js\" defer></script>\n</body>\n</html>\n","structuredPatch":[{"oldStart":1072,"oldLines":6,"newStart":1072,"newLines":7,"lines":[" app.innerHTML=header()+`"," <div class=\"container cpage\">"," <a class=\"backlink\" href=\"/\" data-nav>← Tous les créateurs</a>","+ ${banner?`<div class=\"cbanner\"><img src=\"${esc(banner)}\" alt=\"\" referrerpolicy=\"no-referrer\" onerror=\"this.parentElement.remove()\"></div>`:\"\"}"," <div class=\"cpage-head\">"," <div class=\"cavatar\">${esc(initials(c.display_name))}${photoImg(c)}</div>"," <div style=\"flex:1;min-width:200px\"><h1>${esc(c.display_name)}${verified?checkSvg:\"\"}</h1>"]}],"userModified":false,"replaceAll":false}}} |
|
535 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2ba2c7e3-6f20-4b5f-9abc-619b462d6b5a","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
536 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1GAy9fgDWDeNpZm4K1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS7gsKpAEIERgCKkA/ldrE4eZEWnfGVLX23buH1fqHIhsqKZYIG2/M6El/XL+Z5aDhmGzKyMOq+wnRHT8kBlChsYI4ew4oajB+SgXzMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGVzJ7UBhIMUNLx1fnUY6b5wOwgGgxTu5QQEMOVeUj1sgQiMFOBNVzzmTSpSKFHadbxa2gtK9yiE9j5kqcz11O9RUPUyG/A0Q7auoH9Ep2rWGECtSr2CWSYQhtNDghgraVnF5pefzdzl0edF7t9fy5Ug26MVtZZUi1ybQBiXMSUUHFff9krZ9uyyEa/0fd/vqj3hvaD2uJNOL0tlskL7CxbyEAhpCv5ntGtFEq4H8AwwJhTPirOl5cNvXNH8CS2FIxIE6c68eZ3eNQCHcS9nVhPEMIJHumtTyCPV+tfyN25ywnRixyvpa0gBbxfCWtPwhwUBtKbEC/9V4aEs33qaC6esDhgId2vVWxxWZiY8bnsUv/d5FMlJM6z9SBOq1p7e5b0o7yDa3mhpLlwVD0ozf6X2/eBuXwrOyT54kS0ukWQTSD9JvoGzpFqFBU7gEcS74TQdgCqE5+0qqsxojIF5x3Ps04MZR5HYhMucI3jACvKEkwFkprbLLupH77dDJH7tFltpkAV7BbSGjTNCOXtYXibM067KkupDVxtJAMz4/OJgBO3gp9LGeN4VLcLB8S48w8Vv9YUK87ZDeDjvpqIC3wNc3DgWij/gkVcYXGgSMwCHA3t183HmDMWCpsCJqxSYgKcdR8h+vKxXFQ4azog0zjy9I/Nw2WZvCYIVO9Kl+cs+Pqdr8Gd4NBVjwAyKCOGGeumE4VXaDxUUtCItAu70SdsFmYJ1AMNK2nQPvkTWfe9u6Y5BLnovoug/l4HugnYXl+k1QH3KFKBUoxvELhQaDkzVl712gG/cmVhn57kSHZJ0lMu2sDwy0Lj1rgl3dKaP9/3K1aRm6zS8j8R2Nz/WXURBkDD3fVdw8UJ73yT5ake1MRBcjxD5ejOeu/lS67nl5rxJT2QRtMX9DxHDnjPQ0odj9BPXeMqT7bCtAmSuqzvP3Cp/Q6ZKPxugF0hpF7YKgoPOoLwjGWXuRkIpyTje3CLxj2a1BLK4ECex0pi7sjj8RfQxa9gk5fhT8Yc3qtv2EcngelywxtfpP+eE6ZeqgHITDwL9OMGxagKSdBK2nImQeJ6uqf5W+jhmo6eLAvMKdh8uzoMfUt2kfgrWBSkYciS5V/MrOH6ddsN4TwUbgEtiS+YI1gZBNREMFgIsXuLyLH6NtltB3BkfDPN+7WVPKHrVohCmlgsMLdduWa/93c1sRCq2QnRRQRVomtaXIHbA0ivFcWb+SSITLtDLxThaQDHzGomkSnxWvNswK1m/PvQC8i0WSXCVuRb9nB/dqf6jedWgrCZZ3/hMejciaMeF6B1LQekYBk2wM0oT84t2JqkmiR3e1eGdOBarv3xW3L/BYcgvJbj6N1JzJI6kAY1S6lvALR3XgDAE6QaxMP6css2vO9iAoJ2VUJUmGDfjIKSgi7PqiFjRvNXhxI0lqB2Ogd8VedQ/F3w3oB/x7PYvrsGK0PJgyTmzyqriZyRKnhBk4liVImzRpcdab0l/tKPQlxBiNM3aaEqpUnlR8uZpaey/UMSMA9ia4QwpEOS0xtc8tWIF1FVZMhM6olKxWvCFmLxmyfHVnbXNYdU9nInbU3PRw9yVX/jom0eoANJGVB3jzJ+psi+tOlGA1J3EENIs6zfCHlW3n4KfqH+pCUod3fijGNbN8PugDZVlau3Gwzz+4+tZETXHxrOeYj3xfrem3UKFr8aKAK45ullP04rZ28VOqL/JSXUOKSaZqOC9HzkOoUIxNCKSHNI+4WRuCGZaDFdzgWj73axv5TWxpcLMyt3Whj1KV9NaxxP2DJIgCOvyjAo+b6ysLOMJWa0azAYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1002,"cache_read_input_tokens":136380,"cache_creation":{"ephemeral_5m_input_tokens":1002,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e8d9c665-0b04-4b69-b668-5771f354e425"}} |
|
537 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1GAy9fgDWDeNpZm4K1","type":"message","role":"assistant","content":[{"type":"text","text":"Je rebuild le frontend et vérifie la syntaxe."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1002,"cache_read_input_tokens":136380,"cache_creation":{"ephemeral_5m_input_tokens":1002,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"561bd656-24de-421c-b1b0-57003018bbe9"}} |
|
538 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1GAy9fgDWDeNpZm4K1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018nTUcxBWDzfHr54CRhykbj","name":"Bash","input":{"command":"cd ~/apps/crea-ka && python3 scripts/build_frontend.py && node -e \"\nconst fs=require('fs');\nconst html=fs.readFileSync('frontend/dist/index.html','utf8');\nconst m=html.match(/<script>([\\s\\S]*)<\\/script>/);\nconst scripts=[...html.matchAll(/<script[^>]*>([\\s\\S]*?)<\\/script>/g)];\nfor(const s of scripts){try{new Function(s[1])}catch(e){console.error('JS SYNTAX FAIL:',e.message);process.exit(1)}}\nconsole.log('JS OK,',scripts.length,'blocs');\nfor(const k of ['cbanner','kascore','sparkSvg','live-card','growth_7d'])\n console.log(k, html.includes(k)?'✓':'✗ MANQUANT');\n\"","description":"Rebuild frontend and validate JS syntax + new features present"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1002,"cache_read_input_tokens":136380,"cache_creation":{"ephemeral_5m_input_tokens":1002,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"caacd16e-0793-4689-b265-4ca6d192a1f1"}} |
|
539 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018nTUcxBWDzfHr54CRhykbj","type":"tool_result","content":"[crea-ka] frontend construit → /Users/simon-pierreboucher/apps/crea-ka/frontend/dist/index.html (173 Ko, 22 icônes, tokens ka-ui + écosystème injectés)\nJS OK, 3 blocs\ncbanner ✓\nkascore ✓\nsparkSvg ✓\nlive-card ✓\ngrowth_7d ✓","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"be4a1a0e-8d8d-4cbc-aeeb-33a2e8fa61e1","timestamp":"2026-08-21T01:12:57.328Z","tool_use_result":{"stdout":"[crea-ka] frontend construit → /Users/simon-pierreboucher/apps/crea-ka/frontend/dist/index.html (173 Ko, 22 icônes, tokens ka-ui + écosystème injectés)\nJS OK, 3 blocs\ncbanner ✓\nkascore ✓\nsparkSvg ✓\nlive-card ✓\ngrowth_7d ✓","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
540 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"49438bd6-77e3-4999-a98b-a5fc0de95ac1","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
541 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1HHjULvbsaGTuoN64x","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AVdeTWJSNH4tKWuZktn8pK","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"completed","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"in_progress","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":988,"cache_read_input_tokens":137382,"cache_creation":{"ephemeral_5m_input_tokens":988,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"b590954f-dfd0-40d8-9907-047ae3c0f996"}} |
|
542 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AVdeTWJSNH4tKWuZktn8pK","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"1c40e724-f146-480f-87b9-19d54c6e5baa","timestamp":"2026-08-21T01:13:07.819Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"in_progress","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"pending","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"completed","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"in_progress","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
543 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1HHjULvbsaGTuoN64x","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BKWyBhwMzrnsmbQZsGKfjw","name":"Bash","input":{"command":"pm2 list | grep -i crea","description":"List crea-ka PM2 processes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":988,"cache_read_input_tokens":137382,"cache_creation":{"ephemeral_5m_input_tokens":988,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"4a9bd5eb-c493-4436-9718-e214ccc00e38"}} |
|
544 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BKWyBhwMzrnsmbQZsGKfjw","type":"tool_result","content":"│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.0mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5676 │ 68m │ 6 │ online │ 0% │ 170.6mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 5687 │ 68m │ 26 │ online │ 0% │ 114.6mb │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"9f427d0d-7fc0-40fd-bfce-fb8c0717f72e","timestamp":"2026-08-21T01:13:08.425Z","tool_use_result":{"stdout":"│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.0mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5676 │ 68m │ 6 │ online │ 0% │ 170.6mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 5687 │ 68m │ 26 │ online │ 0% │ 114.6mb │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
545 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e1a3c1b5-ab9d-4c8a-80e3-f8f03403c2e0","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
546 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1J6KAWvYjbGKZ7ymVW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016SJS1dMtWUSSQ8MCkN2Xop","name":"Bash","input":{"command":"pm2 restart crea-ka-web && sleep 3 && curl -s http://localhost:8160/healthz && echo && curl -s \"http://localhost:8160/api/creators/sarah-jade-bleau\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nins=d.get('insights') or {}\nprint('banner_url:', bool(d.get('banner_url')))\nprint('ka_score:', ins.get('ka_score'), ins.get('ka_score_parts'))\nprint('growth_30d:', ins.get('growth_30d'), '| hist pts:', len((ins.get('history') or {}).get('total') or []))\n\"","description":"Restart crea-ka-web and healthcheck API with insights"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1153,"cache_read_input_tokens":138370,"cache_creation":{"ephemeral_5m_input_tokens":1153,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"84b42afd-9b55-4178-90ef-f11763b2ea4e"}} |
|
547 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bzo0um12z","tool_use_id":"toolu_016SJS1dMtWUSSQ8MCkN2Xop","description":"Restart crea-ka-web and healthcheck API with insights","task_type":"local_bash","uuid":"ef0fe6ba-d054-4352-a319-30b75f5475b1","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
548 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016SJS1dMtWUSSQ8MCkN2Xop","type":"tool_result","content":"Use --update-env to update environment variables\n[PM2] Applying action restartProcessId on app [crea-ka-web](ids: [ 87 ])\n[PM2] [crea-ka-web](87) ✓\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 15D │ 0 │ online │ 0% │ 171.5mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 15D │ 13 │ online │ 0% │ 173.2mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 8D │ 14 │ online │ 0% │ 88.6mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 8D │ 0 │ online │ 0% │ 67.1mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 44h │ 21 │ online │ 0% │ 711.9mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 92.4mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 2D │ 6 │ online │ 0% │ 7.1gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 14D │ 6 │ online │ 0% │ 164.9mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 14D │ 3 │ online │ 0% │ 906.8mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 14D │ 0 │ online │ 0% │ 147.6mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.0mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5676 │ 69m │ 6 │ online │ 0% │ 170.1mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 0s │ 27 │ online │ 0% │ 5.7mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 11D │ 0 │ online │ 0% │ 40.3mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 11D │ 0 │ online │ 0% │ 56.7mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.5mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 68.7mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 45.2mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 106.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 44h │ 69 │ online │ 0% │ 321.3mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 196.3mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 96 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 15D │ 6 │ online │ 0% │ 147.4mb │ simon-p… │ disabled │\n│ 1 │ immbot-ngrok │ default │ N/A │ fork │ 76976 │ 16D │ 1 │ online │ 0% │ 70.4mb │ simon-p… │ disabled │\n│ 64 │ khaelor-sh │ default │ N/A │ fork │ 13042 │ 10D │ 2 │ online │ 0% │ 43.3mb │ simon-p… │ disabled │\n│ 65 │ khaelor-sh-ngrok │ default │ N/A │ fork │ 21045 │ 11D │ 0 │ online │ 0% │ 58.5mb │ simon-p… │ disabled │\n│ 16 │ llmindex-benchmark │ default │ 4.23.5 │ fork │ 0 │ 0 │ 0 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 9 │ llmindex-ngrok │ default │ N/A │ fork │ 93363 │ 15D │ 0 │ online │ 0% │ 213.8mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 37h │ 19 │ online │ 0% │ 474.9mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 15D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 8D │ 13 │ online │ 0% │ 90.1mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 8D │ 0 │ online │ 0% │ 91.5mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 14D │ 0 │ online │ 0% │ 348.3mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 716.4mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 25h │ 65 │ online │ 0% │ 881.8mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 8D │ 18 │ online │ 0% │ 72.6mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 8D │ 0 │ online │ 0% │ 64.9mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 25h │ 18 │ online │ 0% │ 160.7mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 99.8mb │ simon-p… │ disabled │\n│ 86 │ resto-ka-sync │ default │ N/A │ fork │ 24658 │ 2D │ 6 │ online │ 0% │ 235.6mb │ simon-p… │ disabled │\n│ 66 │ spbdrive-server │ default │ 2.1.0 │ fork │ 54545 │ 10D │ 3 │ online │ 0% │ 88.9mb │ simon-p… │ disabled │\n│ 68 │ spbdrive-tunnel │ default │ N/A │ fork │ 58322 │ 11D │ 0 │ online │ 0% │ 60.2mb │ simon-p… │ disabled │\n│ 70 │ svgarden-tunnel │ default │ N/A │ fork │ 34955 │ 10D │ 1 │ online │ 0% │ 75.0mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.7mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 14D │ 0 │ online │ 0% │ 39.5mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 14D │ 0 │ online │ 0% │ 57.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\n{\"ok\":true}\nbanner_url: False\nka_score: 86.7 {'audience': 40.0, 'engagement': 25.0, 'presence': 16.0, 'rythme': 0.7, 'verification': 5.0}\ngrowth_30d: None | hist pts: 1","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"73e13862-f61c-45bc-98bf-bde609cb74a2","timestamp":"2026-08-21T01:13:19.558Z","tool_use_result":{"stdout":"Use --update-env to update environment variables\n[PM2] Applying action restartProcessId on app [crea-ka-web](ids: [ 87 ])\n[PM2] [crea-ka-web](87) ✓\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 15D │ 0 │ online │ 0% │ 171.5mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 15D │ 13 │ online │ 0% │ 173.2mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 8D │ 14 │ online │ 0% │ 88.6mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 8D │ 0 │ online │ 0% │ 67.1mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 44h │ 21 │ online │ 0% │ 711.9mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 92.4mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 2D │ 6 │ online │ 0% │ 7.1gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 14D │ 6 │ online │ 0% │ 164.9mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 14D │ 3 │ online │ 0% │ 906.8mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 14D │ 0 │ online │ 0% │ 147.6mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.0mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5676 │ 69m │ 6 │ online │ 0% │ 170.1mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 0s │ 27 │ online │ 0% │ 5.7mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 11D │ 0 │ online │ 0% │ 40.3mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 11D │ 0 │ online │ 0% │ 56.7mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.5mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 68.7mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 45.2mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 106.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 44h │ 69 │ online │ 0% │ 321.3mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 196.3mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 96 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 15D │ 6 │ online │ 0% │ 147.4mb │ simon-p… │ disabled │\n│ 1 │ immbot-ngrok │ default │ N/A │ fork │ 76976 │ 16D │ 1 │ online │ 0% │ 70.4mb │ simon-p… │ disabled │\n│ 64 │ khaelor-sh │ default │ N/A │ fork │ 13042 │ 10D │ 2 │ online │ 0% │ 43.3mb │ simon-p… │ disabled │\n│ 65 │ khaelor-sh-ngrok │ default │ N/A │ fork │ 21045 │ 11D │ 0 │ online │ 0% │ 58.5mb │ simon-p… │ disabled │\n│ 16 │ llmindex-benchmark │ default │ 4.23.5 │ fork │ 0 │ 0 │ 0 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 9 │ llmindex-ngrok │ default │ N/A │ fork │ 93363 │ 15D │ 0 │ online │ 0% │ 213.8mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 37h │ 19 │ online │ 0% │ 474.9mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 15D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 8D │ 13 │ online │ 0% │ 90.1mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 8D │ 0 │ online │ 0% │ 91.5mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 14D │ 0 │ online │ 0% │ 348.3mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 716.4mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 25h │ 65 │ online │ 0% │ 881.8mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 8D │ 18 │ online │ 0% │ 72.6mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 8D │ 0 │ online │ 0% │ 64.9mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 25h │ 18 │ online │ 0% │ 160.7mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 99.8mb │ simon-p… │ disabled │\n│ 86 │ resto-ka-sync │ default │ N/A │ fork │ 24658 │ 2D │ 6 │ online │ 0% │ 235.6mb │ simon-p… │ disabled │\n│ 66 │ spbdrive-server │ default │ 2.1.0 │ fork │ 54545 │ 10D │ 3 │ online │ 0% │ 88.9mb │ simon-p… │ disabled │\n│ 68 │ spbdrive-tunnel │ default │ N/A │ fork │ 58322 │ 11D │ 0 │ online │ 0% │ 60.2mb │ simon-p… │ disabled │\n│ 70 │ svgarden-tunnel │ default │ N/A │ fork │ 34955 │ 10D │ 1 │ online │ 0% │ 75.0mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.7mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 14D │ 0 │ online │ 0% │ 39.5mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 14D │ 0 │ online │ 0% │ 57.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\n{\"ok\":true}\nbanner_url: False\nka_score: 86.7 {'audience': 40.0, 'engagement': 25.0, 'presence': 16.0, 'rythme': 0.7, 'verification': 5.0}\ngrowth_30d: None | hist pts: 1","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
549 |
+{"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bzo0um12z","tool_use_id":"toolu_016SJS1dMtWUSSQ8MCkN2Xop","status":"completed","output_file":"","summary":"Restart crea-ka-web and healthcheck API with insights","uuid":"9e5fb5de-e7a4-4987-bbac-d0016012e0bd","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
550 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"78f13dad-b1e4-41e4-8e70-ddfb2d25a62d","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
551 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1JyAa9D6EKXXsyPJNK","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiwUKpAEIERgCKkAV+heWqX2vqjYuie8EJOTU24G9TMKLs7uGxm+sQgq5anprdLJJcl2hcPk8DV+NlN8WQw1+1dr41rEzR5JYkpLIMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG4zJ7UBhIMgutU6YHHGNo3o56uGgxfVdc2cg5cnnkf5ZMiMJeBbUYQiqdFGyv7GwCPWYd7JkgxZ61GnDSkix7Z91znhD3ctc8xXWqyBMgDcPd9vSqTA2gd6nowAW00OXIu/x8xSE39ZGRSmxxFUa/Sw/HULYob4hk5XJ9cGv7VQ5/aPr09KElEKr/Cot7x7Tg65ydgza04fzSg2anZu/cQmGuuyeLaQgW2AH7fYVxmBf6u7albUQrBWUsd+7F7MKvqDwNSJDjZ81dU5jYEtE2WuOVxOeHotvwFKruddv10weGfv3UlL5WcPQN0lQAvtb2KdTZKUQRMl8tvLB79tRsxfQMzC/7PTpwtZi+ir3TMeaC1xowwHDRtw+FqJTFTp0SvmpwZjebRUUvGENd9ExXmjNFgK1YzIl+55YiTkhk+4qp9A/uzI4FZzwJq38nKUnssak/71aQofUYBiUi5PDylJ6fU7NBmRRZqx045H5eml5hHLgmmzgKxvHsG5Et6F8Kcwow56ni++gGRel9k1wzcjxYZgUdvhz0bZAbtoeyYWevymKUUvXhHZrr1T+/Zzu4rP6BruH+haAf4TS83me8kD872immdSGEmA9Y/9SOsY9xTwk21JLnkUrefOu5e8VcJfKlrAIUqP9IYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4505,"cache_read_input_tokens":139523,"cache_creation":{"ephemeral_5m_input_tokens":4505,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"ec007868-f64f-4f44-9262-7901bd9b57e2"}} |
|
552 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1JyAa9D6EKXXsyPJNK","type":"message","role":"assistant","content":[{"type":"text","text":"API en ligne avec insights et Ka Score servis. Je redémarre le sync pour qu'il reparte avec le pipeline étendu (les bannières/covers se rempliront au fil du passage), puis je committe."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4505,"cache_read_input_tokens":139523,"cache_creation":{"ephemeral_5m_input_tokens":4505,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"59db9633-709a-4855-9cf0-1058c6a7c9f9"}} |
|
553 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1JyAa9D6EKXXsyPJNK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JYZNsvUo7QNhHq8GsChZUe","name":"Bash","input":{"command":"pm2 restart crea-ka-sync --update-env 2>&1 | grep -E \"crea-ka-sync|✓\" | head -3; sleep 4; pm2 logs crea-ka-sync --lines 5 --nostream 2>/dev/null | tail -6","description":"Restart crea-ka-sync and check startup logs"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4505,"cache_read_input_tokens":139523,"cache_creation":{"ephemeral_5m_input_tokens":4505,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"751cbb51-aa67-4a68-bc2d-a12418606485"}} |
|
554 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1JyAa9D6EKXXsyPJNK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015FffXiWbNVW78QMSCr4YWS","name":"Bash","input":{"command":"cd ~/apps/crea-ka && git status --short && git log --oneline -3","description":"Git status before commit"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4505,"cache_read_input_tokens":139523,"cache_creation":{"ephemeral_5m_input_tokens":4505,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"643bb837-9daa-4bb8-9519-a88d1d983471"}} |
|
555 |
+{"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"b6qomavcs","tool_use_id":"toolu_01JYZNsvUo7QNhHq8GsChZUe","description":"Restart crea-ka-sync and check startup logs","task_type":"local_bash","uuid":"2f41d30f-1c88-44da-9cca-22285da576f4","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
556 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JYZNsvUo7QNhHq8GsChZUe","type":"tool_result","content":"[PM2] Applying action restartProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 4206 │ 0s │ 7 │ online │ 0% │ 3.6mb │ simon-p… │ disabled │\n94|crea-ka | [crea-ka] {'source': 'wikipedia-qc', 'creators': 120, 'accounts': 262, 'added': 0, 'updated': 120, 'optout_skipped': 0, 'avg_confidence': 0.85, 'seconds': 98.8}\n94|crea-ka | [crea-ka] sync youtube-recherche (discovery) ...\n94|crea-ka | [crea-ka] sync instagram-decouverte (discovery) ...\n94|crea-ka | [crea-ka] ↷ instagram-decouverte sauté : topsearch Instagram exige une session connectée (voie publique fermée le 2026-08-18)\n94|crea-ka | [crea-ka] sync balados-itunes (discovery) ...","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"6ce7d0df-16cf-40f1-907e-cf9bc161fc6c","timestamp":"2026-08-21T01:13:35.098Z","tool_use_result":{"stdout":"[PM2] Applying action restartProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 4206 │ 0s │ 7 │ online │ 0% │ 3.6mb │ simon-p… │ disabled │\n94|crea-ka | [crea-ka] {'source': 'wikipedia-qc', 'creators': 120, 'accounts': 262, 'added': 0, 'updated': 120, 'optout_skipped': 0, 'avg_confidence': 0.85, 'seconds': 98.8}\n94|crea-ka | [crea-ka] sync youtube-recherche (discovery) ...\n94|crea-ka | [crea-ka] sync instagram-decouverte (discovery) ...\n94|crea-ka | [crea-ka] ↷ instagram-decouverte sauté : topsearch Instagram exige une session connectée (voie publique fermée le 2026-08-18)\n94|crea-ka | [crea-ka] sync balados-itunes (discovery) ...","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
557 |
+{"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"b6qomavcs","tool_use_id":"toolu_01JYZNsvUo7QNhHq8GsChZUe","status":"completed","output_file":"","summary":"Restart crea-ka-sync and check startup logs","uuid":"d7c8ef81-5585-4e2d-869a-85b87e0935ba","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
558 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015FffXiWbNVW78QMSCr4YWS","type":"tool_result","content":" M creaka/connectors/apify_social.py\n M creaka/db.py\n M creaka/schema.py\n M creaka/stats.py\n M creaka/web.py\n M frontend/dist/index.html\n M frontend/src/index.template.html\n?? actors/\ne7c36ce Seeds vague 3 : +392 créateurs QC (Serper niches fines + linktree/beacons), -53 marques/médias purgés de la vague 2 — revue de moine : wall2wallmtl (434 muralistes internationaux) et 947 candidats écartés\n7489ccd Périmètre QC : garde-fous snowball-ig + purge 4 605 fiches hors-Québec\n9b0a685 palmares-apple V4 : balayage des tops 100 par genre (le top général CA est devenu 100 % anglophone → rendement 0 et alertes) ; préfiltre nom/artiste/résumé, attribution QC inchangée, métriques chart_genre/chart_rank_genre","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"183aade0-eca7-4c68-b673-8040dd02faca","timestamp":"2026-08-21T01:13:35.137Z","tool_use_result":{"stdout":" M creaka/connectors/apify_social.py\n M creaka/db.py\n M creaka/schema.py\n M creaka/stats.py\n M creaka/web.py\n M frontend/dist/index.html\n M frontend/src/index.template.html\n?? actors/\ne7c36ce Seeds vague 3 : +392 créateurs QC (Serper niches fines + linktree/beacons), -53 marques/médias purgés de la vague 2 — revue de moine : wall2wallmtl (434 muralistes internationaux) et 947 candidats écartés\n7489ccd Périmètre QC : garde-fous snowball-ig + purge 4 605 fiches hors-Québec\n9b0a685 palmares-apple V4 : balayage des tops 100 par genre (le top général CA est devenu 100 % anglophone → rendement 0 et alertes) ; préfiltre nom/artiste/résumé, attribution QC inchangée, métriques chart_genre/chart_rank_genre","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
559 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"22cc2038-75c7-49a1-a032-4a3669abfbdd","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
560 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1L5UccGMeBedqQSoWx","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlAYKpAEIERgCKkDesRUkICqaOPXBf4ildjCqohUTqMqAbspgDSjfK5FUhG2NmIOQC1611EWQNDgwJtESLtlEHYbkC8hTjXKQ8NWTMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHMzJ7UBhIMCSiANwbLfZLKlPOgGgzW8cBZKLtcmpdNaW0iMNGEkkiCtL1RlYT1NYxQcy4AoJCU1Q7hepSUEdtYjk2yVxyIU5wVFSa1bViRhuvBqyqcBI9GRBN8nY10YvOfKKjHs84yK+ad6qYZ/qgLlUoREDUmTg+/zryv2+v/shiOWJVf6vUUCs4MOPV4tx+Q3NyOdfNOWvvSFhOOSczhxZDmjJEC418z/sHRe/eD6DhuDRFGWk0tV40DGNrGza4k4fha5koKiF74FNce9LJ28mshqvTVL6a6bP7DfzPUYh5Cw2N7yFd1vOWeNJJoygIVM0LNd3l1vU7h0l0WKtk5FAKivaRW6j36L/LRkPF7Htobq7tJAEM2Gx53n88EUe4ravHEXwfH0K6VQBuPVgSat6Sgnz8VBPIWf0quGwVCxYZZ6GEq5GmyHk6EoCzdBlTVKiEdB3ko6IydS0PzIZnVF5s5CAFwIE2FloG99tagbf7dunEEtE9nSjF2iWvZkRq6Th1nYCb6qpcI8NgkpvUA/rjLT1NrLSLWya7bgHgHFryN5QNahuGL8sc8IqI1jKrBlvswpmkTsXRJwY3SNYgC9pkhuP+WURS82p/ol/t5+9H5OIb0HGpcxOnRbSDgNzIBMXqdVOM9CakzIHWjH4IEVK7Rog6v7czntBxfsSUzBZKKQXQbKEDNrCz5Yrmi2pHluYK4lisfE/9foRRmTcpWMPatASYDUW5vm+JYJ4QfPzmGPSwgDk/HwnzA9KWmDJ/lPLDLcIAjZY8p3hxwQEykBtOTDQUzbEL/7LW9PyYPOrD+q0l9NrsUVfy82gyjI73VbhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1274,"cache_read_input_tokens":144028,"cache_creation":{"ephemeral_5m_input_tokens":1274,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"7210f698-e5ff-4110-ad48-12e4c9f8c022"}} |
|
561 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1L5UccGMeBedqQSoWx","type":"message","role":"assistant","content":[{"type":"text","text":"Le sync est reparti (passage complet en cours). Avant de committer, je vérifie les en-têtes d'auteur dans `actors/` (règle §17)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1274,"cache_read_input_tokens":144028,"cache_creation":{"ephemeral_5m_input_tokens":1274,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5c557e8b-0fdf-4110-a3dc-b6cbe1cdde6f"}} |
|
562 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1L5UccGMeBedqQSoWx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015rHcByAWbge3P9pPBuELgi","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && find . -type f | grep -v __pycache__ | while read f; do head -3 \"$f\" | grep -q \"Simon-Pierre Boucher\" || echo \"SANS EN-TÊTE: $f\"; done | sort | head -40","description":"Find actor files missing author header"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1274,"cache_read_input_tokens":144028,"cache_creation":{"ephemeral_5m_input_tokens":1274,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"30aa2a1b-7de5-428d-9c46-241d6c2d8eb1"}} |
|
563 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015rHcByAWbge3P9pPBuELgi","type":"tool_result","content":"SANS EN-TÊTE: ./ka-discord/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-discord/.actor/actor.json\nSANS EN-TÊTE: ./ka-discord/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-discord/requirements.txt\nSANS EN-TÊTE: ./ka-discord/src/__main__.py\nSANS EN-TÊTE: ./ka-facebook/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-facebook/.actor/actor.json\nSANS EN-TÊTE: ./ka-facebook/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-facebook/requirements.txt\nSANS EN-TÊTE: ./ka-facebook/src/__main__.py\nSANS EN-TÊTE: ./ka-fansly/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-fansly/.actor/actor.json\nSANS EN-TÊTE: ./ka-fansly/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-fansly/requirements.txt\nSANS EN-TÊTE: ./ka-fansly/src/__main__.py\nSANS EN-TÊTE: ./ka-instagram/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-instagram/.actor/actor.json\nSANS EN-TÊTE: ./ka-instagram/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-instagram/requirements.txt\nSANS EN-TÊTE: ./ka-instagram/src/__main__.py\nSANS EN-TÊTE: ./ka-kick/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-kick/.actor/actor.json\nSANS EN-TÊTE: ./ka-kick/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-kick/requirements.txt\nSANS EN-TÊTE: ./ka-kick/src/__main__.py\nSANS EN-TÊTE: ./ka-onlyfans/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-onlyfans/.actor/actor.json\nSANS EN-TÊTE: ./ka-onlyfans/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-onlyfans/requirements.txt\nSANS EN-TÊTE: ./ka-onlyfans/src/__main__.py\nSANS EN-TÊTE: ./ka-patreon/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-patreon/.actor/actor.json\nSANS EN-TÊTE: ./ka-patreon/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-patreon/requirements.txt\nSANS EN-TÊTE: ./ka-patreon/src/__main__.py\nSANS EN-TÊTE: ./ka-snapchat/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-snapchat/.actor/actor.json\nSANS EN-TÊTE: ./ka-snapchat/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-snapchat/requirements.txt\nSANS EN-TÊTE: ./ka-snapchat/src/__main__.py","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"56b6bb15-8d62-4eac-a43c-1acabaff6917","timestamp":"2026-08-21T01:13:50.875Z","tool_use_result":{"stdout":"SANS EN-TÊTE: ./ka-discord/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-discord/.actor/actor.json\nSANS EN-TÊTE: ./ka-discord/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-discord/requirements.txt\nSANS EN-TÊTE: ./ka-discord/src/__main__.py\nSANS EN-TÊTE: ./ka-facebook/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-facebook/.actor/actor.json\nSANS EN-TÊTE: ./ka-facebook/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-facebook/requirements.txt\nSANS EN-TÊTE: ./ka-facebook/src/__main__.py\nSANS EN-TÊTE: ./ka-fansly/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-fansly/.actor/actor.json\nSANS EN-TÊTE: ./ka-fansly/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-fansly/requirements.txt\nSANS EN-TÊTE: ./ka-fansly/src/__main__.py\nSANS EN-TÊTE: ./ka-instagram/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-instagram/.actor/actor.json\nSANS EN-TÊTE: ./ka-instagram/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-instagram/requirements.txt\nSANS EN-TÊTE: ./ka-instagram/src/__main__.py\nSANS EN-TÊTE: ./ka-kick/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-kick/.actor/actor.json\nSANS EN-TÊTE: ./ka-kick/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-kick/requirements.txt\nSANS EN-TÊTE: ./ka-kick/src/__main__.py\nSANS EN-TÊTE: ./ka-onlyfans/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-onlyfans/.actor/actor.json\nSANS EN-TÊTE: ./ka-onlyfans/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-onlyfans/requirements.txt\nSANS EN-TÊTE: ./ka-onlyfans/src/__main__.py\nSANS EN-TÊTE: ./ka-patreon/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-patreon/.actor/actor.json\nSANS EN-TÊTE: ./ka-patreon/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-patreon/requirements.txt\nSANS EN-TÊTE: ./ka-patreon/src/__main__.py\nSANS EN-TÊTE: ./ka-snapchat/.actor/Dockerfile\nSANS EN-TÊTE: ./ka-snapchat/.actor/actor.json\nSANS EN-TÊTE: ./ka-snapchat/.actor/input_schema.json\nSANS EN-TÊTE: ./ka-snapchat/requirements.txt\nSANS EN-TÊTE: ./ka-snapchat/src/__main__.py","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
564 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5e5c0cd3-682f-4c01-b72c-decd4be85b33","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
565 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1ME1qv7ZFEQUFYwdeM","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013EeouDQUMvt7g7qs3jeBUg","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && for d in ka-*/; do a=\"${d%/}\"\n# Dockerfile + requirements.txt + __main__.py : en-tête # ; JSON : pas de commentaires possibles → exemptés\nfor f in \"$d.actor/Dockerfile\" \"$d\"requirements.txt; do\n [ -f \"$f\" ] && ! head -3 \"$f\" | grep -q \"Simon-Pierre\" && { printf '# ==============================================================================\\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\\n# File: %s (%s)\\n# Desc: Acteur Apify KA — %s\\n# ==============================================================================\\n' \"$(basename $f)\" \"$a\" \"$(basename $f)\" | cat - \"$f\" > /tmp/hdr && mv /tmp/hdr \"$f\"; }\ndone\nf=\"$d\"src/__main__.py\n[ -f \"$f\" ] && ! head -3 \"$f\" | grep -q \"Simon-Pierre\" && { printf '# ==============================================================================\\n# Author: Simon-Pierre Boucher <contact@spboucher.ai>\\n# File: src/__main__.py (%s)\\n# Desc: Point d'\"'\"'entrée de l'\"'\"'acteur (asyncio.run).\\n# ==============================================================================\\n' \"$a\" | cat - \"$f\" > /tmp/hdr && mv /tmp/hdr \"$f\"; }\ndone\nfind . -type f \\( -name Dockerfile -o -name requirements.txt -o -name __main__.py \\) | while read f; do head -3 \"$f\" | grep -q \"Simon-Pierre\" || echo \"ENCORE SANS: $f\"; done; echo FAIT","description":"Add author headers to Dockerfile/requirements/__main__.py of all actors"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1619,"cache_read_input_tokens":145302,"cache_creation":{"ephemeral_5m_input_tokens":1619,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f47923c7-07fc-4c44-8747-2168a35b2717"}} |
|
566 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013EeouDQUMvt7g7qs3jeBUg","type":"tool_result","content":"FAIT","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"5f9044b2-e3b8-4bac-bb5d-fb36bf68cd39","timestamp":"2026-08-21T01:14:06.307Z","tool_use_result":{"stdout":"FAIT","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
567 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"dbd2b45f-d5ca-470b-a883-918418964104","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
568 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1NMvdPeqBCiAwh6tJP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISuwYKpAEIERgCKkArBEpEbNdIbDAarNnqlsdXjo/7qhoVFIJ6lGwxWtNbvhiz2MJfeqixV6HQiM8YmO1O/M9FE4cIGWSvIyj7Wd7nMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHlzJ7UBhIMpmmIAN9FgrKZOzTIGgwDnbesxO1bXrmBewUiMETN4CThWxfCb2UOBqkdj7ioVkwlvODkY+jOXnETSQGgMYQ+flPPFLrAHhlZzvyxjCrDBP3JhpkXw2mhxc65fSUApX/dbj/R+ZZbV8oa7Zb1mFQj7cGa+QrTBNb92evK1f+t0yArySbTPkJBSiZPjB7PFLbfyOD8zyY2WXYiXUQ2mq0khfZMGJelFVQVjwRgfZKexxAs8KODPay4uWJVexVeSXsRe7nsKBJW600OrKRiYFH2RcvBjDGQVEP2HE0tY1N+ZB4w2IrMMwztLbR3zwkZLp2ueHKKsJpmBSXWs3246BH92bL/WPNeheQugfpTU/AlV2FBftQvaF9MisfTyesu2/K2JY5TeXBKndczmy8AzCg21n6obea1bfyRb50IxNQ1xO3z8spEkz5Q12OO8tJ1+iLmdz9+w6XU4w0TmsaWj9xKEKGBYGjw/0XAdbmY7Q/nuK60LMnEXvxBJSyXjTCpgHogc+zgzYydMAglqe0cwhNQiJdF5RDlscq+0vn4d4vcRIoZSm4Mje71XDtILBI4yqdI0SahQooFRvV970j+sJmxZhUcnzQW47aY7rTBnz4vrZ4orXu1oXIK6jh1EDq+Q8wMMeMUJRs05maJKA5pNfxc/iU5Q5b6ehWSA1/8qzuZjUaNwJTFpBjduXkDwft93mePmfeA1AXXyZBEAQqOfECAyFIuMBP/R0CNXVL70KjetGpdcz+UyL+Qcs8+3jJb1vPls4u51DO7wZERG9kI8kIVa+oy2ojkJBHtdrvWXLd+3EIjQrt1FnKNMgXaPFRTut6co/sHYoFLFbA+0ukUNI6z0zOXZJ/KgRFTXnLz599a0X+P1hgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":689,"cache_read_input_tokens":146921,"cache_creation":{"ephemeral_5m_input_tokens":689,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f7a46c7f-6d55-4c6b-9292-94376024079d"}} |
|
569 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1NMvdPeqBCiAwh6tJP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AW8mWXXFMPD8QFm4GYVDYQ","name":"Bash","input":{"command":"cd ~/apps/crea-ka && python3 -m py_compile actors/ka-*/src/*.py creaka/connectors/apify_social.py creaka/db.py creaka/schema.py creaka/stats.py creaka/web.py && echo COMPILE_OK && git add actors creaka/connectors/apify_social.py creaka/db.py creaka/schema.py creaka/stats.py creaka/web.py frontend/src/index.template.html frontend/dist/index.html && git commit -m \"$(cat <<'EOF'\nFiches légendaires : upgrade majeur des 13 acteurs Apify (bannières, covers TikTok, miniatures YouTube/Twitch, media X, previews Snapchat, /about YouTube, socialMedias Twitch GQL, liens sociaux Patreon) + pipeline étendu (banner_url, metric_maps, cross-links), snapshots quotidiens d'audience, insights/Ka Score /100 par créateur (croissance 7j/30j, engagement pondéré, rythme), fiche frontend enrichie (bannière héro, Ka Score, sparkline, bloc live) — acteurs versionnés dans actors/, 8 poussés et validés par runs de test\n\nCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>\nEOF\n)\" && git push origin main 2>&1 | tail -2","description":"Compile check, commit and push to origin main (spbgit)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":689,"cache_read_input_tokens":146921,"cache_creation":{"ephemeral_5m_input_tokens":689,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"14b135b1-e029-46d5-beaa-fdff6e8356ca"}} |
|
570 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AW8mWXXFMPD8QFm4GYVDYQ","type":"tool_result","content":"COMPILE_OK\n[main 8fa08f4] Fiches légendaires : upgrade majeur des 13 acteurs Apify (bannières, covers TikTok, miniatures YouTube/Twitch, media X, previews Snapchat, /about YouTube, socialMedias Twitch GQL, liens sociaux Patreon) + pipeline étendu (banner_url, metric_maps, cross-links), snapshots quotidiens d'audience, insights/Ka Score /100 par créateur (croissance 7j/30j, engagement pondéré, rythme), fiche frontend enrichie (bannière héro, Ka Score, sparkline, bloc live) — acteurs versionnés dans actors/, 8 poussés et validés par runs de test\n 98 files changed, 4340 insertions(+), 13 deletions(-)\n create mode 100644 actors/ka-discord/.actor/Dockerfile\n create mode 100644 actors/ka-discord/.actor/actor.json\n create mode 100644 actors/ka-discord/.actor/input_schema.json\n create mode 100644 actors/ka-discord/requirements.txt\n create mode 100644 actors/ka-discord/src/__main__.py\n create mode 100644 actors/ka-discord/src/main.py\n create mode 100644 actors/ka-discord/src/net.py\n create mode 100644 actors/ka-facebook/.actor/Dockerfile\n create mode 100644 actors/ka-facebook/.actor/actor.json\n create mode 100644 actors/ka-facebook/.actor/input_schema.json\n create mode 100644 actors/ka-facebook/requirements.txt\n create mode 100644 actors/ka-facebook/src/__main__.py\n create mode 100644 actors/ka-facebook/src/main.py\n create mode 100644 actors/ka-facebook/src/net.py\n create mode 100644 actors/ka-fansly/.actor/Dockerfile\n create mode 100644 actors/ka-fansly/.actor/actor.json\n create mode 100644 actors/ka-fansly/.actor/input_schema.json\n create mode 100644 actors/ka-fansly/requirements.txt\n create mode 100644 actors/ka-fansly/src/__main__.py\n create mode 100644 actors/ka-fansly/src/main.py\n create mode 100644 actors/ka-fansly/src/net.py\n create mode 100644 actors/ka-instagram/.actor/Dockerfile\n create mode 100644 actors/ka-instagram/.actor/actor.json\n create mode 100644 actors/ka-instagram/.actor/input_schema.json\n create mode 100644 actors/ka-instagram/requirements.txt\n create mode 100644 actors/ka-instagram/src/__main__.py\n create mode 100644 actors/ka-instagram/src/main.py\n create mode 100644 actors/ka-instagram/src/net.py\n create mode 100644 actors/ka-kick/.actor/Dockerfile\n create mode 100644 actors/ka-kick/.actor/actor.json\n create mode 100644 actors/ka-kick/.actor/input_schema.json\n create mode 100644 actors/ka-kick/requirements.txt\n create mode 100644 actors/ka-kick/src/__main__.py\n create mode 100644 actors/ka-kick/src/main.py\n create mode 100644 actors/ka-kick/src/net.py\n create mode 100644 actors/ka-onlyfans/.actor/Dockerfile\n create mode 100644 actors/ka-onlyfans/.actor/actor.json\n create mode 100644 actors/ka-onlyfans/.actor/input_schema.json\n create mode 100644 actors/ka-onlyfans/requirements.txt\n create mode 100644 actors/ka-onlyfans/src/__main__.py\n create mode 100644 actors/ka-onlyfans/src/main.py\n create mode 100644 actors/ka-onlyfans/src/net.py\n create mode 100644 actors/ka-patreon/.actor/Dockerfile\n create mode 100644 actors/ka-patreon/.actor/actor.json\n create mode 100644 actors/ka-patreon/.actor/input_schema.json\n create mode 100644 actors/ka-patreon/requirements.txt\n create mode 100644 actors/ka-patreon/src/__main__.py\n create mode 100644 actors/ka-patreon/src/main.py\n create mode 100644 actors/ka-patreon/src/net.py\n create mode 100644 actors/ka-snapchat/.actor/Dockerfile\n create mode 100644 actors/ka-snapchat/.actor/actor.json\n create mode 100644 actors/ka-snapchat/.actor/input_schema.json\n create mode 100644 actors/ka-snapchat/requirements.txt\n create mode 100644 actors/ka-snapchat/src/__main__.py\n create mode 100644 actors/ka-snapchat/src/main.py\n create mode 100644 actors/ka-snapchat/src/net.py\n create mode 100644 actors/ka-threads/.actor/Dockerfile\n create mode 100644 actors/ka-threads/.actor/actor.json\n create mode 100644 actors/ka-threads/.actor/input_schema.json\n create mode 100644 actors/ka-threads/requirements.txt\n create mode 100644 actors/ka-threads/src/__main__.py\n create mode 100644 actors/ka-threads/src/main.py\n create mode 100644 actors/ka-threads/src/net.py\n create mode 100644 actors/ka-tiktok/.actor/Dockerfile\n create mode 100644 actors/ka-tiktok/.actor/actor.json\n create mode 100644 actors/ka-tiktok/.actor/input_schema.json\n create mode 100644 actors/ka-tiktok/requirements.txt\n create mode 100644 actors/ka-tiktok/src/__main__.py\n create mode 100644 actors/ka-tiktok/src/main.py\n create mode 100644 actors/ka-tiktok/src/net.py\n create mode 100644 actors/ka-twitch/.actor/Dockerfile\n create mode 100644 actors/ka-twitch/.actor/actor.json\n create mode 100644 actors/ka-twitch/.actor/input_schema.json\n create mode 100644 actors/ka-twitch/requirements.txt\n create mode 100644 actors/ka-twitch/src/__main__.py\n create mode 100644 actors/ka-twitch/src/main.py\n create mode 100644 actors/ka-twitch/src/net.py\n create mode 100644 actors/ka-x/.actor/Dockerfile\n create mode 100644 actors/ka-x/.actor/actor.json\n create mode 100644 actors/ka-x/.actor/input_schema.json\n create mode 100644 actors/ka-x/requirements.txt\n create mode 100644 actors/ka-x/src/__main__.py\n create mode 100644 actors/ka-x/src/main.py\n create mode 100644 actors/ka-x/src/net.py\n create mode 100644 actors/ka-youtube/.actor/Dockerfile\n create mode 100644 actors/ka-youtube/.actor/actor.json\n create mode 100644 actors/ka-youtube/.actor/input_schema.json\n create mode 100644 actors/ka-youtube/requirements.txt\n create mode 100644 actors/ka-youtube/src/__main__.py\n create mode 100644 actors/ka-youtube/src/main.py\n create mode 100644 actors/ka-youtube/src/net.py\nTo gitsrv:srv/git/crea-ka.git\n e7c36ce..8fa08f4 main -> main","is_error":false}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2f4fc4d1-318e-48c2-8f75-50e4cfeea501","timestamp":"2026-08-21T01:14:20.396Z","tool_use_result":{"stdout":"COMPILE_OK\n[main 8fa08f4] Fiches légendaires : upgrade majeur des 13 acteurs Apify (bannières, covers TikTok, miniatures YouTube/Twitch, media X, previews Snapchat, /about YouTube, socialMedias Twitch GQL, liens sociaux Patreon) + pipeline étendu (banner_url, metric_maps, cross-links), snapshots quotidiens d'audience, insights/Ka Score /100 par créateur (croissance 7j/30j, engagement pondéré, rythme), fiche frontend enrichie (bannière héro, Ka Score, sparkline, bloc live) — acteurs versionnés dans actors/, 8 poussés et validés par runs de test\n 98 files changed, 4340 insertions(+), 13 deletions(-)\n create mode 100644 actors/ka-discord/.actor/Dockerfile\n create mode 100644 actors/ka-discord/.actor/actor.json\n create mode 100644 actors/ka-discord/.actor/input_schema.json\n create mode 100644 actors/ka-discord/requirements.txt\n create mode 100644 actors/ka-discord/src/__main__.py\n create mode 100644 actors/ka-discord/src/main.py\n create mode 100644 actors/ka-discord/src/net.py\n create mode 100644 actors/ka-facebook/.actor/Dockerfile\n create mode 100644 actors/ka-facebook/.actor/actor.json\n create mode 100644 actors/ka-facebook/.actor/input_schema.json\n create mode 100644 actors/ka-facebook/requirements.txt\n create mode 100644 actors/ka-facebook/src/__main__.py\n create mode 100644 actors/ka-facebook/src/main.py\n create mode 100644 actors/ka-facebook/src/net.py\n create mode 100644 actors/ka-fansly/.actor/Dockerfile\n create mode 100644 actors/ka-fansly/.actor/actor.json\n create mode 100644 actors/ka-fansly/.actor/input_schema.json\n create mode 100644 actors/ka-fansly/requirements.txt\n create mode 100644 actors/ka-fansly/src/__main__.py\n create mode 100644 actors/ka-fansly/src/main.py\n create mode 100644 actors/ka-fansly/src/net.py\n create mode 100644 actors/ka-instagram/.actor/Dockerfile\n create mode 100644 actors/ka-instagram/.actor/actor.json\n create mode 100644 actors/ka-instagram/.actor/input_schema.json\n create mode 100644 actors/ka-instagram/requirements.txt\n create mode 100644 actors/ka-instagram/src/__main__.py\n create mode 100644 actors/ka-instagram/src/main.py\n create mode 100644 actors/ka-instagram/src/net.py\n create mode 100644 actors/ka-kick/.actor/Dockerfile\n create mode 100644 actors/ka-kick/.actor/actor.json\n create mode 100644 actors/ka-kick/.actor/input_schema.json\n create mode 100644 actors/ka-kick/requirements.txt\n create mode 100644 actors/ka-kick/src/__main__.py\n create mode 100644 actors/ka-kick/src/main.py\n create mode 100644 actors/ka-kick/src/net.py\n create mode 100644 actors/ka-onlyfans/.actor/Dockerfile\n create mode 100644 actors/ka-onlyfans/.actor/actor.json\n create mode 100644 actors/ka-onlyfans/.actor/input_schema.json\n create mode 100644 actors/ka-onlyfans/requirements.txt\n create mode 100644 actors/ka-onlyfans/src/__main__.py\n create mode 100644 actors/ka-onlyfans/src/main.py\n create mode 100644 actors/ka-onlyfans/src/net.py\n create mode 100644 actors/ka-patreon/.actor/Dockerfile\n create mode 100644 actors/ka-patreon/.actor/actor.json\n create mode 100644 actors/ka-patreon/.actor/input_schema.json\n create mode 100644 actors/ka-patreon/requirements.txt\n create mode 100644 actors/ka-patreon/src/__main__.py\n create mode 100644 actors/ka-patreon/src/main.py\n create mode 100644 actors/ka-patreon/src/net.py\n create mode 100644 actors/ka-snapchat/.actor/Dockerfile\n create mode 100644 actors/ka-snapchat/.actor/actor.json\n create mode 100644 actors/ka-snapchat/.actor/input_schema.json\n create mode 100644 actors/ka-snapchat/requirements.txt\n create mode 100644 actors/ka-snapchat/src/__main__.py\n create mode 100644 actors/ka-snapchat/src/main.py\n create mode 100644 actors/ka-snapchat/src/net.py\n create mode 100644 actors/ka-threads/.actor/Dockerfile\n create mode 100644 actors/ka-threads/.actor/actor.json\n create mode 100644 actors/ka-threads/.actor/input_schema.json\n create mode 100644 actors/ka-threads/requirements.txt\n create mode 100644 actors/ka-threads/src/__main__.py\n create mode 100644 actors/ka-threads/src/main.py\n create mode 100644 actors/ka-threads/src/net.py\n create mode 100644 actors/ka-tiktok/.actor/Dockerfile\n create mode 100644 actors/ka-tiktok/.actor/actor.json\n create mode 100644 actors/ka-tiktok/.actor/input_schema.json\n create mode 100644 actors/ka-tiktok/requirements.txt\n create mode 100644 actors/ka-tiktok/src/__main__.py\n create mode 100644 actors/ka-tiktok/src/main.py\n create mode 100644 actors/ka-tiktok/src/net.py\n create mode 100644 actors/ka-twitch/.actor/Dockerfile\n create mode 100644 actors/ka-twitch/.actor/actor.json\n create mode 100644 actors/ka-twitch/.actor/input_schema.json\n create mode 100644 actors/ka-twitch/requirements.txt\n create mode 100644 actors/ka-twitch/src/__main__.py\n create mode 100644 actors/ka-twitch/src/main.py\n create mode 100644 actors/ka-twitch/src/net.py\n create mode 100644 actors/ka-x/.actor/Dockerfile\n create mode 100644 actors/ka-x/.actor/actor.json\n create mode 100644 actors/ka-x/.actor/input_schema.json\n create mode 100644 actors/ka-x/requirements.txt\n create mode 100644 actors/ka-x/src/__main__.py\n create mode 100644 actors/ka-x/src/main.py\n create mode 100644 actors/ka-x/src/net.py\n create mode 100644 actors/ka-youtube/.actor/Dockerfile\n create mode 100644 actors/ka-youtube/.actor/actor.json\n create mode 100644 actors/ka-youtube/.actor/input_schema.json\n create mode 100644 actors/ka-youtube/requirements.txt\n create mode 100644 actors/ka-youtube/src/__main__.py\n create mode 100644 actors/ka-youtube/src/main.py\n create mode 100644 actors/ka-youtube/src/net.py\nTo gitsrv:srv/git/crea-ka.git\n e7c36ce..8fa08f4 main -> main","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} |
|
571 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2907d0a2-4d8f-4bb2-8890-de953ece039c","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
572 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1PQHnLjMrntjqLA1y4","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISqgUKpAEIERgCKkA60nO9mnKSBXvLVBc5Ji/C1x95sxXoMWrvWhm2426KY8dIRb+Tp0pHpq1Cf0/PJAvav3jT8r7qOuaqkoe4nq2iMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHzzJ7UBhIMXh8rKF9IM31CtILgGgzd8NGtgnjsLuL87ioiMGzUZsOAty0LYAxNk0bOvM+aqwl6MQfZa/gfUk2kKsyTzILZ0l3KnH6UpmEIc27cLCqyA+PY/y8sMWMek1aW0RW5DxUDwD6SyCjSFDw+Ktq3nYchIpaqO9NEcwafRLyFubjZP2dLxupIU2Zncl3CsJ/F4RGxkFxK+HxICt9OM/6WegALcxPRnOC6iaovHWn+irI/7M/7wsIto7VSQeJw+bP6oi6+MxiDbs7nkQsielF86y4kRMh4GARrD1XYeYHuW1hTxL5Y54IMney0GLydBPzFB/AgD8bD7uXxBIYxIfCbakWf2CVJlnggmKq2Ap+4VWZYz4gGYqf4wCDHwrRRMcW3Y9tfFjaQ821W+7WHJHhXnmwY9GpjFKXv4fvDGJ9gGIpFJNdkCyQsURpETK7m12MBQQS0RjrLtYukPyGOBPb30rA/L1QPoI+pAFwSuU02mbnxn7VVMLynKoWDm4r/ZkVsDPybqi0olbL4pD5ors/m6L4BBYSE4MqqQhcLqHI+AEYhiVoKhVwBTLTV8d1IBvYw64QOlldXbiJx3DZWMYRoqU4xAQSrF0rwyzbtagz3vWxYt+9Mlqle4wgQL1hUJNjlT7U0M5AkI4yuRjaN//xMWlEqarQkAwN8dadKMzz9Whorp4bFGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3457,"cache_read_input_tokens":147610,"cache_creation":{"ephemeral_5m_input_tokens":3457,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"905513e7-46fa-4455-b518-3bf5b1dce1d2"}} |
|
573 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1PQHnLjMrntjqLA1y4","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018BwiMX2BraYNxxrQM6eVXR","name":"TodoWrite","input":{"todos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"completed","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"completed","activeForm":"Redéployant et committant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":3457,"cache_read_input_tokens":147610,"cache_creation":{"ephemeral_5m_input_tokens":3457,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"2dcfac6a-b00b-49ff-9670-c1fe1d216f74"}} |
|
574 |
+{"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018BwiMX2BraYNxxrQM6eVXR","type":"tool_result","content":"Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable"}]},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"e30e9deb-8019-4bef-bb01-bb3dccec3a15","timestamp":"2026-08-21T01:14:33.459Z","tool_use_result":{"oldTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"completed","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"in_progress","activeForm":"Redéployant et committant"}],"newTodos":[{"content":"Tirer les sources des 13 acteurs Apify (apify pull) dans le repo","status":"completed","activeForm":"Tirant les acteurs"},{"content":"Analyser pipeline + acteurs pour cerner les lacunes","status":"completed","activeForm":"Analysant le pipeline"},{"content":"Upgrader les acteurs majeurs (tiktok, youtube, x, twitch)","status":"completed","activeForm":"Upgradant les acteurs majeurs"},{"content":"Upgrader les acteurs secondaires (kick, snapchat, patreon, discord)","status":"completed","activeForm":"Upgradant les acteurs secondaires"},{"content":"Pousser les 8 acteurs modifiés (apify push --force)","status":"completed","activeForm":"Poussant les acteurs"},{"content":"Runs de test de validation (ka-twitch GQL, ka-youtube /about)","status":"completed","activeForm":"Validant par runs de test"},{"content":"Étendre apify_social.py : banner_url schéma + metric_maps + bio_urls","status":"completed","activeForm":"Étendant apify_social.py"},{"content":"db.py : table snapshots + follower_history ; insights/Ka-Score ; web.py get_creator","status":"completed","activeForm":"Ajoutant snapshots + insights"},{"content":"Frontend : fiche créateur enrichie (bannière, galerie, stats, sparkline) + rebuild","status":"completed","activeForm":"Enrichissant le frontend"},{"content":"Redéployer (pm2 restart), healthcheck, commit + push origin main","status":"completed","activeForm":"Redéployant et committant"}],"verificationNudgeNeeded":false}}} |
|
575 |
+{"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"74d4b6bb-091b-42fe-ae4e-de492fab55a3","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca"}} |
|
576 |
+{"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeF1QN3Np4apKQgZnHmyC","type":"message","role":"assistant","content":[{"type":"text","text":"Terminé — commit `8fa08f4` poussé sur origin main (spbgit), tout est déployé et vérifié.\n\n## Récapitulatif du major upgrade\n\n**Acteurs Apify (8 modifiés, tous poussés et validés par runs de test)**\n- **ka-tiktok** : covers de vidéos, saves, durée, hashtags, musique, `videos_per_week`, `last_video_at`, `top_hashtags`\n- **ka-youtube** : bannière de chaîne, miniatures `i.ytimg.com` par vidéo, page `/about` (vues totales, date de création, pays, liens externes auto-déclarés), badge vérifié\n- **ka-x** : `media_urls` par tweet (jusqu'à 4 images)\n- **ka-twitch** : bannière, miniatures VOD, live (titre + miniature 640×360), liens sociaux du panneau À propos via GQL `socialMedias` — **validé en live sur lirik**\n- **ka-kick/snapchat/patreon/discord** : miniature+catégorie live, previews story/spotlight, cover+`creation_name`+liens sociaux, bannière/splash/vanity. Le push ka-discord bloqué a été réparé en ciblant l'ID plateforme explicite.\n\n**Pipeline (`apify_social.py`, `schema.py`)** : `banner_url` dans le schéma Creator, avatar/bannière rafraîchis à chaque passage depuis la plateforme principale (les CDN signés expirent), metric_maps étendus sur les 9 plateformes, nouveaux cross-links §12.1 depuis YouTube `/about`, Twitch et Patreon.\n\n**Stats légendaires** : table `snapshots` (un point audience/jour/compte, amorcée avec 10 311 comptes), `follower_history`, et `insights` sur `/api/creators/{cid}` : **Ka Score /100** (audience 40 · engagement 25 · multi-plateforme 20 · rythme 10 · vérification 5), croissance 7 j/30 j, engagement pondéré, rythme de publication, dernière activité.\n\n**Frontend** : bannière héro, carte Ka Score avec jauges, sparkline SVG d'évolution d'audience, KPIs de croissance ±, bloc « EN DIRECT » avec miniature, previews Snapchat et images de contenu élargies (covers/miniatures/media).\n\n**Déploiement** : frontend rebuildé, `crea-ka-web` redémarré (healthz OK, Ka Score servi — ex. sarah-jade-bleau 86,7/100), `crea-ka-sync` relancé — le passage en cours remplit bannières, covers et nouvelles métriques au fil de l'enrichissement (rotation 7 j pour couvrir tout le bassin)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":935,"cache_read_input_tokens":151067,"cache_creation":{"ephemeral_5m_input_tokens":935,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","uuid":"f7579974-4eca-4034-adf9-8e6a2d470a93"}} |
|
577 |
+{"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":1361357,"duration_api_ms":1346637,"num_turns":138,"result":"Terminé — commit `8fa08f4` poussé sur origin main (spbgit), tout est déployé et vérifié.\n\n## Récapitulatif du major upgrade\n\n**Acteurs Apify (8 modifiés, tous poussés et validés par runs de test)**\n- **ka-tiktok** : covers de vidéos, saves, durée, hashtags, musique, `videos_per_week`, `last_video_at`, `top_hashtags`\n- **ka-youtube** : bannière de chaîne, miniatures `i.ytimg.com` par vidéo, page `/about` (vues totales, date de création, pays, liens externes auto-déclarés), badge vérifié\n- **ka-x** : `media_urls` par tweet (jusqu'à 4 images)\n- **ka-twitch** : bannière, miniatures VOD, live (titre + miniature 640×360), liens sociaux du panneau À propos via GQL `socialMedias` — **validé en live sur lirik**\n- **ka-kick/snapchat/patreon/discord** : miniature+catégorie live, previews story/spotlight, cover+`creation_name`+liens sociaux, bannière/splash/vanity. Le push ka-discord bloqué a été réparé en ciblant l'ID plateforme explicite.\n\n**Pipeline (`apify_social.py`, `schema.py`)** : `banner_url` dans le schéma Creator, avatar/bannière rafraîchis à chaque passage depuis la plateforme principale (les CDN signés expirent), metric_maps étendus sur les 9 plateformes, nouveaux cross-links §12.1 depuis YouTube `/about`, Twitch et Patreon.\n\n**Stats légendaires** : table `snapshots` (un point audience/jour/compte, amorcée avec 10 311 comptes), `follower_history`, et `insights` sur `/api/creators/{cid}` : **Ka Score /100** (audience 40 · engagement 25 · multi-plateforme 20 · rythme 10 · vérification 5), croissance 7 j/30 j, engagement pondéré, rythme de publication, dernière activité.\n\n**Frontend** : bannière héro, carte Ka Score avec jauges, sparkline SVG d'évolution d'audience, KPIs de croissance ±, bloc « EN DIRECT » avec miniature, previews Snapchat et images de contenu élargies (covers/miniatures/media).\n\n**Déploiement** : frontend rebuildé, `crea-ka-web` redémarré (healthz OK, Ka Score servi — ex. sarah-jade-bleau 86,7/100), `crea-ka-sync` relancé — le passage en cours remplit bannières, covers et nouvelles métriques au fil de l'enrichissement (rotation 7 j pour couvrir tout le bassin).","stop_reason":"end_turn","session_id":"421167e4-872a-4d48-8f92-42b50bf5d5ca","total_cost_usd":6.540488099999999,"usage":{"input_tokens":2614,"cache_creation_input_tokens":350570,"cache_read_input_tokens":11447119,"output_tokens":76283,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":350570},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":957,"cache_read_input_tokens":151067,"cache_creation_input_tokens":935,"cache_creation":{"ephemeral_5m_input_tokens":935,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":509,"outputTokens":6258,"cacheReadInputTokens":402984,"cacheCreationInputTokens":53522,"webSearchRequests":0,"costUSD":0.1389999,"contextWindow":200000,"maxOutputTokens":32000},"claude-fable-5":{"inputTokens":126355,"outputTokens":84910,"cacheReadInputTokens":11447119,"cacheCreationInputTokens":350570,"webSearchRequests":0,"costUSD":6.401488199999999,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"0c9572f6-55ac-4d2b-95bc-402e9dca3398"}} |
|
578 |
+{"type":"status","state":"idle"} |