{"type":"user_prompt","text":"Il y a juste une complétude de 6% des créateur sur crea-ka peux tu faire un effort et faire en sorte que lon ait de quoi pour tout la gagne dispo force la couverture avec acteur apify pour chaque média sociaux","ts":1787326499904} {"type":"status","state":"running"} {"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/crea-ka","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","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":"61218f99-29fa-4d9b-8984-6088ad6d5f67","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/"},"fast_mode_state":"off"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6b6f832f-bc00-4fe2-83a5-c96141089215","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG922wk5Lpg9FwQ9kaA6","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2gQKpAEIERgCKkDn63tHAS3OGINLn8rN3alqEkUrx9JsQ645Z1aWaVybisQMg/OHIKWlURPRRTaaEzDfRZv5ZuJ/KZ4qenceYNxVMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGp4KHUBhIM7GZVsvAUaPhzD7U7GgxM6GVXHDYbhWcQAdIiMCFF9Xwngbv2GsZwyVotVSVqF+a7WKmRhbdkdB6irl97DmvfPxEGOMvIYx4EVDjo3yriAk1BpS9ectObIa4eoB8AJWG++g6o7N8gUfSbqtS9pVBS+L+EIw50g/+ySkEf4djNqx+XbjxRd6msQypOTsD2zjgrkCQVKqHroXnhV0jRWgwhEuw36mZyWTfmeQQkJcRBVlgj42217ZUrTKR6BH6TSl9AO4tHhL/GnOX9CyWSPAAWhKfO8w1lewl6p9utMsOwTzvkCHz9osZNqWQpnJwMzuJPWye0yUqhA6/fVrbQmzfHLLKflVJq7BQixu+hZ5ot39gx7LSqYI7E6gPCIaU/v6HpyYAF8X0N3GJhPCrheiK+6oTlrvyxnY1O5GJ9FKqNvfD6td+4NkPlDw8d0GWpU9WYC1oP+qgXYQJRlwt04FbnJmN9cNnZse+Wmo4HXq9IO9ylbECWvOxTDIxct9dxXZW5XNQ05a2Kna8XcpQvhDfYJceUVDTumlXa6aWCm5Sqw6aE5k0nHa59CSbYWGHtKOUnsxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44593,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44593,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7f0c3fda-25da-4180-83f3-7f05bfbc3e5d"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG922wk5Lpg9FwQ9kaA6","type":"message","role":"assistant","content":[{"type":"text","text":"Je vais d'abord regarder l'état actuel de la base et du pipeline d'enrichissement pour comprendre d'où vient ce 6 %."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44593,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44593,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2e8f1b97-2800-469d-a8e4-4efa6d5a15b2"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG922wk5Lpg9FwQ9kaA6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019YrjQXLfoQ6EH4cEXGMrw4","name":"Bash","input":{"command":"ls ~/apps/crea-ka && ls ~/apps/crea-ka/pipeline 2>/dev/null | head -50","description":"List repo structure"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44593,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44593,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b5afbaef-a1ca-4c65-9138-9644ba3728c0"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019YrjQXLfoQ6EH4cEXGMrw4","type":"tool_result","content":"CLAUDE.md\nREADME.md\nactors\ncreaka\ndata\ndocs\nfrontend\nrequirements.txt\nrun.py\nscripts\ntests","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d62192d6-3e2f-4246-94ba-b1de909f4878","timestamp":"2026-08-21T15:35:07.214Z","tool_use_result":{"stdout":"CLAUDE.md\nREADME.md\nactors\ncreaka\ndata\ndocs\nfrontend\nrequirements.txt\nrun.py\nscripts\ntests","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG922wk5Lpg9FwQ9kaA6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Pm18B1yDALScCLP5NVBijB","name":"Bash","input":{"command":"ls ~/apps/crea-ka/data 2>/dev/null | head; ls ~/apps/crea-ka/*.db 2>/dev/null; find ~/apps/crea-ka -maxdepth 2 -name \"*.db\" 2>/dev/null","description":"Find the database files"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":44593,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":44593,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ba061396-0f19-488a-8a9f-1887ee73bfe2"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Pm18B1yDALScCLP5NVBijB","type":"tool_result","content":"creaka.db\ncreaka.db-shm\ncreaka.db-wal\noptout.json\nseed\nsources.json\n(eval):1: no matches found: /Users/simon-pierreboucher/apps/crea-ka/*.db\n/Users/simon-pierreboucher/apps/crea-ka/data/creaka.db","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a67a3756-0fb3-4c98-a8d0-dfedc7d9d7c1","timestamp":"2026-08-21T15:35:08.400Z","tool_use_result":{"stdout":"creaka.db\ncreaka.db-shm\ncreaka.db-wal\noptout.json\nseed\nsources.json\n(eval):1: no matches found: /Users/simon-pierreboucher/apps/crea-ka/*.db\n/Users/simon-pierreboucher/apps/crea-ka/data/creaka.db","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"74966884-8987-4100-907b-c396c6a632f1","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG92bjxnrPK75B9KM7Cc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XQy9TefeSzgfe2K9tT1syA","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \".tables\" && sqlite3 data/creaka.db \"SELECT COUNT(*) FROM creators;\" 2>/dev/null","description":"Inspect DB tables and creator count"},"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":44593,"cache_creation":{"ephemeral_5m_input_tokens":632,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1e3062e3-f1f4-4f8a-9ca0-37a7c80e9424"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XQy9TefeSzgfe2K9tT1syA","type":"tool_result","content":"accounts creators snapshots sync_log \n12982","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2f8c8671-6b30-4135-8328-5bb5933aa14f","timestamp":"2026-08-21T15:35:12.192Z","tool_use_result":{"stdout":"accounts creators snapshots sync_log \n12982","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG92bjxnrPK75B9KM7Cc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AYLsPxSim6YZoY8X4R7yMA","name":"Bash","input":{"command":"cd ~/apps/crea-ka && ls creaka creaka/* 2>/dev/null | head -60 && ls scripts | head -40","description":"List package and scripts"},"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":44593,"cache_creation":{"ephemeral_5m_input_tokens":632,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a74f0d5a-f988-44ee-bb46-92fa74e7ddb7"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AYLsPxSim6YZoY8X4R7yMA","type":"tool_result","content":"creaka/__init__.py\ncreaka/auth.py\ncreaka/db.py\ncreaka/dedup.py\ncreaka/ethics.py\ncreaka/identity.py\ncreaka/ingest.py\ncreaka/kapdf.py\ncreaka/normalize.py\ncreaka/schema.py\ncreaka/stats.py\ncreaka/web.py\n\ncreaka:\n__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\n\ncreaka/__pycache__:\n__init__.cpython-314.pyc\nauth.cpython-314.pyc\ndb.cpython-314.pyc\ndedup.cpython-314.pyc\nethics.cpython-314.pyc\nidentity.cpython-314.pyc\ningest.cpython-314.pyc\nkapdf.cpython-314.pyc\nnormalize.cpython-314.pyc\nschema.cpython-314.pyc\nstats.cpython-314.pyc\nweb.cpython-314.pyc\n\ncreaka/connectors:\n__init__.py\n__pycache__\napify_social.py\nbalados_itunes.py\nbalados_rss.py\nbase.py\nbio_liens.py\ninstagram.py\nkick_decouverte.py\nlinkinbio.py\nlistes_medias.py\nonlyqueb.py\npalmares_apple.py\npodcastindex.py\nregion_bio.py\nsnowball.py\nbuild_frontend.py\ndeploy-m3u96b.sh\ngen_connector_docs.py\nscreenshots.sh","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"79ef2dd5-ee0d-48df-9e74-ef0708869724","timestamp":"2026-08-21T15:35:12.904Z","tool_use_result":{"stdout":"creaka/__init__.py\ncreaka/auth.py\ncreaka/db.py\ncreaka/dedup.py\ncreaka/ethics.py\ncreaka/identity.py\ncreaka/ingest.py\ncreaka/kapdf.py\ncreaka/normalize.py\ncreaka/schema.py\ncreaka/stats.py\ncreaka/web.py\n\ncreaka:\n__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\n\ncreaka/__pycache__:\n__init__.cpython-314.pyc\nauth.cpython-314.pyc\ndb.cpython-314.pyc\ndedup.cpython-314.pyc\nethics.cpython-314.pyc\nidentity.cpython-314.pyc\ningest.cpython-314.pyc\nkapdf.cpython-314.pyc\nnormalize.cpython-314.pyc\nschema.cpython-314.pyc\nstats.cpython-314.pyc\nweb.cpython-314.pyc\n\ncreaka/connectors:\n__init__.py\n__pycache__\napify_social.py\nbalados_itunes.py\nbalados_rss.py\nbase.py\nbio_liens.py\ninstagram.py\nkick_decouverte.py\nlinkinbio.py\nlistes_medias.py\nonlyqueb.py\npalmares_apple.py\npodcastindex.py\nregion_bio.py\nsnowball.py\nbuild_frontend.py\ndeploy-m3u96b.sh\ngen_connector_docs.py\nscreenshots.sh","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4e96285f-9244-499e-ae14-08c67dce897c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG92vpg4pHsvibuU3dNp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0171sHNA1DYVgmpgP91Lv2Vo","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"SELECT platform, COUNT(*) total, SUM(CASE WHEN followers IS NOT NULL THEN 1 ELSE 0 END) with_followers, SUM(CASE WHEN last_checked IS NOT NULL THEN 1 ELSE 0 END) checked FROM accounts GROUP BY platform ORDER BY total DESC;\"","description":"Account coverage by platform"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":875,"cache_read_input_tokens":45225,"cache_creation":{"ephemeral_5m_input_tokens":875,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b0ee3a39-59f2-404f-9afb-49ce99d66058"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0171sHNA1DYVgmpgP91Lv2Vo","type":"tool_result","content":"youtube|9545|8684|9545\ninstagram|2161|1195|2161\ntiktok|547|406|547\npodcast|493|0|493\ntwitch|482|455|482\nx|440|84|440\nfacebook|357|18|357\nonlyfans|256|0|256\nsite-web|185|0|185\nkick|145|145|145\ndiscord|56|28|56\nspotify|51|0|51\nmym|25|0|25\npatreon|22|6|22\nlinkedin|21|0|21\nsubstack|21|0|21\nfansly|19|19|19\nsnapchat|15|1|15\nthreads|5|3|5","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"507daf9e-1ad5-4cf7-85f7-e2a3a0203663","timestamp":"2026-08-21T15:35:17.684Z","tool_use_result":{"stdout":"youtube|9545|8684|9545\ninstagram|2161|1195|2161\ntiktok|547|406|547\npodcast|493|0|493\ntwitch|482|455|482\nx|440|84|440\nfacebook|357|18|357\nonlyfans|256|0|256\nsite-web|185|0|185\nkick|145|145|145\ndiscord|56|28|56\nspotify|51|0|51\nmym|25|0|25\npatreon|22|6|22\nlinkedin|21|0|21\nsubstack|21|0|21\nfansly|19|19|19\nsnapchat|15|1|15\nthreads|5|3|5","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG92vpg4pHsvibuU3dNp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015SjWvdaxAYvWAFRjypfqb5","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \".schema accounts\" && sqlite3 data/creaka.db \".schema creators\" | head -40","description":"Show table schemas"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":875,"cache_read_input_tokens":45225,"cache_creation":{"ephemeral_5m_input_tokens":875,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"59503352-f308-401f-b7a1-b5e78c6341f4"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015SjWvdaxAYvWAFRjypfqb5","type":"tool_result","content":"CREATE TABLE 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, metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX idx_accounts_creator ON accounts(creator_id);\nCREATE TABLE 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 INDEX idx_creators_status ON creators(status);\nCREATE INDEX idx_creators_tier ON creators(audience_tier);","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"38fbe664-639d-4f44-93ad-7d1220ae5628","timestamp":"2026-08-21T15:35:18.392Z","tool_use_result":{"stdout":"CREATE TABLE 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, metrics TEXT,\n PRIMARY KEY (platform, handle)\n);\nCREATE INDEX idx_accounts_creator ON accounts(creator_id);\nCREATE TABLE 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 INDEX idx_creators_status ON creators(status);\nCREATE INDEX idx_creators_tier ON creators(audience_tier);","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d261592a-2b48-4548-940b-6301d160a515","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93LWx1F5ngokQKUWQ9","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS5QMKpAEIERgCKkA5FOHRPulQ/ojshq+9B1HZVECkILUeioLHW+9GVhHZURjIvC5f16KlrtsShxzAGYedbKHHckIF3ko+Z2+M377vMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG84KHUBhIMb+xesskM+5t3AyrjGgzQdzslmUEnsMwFZ78iMMsizqO5HijtYJshkEdxb4A8mkmLau/YN82kkL/FRxQvojU7ejfYjheBm+eX2qLtlirtAQibVcT3LfSqGDDkaB1kVGske2c9rNrDQYzYoP6ZnF/TbVUEVyWL7REj4oDEh6BKhlr+66RI+qf0YMboAW6eJ705d7+Cj7PRwhpnnzEc5lwXkgf5IIGSsk1ClrFFWY1cOm6biYnqO4SjF6UOIc4F1uwEPd0U1NYt41+AitOtDpcnV8jDfPLrXWSXBeNN53jyBhxqmak/6I/dJVCGm5Qw+dmhIuOYyypBpYU/SjoMUnfxo695FW2abALXR7TCVgtAN39ECq30G7LwI6TOjUz2Z3FncWcEDhTtxIEPCkU3rhr47zukT+hnM8jmkzwaoRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1108,"cache_read_input_tokens":46100,"cache_creation":{"ephemeral_5m_input_tokens":1108,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2174f318-e3d2-496d-b526-de5de3205ccf"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93LWx1F5ngokQKUWQ9","type":"message","role":"assistant","content":[{"type":"text","text":"Je vois — 12 982 créateurs, mais l'enrichissement Apify n'a touché qu'une fraction. Je regarde comment la « complétude » est calculée et comment le connecteur `apify_social` fonctionne."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1108,"cache_read_input_tokens":46100,"cache_creation":{"ephemeral_5m_input_tokens":1108,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"342a3559-35f4-42e9-badb-3d010b45d0d3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93LWx1F5ngokQKUWQ9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016m8ApDppxUxctS3zk9TCYw","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -rn -i \"complet\" creaka frontend --include=\"*.py\" --include=\"*.js\" --include=\"*.html\" -l | head; grep -rn -i \"completude\\|completeness\\|complet\" creaka/stats.py | head -20","description":"Find completeness calculation"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1108,"cache_read_input_tokens":46100,"cache_creation":{"ephemeral_5m_input_tokens":1108,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"18763b22-74a8-4483-adad-86014eeebb13"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016m8ApDppxUxctS3zk9TCYw","type":"tool_result","content":"creaka/connectors/x_profil.py\ncreaka/connectors/podcastindex.py\ncreaka/connectors/balados_rss.py\ncreaka/connectors/balados_itunes.py\ncreaka/connectors/youtube_recherche.py\ncreaka/connectors/apify_social.py\ncreaka/db.py\ncreaka/ingest.py\ncreaka/web.py\ncreaka/kapdf.py\ncreaka/stats.py:311: completeness = round((_cov(n_bio) + _cov(n_loc) + _cov(n_reach)\ncreaka/stats.py:322: {\"id\": \"complete\", \"label\": \"Complétude moyenne des fiches \"\ncreaka/stats.py:323: \"(bio, lieu, audience, photo)\", \"value\": completeness,","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2187cf5b-1704-43d4-91e4-cad5dccd5e02","timestamp":"2026-08-21T15:35:26.992Z","tool_use_result":{"stdout":"creaka/connectors/x_profil.py\ncreaka/connectors/podcastindex.py\ncreaka/connectors/balados_rss.py\ncreaka/connectors/balados_itunes.py\ncreaka/connectors/youtube_recherche.py\ncreaka/connectors/apify_social.py\ncreaka/db.py\ncreaka/ingest.py\ncreaka/web.py\ncreaka/kapdf.py\ncreaka/stats.py:311: completeness = round((_cov(n_bio) + _cov(n_loc) + _cov(n_reach)\ncreaka/stats.py:322: {\"id\": \"complete\", \"label\": \"Complétude moyenne des fiches \"\ncreaka/stats.py:323: \"(bio, lieu, audience, photo)\", \"value\": completeness,","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93LWx1F5ngokQKUWQ9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SBEuvw94M74tN4dB9nQrWU","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":1108,"cache_read_input_tokens":46100,"cache_creation":{"ephemeral_5m_input_tokens":1108,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0de0606a-5792-48ac-81bd-fe274cc88ddd"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SBEuvw94M74tN4dB9nQrWU","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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-` 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 \"videos_per_week\": \"videos_per_week\",\n336\t \"last_video_at\": \"last_video_at\",\n337\t \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n338\t \"top_video\": \"top_video\"}\n339\t\n340\t def bio_urls(self, it: dict) -> list[str]:\n341\t return [it.get(\"bio_link\") or \"\"]\n342\t\n343\t\n344\tclass ApifyX(_ApifySocialEnrich):\n345\t source_id = \"x-apify\"\n346\t platform = \"x\"\n347\t actor = \"ka-x\"\n348\t cap = 1800\n349\t content_key = \"recent_tweets\"\n350\t metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n351\t \"location\": \"location\", \"created_at\": \"created_at\",\n352\t \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n353\t \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n354\t \"avg_replies\": \"avg_replies\",\n355\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n356\t \"tweets_per_week\": \"tweets_per_week\",\n357\t \"last_tweet_at\": \"last_tweet_at\",\n358\t \"media_share_pct\": \"media_share_pct\",\n359\t \"reply_share_pct\": \"reply_share_pct\",\n360\t \"is_blue_verified\": \"is_blue_verified\",\n361\t \"likes_given\": \"likes_given\", \"website\": \"website\",\n362\t \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n363\t\n364\t def extra_input(self) -> dict:\n365\t return {\"maxTweets\": 20}\n366\t\n367\t def bio_urls(self, it: dict) -> list[str]:\n368\t return [it.get(\"website\") or \"\"]\n369\t\n370\t\n371\tclass ApifyFacebook(_ApifySocialEnrich):\n372\t source_id = \"facebook-apify\"\n373\t platform = \"facebook\"\n374\t actor = \"ka-facebook\"\n375\t cap = 400\n376\t revisit_days = 7\n377\t metric_map = {\"category\": \"category\"}\n378\t\n379\t\n380\tclass ApifyThreads(_ApifySocialEnrich):\n381\t source_id = \"threads-apify\"\n382\t platform = \"threads\"\n383\t actor = \"ka-threads\"\n384\t cap = 60\n385\t revisit_days = 7\n386\t content_key = \"recent_posts\"\n387\t metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n388\t\n389\t\n390\tclass ApifySnapchat(_ApifySocialEnrich):\n391\t source_id = \"snapchat-apify\"\n392\t platform = \"snapchat\"\n393\t actor = \"ka-snapchat\"\n394\t cap = 60\n395\t revisit_days = 7\n396\t metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n397\t \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n398\t \"subcategory\": \"subcategory\",\n399\t \"publisher_type\": \"publisher_type\",\n400\t \"story_snaps_count\": \"story_snaps\",\n401\t \"lenses_count\": \"lenses\",\n402\t \"story_previews\": \"story_previews\",\n403\t \"spotlight_previews\": \"spotlight_previews\",\n404\t \"website\": \"website\",\n405\t \"spotlight_highlights_count\": \"spotlight_highlights\"}\n406\t\n407\t def bio_urls(self, it: dict) -> list[str]:\n408\t return [it.get(\"website\") or \"\"]\n409\t\n410\t\n411\tclass ApifyYouTube(_ApifySocialEnrich):\n412\t source_id = \"youtube-apify\"\n413\t platform = \"youtube\"\n414\t actor = \"ka-youtube\"\n415\t cap = 1800\n416\t content_key = \"recent_videos\"\n417\t metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n418\t \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n419\t \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n420\t \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n421\t \"external_links\": \"external_links\",\n422\t \"last_video_published\": \"last_video_published\"}\n423\t\n424\t def bio_urls(self, it: dict) -> list[str]:\n425\t # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n426\t return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n427\t if isinstance(ln, dict)]\n428\t\n429\t\n430\tclass ApifyTwitch(_ApifySocialEnrich):\n431\t source_id = \"twitch-apify\"\n432\t platform = \"twitch\"\n433\t actor = \"ka-twitch\"\n434\t cap = 1200\n435\t content_key = \"recent_videos\"\n436\t metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n437\t \"team\": \"team\", \"created_at\": \"created_at\",\n438\t \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n439\t \"live_game\": \"live_game\",\n440\t \"last_broadcast_at\": \"last_broadcast_at\",\n441\t \"last_broadcast_game\": \"last_broadcast_game\",\n442\t \"last_broadcast_title\": \"last_broadcast_title\",\n443\t \"live_title\": \"live_title\",\n444\t \"live_thumbnail\": \"live_thumbnail\",\n445\t \"avg_video_views\": \"avg_video_views\",\n446\t \"social_links\": \"social_links\",\n447\t \"recent_games\": \"recent_games\"}\n448\t\n449\t def bio_urls(self, it: dict) -> list[str]:\n450\t # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n451\t return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n452\t if isinstance(ln, dict)]\n453\t\n454\t\n455\tclass ApifyKick(_ApifySocialEnrich):\n456\t source_id = \"kick-apify\"\n457\t platform = \"kick\"\n458\t actor = \"ka-kick\"\n459\t cap = 600\n460\t metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n461\t \"subscription_enabled\": \"subscription_enabled\",\n462\t \"live_title\": \"live_title\",\n463\t \"live_thumbnail\": \"live_thumbnail\",\n464\t \"live_category\": \"live_category\",\n465\t \"vod_enabled\": \"vod_enabled\",\n466\t \"recent_categories\": \"recent_categories\"}\n467\t\n468\t def cross_links(self, cr: Creator, it: dict) -> None:\n469\t \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n470\t socials = it.get(\"social_links\") or {}\n471\t tmpl = {\"instagram\": \"https://instagram.com/{}\",\n472\t \"twitter\": \"https://x.com/{}\",\n473\t \"youtube\": \"https://youtube.com/{}\",\n474\t \"tiktok\": \"https://www.tiktok.com/@{}\",\n475\t \"facebook\": \"https://facebook.com/{}\",\n476\t \"discord\": \"https://discord.gg/{}\"}\n477\t for key, val in socials.items():\n478\t val = (val or \"\").strip()\n479\t if not val:\n480\t continue\n481\t url = val if val.startswith(\"http\") else \\\n482\t tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n483\t hit = platform_from_url(url)\n484\t if hit and hit[0] != self.platform:\n485\t cr.platforms = merge_accounts(cr.platforms, [\n486\t account(hit[0], hit[1], \"cross_link\",\n487\t url=url).finalize()])\n488\t\n489\t\n490\tclass ApifyOnlyFans(_ApifySocialEnrich):\n491\t source_id = \"onlyfans-apify\"\n492\t platform = \"onlyfans\"\n493\t actor = \"ka-onlyfans\"\n494\t cap = 100\n495\t revisit_days = 7\n496\t metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n497\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n498\t \"streams_count\": \"streams\",\n499\t \"subscribe_price_usd\": \"subscribe_price_usd\",\n500\t \"is_free\": \"is_free\", \"location\": \"location\"}\n501\t\n502\t def bio_urls(self, it: dict) -> list[str]:\n503\t return [it.get(\"website\") or \"\"]\n504\t\n505\t\n506\tclass ApifyFansly(_ApifySocialEnrich):\n507\t source_id = \"fansly-apify\"\n508\t platform = \"fansly\"\n509\t actor = \"ka-fansly\"\n510\t cap = 600\n511\t revisit_days = 7\n512\t metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n513\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n514\t \"following\": \"following\", \"location\": \"location\"}\n515\t\n516\t\n517\tclass ApifyPatreon(_ApifySocialEnrich):\n518\t source_id = \"patreon-apify\"\n519\t platform = \"patreon\"\n520\t actor = \"ka-patreon\"\n521\t cap = 400\n522\t revisit_days = 7\n523\t metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n524\t \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\",\n525\t \"creation_name\": \"creation_name\",\n526\t \"social_links\": \"social_links\"}\n527\t\n528\t def bio_urls(self, it: dict) -> list[str]:\n529\t # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n530\t return list((it.get(\"social_links\") or {}).values())\n531\t\n532\t\n533\tclass ApifyDiscord(_ApifySocialEnrich):\n534\t source_id = \"discord-apify\"\n535\t platform = \"discord\"\n536\t actor = \"ka-discord\"\n537\t cap = 400\n538\t revisit_days = 7\n539\t metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n540\t \"partnered\": \"partnered\", \"guild_id\": \"guild_id\",\n541\t \"channel\": \"channel\", \"splash\": \"splash\",\n542\t \"vanity_url_code\": \"vanity_url_code\"}\n543\t\n544\t def target_of(self, acc) -> str:\n545\t \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n546\t handle minusculé) : discord.gg/ ou discord.com/invite/.\"\"\"\n547\t url = acc.url or \"\"\n548\t for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n549\t if sep in url:\n550\t return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n551\t return acc.handle\n552\t\n553\t\n554\t# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n555\t\n556\t# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n557\t# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)\n558\t_THEMES = [\"humoriste\", \"gamer\", \"gaming\", \"maquillage\", \"beauté\", \"fitness\",\n559\t \"cuisine\", \"chef\", \"mode\", \"voyage\", \"musique\", \"chanteuse\",\n560\t \"chanteur\", \"danse\", \"maman\", \"papa\", \"plein air\", \"chasse\",\n561\t \"pêche\", \"photographe\", \"artiste\", \"peintre\", \"tatoueur\",\n562\t \"coiffure\", \"esthétique\", \"auto\", \"moto\", \"déco\", \"immobilier\",\n563\t \"entrepreneure\", \"podcast\", \"comédien\", \"comédienne\", \"drag\",\n564\t \"twitch\", \"youtubeur\", \"youtubeuse\", \"tiktokeuse\", \"influenceuse\",\n565\t \"créateur de contenu\", \"créatrice de contenu\"]\n566\t_MARKERS = [\"québec\", \"qc\", \"montréal\", \"mtl\"]\n567\t\n568\t\n569\tclass InstagramDecouverteApify(BaseConnector):\n570\t \"\"\"Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).\n571\t\n572\t L'endpoint topsearch du web public retourne les meilleurs comptes pour un\n573\t mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes\n574\t (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes\n575\t résultats chaque jour. Signal : profil_source (0.98) — le compte découvert\n576\t EST la source de la fiche.\n577\t \"\"\"\n578\t\n579\t source_id = \"instagram-decouverte\"\n580\t kind = \"discovery\"\n581\t queries_per_pass = 40\n582\t\n583\t def build_queries(self) -> list[str]:\n584\t combos = [f\"{t} {m}\" for t in _THEMES for m in _MARKERS]\n585\t day = datetime.now(timezone.utc).timetuple().tm_yday\n586\t start = (day * self.queries_per_pass) % len(combos)\n587\t rotated = combos[start:] + combos[:start]\n588\t return rotated[:self.queries_per_pass]\n589\t\n590\t def fetch(self) -> list[Creator]:\n591\t # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais\n592\t # une session authentifiée (401 « Server Error » depuis toute IP non\n593\t # connectée). La découverte par mots-clés est donc suspendue proprement\n594\t # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram\n595\t # (instagram-apify) alimente `related_profiles` comme graine future.\n596\t raise SkipSource(\"topsearch Instagram exige une session connectée \"\n597\t \"(voie publique fermée le 2026-08-18)\")\n598\t items = run_actor(\"ka-instagram\", { # noqa: unreachable (repli futur)\n599\t \"usernames\": [],\n600\t \"queries\": self.build_queries(),\n601\t \"proxyConfiguration\": RESIDENTIAL,\n602\t })\n603\t creators: list[Creator] = []\n604\t seen: set[str] = set()\n605\t for it in items:\n606\t if it.get(\"kind\") != \"search_user\" or not it.get(\"username\"):\n607\t continue\n608\t handle = str(it[\"username\"]).lower()\n609\t if handle in seen:\n610\t continue\n611\t seen.add(handle)\n612\t acc = account(\"instagram\", handle, \"profil_source\",\n613\t followers=parse_count(it.get(\"followers\")),\n614\t verified=it.get(\"is_verified\"),\n615\t url=f\"https://www.instagram.com/{handle}/\")\n616\t creators.append(Creator(\n617\t source=self.source_id,\n618\t external_id=handle,\n619\t display_name=it.get(\"full_name\") or handle,\n620\t avatar_url=it.get(\"avatar\") or None,\n621\t platforms=[acc],\n622\t notes=f\"découvert via recherche Instagram « {it.get('query')} »\",\n623\t ))\n624\t return creators\n625\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a00dff41-a19f-4ee9-b4a2-1e9a3e781e64","timestamp":"2026-08-21T15:35:27.219Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":625,"startLine":1,"totalLines":625}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"51c5e22b-8962-4ab4-9b09-4ea6a3b99663","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93z3g1zYBDQZSk1T3w","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS0wMKpAEIERgCKkBtwvrZDpclEnLHz7HRrMvm/RHXlPiHSYyLcAM+2o+GUd6qzRYOaqVdU1eHQKp8t/sUJYfOyMCvCrKm9TQfYEx5Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHE4KHUBhIMD5ZQtbb/+B/cr/jTGgz4XDN+3KlL8dMxrykiMLsOcNYi3OZMTrxK66YkYJyvZ+1Vq5eB8Gr6OQF3iG4zNPr+6uh3LFTWwNRrRmAHNSrbAWTWeLAh0D4apppP2c1fKm7+XeO7yCM6W1RJyASTkfEzwoQ0Oz3cabmucEBfjKYIX/PXpyqwg02q6Naj/dlW+w4UErh57q3beb2vvkypwZCqXrA6GUV6yTvl1ou0eKinv1U9lnLnBwDczcUh+V3NaET9hfuZ/eS3E8CWuXEMjqFa6TuvWx1dGBDeDwMbeM7BH4BZiP6e89nShcMG4tj9m/I0gYr2VE4XoxgUXC6M9orG7+TMdbsHIx/Hb5taUJaV0nCx6kTzu7KoGRy6qarOJP4cqfjYFgKkV9VlPRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":12966,"cache_read_input_tokens":47208,"cache_creation":{"ephemeral_5m_input_tokens":12966,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9ccb2e14-95f8-4234-894e-4b033d4dad63"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93z3g1zYBDQZSk1T3w","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EeQHitHW5L6K9oAB7hEhsY","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sed -n '270,340p' creaka/stats.py","description":"Read completeness stat code"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":12966,"cache_read_input_tokens":47208,"cache_creation":{"ephemeral_5m_input_tokens":12966,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7d09145e-397c-4de5-ab70-73a2c33d096a"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EeQHitHW5L6K9oAB7hEhsY","type":"tool_result","content":" pts_cumul = []\n for d in day_axis:\n running += adds_by_day.get(d, 0)\n pts_cumul.append({\"t\": d.isoformat(), \"v\": running})\n reach = sum(r[\"total_reach\"] for r in creators if r[\"total_reach\"])\n\n kpis = [\n {\"id\": \"creators\", \"label\": \"Créateurs au répertoire\", \"value\": total_end,\n \"unit\": \"\", **_delta(total_end, total_start), \"spark\": _spark(pts_cumul)},\n {\"id\": \"accounts\", \"label\": \"Comptes publics reliés\", \"value\": n_accounts,\n \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"added\", \"label\": \"Créateurs ajoutés sur la période\",\n \"value\": added_cur, \"unit\": \"\", **_delta(added_cur, added_prev),\n \"spark\": _spark(pts_added)},\n {\"id\": \"platforms\", \"label\": \"Plateformes couvertes\",\n \"value\": len(plat_rows), \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"multi\", \"label\": \"Créateurs multi-plateformes\", \"value\": n_multi,\n \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"verified\", \"label\": \"Comptes vérifiés (badge)\",\n \"value\": n_verified, \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"niches\", \"label\": \"Niches couvertes\", \"value\": len(niche_total),\n \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"regions\", \"label\": \"Régions représentées\",\n \"value\": len(region_total), \"unit\": \"\", \"delta_pct\": None},\n ]\n if reach:\n kpis.append({\"id\": \"reach\", \"label\": \"Portée cumulée connue\",\n \"value\": reach, \"unit\": \"abonnés\", \"delta_pct\": None})\n if n_active:\n kpis.append({\"id\": \"acc_avg\", \"label\": \"Comptes reliés par créateur (moy.)\",\n \"value\": round(n_accounts / n_active, 2), \"unit\": \"\",\n \"delta_pct\": None})\n\n # ---- jauges : couvertures & complétude (calculées, pas estimées) ----\n def _cov(n: int) -> float:\n return round(100.0 * n / n_active, 1) if n_active else 0.0\n n_bio = sum(1 for r in creators if r[\"bio\"])\n n_loc = sum(1 for r in creators if r[\"region\"] or r[\"city\"])\n n_reach = sum(1 for r in creators if r[\"total_reach\"])\n n_avatar = sum(1 for r in creators if r[\"has_avatar\"])\n # complétude moyenne d'une fiche = moyenne des 4 champs clés remplis\n completeness = round((_cov(n_bio) + _cov(n_loc) + _cov(n_reach)\n + _cov(n_avatar)) / 4, 1) if n_active else 0.0\n gauges = [\n {\"id\": \"multi\", \"label\": \"Créateurs multi-plateformes (2 comptes et +)\",\n \"value\": _cov(n_multi), \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"bio\", \"label\": \"Fiches avec biographie\", \"value\": _cov(n_bio),\n \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"loc\", \"label\": \"Fiches avec ville ou région déclarée\",\n \"value\": _cov(n_loc), \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"reach\", \"label\": \"Fiches avec audience connue\",\n \"value\": _cov(n_reach), \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"complete\", \"label\": \"Complétude moyenne des fiches \"\n \"(bio, lieu, audience, photo)\", \"value\": completeness,\n \"max\": 100, \"unit\": \"%\"},\n ]\n\n # ---- séries quotidiennes ----\n cmp_added = [{\"t\": (prev_from + timedelta(days=i)).isoformat(),\n \"v\": adds_by_day.get(prev_from + timedelta(days=i), 0)}\n for i in range(span)]\n series = [\n {\"id\": \"added\", \"title\": \"Créateurs ajoutés par jour\", \"unit\": \"créateurs\",\n \"kind\": \"line\", \"points\": pts_added,\n **({\"compare\": cmp_added} if any(c[\"v\"] for c in cmp_added) else {})},\n {\"id\": \"cumul\", \"title\": \"Taille cumulative du répertoire\",\n \"unit\": \"créateurs\", \"kind\": \"area\", \"points\": pts_cumul},\n ]\n # portée cumulée découverte (somme des audiences connues des fiches ajoutées)\n if reach_day:\n run_r = sum(v for dd, v in reach_day.items() if dd < p_from)","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8cd10854-a5d3-4e4e-8452-2ba19f61bb72","timestamp":"2026-08-21T15:35:33.547Z","tool_use_result":{"stdout":" pts_cumul = []\n for d in day_axis:\n running += adds_by_day.get(d, 0)\n pts_cumul.append({\"t\": d.isoformat(), \"v\": running})\n reach = sum(r[\"total_reach\"] for r in creators if r[\"total_reach\"])\n\n kpis = [\n {\"id\": \"creators\", \"label\": \"Créateurs au répertoire\", \"value\": total_end,\n \"unit\": \"\", **_delta(total_end, total_start), \"spark\": _spark(pts_cumul)},\n {\"id\": \"accounts\", \"label\": \"Comptes publics reliés\", \"value\": n_accounts,\n \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"added\", \"label\": \"Créateurs ajoutés sur la période\",\n \"value\": added_cur, \"unit\": \"\", **_delta(added_cur, added_prev),\n \"spark\": _spark(pts_added)},\n {\"id\": \"platforms\", \"label\": \"Plateformes couvertes\",\n \"value\": len(plat_rows), \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"multi\", \"label\": \"Créateurs multi-plateformes\", \"value\": n_multi,\n \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"verified\", \"label\": \"Comptes vérifiés (badge)\",\n \"value\": n_verified, \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"niches\", \"label\": \"Niches couvertes\", \"value\": len(niche_total),\n \"unit\": \"\", \"delta_pct\": None},\n {\"id\": \"regions\", \"label\": \"Régions représentées\",\n \"value\": len(region_total), \"unit\": \"\", \"delta_pct\": None},\n ]\n if reach:\n kpis.append({\"id\": \"reach\", \"label\": \"Portée cumulée connue\",\n \"value\": reach, \"unit\": \"abonnés\", \"delta_pct\": None})\n if n_active:\n kpis.append({\"id\": \"acc_avg\", \"label\": \"Comptes reliés par créateur (moy.)\",\n \"value\": round(n_accounts / n_active, 2), \"unit\": \"\",\n \"delta_pct\": None})\n\n # ---- jauges : couvertures & complétude (calculées, pas estimées) ----\n def _cov(n: int) -> float:\n return round(100.0 * n / n_active, 1) if n_active else 0.0\n n_bio = sum(1 for r in creators if r[\"bio\"])\n n_loc = sum(1 for r in creators if r[\"region\"] or r[\"city\"])\n n_reach = sum(1 for r in creators if r[\"total_reach\"])\n n_avatar = sum(1 for r in creators if r[\"has_avatar\"])\n # complétude moyenne d'une fiche = moyenne des 4 champs clés remplis\n completeness = round((_cov(n_bio) + _cov(n_loc) + _cov(n_reach)\n + _cov(n_avatar)) / 4, 1) if n_active else 0.0\n gauges = [\n {\"id\": \"multi\", \"label\": \"Créateurs multi-plateformes (2 comptes et +)\",\n \"value\": _cov(n_multi), \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"bio\", \"label\": \"Fiches avec biographie\", \"value\": _cov(n_bio),\n \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"loc\", \"label\": \"Fiches avec ville ou région déclarée\",\n \"value\": _cov(n_loc), \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"reach\", \"label\": \"Fiches avec audience connue\",\n \"value\": _cov(n_reach), \"max\": 100, \"unit\": \"%\"},\n {\"id\": \"complete\", \"label\": \"Complétude moyenne des fiches \"\n \"(bio, lieu, audience, photo)\", \"value\": completeness,\n \"max\": 100, \"unit\": \"%\"},\n ]\n\n # ---- séries quotidiennes ----\n cmp_added = [{\"t\": (prev_from + timedelta(days=i)).isoformat(),\n \"v\": adds_by_day.get(prev_from + timedelta(days=i), 0)}\n for i in range(span)]\n series = [\n {\"id\": \"added\", \"title\": \"Créateurs ajoutés par jour\", \"unit\": \"créateurs\",\n \"kind\": \"line\", \"points\": pts_added,\n **({\"compare\": cmp_added} if any(c[\"v\"] for c in cmp_added) else {})},\n {\"id\": \"cumul\", \"title\": \"Taille cumulative du répertoire\",\n \"unit\": \"créateurs\", \"kind\": \"area\", \"points\": pts_cumul},\n ]\n # portée cumulée découverte (somme des audiences connues des fiches ajoutées)\n if reach_day:\n run_r = sum(v for dd, v in reach_day.items() if dd < p_from)","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG93z3g1zYBDQZSk1T3w","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ro7hwxXLCHPAbmRiJbyp4E","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"\nSELECT\n (SELECT COUNT(*) FROM creators WHERE status='active') actifs,\n (SELECT COUNT(*) FROM creators WHERE status='active' AND bio IS NOT NULL AND bio != '') avec_bio,\n (SELECT COUNT(*) FROM creators WHERE status='active' AND (region IS NOT NULL OR city IS NOT NULL)) avec_lieu,\n (SELECT COUNT(*) FROM creators WHERE status='active' AND total_reach IS NOT NULL AND total_reach > 0) avec_reach,\n (SELECT COUNT(*) FROM creators WHERE status='active' AND doc LIKE '%avatar_url%' AND json_extract(doc,'$.avatar_url') IS NOT NULL) avec_avatar;\"","description":"Compute completeness components live"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":12966,"cache_read_input_tokens":47208,"cache_creation":{"ephemeral_5m_input_tokens":12966,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"26ab6c88-2dc0-4d9b-9b73-39132a3ed5a2"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ro7hwxXLCHPAbmRiJbyp4E","type":"tool_result","content":"12739|9833|1837|10398|11276","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"57a71c39-816f-4549-a2ef-287a4512f524","timestamp":"2026-08-21T15:35:37.092Z","tool_use_result":{"stdout":"12739|9833|1837|10398|11276","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8e147df6-936c-4b0d-a0f1-3eef369c26d4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG94iPNMvHg1knBZY8Si","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/ggKpAEIERgCKkAyNUaycjIyv0uD8Y4vfU/gLXhFVIuvebcn86nzze94NNIja1UBHjVtK1hOq1OvysUBR5iOoSlSIuI4n7iswYD9Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHT4KHUBhIMK9SHxa+6klPxnpXSGgzeqYRnMR1fCgKgpOsiMEPshcDSd58+sqsmGCGCoVH72y52RwGfAkng7CWnxix0U5ay+NurnsfmJt/vTixPhyqGB/o18VObSqVMj6E1M1g0R+2DZF93VuUkW8QJL5+y+XP2X8YQv+H35PktJ6Ig1plap+BmbyHKn9hMwocpUONxlyK7E7GqdFr6L/MpMjYJkb4cYPeCwzAxR5BqqIQt+ouTuYsv9U9gVPBN4iEkEXD9UDm1UNyAjK3+9OEb4PoEqRUSTq2/QpHUpyUV14sERDtht/T8Ldp7zRMU2gKKecmk+Cz/PvaZdMmeKe8phcfhsMnPC6tIoMMbEhU/eCgcoFlNtfvj8NT8upTipmfqzabl3NWVXnK3VlOVps3XrdAQ6HldXi1I4jP/qiChzwCZPt/xrcPt0VjhZQK9MlEjavn1pNPB8zbhz8SKrPlvkVhZx5XdzpUyrMi8Aq/vo2Srq3nER7FVYEhKeb9pqhNTCv9hslnymlitMaQOyvU1UQ70O6YvnpveYMQwLPlLc4iVd6UlgkB9y9JMaYZSd5qe3huOhnoDJiQBKUItgRNIXEkZb/hxBc96aqMfD41XwNGF6B2UgVIw354zkrVWiOVJfFWNT/b8jAacEf8ZDXmFI/Qwi4isADNmi4eTTixQKX/By58Ywik1DqOqDjJnI3+fVJmRzYOFzjkLXoEgvBzhtWN8Ah6Ok6SISq7kTKI2t5OZE4RHbmOHdU+SywAx93BfcCjhVA13v8pFelSVzAHG46nHh0JkjDy6TmSpUrSK54o2qXN6ctSr4lUhKTHZZPtdJXY9WKZSX5AyUo+DDldH2kUJ+/WKnZUG3Omitk0NLF83ROhYlndfuQvfaBrlhZS81lc988Y1ArzTUKu1oir3v67ths76Wb9pXmm5cAyZr8Re9y4mRd4NHOPgXeT4tb05FyigggobFmUxcUyh8lEU04J0KGwlGknjJNPcO1GQ4sX6HAA6VutOGrgbtxpjNRzvHdsORjN5G5oqgzrKubLakNYew3anBrjijWwC0Q2JIg5Bel8QvLe7XrlRaEuy/5u5il+yoXl+5Kd5Xi+c/eLP/3mDUJePbPbXFhrA3CVxp2PEO8wtAOLN0ruc384OMnDoq7+BwZSXNJwfpwcEvpVvneXpXbkOkACsVLB4vG+id7DN5pUp2qoW0cR9tr8TXeyEhiaHWv2LTCYpeh8Evt5ffA6HrxG3zCW/d2zStl7DUvK0nj6xegb4SKwY/X6hCW+qDa6xFG6VjwxYdZUUTCzWHhFKKG7nd8/ggvoSGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2244,"cache_read_input_tokens":60174,"cache_creation":{"ephemeral_5m_input_tokens":2244,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ed0c4b13-07be-4d20-a085-786d969349f9"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG94iPNMvHg1knBZY8Si","type":"message","role":"assistant","content":[{"type":"text","text":"Les jauges globales sont à ~65 %, donc le 6 % vient probablement d'ailleurs — je vérifie la part de créateurs réellement enrichis par les acteurs Apify (tampons `*_at` dans `metrics`)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2244,"cache_read_input_tokens":60174,"cache_creation":{"ephemeral_5m_input_tokens":2244,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"dfd337f5-ae1f-4c4a-ba9d-6eb5909061af"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG94iPNMvHg1knBZY8Si","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CAAHt95j6KwzAP89i8xCZE","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"\nSELECT COUNT(DISTINCT creator_id) FROM accounts WHERE metrics LIKE '%-apify_at%';\" && sqlite3 data/creaka.db \"\nSELECT platform,\n COUNT(*) total,\n SUM(CASE WHEN metrics LIKE '%-apify_at%' THEN 1 ELSE 0 END) enrichis_apify\nFROM accounts GROUP BY platform ORDER BY total DESC;\"","description":"Creators/accounts enriched by Apify actors"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2244,"cache_read_input_tokens":60174,"cache_creation":{"ephemeral_5m_input_tokens":2244,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ef422867-5512-487f-8657-18a810c4e3ad"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CAAHt95j6KwzAP89i8xCZE","type":"tool_result","content":"10662\nyoutube|9545|8847\ninstagram|2161|1155\ntiktok|547|404\npodcast|493|0\ntwitch|482|454\nx|440|54\nfacebook|357|78\nonlyfans|256|0\nsite-web|185|0\nkick|145|145\ndiscord|56|28\nspotify|51|0\nmym|25|0\npatreon|22|6\nlinkedin|21|0\nsubstack|21|0\nfansly|19|19\nsnapchat|15|8\nthreads|5|3","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a1ed7bf8-1c5c-4cb4-9ddb-4773acdc9760","timestamp":"2026-08-21T15:35:50.848Z","tool_use_result":{"stdout":"10662\nyoutube|9545|8847\ninstagram|2161|1155\ntiktok|547|404\npodcast|493|0\ntwitch|482|454\nx|440|54\nfacebook|357|78\nonlyfans|256|0\nsite-web|185|0\nkick|145|145\ndiscord|56|28\nspotify|51|0\nmym|25|0\npatreon|22|6\nlinkedin|21|0\nsubstack|21|0\nfansly|19|19\nsnapchat|15|8\nthreads|5|3","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG94iPNMvHg1knBZY8Si","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RjAUxoLMkSEMSdGcm3FYop","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -rn -i \"6 %\\|6%\\|complet\" frontend/*.html frontend/js/*.js 2>/dev/null | grep -i complet | head -10; ls frontend","description":"Find where completeness shows in frontend"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2244,"cache_read_input_tokens":60174,"cache_creation":{"ephemeral_5m_input_tokens":2244,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3df8f797-8736-4e63-9327-bd1c96a80dd6"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RjAUxoLMkSEMSdGcm3FYop","type":"tool_result","content":"(eval):1: no matches found: frontend/*.html\ndist\nsrc","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3b3a8772-eab9-4317-8bd4-8dcf01c06ec2","timestamp":"2026-08-21T15:35:51.444Z","tool_use_result":{"stdout":"(eval):1: no matches found: frontend/*.html\ndist\nsrc","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ddf96838-f897-45c0-8d16-e90a679540c2","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG95nMWsLkGgAbfEn3cL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013mVbCbUaidZHJdg2qAkaRb","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -rn -i \"complétude\\|completude\" frontend/src creaka --include=\"*.js\" --include=\"*.py\" --include=\"*.html\" -l | head; grep -rn \"fiche_complete\\|complete\" creaka/web.py | head -10","description":"Locate completeness in frontend source"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1091,"cache_read_input_tokens":62418,"cache_creation":{"ephemeral_5m_input_tokens":1091,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3cd8285f-dd8c-4927-ade1-7c8109e4da1f"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013mVbCbUaidZHJdg2qAkaRb","type":"tool_result","content":"frontend/src/index.template.html\ncreaka/web.py\ncreaka/stats.py","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c73adf64-0ad9-427f-803e-63271bf7e2eb","timestamp":"2026-08-21T15:35:59.858Z","tool_use_result":{"stdout":"frontend/src/index.template.html\ncreaka/web.py\ncreaka/stats.py","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG95nMWsLkGgAbfEn3cL","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YCUtU7fTP9q4STvBpZgVqs","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"\nSELECT COUNT(*) FROM creators c WHERE c.status='active'\nAND NOT EXISTS (SELECT 1 FROM accounts a WHERE a.creator_id=c.id AND a.metrics NOT LIKE '%-apify_at%' AND a.platform NOT IN ('podcast','site-web','spotify','linkedin','substack','mym','autre'));\" 2>/dev/null; sqlite3 data/creaka.db \"\nSELECT COUNT(*) FROM creators WHERE status='active' AND bio IS NOT NULL AND bio!='' AND (region IS NOT NULL OR city IS NOT NULL) AND total_reach>0 AND json_extract(doc,'\\$.avatar_url') IS NOT NULL;\"","description":"Count fully-complete creator records"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1091,"cache_read_input_tokens":62418,"cache_creation":{"ephemeral_5m_input_tokens":1091,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"45c0c41b-2582-4e7c-97d2-d0058bcbe256"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YCUtU7fTP9q4STvBpZgVqs","type":"tool_result","content":"12618\n1633","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"161847fa-be9d-4034-8c6c-656c714066ea","timestamp":"2026-08-21T15:36:02.006Z","tool_use_result":{"stdout":"12618\n1633","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7675b358-e3bd-4176-84a2-a38d473d65f6","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG96ZB3J47v3w2ynLhZx","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrgMKpAEIERgCKkCRF+EC0V1isuYU5PfvGltd9wBaOPsBWyLz2Qo46AjucKBcFPBnJRTVpqxF2apQHrzmHi14rX7EHKuREAk6XvPDMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHn4KHUBhIMka+PI0Jf56761MyHGgxlaON63jpt51uBf1YiMGzPjeZTDtrCYakQhUN6tSScjoBmWJsvOXaZ7iDVnO/nj4f+tgslBJrb0BqSbSP1yiq2AcJY+1DbRx114lX9CHgi983HdwM3wk/qXewUHh9SnjrS702wMu697msF9a40ogQ4pc/m7ccsyFpoeVtqrxKflKi9byYuKqw14EalwjN7U9dCS2d24TjZmVwEIxgxikYcC5L0XUmUBV8CefAPMTzzrfmGGSWtkpEY1zx7iDbpnj2vCHCdX+rsE5p+4HQIi1D29SyWAT1TzdFC12Xp9mmsdYtXcx7zuZb0e+UkERd4zbUUuD3bYaHJGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":621,"cache_read_input_tokens":63509,"cache_creation":{"ephemeral_5m_input_tokens":621,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6472a4cb-97df-4792-8865-6b9c201c604b"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG96ZB3J47v3w2ynLhZx","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015SBYCS8GReZSXZTdbV6vJD","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -n -i \"complétude\\|completude\\|complete\" creaka/web.py frontend/src/index.template.html | head -20","description":"Find completeness references"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":621,"cache_read_input_tokens":63509,"cache_creation":{"ephemeral_5m_input_tokens":621,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b1a3e2c1-198a-4d0d-a8b7-9a4a8eca1dcb"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015SBYCS8GReZSXZTdbV6vJD","type":"tool_result","content":"creaka/web.py:153: La vie privée prime sur la complétude (§15) : on masque d'abord, la\nfrontend/src/index.template.html:1124:/* --- jauge demi-arc (taux, couvertures, complétude) --- */","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5629f630-9c12-44b9-a406-e28463a44301","timestamp":"2026-08-21T15:36:09.120Z","tool_use_result":{"stdout":"creaka/web.py:153: La vie privée prime sur la complétude (§15) : on masque d'abord, la\nfrontend/src/index.template.html:1124:/* --- jauge demi-arc (taux, couvertures, complétude) --- */","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7de59d02-218a-48ad-93d6-57e0cc0e12dc","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG975GXswDy4ZpWnF3UR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016P7kpfqCaoUb4ijyQKQq21","name":"Bash","input":{"command":"cd ~/apps/crea-ka && python3 -c \"\nimport json, sqlite3\ndb = sqlite3.connect('data/creaka.db')\nrows = db.execute(\\\"SELECT bio, region, city, total_reach, doc FROM creators WHERE status='active'\\\").fetchall()\nn = len(rows)\nn_bio = sum(1 for r in rows if r[0])\nn_loc = sum(1 for r in rows if r[1] or r[2])\nn_reach = sum(1 for r in rows if r[3])\nn_av = sum(1 for r in rows if json.loads(r[4]).get('avatar_url'))\nprint('actifs', n)\nfor lbl, v in [('bio',n_bio),('lieu',n_loc),('reach',n_reach),('avatar',n_av)]:\n print(lbl, v, round(100*v/n,1))\nprint('complétude moyenne', round((n_bio+n_loc+n_reach+n_av)/4/n*100,1))\n\"","description":"Recompute the exact completeness gauge"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":282,"cache_read_input_tokens":64130,"cache_creation":{"ephemeral_5m_input_tokens":282,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"77cbe02b-3e6d-4172-8bf9-dcd170ea93e6"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016P7kpfqCaoUb4ijyQKQq21","type":"tool_result","content":"actifs 12739\nbio 9833 77.2\nlieu 1837 14.4\nreach 10398 81.6\navatar 11276 88.5\ncomplétude moyenne 65.4","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6afb9a83-9330-469a-87d5-2c875177f7f2","timestamp":"2026-08-21T15:36:18.515Z","tool_use_result":{"stdout":"actifs 12739\nbio 9833 77.2\nlieu 1837 14.4\nreach 10398 81.6\navatar 11276 88.5\ncomplétude moyenne 65.4","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5833d295-88e2-41db-9062-1442cbb7edaa","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG97mKKD15qoEjWph6oU","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS6gUKpAEIERgCKkDmjMaXPxLwvCE22EeZRQDJmOqUA5yn1PVAmjFj4vrqEcMP1SAns81xuNYK+WKD1+Ku4VlB2Ag3Q97HYCE/P7fuMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH74KHUBhIMc5bArCGApJoSpukiGgxk4XHr4WrNmiuePbIiMLcbd/DEEOD+VxvtFnzUq9hMdtqvbrJBYXW0A7gZLmOFCNw6XFE15UWuznWjWHnAYCryAwpHtlZZH9jTNkjZp7gjTC/hTHOQAtr89P1rbSl0xjdq27LrHKT3/kZhu1z6FLHqXR4oCMU3YIgJjMBauFszbVROu/TUHZaw5FJjSD9GLSzWo8lbwWWBM5nKDN8QqG0zDjkfB5b0gcTY+CYRWOP1zUI+8z+Bn6/lnacCC3d9yYNzMaLUK04BlAOglkgdNCual5dc3gyLik/hNxvtWJmg7Ebq/4w0iXlQUejwDGp52kENBgfMhUSdkDKrrMvDYDJFIf0/Jovg33d0w1sTVPeyaq1IvKOkn129VRgyJJ6S9XbTYTw7zt9RMm3E71bTfwMVtGsitBuCkvs+FrviOCh49p4fOMh5PqFZM1Qaq6m34xderxelEdGsp02cQYZ3GyDLz92b9dvCvVbjXbBt9URsBES/v0i1aDLgpYSBMaJB2Sgjy+tuHfEGu54rDxpxM5Ad7OW/8IIjPaWhNszXbGdgkkAx3ppeD3quUZ1RBWbmYZJDOyzRLJp3O/Elqjc+9GpuCxGPQ7gZ4Ia0YoquBs5syo6OZKrfXkw2o7yp1LMQAl2ghhS6erEvtFO4e3lZIIJWuZMw4PAFg4xXDRMX+9RRnEFms7OXDvXGa6phfwEH6xlcsIHb/gUIwayzUjegcQlIKELc6OLEbRblbUuJhKWjN7AUmxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":473,"cache_read_input_tokens":64412,"cache_creation":{"ephemeral_5m_input_tokens":473,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"80c2bf6d-0dd4-47be-bbb7-248fa8ecebba"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG97mKKD15qoEjWph6oU","type":"message","role":"assistant","content":[{"type":"text","text":"La jauge calculée en base est à 65,4 % — je vérifie ce que le site live affiche (peut-être une valeur périmée ou une autre métrique)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":473,"cache_read_input_tokens":64412,"cache_creation":{"ephemeral_5m_input_tokens":473,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8b3fbac4-6f67-4f43-9e52-8ed8e7d00f4f"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG97mKKD15qoEjWph6oU","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01B5owh8ZeVYULDLS5kEavZG","name":"Bash","input":{"command":"pm2 list | grep -i crea; cd ~/apps/crea-ka && grep -rn \"port\\|PORT\" run.py | head -5","description":"Find crea-ka process and port"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":473,"cache_read_input_tokens":64412,"cache_creation":{"ephemeral_5m_input_tokens":473,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5904f909-1101-4437-a4f5-3e7406f60d08"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B5owh8ZeVYULDLS5kEavZG","type":"tool_result","content":"│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 4206 │ 14h │ 7 │ online │ 0% │ 394.9mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 14h │ 27 │ online │ 0% │ 145.6mb │ simon-p… │ disabled │\nrun.py:10: python run.py serve [port] # démarre l'API + le frontend (défaut 8160)\nrun.py:13:from __future__ import annotations\nrun.py:15:import os\nrun.py:16:import sys\nrun.py:17:from pathlib import Path","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"615ea01b-7210-4857-990f-1b82e052db27","timestamp":"2026-08-21T15:36:27.888Z","tool_use_result":{"stdout":"│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 4206 │ 14h │ 7 │ online │ 0% │ 394.9mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 14h │ 27 │ online │ 0% │ 145.6mb │ simon-p… │ disabled │\nrun.py:10: python run.py serve [port] # démarre l'API + le frontend (défaut 8160)\nrun.py:13:from __future__ import annotations\nrun.py:15:import os\nrun.py:16:import sys\nrun.py:17:from pathlib import Path","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a2ef82a5-bf34-4b0c-8ec3-6cad2d53e3f2","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG98UsPLrpNEs6af4vYh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01VBZbKeVqBrGkeZrUx9Nzbk","name":"Bash","input":{"command":"curl -s \"http://localhost:8160/api/stats\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nfor g in d.get('gauges',[]): print(g['id'], g['value'], g['label'][:60])\n\" 2>/dev/null || curl -s \"http://localhost:8160/api/stats\" | head -c 300","description":"Check live stats gauges"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":838,"cache_read_input_tokens":64885,"cache_creation":{"ephemeral_5m_input_tokens":838,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d30f5d97-9fe4-4ffc-91c4-3a335b4549d8"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01VBZbKeVqBrGkeZrUx9Nzbk","type":"tool_result","content":"(Bash completed with no output)","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"78298d1c-fbe6-46c1-bbbe-3c0414570de8","timestamp":"2026-08-21T15:36:35.037Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"06b564d6-0baa-49e8-a45b-cae1f28171be","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG98z1LBVhGZpWYnKiSQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PKSQaEQn3NKPXMtyqPrkQC","name":"Bash","input":{"command":"curl -s -o /dev/null -w \"%{http_code}\\n\" http://localhost:8160/ ; curl -s http://localhost:8160/api/stats | head -c 400; echo; pm2 describe crea-ka-web 2>/dev/null | grep -E \"script|args\" | head -5","description":"Probe web app and stats endpoint"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":206,"cache_read_input_tokens":65723,"cache_creation":{"ephemeral_5m_input_tokens":206,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fbb15af3-436a-4dbe-9295-2622a3c238ca"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PKSQaEQn3NKPXMtyqPrkQC","type":"tool_result","content":"200\n{\"creators\":12739,\"accounts\":14603,\"by_platform\":{\"youtube\":9544,\"instagram\":2161,\"tiktok\":547,\"twitch\":482,\"x\":440,\"facebook\":357,\"onlyfans\":256,\"podcast\":251,\"site-web\":185,\"kick\":145,\"discord\":56,\"spotify\":51,\"mym\":25,\"patreon\":22,\"substack\":21,\"linkedin\":21,\"fansly\":19,\"snapchat\":15,\"threads\":5},\"by_tier\":{\"macro\":214,\"mega\":55,\"micro\":730,\"nano\":11740},\"by_niche\":{\"autre\":3603,\"musique\":1547,\n│ script path │ /Users/simon-pierreboucher/apps/crea-ka/.venv/bin/python │\n│ script args │ run.py serve 8160 │\n│ interpreter args │ N/A │\n│ script id │ 87 │","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c3d77349-eb97-4e83-967a-85ae9480fdc2","timestamp":"2026-08-21T15:36:41.289Z","tool_use_result":{"stdout":"200\n{\"creators\":12739,\"accounts\":14603,\"by_platform\":{\"youtube\":9544,\"instagram\":2161,\"tiktok\":547,\"twitch\":482,\"x\":440,\"facebook\":357,\"onlyfans\":256,\"podcast\":251,\"site-web\":185,\"kick\":145,\"discord\":56,\"spotify\":51,\"mym\":25,\"patreon\":22,\"substack\":21,\"linkedin\":21,\"fansly\":19,\"snapchat\":15,\"threads\":5},\"by_tier\":{\"macro\":214,\"mega\":55,\"micro\":730,\"nano\":11740},\"by_niche\":{\"autre\":3603,\"musique\":1547,\n│ script path │ /Users/simon-pierreboucher/apps/crea-ka/.venv/bin/python │\n│ script args │ run.py serve 8160 │\n│ interpreter args │ N/A │\n│ script id │ 87 │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"99a763a5-5720-4827-a0e5-cf6a94a7b27a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG99SghE7iyhyqzUhhok","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISogcKpAEIERgCKkAjkoMf0rhY7I4pH/T7jV88Km96fVVsetzxbOLZiap+KqtZT0+XLQbDG09LiZJXhY25M2FiSdiG3P/5XIhrvuDRMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGS4aHUBhIMDS1SmP4ctvzVCBj8GgxLuvCD75RmzmtwWCUiMKm9H6v1q0rveQZ/Vu8WVX2CUtTnFfHfGZWTO6It4VBhiRCM952vwph18Q+6DXLR2SqqBVvjg1KqtlclZEsg+bZL6zMnIHgnFyqTb0/ngG8ykEHWgu/vqsWhBYf0Mwkfvqud0ukrvPqLU3cTbwJgl5/ozCkjGdVrpblnFIiZWrNdKB22zNn+E0N7X8QUqpX/2KGQARA3yKVmz5lW2re5KGTnE7UmLHZzLYgTglsng8JQFOh6LXIsBSaW20CvMXoKOrUn+/gN2YH9HAPsQVWuJLn1LCy0Xa5a0HirYJi03kyBpVCviohKiPssm2G+xsaM6Gmlbi/r7GRjFGvN4cC90SrxL9w38TVzJSrO5+iO0pIjBJoJnAOxu9yLxeP6yTazjdrF+14rPvmAKBFNOqKWsaTwMRliqAtuQdLFu08MJWJNxTqR6Z0ZPLeVieMOYPQjMrZXaR8IKUD5L3hCpmWmdUKVg+F5RP4EUkEUGI4gxDqwFqnbVJ92+vfkbBAju1l4mTv6OEJGJIoLSw/xdtbpxKZSoA2LcA0/cb6wxudaVVeFuRV9yLUfDRAvdLgb07q4nd4f2ND/7nW9qA6s1JKdpH5pZys9/Sw/+p3QKVGfhoDqPT6+BHceo6O+Zq9Sr1VnwuhBjjlZINl/ohZG8d4xzBCDWylt9r+j15ZdLoKQd8Z0LTPA5vlRwJodoF+ePCkzKPfj7bPKepMyCXPjwANoEpjh6sgEOAW22WY/qsIM0xDxRfDACvPl9nSXgT3HNHUnGCndGieSOUp4PCX3ufdVTCUvdYO/Yltch3s3GkSXQnKqEJ0orxR/4MwaiEZrzMIwThLBRTLp3b2lEaH0Mdn8VUlgnHcxVV6RLrX/Eeg6prm8I4XzICayQamsVK3wKTW0dFRxkUHy+U+qtqVfLSEAdJgkhQz18WB0dgpmYETMxAnjGLtNbMNjjQJtE3j1clfkVgKeTw3r8WPXth2Z4zkYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":486,"cache_read_input_tokens":65929,"cache_creation":{"ephemeral_5m_input_tokens":486,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"63d6e59d-ecef-4964-9154-158de9557308"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG99SghE7iyhyqzUhhok","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FwB3xQXF9q93Ds1T5r9a4F","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -n \"route\\|def api\" creaka/web.py | head -30","description":"List API routes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":486,"cache_read_input_tokens":65929,"cache_creation":{"ephemeral_5m_input_tokens":486,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ada46658-5a23-41b3-9dc8-448334bf0e53"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FwB3xQXF9q93Ds1T5r9a4F","type":"tool_result","content":"48:app.include_router(auth.router)","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9b12306e-62e7-4b68-bc56-3b3eb0a94906","timestamp":"2026-08-21T15:36:51.034Z","tool_use_result":{"stdout":"48:app.include_router(auth.router)","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"70a39026-036c-488b-bdf2-dcb98e82fd26","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9AAWe1esfMrRVwYZDh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JfWZgt8SdSmUAdAuW36P2Z","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -n \"@app\\|@router\" creaka/web.py | head -40","description":"List FastAPI endpoints"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":391,"cache_read_input_tokens":66415,"cache_creation":{"ephemeral_5m_input_tokens":391,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"82333fe6-bc1d-4ec5-92dc-a24fa47fb283"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JfWZgt8SdSmUAdAuW36P2Z","type":"tool_result","content":"51:@app.get(\"/healthz\")\n56:@app.get(\"/api/health\")\n63:@app.get(\"/api/creators\")\n73:@app.get(\"/api/creators/{cid}\")\n84:@app.get(\"/api/stats\")\n92:@app.get(\"/api/stats/dashboard\")\n104:@app.get(\"/api/stats/report\")\n125:@app.get(\"/api/taxonomies\")\n133:@app.get(\"/api/sources\")\n149:@app.post(\"/api/optout\")\n169:@app.get(\"/\")\n177:@app.get(\"/{path:path}\")","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"148ad6b9-fde0-42e6-b151-c13aac6ee9a2","timestamp":"2026-08-21T15:36:56.579Z","tool_use_result":{"stdout":"51:@app.get(\"/healthz\")\n56:@app.get(\"/api/health\")\n63:@app.get(\"/api/creators\")\n73:@app.get(\"/api/creators/{cid}\")\n84:@app.get(\"/api/stats\")\n92:@app.get(\"/api/stats/dashboard\")\n104:@app.get(\"/api/stats/report\")\n125:@app.get(\"/api/taxonomies\")\n133:@app.get(\"/api/sources\")\n149:@app.post(\"/api/optout\")\n169:@app.get(\"/\")\n177:@app.get(\"/{path:path}\")","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6910a17b-878e-4dfa-97a1-a62266d37ec3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9AeWNeHvURP9vYpVez","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01M98TyzRbMkg8C9TGCcnHv6","name":"Bash","input":{"command":"curl -s \"http://localhost:8160/api/stats/dashboard\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nfor g in d.get('gauges',[]): print(g['id'], g['value'], '|', g['label'][:70])\n\"","description":"Read live dashboard gauges"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":295,"cache_read_input_tokens":66806,"cache_creation":{"ephemeral_5m_input_tokens":295,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8452e864-b9e4-42e4-88b2-ba0ef85679fc"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01M98TyzRbMkg8C9TGCcnHv6","type":"tool_result","content":"multi 6.8 | Créateurs multi-plateformes (2 comptes et +)\nbio 77.2 | Fiches avec biographie\nloc 14.4 | Fiches avec ville ou région déclarée\nreach 81.6 | Fiches avec audience connue\ncomplete 65.4 | Complétude moyenne des fiches (bio, lieu, audience, photo)","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"52568956-957f-457f-9656-1fd96455c53d","timestamp":"2026-08-21T15:37:03.557Z","tool_use_result":{"stdout":"multi 6.8 | Créateurs multi-plateformes (2 comptes et +)\nbio 77.2 | Fiches avec biographie\nloc 14.4 | Fiches avec ville ou région déclarée\nreach 81.6 | Fiches avec audience connue\ncomplete 65.4 | Complétude moyenne des fiches (bio, lieu, audience, photo)","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"77381090-df43-499d-88b0-a898c3163c70","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9B6NsLJGn9dLC3DJzk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9RsKpAEIERgCKkBevHVgmNSFV1CdiPC8Xoe5C9Sx0gp/xA1vW6Z2QqooGyG/T2B7NXnDbYa6d2IZ6FjlwHovooWR31qp7NFhAmo3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG34aHUBhIM/FpYQJ8gcNE5yl79GgzVBbZAo9IXPeUfhoEiMC/+WhQ7hJTAVOIigEqIWlsdVj2GtF8Lc+ghvH/9MsqZiNu9WmljXiNHgZsMX//vSCr9GShEx06P9jTr6DwcZniLNq2mJfceXoBJQC7hm1GsytHPqAHJpeN0zL7xTnjKWmk20IdaU0Xg7WNEJvCy2R2faxbDNi40DhGxPaOPv0iqC/ED4bRRw+45LV8AreElo4QhwcSZA1HLa1XtyOwuEnrkoalGEDpaMJHMbRMmUooLTXRw1WprIJHP/Qwy8gUGXZhnAFT24TWN5FhdAb+4m10zbDo3kOoFd1Ojv5pKiIUunis0jCCidz/TPE95MAU9p+W/gbaOOklWc2AKO7t60NfzugLXHS9ZhXu77Aj9M0s/V9IEJt0KmKVUYJNpO5zffVcrTLkMgX0spVF9Do0J89z4scH+VV9rT/1aX4xZtEvgviAKmFhq95Zt0b499JfWrP9lpRxq4SkMDe7Dmc2mh7Wu4W2dQP+NRo9g8T2IaxFIyiut72gC9mUCzA78sshZsc/f/d1HZQgW4k/tp2fkdB3yB7g27lJF0g/WeLbNaNpirMufysqNImBx1TJgbevVv7YLL2AhbiqpSNxVdP6pdtf6Kq4BCmjkLwIm1+ENASFwNpL11QDFZqdGmqgFZH75XuzAPQ28r2KF0KUBYDLZa/xW4f6FZuc584cJjsCsrQjnd6r4BeAhbFzZZWprqvnvo7gbFPDUFTWftldxjnSCZReE/cPeTTD4vegV3gR8wmJQAX+cvRbW/ywNMhPVZHNoyE8kqCJ2xumCnHzuKynv7BwtZVhwJX16NxgD4d20/vI/H5O0qOy8LZfrHTdqbxSRrUgnGodAqncfBtMubClNQKV1chsXJ+quEPEYomxhNMVyOwtpuXncmIKtE7RnPeNVqE4niuRSfIwG/L1fc+Xpe4xeosITB6K608Jp4Y13kL58j0dTaiVHOqdwDVr3fto9cNa/ROx4vxGPBrgITnYal5LaMTxSfKYnPbLNZHnTZ9MInpWqvfHB1aFigLUhAFbzPgjxdVp7PBLrbATg6XwNVn6NGy8/NMw+O+GAkALaTc7Y3aItZnjfMQ721iBvjKj7Oci39k3lzpIW9PVzYcjVGDTQkjGgevpZ775G5RSs4IcRuIiVJTTUl40KDaq/45CbpU+83vLFb3kfpuQAd1/vm9+GlOoaXXm7pkdc/gWp6ZmUpM3yL/ZSvxOdlpyod0OUYTyzBk0g8RMYX4R4OYvhZ6A/Pti+tswFuDykOSG4Clr2HPBBOSBpRVH38zoEgzfVsEmByJjVPTUYS1TBGQ3YRzqFpghOYrXqP6HE4e6ayv6PhbhBcSYAtHoDft5CSKryK0Fn3IrbIoqE1f2APl/8YyJGGZO8K2QhlTxp9Dx/4ezcOTWHtH3SsTMu5Sizqx89pHh0Qtu1A4Lt4Ph7T4VF3/dLx+DLEq6Xca0wLlLtPs8Hr01Obx7CAzKdD7InSRNt1Z3/MgJwKFzjnYN7nlreHdaa0zjRnotoARbhsqj0a3bGPeioZULHqx+PCkOrp6p3wA3pEyDNxDZPnrzp+Bk363Ggq8mZOp3Rbq7tphIpMr4jkcWX4GDZUIlYfdUEpLgETo8qHgJ5aVL3KQeF5Yo6/DvdPejsHjfmPusCvGT3sb26fatJ5Sr6waqpAWLLrBBa4DUPn/011rg/PR0/NykWDSehuqkX5afFCJ2mBdcJM3feV7ZoAti5QUEc0MDZoGxIPUWmaArDXU+Cj/ng6OrUFCYGYeEbMUZtONc5ODgnLdrGrG1V0rc/fXj8RiB60hE250Pw40a6mbfCXPmUevcAjqJm3UNC+CBk/vpIY7A5EXCw4PD9MTWQR2rscVkqPYW+Qth0y6ChGg0ckXkNsOKZHDjA7sRk3r52975KfSpihpai/xAK0R2iN/R4TNL0tSfIwD5midoiUevbQEAL+Kgc1Ewic/TjwHowruSm9DsXofzYfumf4NnnX8oUYNjN2raTaWkf/CDo9Si20dwWFK3tEoYRhVxpdT+lUZ+lMib+eJ5FHnsfsYQZEjtLwh6bEWJMxpNz/6iji0bFlaH6/OAlBxa9ykZd9e0NPLfAwkuPzOXcz9TFHInm3C2ECs/oKba/GzTzukGhNSZOUHlox6ue1CIovkR2qBIM2JOfgkRJQEKwXRIz/Z5chGOV6Xj9GECd6/4np066i6KUz7tYClhqsn4r1vPPlYCAs/z6U9neYFfueE5bhM1ttf1TrrI/X6hqAHNfBbL4RUp/SRNOWivdKG1pq/iclmnKY97Hfys0JFRfJ2FmrjGYggi9kbraRBt5t+axD7bqruT9Z311ua2sW2gy1L3CluX8O87OhE/GuoN8SZTN9zt400x6DBRhVSvGX+mWEZfF5eRbHNHCUV8P4ubjM4y3719Tdq8HAQdcITX5HT74/3HrTcLJva4LrOsFEHp/Wk9iyuRH2oNKZj1wYtaNLCcOgM2i5J03/m3/dWaZ2fr/XiRFDZZ0KopSB1md2UlgCtqcWbofdpj+GxYkOUzb0GHGnIi4BaH47OPz1HlJ2hdzjD1Vm1f23QdTNQCRVYpMqZqz+tTT7kACos0IOBWiTAjT/q1u4k1DlxaiVFJVwNDiFs2mi9Oh61h14tPv5ze3Bky65fMywKeXdPKgqiyNUgdizhMOZUkQaXXZXLy1Din7Aq7B+Wj7ywd7eLhKbPe8HY6muYVMMFTszqDe0321OZW9+L2P0i5zM9Qd2KGxkZcdbTS9aj6MZwe43djXsHGFzPx3pjbHMJ19mHaIZPFIsVf47VWUxZwC3bZRGb/b1pwh+slys31n+iH2pB3zvoCVoclk6FMf7dsLDmNvub3cQUMQa7zAAKifPSUUc0RC2DHLAXNLSpJ6OEGlPvTOns3Hh/hZj5DX5n4cMjyhFpqSyinVROXwIlk+9d7BC8iMJFoT+S/6enptTfKMfsZLkGmTnyS610kGw8/EAKZ4Y4mz7mu1wkw7QYnRKsFIrRweDNJgqO1+gIGZIX7nOisJuyddfAGO2DGF/QeG1M31BceaYaUvIIV6L8WPBbWj/DmlBZyZGer8LyikCLo8YWlC/ZJtX9DEVQ3+sYos8raIzLWlv2+DztdI8RCBnGu6eYl7Dzfp2a9FQhAUpDYUmqXx858ZPj4Ox1J4GsW9ZJL0XaDNNbE/2hkWzvguOq8CcudPiMD4ZXgiqbE/FoA5hO3dzr0Dl0rJ2dEZA+lCm+kKfQp+SxTxc0wopD0RYMiSe0qAde+8ZQKlSmttsf3Lo4GBvq/RHJ+04/KR/TBsZVlk7e4kGTNnFw5GTQpxTyRzultRn7dY6Pa3vBKvayx9ogYIJGXhkB6oce3XCPlzSgRImQfT2nfMzCi4R8Ds9qBMGOF/ufpnrYGzyMx7Pdre4B0/z+yUwUlYsfFWqPo26UTUUkk54ml5YoBVvH4rQrAoelSxFAgpI86ys05o+zqNYHEAhqkD7uTO4WemXAKH/rg4Fe/cYCteoFDv9y7DfpNlZVMb3Y3dj1wE5/7DHHQ8zZBBdMKZixPVcdF+aumYFLMNyfooNznT1rErfnZx7LMRG8ZuM5p/PCHfplruAOPkNl0JfweYydtQ5qzYcpsJGfATH0wW9xyCMYOLM9u+oeeEvNuE9pURTT3ODtF6gmypA3ayh2jTjKQbT606ftR5YnZ3NsYLQULfKnAIIgCJaHFthIKpv706eFwrZoRUNwmwc5bGUgAJxaJZ9+iuJ7SgvyvsNYmP+REz/Sx0j0WKocQGAf6e2optE30NtXuyikXn9LMuVE5NxYvjZSaL79N9baLZoC7Ho8zFTm+hffr/0P0iUoxpX4BMK8Jr1NZu8zMYyPcLEEkgDxRWYvH/AwDm99eypxPzEEo7eqXC5ZIlz8WvzbT08n3912GrjuZul5UKNp1q3iWFSxcfjWqCxyDC/IbpBcP5G95ppzp5rKZhK8MshzwpbVZtoJZAQchqMdNUtN64SVcOwqamyQIRvTl7RZps0p7TkBsVDNnp5dxjLLwCLQC2XAMm/Lr9rksg169EpgoGAPXibe8Aefq0qIdwsdFB/ohoTfzjd9WlkC2Xx9h3jsvj0iVGT3/f9VF/x0m64T+AZ1xo4ku0gILJuRCEWlRMPZKhYlk2Q3on9HuWtCW770LpcgqzMy+AdcjoCPzNSLbatp+7ypfYXwNXR4fEurKCwTke5amCm+cb8V6X5553yN2ggETRAYM2vor+VHQJPHcWQXSZ0HiGnRVcJ2MkGumEwQEgE1FnWWDrFCNDweUI8ARwp9IaMibk62hEjb1buQSmKgGBcAom/2JD3bswBf6I58hHpSuqEhvEPtLpSfweMHo1szc7mHyecwvblNmFSNgDgmNffR8o9ZTg5sfo2PoHI+LVuxiZweWIEkYMqLV/MrvftIQkct+bTOGgxIcm355GOBFgyKnO8Ji6BYZ9oshdoIIBckz37FaOskpMvDmRYsS9G7HW6xV/X01sVm870kfsdOvACvPBVGItiEuo4FSvo6MYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":297,"cache_read_input_tokens":67101,"cache_creation":{"ephemeral_5m_input_tokens":297,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4e9d2525-28a1-461e-82d5-20a5cc2aa046"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9B6NsLJGn9dLC3DJzk","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlAUKpQEIERgCKkCah8iKFdtOli/ILRpWGH9xzQSLl60+9qHtdRTQ/5RYF+OTOZZKx9KpCQj8XoU10v3w6T/v8Y48eJjQp9p6R6wcMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBt+Gh1AYSDL1iiVelGz0BqJcu2BoM7h+Bvk+saf6tan1OIjBivvHXGpNayEJKSTMU4FghO7U47uTgCenRSW0I0GEoUKmTIxn1Usd76dh/DZexOBMqmwP0VTFD6emhW6Ds2GHsk2PNTVE4dEAzAKCAio/H34zo7DEarZl6DgpG1nDuzQTwURFQofdNUug/6oOOpvvb6c7GL4evEE2Q/1XpP94XwRp3voRPQNh55yftM5EVJQ829spbUvX7zsUtk/I8HkxPbbo/bTPJ45pUwlc0ovQVWgvsuD7VqZ52I8k+vQHDwca0IX1+9JGd6go2bMw1FHewmJq5R00pAkhmT2iPM/4INDcEnM74EaMnVt9cQdxla7ycY3dORM7I/hHLiIH3PNw3hjUU9xOcGLnHjgtQcRmWPWXfoTbDyA1woeCmXiJjeJj6F++U9KQUyzB7It5+wTcP1OaR9VHhOvYntHze/Ke9sbJ1kQT34OLjjRYtwLF/C+GSiYelj2WWY1K0zJOR48wNkde+8d66uZt+O1kAihn5l+IhTp7SHfNVuNtKWCLrnRfncq5mmTMeb6fF9aKsKZAbYHA6E+79Wgf5ebHFXLy7uKfRNj6PBXqP/J0ubbkX388q0RpLzhwkDU5y7TYi+Nu3eOKV2tKwB7EPQIOYgaEYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":297,"cache_read_input_tokens":67101,"cache_creation":{"ephemeral_5m_input_tokens":297,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9252b692-4c8f-426f-a07c-68665b535a7e"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9B6NsLJGn9dLC3DJzk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GXW1iF5PRZtF4E2NgXF7vM","name":"Bash","input":{"command":"cd ~/apps/crea-ka && python3 -c \"\nimport json, sqlite3\ndb = sqlite3.connect('data/creaka.db')\n# comptes enrichis dont metrics contient des liens exploitables\nrows = db.execute(\\\"SELECT platform, metrics FROM accounts WHERE metrics IS NOT NULL\\\").fetchall()\nfrom collections import Counter\nc = Counter(); links_total = Counter()\nfor p, m in rows:\n try: d = json.loads(m)\n except: continue\n for k in ('external_links','social_links','bio_links'):\n v = d.get(k)\n if v:\n c[(p,k)] += 1\n links_total[(p,k)] += len(v) if isinstance(v,(list,dict)) else 1\nfor k in sorted(c): print(k, c[k], 'comptes,', links_total[k], 'liens')\n\"","description":"Count harvested but maybe unexploited cross-links"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":297,"cache_read_input_tokens":67101,"cache_creation":{"ephemeral_5m_input_tokens":297,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ab4daf2c-7217-469f-9df4-4e3f89111abc"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GXW1iF5PRZtF4E2NgXF7vM","type":"tool_result","content":"('instagram', 'bio_links') 250 comptes, 391 liens\n('twitch', 'social_links') 44 comptes, 137 liens\n('youtube', 'external_links') 97 comptes, 256 liens","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d1c32cc2-5ad8-481c-8686-ab61382ee64f","timestamp":"2026-08-21T15:37:31.152Z","tool_use_result":{"stdout":"('instagram', 'bio_links') 250 comptes, 391 liens\n('twitch', 'social_links') 44 comptes, 137 liens\n('youtube', 'external_links') 97 comptes, 256 liens","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9B6NsLJGn9dLC3DJzk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Di9XwzguqAznYDLVHRxTvY","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"SELECT cnt, COUNT(*) FROM (SELECT creator_id, COUNT(*) cnt FROM accounts GROUP BY creator_id) GROUP BY cnt ORDER BY cnt;\" && sqlite3 data/creaka.db \"SELECT signal, COUNT(*) FROM accounts GROUP BY signal ORDER BY 2 DESC;\"","description":"Distribution of accounts per creator and signals"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":297,"cache_read_input_tokens":67101,"cache_creation":{"ephemeral_5m_input_tokens":297,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c05667e6-e523-4602-98e0-0da5e50d6837"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Di9XwzguqAznYDLVHRxTvY","type":"tool_result","content":"1|12035\n2|390\n3|230\n4|112\n5|74\n6|35\n7|11\n8|7\n9|4\n10|1\n12|2\n13|2\n16|2\n17|2\n18|1\nprofil_source|9823\nlistes_medias|2610\ncross_link|1157\nlink_in_bio|886\nbase_publique|334\nmention|36","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5d94dfde-d0a4-4ee4-8b20-62b049e36665","timestamp":"2026-08-21T15:37:32.443Z","tool_use_result":{"stdout":"1|12035\n2|390\n3|230\n4|112\n5|74\n6|35\n7|11\n8|7\n9|4\n10|1\n12|2\n13|2\n16|2\n17|2\n18|1\nprofil_source|9823\nlistes_medias|2610\ncross_link|1157\nlink_in_bio|886\nbase_publique|334\nmention|36","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7201ac51-770a-457e-abea-783639a2dd1f","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9DDS5tzgN7LPipPhjC","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISqiAKpAEIERgCKkDLb+8UA8qsF3BD9YlFIVf1163vt+WXi8xBVvkUs/CZNtLTmjEHF4r0IBLh8tyXktooTNSMn3DoVufmAGkdRm2vMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHY4aHUBhIMh+VhTtUqPNvK36HxGgxuVtSiKhhNLipARKkiMILNVB0AdUzomq8Ffq3POmMPTuePJzrTRD3QQ2wMhssMhxBe4FzFMp/+Gj8G6gGsqCqyHpC1CO3xLD1Viq+RqVLJDzBvDi0PJ4fYjpZxaw2MHInltcBjwKRPKHlmwGlaJdbSEKPBXqi7srPKUhtIFi/dTBq3dBZKhTbx7Eh6rIBteIN2HWA0SkoLoMHADRSOopJCiCh9Q1O0PleudyWxrXwDhHez7lZyzb+NtPhIOcaWqJ6pmoZjzU/+VOuiSHSdpICMjiMgFbRD/yWo6CUs8sqC/5njgv0E15XC+f82vdJfLA0bbvywOyxm2N6Iw9K/NNhdfd3qqCPbTrhKYeMR8j8MQqkrD/HYpChi+x+Q5swMW04ltCyJ13c2PumCk3FvCD0hpGQJ3FJOZfM14D8Lvfpgq/2qzuO+PXtXohUjEXMqu1GSVWleXn54WtCoQnxzZAdv8ajK99C+FOpWMsE2hl6kAuA9VtYn7P9nBYyqjUht1kC/RIb6OEXJ/amjEfD7HEYIS7WOhtd+vMpcDqtnqcMAUQrKYhqPJZ0xkzSWzvQCeiD+9xXbwLGPsAWPHRTebh5K/3RipmpPE4ppNA8z+/+xylSgbKYabkZtIb/vEWlH3lSV6Q8QVhVuJcoSxHFeGG90GnjL0Whq4xO2yfzI8WWMElAXJ8YzuT5EBhdeE5LEW2XmF8KNIC88/i+zq1Hp1RHc+W/Ay2YBzmdD3Mx7QH5o7T/Cubse9hu+HvAToXr71S4lRljgbE3naa5MByuN4IOq13l9k5N678ZOUCRrTB13eD5NoOuRfO8lUWPaHiKMlmQV/JeR0uRbZsm66YQxYKR4EViuIku0FzwakFxPLahrkYy/TXkX8kRoug9G3hXYr7TEA7RyHFH7bSBwKKF4fwps2JwtIuq6jSBhSXajX6akca25e0zoPlJmg399tKN/mEhz3RaAEeyc+rX/7Ol2OEa0miaRuE4uw7PGUVaWtvaCj0cz6wgMK3PZd0Qoh40nR2fAgOpBrbY5zvjs0cUQvhGh13r+FHuQprAZESG62fAK7/MNPt0129WAqnVSDWcln+XGszcAl1o5Zc2dPEBhYoL7NU7mRrxFn1TruFUdoPjhy74xFfX7gwleerlOUX0TqJRuXvmE1Nmyf85S3LU6uV6/DpafxH9vk+noGvt7PrGrkyK1aeyAuah0vz7jDuD7ziERIDqSn4z9airImSVdW2jNzD+2807G3JtTE83hhFft2VRSGJcKXBsY9MiNXalmx30My9Ez2q8Lb4cbj5Kg7hvpgzSbuB+2SIqT0hz6nMJv4sFbWiu3dOfT1KcrnhBBy02/FiEtqAiYlsmFUBT1Yl3TnLRsJpBoNPkkeSmId7OC6amNdYYMArULEw03jn+QK4GQbxMVn+GumSvajuigrqkamy8DpNlVx11JnALr6NIMBwZedtI9Nrhvcqp1T19vfu5QlUcCOpWdQjbGZQ6R2X1+2/WtrdRfmFSF/0my4eP2qlUuS0/jzphRl1No/0Dyukdf0mmxlE9PuB9AykFwAjCDlj46q9dOriboNQYwTDyCPfuGAP1H2lE/iRcGRARh2o5ohz4+wHuIGjop6WhLN6qUdfnNF2hSmjZOlNHbYvTeirVzdjYVNIfwrmWsZeUf9qnD1ooKOWjZTY9ktNGLG68AE5aK+Q6fdRPlJpQDd4znnf0cneYaI/yoNANLlVKVL4GLsgvhQt0ohSNjIJhmcNkYqoxcgeweZpBiVmWC97HGkFS5XiTlew3ep+24kFM/12pJ84TGfApAmQmPX2Iraqsd/LUaSzigA5Na651sUq4JBABW4BE2XHy5y8lMjnEiNrl8B3QSl/KefNsfr2wAApg4PSFvvIZG8C1ZhUKLAvbEEABFLq4FYJ2Dfi2I5dY7+zC2MlcENC8SABlLmwWOqnhMpo7n925ILZ2JvSmr36fZBQK9mQZHtlTSavz8KrVdIyL5UFUSiDyDuuTMoPZRrsYll+DhbEFedr558tQUpfm2pfcdZ6opzVLbdbvUG/Mht2Wsws40+fK5+gbG+LDG0Ag936g1cwO0WtoyKMvB4/Fi+wkSfSvnu9KhuBt2pwsAcvpDbg9wbSxfrPJFHip2uO34WvuKLb1TIUlUqXnAqyFCFLWFn+7abVo9TtfiBY55z8RCkgjHzgvE7yM87b6a8YYpOedqwW/gzO1xm10eTtrHOyy6815NbcGnSoJ/wEQ3VVB07XUTphQIG51Bxmy6h0t5HAMd3zCeeQk8/IGN7Ig5iObdz7oSwil6ezKFQZEXnCHQpx01CXqYmyUxrcbvwzLnJtLH87QSjPumoNwORp5EapefKSwyMFiYPtL6L1+qfQC2kUjfeDGQ4bYXmCfE6BSrtPaePC8UkKcMBygqgpImAns3G7PKUDnay/3EfwAfkXtDpmq1lJwjcYd/U8flkP3HmBkdGc9BYzOaLcSJMdC15hB2X1BRj0ebqLmmo9ToQ123MN5N2gqdMZhVNYO/SBro1h+MsRmUXCwzV/+R0MxqbxL1+8wqUZxH/5rPL+DH/3PjrhEOOFJQ2gburvztdPEvgykYcjQUWWox1otzrsyBxYQrzoeaYOEGm6VxwHtEQnWqlgsD50QHKdTXCRWf5rImJlIXOaKmWSG52PsnEZP3HlBt8EIVDo1zo5rWveMNAz/jbBxSSfqDRFflKHEGcTvzJakJqKzwkktJKsMG+a01qL8rSSROgq+H8SMvVMQznoeMFkgoMkLfAbzi2dtjjoi4r12T1pJe5G/pBHzcqmMSVBWsyMDbYlQ466dz95ylNzz4b1rdC5Ge0VxqHANWaAcRnJKGtyNaA9ihh4WB8gcG5YKcDG1/eNgEp34DGBgAipb2T/LHWzL7axW/UfYjVkmFV1hbhw8L3W/XFmzTWycxSKu5SuR+lhg3Jz+wVEnsI7lgeTkQaBjxUk7iy9BcBaDNcSXOc5URpweFtXQdoDM0iAsTqvjBagtn2I0I8bjhNQKL0/UWqOK74blLcsBuKrU+Uua0EZx5y2si/23laknf6IjQ+quay+NTkZBz4wGeGwOb0uSfyB2J9pTDWXj9z4zgE/P8JTykInRrx1P/VIaTd4nS3jBTa5jrB8P89Ub8FwfU33ms8iM+RQgF5ZitcDA7vNIM5iAXttZEK7X9RW6u/FMJxnBZ2JoGwbbaE9LMj+WiuT0k00w+Hos0ZqJ+ZcEQR+0qT6pJQ30APPPYnPgnT5EU93/AsCTXbRV3f17qy4V9dUCEtjJ917MO4dbRih/tihM0WUGuSBe1W4eDVLjt4rNODxE8If/+RXTgIy6DRahV6Cf4420upo9PhaNoE6V/jW8wTrB+tnX/VR2Y1PrXnVMLtGfFF5poqA6TH8J4hkLy7gzvA2c/XpputQqjUFp4odVIAQTu2rI0lugMlN1eFVcs0eXIwaJN4yU+XnWqPKosRDhEKyoWZ9rD5p61rkpBdRaCZXyvZPrc36AoxwBq8mqbxzYpFupzXvOWdHjg8R0y0dqwHeF/fc4IWLooujFjV4y9YCL5xzlPHKTvmTPeK+c7BBBVxdv0Y+2jlC4j+45xRgJWkjeuznUCkt09sJBDIRYHqLzb50FDbPE5xoEfCplF7kNOsyDG+wvL/0eB/s5jJyOhn5lTIuTGXUhNun+dttl69v71cOK4PXMYn1kXPShLggm9Ug39EFLOxHv4ke8SjdOnAk0ckZkWGtH3LInIsem/Z0VGZZtHLDgWEar5E7kihg0sO3JueBSxMm7Tr2ybDWmD93pUq20uPKIRYJSB7o6gn9jbOD6qGGN9K35dt3a7MG0FXX1deszEsBkqDu4nMMcWgA3PLArzS46hacq6I8qYlrDpPhlHcPUpWFiVgOJjGfYnb8/lyJ6raEjIw20HQ1wNJPSsw95OBhAt5OHMyMWuOyNpmPfQL0FXmfHnUMWMFmuaklaf447pjuotgh8yWXB9ehQoaG+UEkcuNSPdIB9koeiUgwoo+cQHTYzpNwx8dcs0LacdCfjMLu83J5CV9ij+jUeLURijxXkg1E8XuXNfFs1bc0FcRqAPSUVfG4TTeadoFChHJx5Bqf7ALPd9cWWgwVn/wsmvUOrwoltkZup2Avdk9osAYuLXfA+Dyiq3CKYKIcaZ8185QGH1yoilTvSEZPG6eevwjXhD+kBDGFy2mGlQVZdXjIhZpRsRRs0Zd1DT/ZALd3u8mtUV6LcpcLzg5weXlD0jQpVpWwfJPzsCOBTg/b2CvIyzruQ/EKqBjIIhdlGRhZkv7LuuLdirXR8T0qOb6wpjdNa3EDAJ5wt1OaXKuULy2wdnuG1C82i1UUmnFeqCYQtx7GwUojhtS/Leb5RlIHLXzv+OtRJIG+yBUljM5Y/pTW8N2qiZYYtbK3Bkheg5szCLg/VDVvDAEwdhfU1OGbdOhadMlWI3bcqPNpYls9rwIGXHthv17YZOm7f31K2zujdqkPtti/3dWXggPv7hCPLk/S6wIGRiKQtzb95kInLDEN021xQXMHcb+KMJyd2X6kDUOhojvV2qS3u4/kPe5uOy8Jf90pepaj6jWLlyaUxIZ9jgNJ47GzqIVamF+SqIzQLgBMdV2uNGwEelWbzS77ScH06P215hWbtrg0lLl/qZXGeWB1Bf7+Tb4Hs+0tkUhuyoZxre4NlvYr4zMDMoCrsPGnYZX/SI/6gtPae6o/+3fv0t8H5UMgPaI/AMNvUsVSkoSdYEZi4rqVeYx+2xpdCVx4cUJJvkGmCElZb946n8N0AgMdRHKzVR5WlDhBBB1nVQbBYUk6guPa/ukIEXLC4PlFAi4p3A9/DxM5flRJtuXrwlEhXgk8T8sBm2t94czWbipiUQ/S+5hVyis/kdUEfSmSBSA1uJqIhmNocrqGsCXqMTHlRIBM3Hj51CCGMI935kRzwfMpceDhGEhQLr47cAqP3RmK7Hkibur1YXhb2F7w+p3sKAkSuEf+De+GbBvk/Uut8eEtIhvTl56FJZPJTClrBlE9oQhKKDHz8V9PUd0+X50x1W3kiRJpJQT7yKmax+J28A/1TdFMyhUL4u9djiAD/CFKX8dPQTxCdbtbRQtDaVeUZTTLaZfEMcchNOTKXSMRgwD/SO53ioaeFDPVeRUvOH77hNkkXDRqmj2jrhmJMDHHX2vTfla44gxqXWIdrq6/E82cjx3mArcdTIIOyWDqC9urBJCeLVkelk9Pun8JN1llMMPzpU5/Aza9HCmoIsSdUBrh93R3XmcL7SzD3vtvxwn6bsvL+bGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2160,"cache_read_input_tokens":67398,"cache_creation":{"ephemeral_5m_input_tokens":2160,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7e9ba361-7edd-40c8-8d48-acf5f11ffcc3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9DDS5tzgN7LPipPhjC","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStAQKpQEIERgCKkBRAnvUjtl0I1hw8kMAoRXHOiZ0/Ef3QwwIzA2hCqU/MzKz7U5AVtepjQc8HWsMmgOTRQZVPwpIf5KMcrZHcn3nMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB2OGh1AYSDNNOVyE72EltrdwevBoMMkWz/1ThNL202KLwIjAnDVADnS32A0Ky8IRk09FdGZBs3ZzegGnCzI1XA5WLl5UPFvuSD+/ZN7/dpstIjLoquwJH0CgGP7cX1Xo2m2Z+T+by418MDTT5B0QpLZIZVMb41Y6/lpXPpRWwjOUFUFK8x2xehdA2e6eq0isuXKou/9hQ7vNE+E9Hr0ZLSNic4kl+uDAfQsXXbQceu9/puunYctkBC9IfMYKVluF9bmwXbY+k3SkTWTSqETC9oD4SO0lo0ashhe9jPQUcVx5ZMJOZ+PplAWluJaal04qq5CPxK6B2rCq87CfQksYLUMv+hppOpjpYujnCnKU201vWiJMt5rMW/c8RQ+zUkWBW+ye567QSH7p9xMcOraUZ0DcgxVUvgk2cCG7dqgVOQJxoD1nP/bEAGUrYSVTq0ar5aj87QnuzJynjKephfwj2lLYwMkAr5039Vs+CWY6GtNFeIaSx/dDjF3yDA42r4088iBLo9azVMNPtxP6q6EtyjoEYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2160,"cache_read_input_tokens":67398,"cache_creation":{"ephemeral_5m_input_tokens":2160,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"133f2de8-9e1b-4dcc-b90b-a633175e5503"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9DDS5tzgN7LPipPhjC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SNqqoVUacRP2QPu5FJ7urY","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -n \"apify\\|Apify\" creaka/ingest.py | head -20; sqlite3 data/creaka.db \"SELECT source, started_at, creators_seen, accounts_linked, errors, status FROM sync_log WHERE source LIKE '%apify%' ORDER BY started_at DESC LIMIT 25;\" 2>/dev/null || sqlite3 data/creaka.db \".schema sync_log\"","description":"Check sync orchestration and Apify run logs"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2160,"cache_read_input_tokens":67398,"cache_creation":{"ephemeral_5m_input_tokens":2160,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e9997119-9b33-4c22-b962-25c2b6595349"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SNqqoVUacRP2QPu5FJ7urY","type":"tool_result","content":"33:ENRICH_ORDER = [\"instagram-apify\", \"snowball-ig\", \"tiktok-apify\", \"x-apify\",\n34: \"facebook-apify\", \"threads-apify\", \"snapchat-apify\",\n35: \"youtube-apify\", \"twitch-apify\", \"kick-apify\",\n36: \"fansly-apify\", \"patreon-apify\", \"discord-apify\",\nCREATE TABLE 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);","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"60f78695-f35b-4f7f-bfed-b018491fe9c4","timestamp":"2026-08-21T15:38:03.789Z","tool_use_result":{"stdout":"33:ENRICH_ORDER = [\"instagram-apify\", \"snowball-ig\", \"tiktok-apify\", \"x-apify\",\n34: \"facebook-apify\", \"threads-apify\", \"snapchat-apify\",\n35: \"youtube-apify\", \"twitch-apify\", \"kick-apify\",\n36: \"fansly-apify\", \"patreon-apify\", \"discord-apify\",\nCREATE TABLE 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);","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9DDS5tzgN7LPipPhjC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TZjJEmeCZasq63r8QwR55r","name":"Bash","input":{"command":"cd ~/apps/crea-ka && pm2 describe crea-ka-sync | grep -E \"script|args\"; grep -n \"sync\\|connectors\\|CONNECTORS\" run.py | head -20","description":"See how sync process runs connectors"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2160,"cache_read_input_tokens":67398,"cache_creation":{"ephemeral_5m_input_tokens":2160,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4afdf617-d96c-496b-aef0-b220bb346293"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TZjJEmeCZasq63r8QwR55r","type":"tool_result","content":"│ script path │ /Users/simon-pierreboucher/apps/crea-ka/.venv/bin/python │\n│ script args │ run.py watch 24 │\n│ interpreter args │ N/A │\n│ script id │ 94 │\n5:# Desc: Point d'entrée Créa-Ka — `sync`, `watch`, `serve` (calqué sur Lou-Ka)\n8: python run.py sync [source ...] # synchronise (découverte + enrichissement)\n9: python run.py watch [heures] # synchronise en boucle (défaut 24 h)\n31: if cmd == \"sync\":","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"49af0693-b2ad-462c-b3ff-24f5ec871a16","timestamp":"2026-08-21T15:38:04.957Z","tool_use_result":{"stdout":"│ script path │ /Users/simon-pierreboucher/apps/crea-ka/.venv/bin/python │\n│ script args │ run.py watch 24 │\n│ interpreter args │ N/A │\n│ script id │ 94 │\n5:# Desc: Point d'entrée Créa-Ka — `sync`, `watch`, `serve` (calqué sur Lou-Ka)\n8: python run.py sync [source ...] # synchronise (découverte + enrichissement)\n9: python run.py watch [heures] # synchronise en boucle (défaut 24 h)\n31: if cmd == \"sync\":","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ddbd0ab8-b044-461a-93f3-ac0cbdbec063","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9FdGMbKnr77MESFcYp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PKJotSUU6UQu5kgTjbA2ur","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"SELECT ts, source, creators, accounts, errors, seconds, alert FROM sync_log WHERE source LIKE '%apify%' OR source IN ('onlyfans-apify') ORDER BY ts DESC LIMIT 30;\"","description":"Recent Apify sync log entries"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2529,"cache_read_input_tokens":69558,"cache_creation":{"ephemeral_5m_input_tokens":2529,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c93a1dc3-07ec-4e6d-b73c-586204becbd4"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PKJotSUU6UQu5kgTjbA2ur","type":"tool_result","content":"2026-08-21T04:40:11Z|onlyfans-apify|0|0|0|257.6|0 créateur retourné — source possiblement bloquée\n2026-08-21T04:06:36Z|discord-apify|17|67|0|61.5|\n2026-08-21T04:05:34Z|patreon-apify|3|8|0|31.2|\n2026-08-21T04:05:03Z|fansly-apify|0|0|0|0.5|0 créateur retourné — source possiblement bloquée\n2026-08-21T04:05:02Z|kick-apify|17|21|0|31.2|\n2026-08-21T04:04:31Z|twitch-apify|51|188|0|46.3|\n2026-08-21T04:03:45Z|youtube-apify|225|393|0|122.2|\n2026-08-21T04:01:43Z|snapchat-apify|1|1|0|33.2|\n2026-08-21T04:01:09Z|facebook-apify|0|0|0|143.3|0 créateur retourné — source possiblement bloquée\n2026-08-21T04:01:09Z|threads-apify|0|0|0|0.6|0 créateur retourné — source possiblement bloquée\n2026-08-21T03:58:46Z|x-apify|0|0|0|318.3|0 créateur retourné — source possiblement bloquée\n2026-08-21T03:53:27Z|tiktok-apify|53|66|49|167.1|\n2026-08-21T03:50:39Z|instagram-apify|348|407|0|137.5|\n2026-08-20T22:09:42Z|onlyfans-apify|0|0|0|257.7|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:33:39Z|discord-apify|1|5|0|31.4|\n2026-08-20T21:33:07Z|patreon-apify|2|16|0|31.3|\n2026-08-20T21:32:36Z|fansly-apify|0|0|0|0.8|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:32:35Z|kick-apify|2|9|0|16.4|\n2026-08-20T21:32:19Z|twitch-apify|32|40|0|76.6|\n2026-08-20T21:31:02Z|youtube-apify|446|603|0|107.0|\n2026-08-20T21:29:15Z|snapchat-apify|4|12|0|46.6|\n2026-08-20T21:28:29Z|threads-apify|0|0|0|134.0|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:26:15Z|facebook-apify|0|0|0|143.6|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:23:51Z|x-apify|5|16|1|333.2|\n2026-08-20T21:18:18Z|tiktok-apify|170|395|52|106.8|\n2026-08-20T21:16:30Z|instagram-apify|658|789|0|107.6|\n2026-08-20T03:57:03Z|onlyfans-apify|0|0|0|242.5|0 créateur retourné — source possiblement bloquée\n2026-08-20T03:23:56Z|discord-apify|3|16|0|31.3|\n2026-08-20T03:23:24Z|patreon-apify|0|0|0|31.2|0 créateur retourné — source possiblement bloquée\n2026-08-20T03:22:53Z|fansly-apify|1|8|0|16.0|","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7ad0410b-f8bb-439a-ac0c-eb499ea6a0e0","timestamp":"2026-08-21T15:38:09.987Z","tool_use_result":{"stdout":"2026-08-21T04:40:11Z|onlyfans-apify|0|0|0|257.6|0 créateur retourné — source possiblement bloquée\n2026-08-21T04:06:36Z|discord-apify|17|67|0|61.5|\n2026-08-21T04:05:34Z|patreon-apify|3|8|0|31.2|\n2026-08-21T04:05:03Z|fansly-apify|0|0|0|0.5|0 créateur retourné — source possiblement bloquée\n2026-08-21T04:05:02Z|kick-apify|17|21|0|31.2|\n2026-08-21T04:04:31Z|twitch-apify|51|188|0|46.3|\n2026-08-21T04:03:45Z|youtube-apify|225|393|0|122.2|\n2026-08-21T04:01:43Z|snapchat-apify|1|1|0|33.2|\n2026-08-21T04:01:09Z|facebook-apify|0|0|0|143.3|0 créateur retourné — source possiblement bloquée\n2026-08-21T04:01:09Z|threads-apify|0|0|0|0.6|0 créateur retourné — source possiblement bloquée\n2026-08-21T03:58:46Z|x-apify|0|0|0|318.3|0 créateur retourné — source possiblement bloquée\n2026-08-21T03:53:27Z|tiktok-apify|53|66|49|167.1|\n2026-08-21T03:50:39Z|instagram-apify|348|407|0|137.5|\n2026-08-20T22:09:42Z|onlyfans-apify|0|0|0|257.7|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:33:39Z|discord-apify|1|5|0|31.4|\n2026-08-20T21:33:07Z|patreon-apify|2|16|0|31.3|\n2026-08-20T21:32:36Z|fansly-apify|0|0|0|0.8|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:32:35Z|kick-apify|2|9|0|16.4|\n2026-08-20T21:32:19Z|twitch-apify|32|40|0|76.6|\n2026-08-20T21:31:02Z|youtube-apify|446|603|0|107.0|\n2026-08-20T21:29:15Z|snapchat-apify|4|12|0|46.6|\n2026-08-20T21:28:29Z|threads-apify|0|0|0|134.0|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:26:15Z|facebook-apify|0|0|0|143.6|0 créateur retourné — source possiblement bloquée\n2026-08-20T21:23:51Z|x-apify|5|16|1|333.2|\n2026-08-20T21:18:18Z|tiktok-apify|170|395|52|106.8|\n2026-08-20T21:16:30Z|instagram-apify|658|789|0|107.6|\n2026-08-20T03:57:03Z|onlyfans-apify|0|0|0|242.5|0 créateur retourné — source possiblement bloquée\n2026-08-20T03:23:56Z|discord-apify|3|16|0|31.3|\n2026-08-20T03:23:24Z|patreon-apify|0|0|0|31.2|0 créateur retourné — source possiblement bloquée\n2026-08-20T03:22:53Z|fansly-apify|1|8|0|16.0|","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9FdGMbKnr77MESFcYp","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FT8hQvmRaf1PSkfas4HxSh","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sed -n '25,80p' creaka/ingest.py","description":"Read ingest orchestration"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2529,"cache_read_input_tokens":69558,"cache_creation":{"ephemeral_5m_input_tokens":2529,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4bb6cedb-838f-4244-9a00-68c1121d82b8"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FT8hQvmRaf1PSkfas4HxSh","type":"tool_result","content":"from . import db\nfrom .connectors import CONNECTORS\nfrom .connectors.base import SkipSource\nfrom .dedup import dedupe\n\n# ordre d'exécution des connecteurs d'enrichissement — link-in-bio vers la\n# fin (il consomme les link_in_bio_url des étapes précédentes) ; bio-liens en\n# tout dernier (il relit les bios enrichies, sans aucune requête)\nENRICH_ORDER = [\"instagram-apify\", \"snowball-ig\", \"tiktok-apify\", \"x-apify\",\n \"facebook-apify\", \"threads-apify\", \"snapchat-apify\",\n \"youtube-apify\", \"twitch-apify\", \"kick-apify\",\n \"fansly-apify\", \"patreon-apify\", \"discord-apify\",\n \"instagram-profil\", \"tiktok-profil\", \"youtube\", \"twitch\",\n \"x-profil\", \"balados-rss\", \"podcastindex\",\n \"link-in-bio\", \"bio-liens\", \"region-bio\"]\n\n\ndef _finalize_batch(creators: list) -> tuple[list, int]:\n finalized, dropped = [], 0\n for cr in creators:\n try:\n finalized.append(cr.finalize())\n except Exception: # une fiche malformée ne bloque pas la source\n dropped += 1\n return finalized, dropped\n\n\ndef load_active(con) -> list:\n \"\"\"Fiches actives (jamais les opted_out : exclues des ré-agrégations, §15).\"\"\"\n rows = con.execute(\n \"SELECT * FROM creators WHERE status='active' ORDER BY total_reach DESC\"\n ).fetchall()\n return [db.creator_from_row(r) for r in rows]\n\n\ndef run(sources: list[str] | None = None) -> list[dict]:\n \"\"\"Exécute l'ingestion pour toutes les sources (ou celles demandées).\"\"\"\n con = db.connect()\n results: list[dict] = []\n discovery = [sid for sid, cls in CONNECTORS.items() if cls.kind == \"discovery\"]\n enrichment = [sid for sid in ENRICH_ORDER if sid in CONNECTORS]\n enrichment += [sid for sid, cls in CONNECTORS.items()\n if cls.kind == \"enrichment\" and sid not in enrichment]\n targets = sources or (discovery + enrichment)\n\n for sid in targets:\n cls = CONNECTORS.get(sid)\n if cls is None:\n print(f\"[crea-ka] connecteur inconnu : {sid}\", file=sys.stderr)\n continue\n t0 = time.time()\n print(f\"[crea-ka] sync {sid} ({cls.kind}) ...\")\n try:\n connector = cls()\n if cls.kind == \"discovery\":\n batch = connector.fetch()","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"60e33fb9-c727-4517-a781-9d6f132c5401","timestamp":"2026-08-21T15:38:10.366Z","tool_use_result":{"stdout":"from . import db\nfrom .connectors import CONNECTORS\nfrom .connectors.base import SkipSource\nfrom .dedup import dedupe\n\n# ordre d'exécution des connecteurs d'enrichissement — link-in-bio vers la\n# fin (il consomme les link_in_bio_url des étapes précédentes) ; bio-liens en\n# tout dernier (il relit les bios enrichies, sans aucune requête)\nENRICH_ORDER = [\"instagram-apify\", \"snowball-ig\", \"tiktok-apify\", \"x-apify\",\n \"facebook-apify\", \"threads-apify\", \"snapchat-apify\",\n \"youtube-apify\", \"twitch-apify\", \"kick-apify\",\n \"fansly-apify\", \"patreon-apify\", \"discord-apify\",\n \"instagram-profil\", \"tiktok-profil\", \"youtube\", \"twitch\",\n \"x-profil\", \"balados-rss\", \"podcastindex\",\n \"link-in-bio\", \"bio-liens\", \"region-bio\"]\n\n\ndef _finalize_batch(creators: list) -> tuple[list, int]:\n finalized, dropped = [], 0\n for cr in creators:\n try:\n finalized.append(cr.finalize())\n except Exception: # une fiche malformée ne bloque pas la source\n dropped += 1\n return finalized, dropped\n\n\ndef load_active(con) -> list:\n \"\"\"Fiches actives (jamais les opted_out : exclues des ré-agrégations, §15).\"\"\"\n rows = con.execute(\n \"SELECT * FROM creators WHERE status='active' ORDER BY total_reach DESC\"\n ).fetchall()\n return [db.creator_from_row(r) for r in rows]\n\n\ndef run(sources: list[str] | None = None) -> list[dict]:\n \"\"\"Exécute l'ingestion pour toutes les sources (ou celles demandées).\"\"\"\n con = db.connect()\n results: list[dict] = []\n discovery = [sid for sid, cls in CONNECTORS.items() if cls.kind == \"discovery\"]\n enrichment = [sid for sid in ENRICH_ORDER if sid in CONNECTORS]\n enrichment += [sid for sid, cls in CONNECTORS.items()\n if cls.kind == \"enrichment\" and sid not in enrichment]\n targets = sources or (discovery + enrichment)\n\n for sid in targets:\n cls = CONNECTORS.get(sid)\n if cls is None:\n print(f\"[crea-ka] connecteur inconnu : {sid}\", file=sys.stderr)\n continue\n t0 = time.time()\n print(f\"[crea-ka] sync {sid} ({cls.kind}) ...\")\n try:\n connector = cls()\n if cls.kind == \"discovery\":\n batch = connector.fetch()","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"95939ad9-5c4c-4b1b-aff1-58158c1c75c3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9G1ZnoyGdkXx7vgox1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS0Q4KpAEIERgCKkAcXCsRPeyYO739/b/lXojGk/2g5EdaKwyzP5t9tydpzO66Cr6Wt1E1eFqOxBlkEGBHxaV5xLhaS323wi6VBDO/Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH04aHUBhIMIv56unrhltKMCxS4GgwGhAxTqw0BCSUC9TMiMFlYzjCqsRxxHOr+Ty52SE+jirUxP+yFyhc2HfONjiypwH4tHYGq8JBzungPFRHVByrZDFSriJw6ik4eQ+HgaqJAqqRlWXrISo5HrlOkADkmJk1kiEQdpiO0efQ88D3eBRbcu2gLeb/0Fslud6+jZkwyHKm3ntvXep2fI1x+ak81JwXUnlaJEIsEigpbxccpRyXe9LFN6xLr3zwnTpX/IW2FDQOg+BKuqyH+TKgSKhXrBzYhN9vKhhVQWL/nKWYDBB0oBXubdHXZgMBqxYwIISRP06XAIuB6tzxwhDt4+WR9MgqtxUoN86mTbY6QS/PB8ApsnrV9eYRV69vjQAmEzTYQqCytfrEIAd1qtgItWNh0oGgUWrbUB6MIB8jQLu9mfLXd5a5i8BVwTUkDSt9RA+qZsWysSXY53/gC+3mL6FpHOgPuLwBwnniQnIQ1JBUXitQUjn1/XJ20gHeCb4rgL37Enp5AjP5qrwtiaEBRAjn8iSGZL0bvPGTAhHtHgp9V6AwtOznNoEqMYrLztBk8E8Ie/xDx7sZWaLGc/kSqUk5HkOSjSLwnYC7ejfbYHrXUnoFO9kY0dgD2M8g5Zj3Z0870cmmeem48+OEui1tOs1n2JB2EgjjAL/vcxOBLoLf94GriR1xhuRPcEXFt1TTuonZuU9XYgxp8rZ8/rlypqWlgSR/QECdM7VxQAdNCgZTJmufEowgXTpfo4K9EJ/CYbrkYNeKkSLJr+bjleaFYJG8JxTyVhWcw4crUeMTix75GVYqBvz2OJ1mbnZ53JF6dKPq8u8X2toDmamcQcRM8TQ89FzO4Z/NoE2mMPuHnhfC12YQhJkeU2klP8ST4wV5vslcCYeSsSU8XCKi7Z9PjtsnkyZyR4JWh5kAQ6qXEFgEdkO2yvsU11avBpDGSV8rM1JXRSi+3rwRPQx0cdLgt13JGHsz33DGAILLTWB3CkSeQXrVUmdfifG6WX3GeYTfIXF54Wgxf0gHdfvLGDebPKFFvyPqUITkeEJp1ngQ0B8WrqR5lkDtifzS6nv+Xz8k3QNAzF6jglZMT5ggcKW9HzwoVxk0VlqnK/cHrG4f0lvXsJBTWZE29ko5eILDCAUzrNtgM/raVLdJyPHbRQ2Vw6194qCpEOlcFcerGgxpVaEXrdvXZEXdec7L0PoklWKekT4jAtZQBvMaEO0eSC4vdgU5wA9UUf+GMu/GS8ymVNMdHm2Vcaz8Eqc10phPvE1JyY5cvPcOtQQOBcab5fIyoaso8PQcPO4YT9o2Vc0bHqg50nqwHpZKk/trQ+fDSOCiC2QjpUySrSxJF0UzzQsdQKApio2IT2KhYxifp7mZu246CvbhaBaKfe59/FxwT9/wl15TDs1Mb1iK03M/8R7tYp7B+GPkoRGG5l+VCiHVzHOH2UUdQDiS8marrDErehKAHDuCDwdN9cHCxPNxcGL+UD1a+RNlqfj/qa48bDF+Znhjw4IpF0kqVvoEVQL2+4+WSiZPNDOvY/eTiB0nhbBFQOZ5kcGIRwekpyoeXyoYkpHqOPuL0jgYWqh90WNEPUibeocrfYwjLCL9CfhAtqjmTr62lTO7GUe8lRvo7/2h1FFFIX2sAAiahLPJusFXXruOqkp2t5qKgvQbRRhu5yJLsnfCn5UEZr6zOLqatmKQvr3evx3+Gd2jsWdty53V4mS+eW3ogMoa32YKs4P0VxYv4+rU6JSN07PepJGjKDhg604d2ZfsEwRQfuqAF6yT7JUYpFgt+pVM1He1VfSOneibmtz50k+7IZc3D+OKgwHKfuaQhCKGxzSmn/ixa4YR3m+ibaymVnQvnoAMESuNhDLMktEvPaLzH0uYO9/5gO7LYiCPF4DsK5BsP2El/2r8I0cDRJZufowQkX/Uoq49IDnfLJqeUB1wWjlnPZNjNFOSJHRvpGbPRgU1SUjL7trpTHGuZ63YSYqNXL6SNBfpoMUBdH/zljTwL3IhbsAeG6h6QexBPi8lgVhFQqKnIXnvV7XXw85+25MwuFxFXPaCdCStFN0HCbJ8V7ptoQQosNqW9i3AuVTLWXdQZR5UKLqwy0MzaFPsDjAy10Kd+7pTd100ChK7edCJS8Yx1Z7PUdMy6Tgr+bUHuyOxUE9HPCOuHc2WrFRzLRKpr3PwDvCq1l18juRfP4lWgQB3V9HmWQugRBY8RP5EI4vFLM3pnK6hCTLUnr0Ji1HDew51hDQ4NiJXL5AOO+Aeb7q/mIvznr/pAGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2633,"cache_read_input_tokens":72087,"cache_creation":{"ephemeral_5m_input_tokens":2633,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"23f5baf9-bf16-4901-af5d-c8205bea3cf6"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9G1ZnoyGdkXx7vgox1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/gYKpQEIERgCKkDD8s7rUNYmxCSb7JHvYC9YRh3v1OrbLYM5cDvD/6nHCYch8/x9Ss76SSFdn+tm/cH4XR3CNsM/Y10SfLrjVy9bMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB9OGh1AYSDK/IMTEynawEFXUz6hoMU5P402svj198QM8RIjAVAbB5O91s9rIAFvITOpadDbL+kbUDRr13nqJokd1MO11BaiS3J98c5HULDH3uwVYqhQWAh/UWWD9CrBY4Xi7QbUD+mwrestYIBYL63kxhrFzDcu00AJdH4oVWksZLsaYeOv2nn2RlmItu9oV+Rx3cmHYf7c0Lt53aURG1Li4+hV9fCjm/Ia4CvHtOT9LuIvQfGypv/z3EJUlC22wp/0LWDzsy4QDrvdgJmhuEiKTbbxfoiUgZB/frNfd230d71JQOhMuYURNsegrj1KL/Tz/p6/JGziXNXzogpVWGKruvSf9MScjuUpsG1gcycEmYV0hxSi0ljuNGLgQSnY12qbIdyA90hgssEpHwmY1ME2fAy4EnafbrNGHvVjeSV5WbOSWt9YuvzgKqLgPV+pcHKKKxugO4jQ+2VTiownfi8WFhb2kxLpHOMUZ6qeYro36oGQqVw2br3VmtzeBqhhnBq/o52SzJavwJtGgPfql6TZ4ZKrNtBVWZsoBg2j62QJWqiFRvH0ols67jv4ssvxllVBy+0cNj3HAaQRz9NItSgzztptW6zdp9Lcz8+MCYSrxJEfBVFFjobGgepCHWgtW+TZkaAOmLceD6XANEzLQ1LA5aJyRhybIw5D9m6KJOk+eSTGaBrhaM3oyrt13CchlbNEL1lbYhE95xLkUdIL1jGQ/abMLRCRQbXcOjfjpxZI1G/StgKOqiQqqvVtGxL9bpFbp0VYBFBz80/91+lZ01zPx1v0HRssTWfdoV5wvq5WNEWR6Giw80Ax8O1ZjPquQ+dSdTx53YT8O0jQIyGmX8InNgcsZZnwKhppGzv7y760TI7c+F7kWrUp2+bvsu0cUjnGMuv1TS4ZAa0Q3kWmNS1Cl3QSz2Vz4ifPnC4Ily4AMMiDc4id1CwckiXjoUUO7gvbwQpyI+IZVPIVYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2633,"cache_read_input_tokens":72087,"cache_creation":{"ephemeral_5m_input_tokens":2633,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"257b9f6a-8806-4b5c-96ee-70742a6068e0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9G1ZnoyGdkXx7vgox1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Aa3ZpKiGBRwQUCMoye4FSR","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":2633,"cache_read_input_tokens":72087,"cache_creation":{"ephemeral_5m_input_tokens":2633,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6398c050-536e-4e73-b33d-73f22a579bbb"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01Aa3ZpKiGBRwQUCMoye4FSR","content":[{"type":"tool_reference","tool_name":"TodoWrite"}]}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a3dac744-ae1b-423b-a55b-262146208c2e","timestamp":"2026-08-21T15:38:29.279Z","tool_use_result":{"matches":["TodoWrite"],"query":"select:TodoWrite","total_deferred_tools":17}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9G1ZnoyGdkXx7vgox1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KASbUfe6Py1rdaNJ29MH91","name":"Bash","input":{"command":"cd ~/apps/crea-ka && source .env 2>/dev/null; export APIFY_TOKEN=${APIFY_TOKEN:-$(grep APIFY_TOKEN ~/.claude/.env | cut -d= -f2)}; sqlite3 data/creaka.db \"SELECT handle FROM accounts WHERE platform='x' LIMIT 5;\"","description":"Get sample X handles for actor test"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2633,"cache_read_input_tokens":72087,"cache_creation":{"ephemeral_5m_input_tokens":2633,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"04af9cdd-1b50-454e-80e2-82a553860ecb"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KASbUfe6Py1rdaNJ29MH91","type":"tool_result","content":"808nyc\n__wasted_kidz__\n_canadianbaecon\n_louist\n_rosiefever","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9f1e91c2-01c6-40ab-a715-d886674de69f","timestamp":"2026-08-21T15:38:31.606Z","tool_use_result":{"stdout":"808nyc\n__wasted_kidz__\n_canadianbaecon\n_louist\n_rosiefever","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ba021775-6a5f-4385-ae9e-a03be5c7a0b1","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9HaF6MWNHyMx68snYq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014idigXusfT7bK83Eg4mroS","name":"TodoWrite","input":{"todos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"in_progress","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer et réparer ka-facebook / ka-threads / ka-onlyfans","status":"pending","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"pending","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":79540,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":79540,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3c849bdb-98e8-4276-b8a5-ce6ea8209ed1"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014idigXusfT7bK83Eg4mroS","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"61bf9288-8c3a-4aa1-8be8-88187308c3a7","timestamp":"2026-08-21T15:38:38.214Z","tool_use_result":{"oldTodos":[],"newTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"in_progress","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer et réparer ka-facebook / ka-threads / ka-onlyfans","status":"pending","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"pending","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8d056058-f416-4197-ad2c-00ea655dbd36","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9J4Yw1QMSRDVE4LCYt","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmwMKpAEIERgCKkBhbqrqAIAeIKf20mR5AAMhfrZfGssz0zHMKvn7QdLWZGiVRP3SM+ucJBvkxP8vaBT5GrKB895NYtpBhb9Mz+BDMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGC4qHUBhIMatc3DHCp7v7RT0TAGgzVK0hEfddRB0cLLAwiMOVi6/HBdV2kPxsh80v9kNjKIpPcZSo27HQ8R0APEqM2PT+aXrn+yVnigo4vUpBRpiqjAQP7ffRf+LpNpXaaW7sgtSM7hDwrB22yreyx1Dg697/0BKc+rxWpTDWHyZ9v6gzBfKvJfnW4OOj3fj6lxC+utkH9/iFApmFFnYiPB6yUuUZ/OfA/bfQUAUuMDxveen8+0Gg2hZ5EUduukqIiImQLEe8TiYVIinxEbDaWig+mMlHEz2df5NYtigz+LkEUH+rnNCuKaxanNB9HDOmSg3GQ28NFEJoYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":477,"cache_read_input_tokens":79540,"cache_creation":{"ephemeral_5m_input_tokens":477,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8f9795e2-2430-4ce4-afd5-b6d46eadf29f"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9J4Yw1QMSRDVE4LCYt","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YbjuwP391oFV5Udsg7bPu9","name":"Bash","input":{"command":"cd ~/apps/crea-ka && export $(grep -E '^APIFY_TOKEN' ~/.claude/.env | xargs) 2>/dev/null; [ -f .env ] && export $(grep -E '^APIFY_TOKEN' .env | xargs) 2>/dev/null; python3 - <<'EOF'\nimport os, requests, json, time\ntoken = os.environ[\"APIFY_TOKEN\"]\nh = {\"Authorization\": f\"Bearer {token}\"}\ninp = {\"usernames\": [\"_louist\", \"808nyc\"], \"maxTweets\": 20,\n \"concurrency\": 2,\n \"proxyConfiguration\": {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}}\nr = requests.post(\"https://api.apify.com/v2/acts/gorgeous_thistle~ka-x/runs\",\n json=inp, headers=h, params={\"memory\": 1024}, timeout=60)\nr.raise_for_status()\nrun = r.json()[\"data\"][\"id\"]\nprint(\"run\", run)\nwhile True:\n time.sleep(10)\n s = requests.get(f\"https://api.apify.com/v2/actor-runs/{run}\", headers=h, timeout=60).json()[\"data\"]\n if s[\"status\"] in (\"SUCCEEDED\",\"FAILED\",\"ABORTED\",\"TIMED-OUT\"): break\nprint(\"status\", s[\"status\"], \"dataset\", s[\"defaultDatasetId\"])\nitems = requests.get(f\"https://api.apify.com/v2/datasets/{s['defaultDatasetId']}/items?clean=true\", headers=h, timeout=60).json()\nprint(json.dumps(items, indent=1)[:2000])\nEOF","description":"Test ka-x actor with 2 handles","timeout":400000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":477,"cache_read_input_tokens":79540,"cache_creation":{"ephemeral_5m_input_tokens":477,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b356bba4-c26b-484f-974c-b0e2553f3472"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"Exit code 1\nTraceback (most recent call last):\n File \"\", line 1, in \nModuleNotFoundError: No module named 'requests'","is_error":true,"tool_use_id":"toolu_01YbjuwP391oFV5Udsg7bPu9"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"cc5d2acd-6f08-42d4-98fd-cde0789aa9e9","timestamp":"2026-08-21T15:38:48.665Z","tool_use_result":"Error: Exit code 1\nTraceback (most recent call last):\n File \"\", line 1, in \nModuleNotFoundError: No module named 'requests'"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c4d3a365-3328-461d-a525-9449089a46c0","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9Js6dZWPCqn9BocScs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XmvCi7pRzhaPHp4tFsMiV5","name":"Bash","input":{"command":"cd ~/apps/crea-ka && export $(grep -E '^APIFY_TOKEN' ~/.claude/.env | xargs) 2>/dev/null; [ -f .env ] && export $(grep -E '^APIFY_TOKEN' .env | xargs) 2>/dev/null; .venv/bin/python - <<'EOF'\nimport os, requests, json, time\ntoken = os.environ[\"APIFY_TOKEN\"]\nh = {\"Authorization\": f\"Bearer {token}\"}\ninp = {\"usernames\": [\"_louist\", \"808nyc\"], \"maxTweets\": 20,\n \"concurrency\": 2,\n \"proxyConfiguration\": {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}}\nr = requests.post(\"https://api.apify.com/v2/acts/gorgeous_thistle~ka-x/runs\",\n json=inp, headers=h, params={\"memory\": 1024}, timeout=60)\nr.raise_for_status()\nrun = r.json()[\"data\"][\"id\"]\nprint(\"run\", run)\nwhile True:\n time.sleep(10)\n s = requests.get(f\"https://api.apify.com/v2/actor-runs/{run}\", headers=h, timeout=60).json()[\"data\"]\n if s[\"status\"] in (\"SUCCEEDED\",\"FAILED\",\"ABORTED\",\"TIMED-OUT\"): break\nprint(\"status\", s[\"status\"], \"dataset\", s[\"defaultDatasetId\"])\nitems = requests.get(f\"https://api.apify.com/v2/datasets/{s['defaultDatasetId']}/items?clean=true\", headers=h, timeout=60).json()\nprint(json.dumps(items, indent=1)[:3000])\nEOF","description":"Test ka-x actor with venv python","timeout":400000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":802,"cache_read_input_tokens":80017,"cache_creation":{"ephemeral_5m_input_tokens":802,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3b3dcf3a-16e8-46d2-ba08-f87fdd9a126f"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"baxabesuf","tool_use_id":"toolu_01XmvCi7pRzhaPHp4tFsMiV5","description":"Test ka-x actor with venv python","task_type":"local_bash","uuid":"e2d41005-b181-47e3-96b8-8dd5c2902ecc","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"baxabesuf","tool_use_id":"toolu_01XmvCi7pRzhaPHp4tFsMiV5","status":"completed","output_file":"","summary":"Test ka-x actor with venv python","uuid":"68e23d2a-f8c7-43de-a3c2-8ab7dee6e448","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XmvCi7pRzhaPHp4tFsMiV5","type":"tool_result","content":"run BC4mWfW5luzITBaJl\nstatus SUCCEEDED dataset DsGTpMFTE6DXjDAeh\n[\n {\n \"kind\": \"profile\",\n \"platform\": \"x\",\n \"username\": \"808nyc\",\n \"found\": false,\n \"error\": \"user_not_found\"\n },\n {\n \"kind\": \"profile\",\n \"platform\": \"x\",\n \"found\": true,\n \"id\": \"33941703\",\n \"username\": \"_LouisT\",\n \"full_name\": \"Louis T\",\n \"biography\": \"Pas journaliste. Humoriste.\",\n \"location\": \"\",\n \"website\": \"http://louist.ca\",\n \"followers\": 85989,\n \"following\": 142,\n \"tweets_count\": 31533,\n \"listed_count\": 213,\n \"likes_given\": 8290,\n \"is_verified\": false,\n \"is_blue_verified\": false,\n \"is_protected\": false,\n \"created_at\": \"Tue Apr 21 15:44:01 +0000 2009\",\n \"account_age_days\": 6331,\n \"avatar\": \"https://pbs.twimg.com/profile_images/1726613187187838977/hhNsFdrh_400x400.jpg\",\n \"banner\": \"https://pbs.twimg.com/profile_banners/33941703/1706718198\",\n \"avg_likes\": 1343,\n \"avg_retweets\": 93,\n \"avg_replies\": 67,\n \"engagement_rate_pct\": 1.67,\n \"tweets_per_week\": 0.14,\n \"last_tweet_at\": \"Wed Jun 02 15:09:16 +0000 2021\",\n \"reply_share_pct\": 0,\n \"media_share_pct\": 10,\n \"top_hashtags\": [],\n \"top_tweet\": {\n \"url\": \"https://x.com/_LouisT/status/1400107253810802699\",\n \"likes\": 1953,\n \"retweets\": 12\n },\n \"recent_tweets\": [\n {\n \"id\": \"1400107253810802699\",\n \"url\": \"https://x.com/_LouisT/status/1400107253810802699\",\n \"text\": \"C\\u2019est \\u00e7a qui arrive quand on est pogn\\u00e9 \\u00e0 la maison trop longtemps sans avoir rien d\\u2019autres \\u00e0 faire.\\n\\nJe tiens la CAQ directement responsable! https://t.co/nRISWTjEHa\",\n \"likes\": 1953,\n \"retweets\": 12,\n \"replies\": 119,\n \"quotes\": 9,\n \"created_at\": \"Wed Jun 02 15:09:16 +0000 2021\",\n \"is_retweet\": false,\n \"is_reply\": false,\n \"is_quote\": false,\n \"lang\": \"fr\",\n \"hashtags\": [],\n \"mentions\": [],\n \"media_type\": \"photo\",\n \"media_count\": 1,\n \"media_urls\": [\n \"https://pbs.twimg.com/media/E24uH6hXoAI9Fwq.jpg\"\n ],\n \"links\": []\n },\n {\n \"id\": \"1341514579944288265\",\n \"url\": \"https://x.com/_LouisT/status/1341514579944288265\",\n \"text\": \"Le fabriquant du vaccin dit de ne pas s\\u2019inqui\\u00e9ter d\\u2019une mutation du virus. Une mise-\\u00e0-jour sera t\\u00e9l\\u00e9chargeable via la puce.\\n\\nSuffira de se mettre la t\\u00eate proche de votre routeur et mentionner \\u00e0 voix haute \\u00ab\\u00a0j\\u2019accepte\\u00a0\\u00bb.\\n\\nEnsuite aller vous coucher, pour vous red\\u00e9marrer.\",\n \"likes\": 1908,\n \"retweets\": 248,\n \"replies\": 55,\n \"quotes\": 39,\n \"created_at\": \"Tue Dec 22 22:42:54 +0000 2020\",\n \"is_retweet\": false,\n \"is_reply\": false,\n \"is_quote\": false,\n \"lang\": \"fr\",\n \"hashtags\": [],\n \"mentions\": [],\n \"media_type\": null,\n \"media_count\": null,\n \"media_urls\": [],\n \"links\": []\n },\n {\n \"id\": \"1344099235877310465\",\n \"url\": \"https://x.com/_LouisT/status/1344099235877310465\",\n \"text\": \"O\\u00f9 est Pierre Arcand? https://t.co/au8CnHEssE\",\n \"likes\": 1612,\n \"retweets\": 128,\n \"replies\": 92,\n \"quotes\": 22,\n \"created_at\": \"Wed Dec 30 01:53:24 +0000 2020\",","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"31303209-e47c-472e-b3ef-6ec0df947278","timestamp":"2026-08-21T15:39:18.962Z","tool_use_result":{"stdout":"run BC4mWfW5luzITBaJl\nstatus SUCCEEDED dataset DsGTpMFTE6DXjDAeh\n[\n {\n \"kind\": \"profile\",\n \"platform\": \"x\",\n \"username\": \"808nyc\",\n \"found\": false,\n \"error\": \"user_not_found\"\n },\n {\n \"kind\": \"profile\",\n \"platform\": \"x\",\n \"found\": true,\n \"id\": \"33941703\",\n \"username\": \"_LouisT\",\n \"full_name\": \"Louis T\",\n \"biography\": \"Pas journaliste. Humoriste.\",\n \"location\": \"\",\n \"website\": \"http://louist.ca\",\n \"followers\": 85989,\n \"following\": 142,\n \"tweets_count\": 31533,\n \"listed_count\": 213,\n \"likes_given\": 8290,\n \"is_verified\": false,\n \"is_blue_verified\": false,\n \"is_protected\": false,\n \"created_at\": \"Tue Apr 21 15:44:01 +0000 2009\",\n \"account_age_days\": 6331,\n \"avatar\": \"https://pbs.twimg.com/profile_images/1726613187187838977/hhNsFdrh_400x400.jpg\",\n \"banner\": \"https://pbs.twimg.com/profile_banners/33941703/1706718198\",\n \"avg_likes\": 1343,\n \"avg_retweets\": 93,\n \"avg_replies\": 67,\n \"engagement_rate_pct\": 1.67,\n \"tweets_per_week\": 0.14,\n \"last_tweet_at\": \"Wed Jun 02 15:09:16 +0000 2021\",\n \"reply_share_pct\": 0,\n \"media_share_pct\": 10,\n \"top_hashtags\": [],\n \"top_tweet\": {\n \"url\": \"https://x.com/_LouisT/status/1400107253810802699\",\n \"likes\": 1953,\n \"retweets\": 12\n },\n \"recent_tweets\": [\n {\n \"id\": \"1400107253810802699\",\n \"url\": \"https://x.com/_LouisT/status/1400107253810802699\",\n \"text\": \"C\\u2019est \\u00e7a qui arrive quand on est pogn\\u00e9 \\u00e0 la maison trop longtemps sans avoir rien d\\u2019autres \\u00e0 faire.\\n\\nJe tiens la CAQ directement responsable! https://t.co/nRISWTjEHa\",\n \"likes\": 1953,\n \"retweets\": 12,\n \"replies\": 119,\n \"quotes\": 9,\n \"created_at\": \"Wed Jun 02 15:09:16 +0000 2021\",\n \"is_retweet\": false,\n \"is_reply\": false,\n \"is_quote\": false,\n \"lang\": \"fr\",\n \"hashtags\": [],\n \"mentions\": [],\n \"media_type\": \"photo\",\n \"media_count\": 1,\n \"media_urls\": [\n \"https://pbs.twimg.com/media/E24uH6hXoAI9Fwq.jpg\"\n ],\n \"links\": []\n },\n {\n \"id\": \"1341514579944288265\",\n \"url\": \"https://x.com/_LouisT/status/1341514579944288265\",\n \"text\": \"Le fabriquant du vaccin dit de ne pas s\\u2019inqui\\u00e9ter d\\u2019une mutation du virus. Une mise-\\u00e0-jour sera t\\u00e9l\\u00e9chargeable via la puce.\\n\\nSuffira de se mettre la t\\u00eate proche de votre routeur et mentionner \\u00e0 voix haute \\u00ab\\u00a0j\\u2019accepte\\u00a0\\u00bb.\\n\\nEnsuite aller vous coucher, pour vous red\\u00e9marrer.\",\n \"likes\": 1908,\n \"retweets\": 248,\n \"replies\": 55,\n \"quotes\": 39,\n \"created_at\": \"Tue Dec 22 22:42:54 +0000 2020\",\n \"is_retweet\": false,\n \"is_reply\": false,\n \"is_quote\": false,\n \"lang\": \"fr\",\n \"hashtags\": [],\n \"mentions\": [],\n \"media_type\": null,\n \"media_count\": null,\n \"media_urls\": [],\n \"links\": []\n },\n {\n \"id\": \"1344099235877310465\",\n \"url\": \"https://x.com/_LouisT/status/1344099235877310465\",\n \"text\": \"O\\u00f9 est Pierre Arcand? https://t.co/au8CnHEssE\",\n \"likes\": 1612,\n \"retweets\": 128,\n \"replies\": 92,\n \"quotes\": 22,\n \"created_at\": \"Wed Dec 30 01:53:24 +0000 2020\",","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"168cf4c4-7f4f-4aa9-b472-e054c18012d0","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9M5K9g8kAvFfetyRJi","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISphAKpAEIERgCKkAWPR/ujPxuFzeO2T9OsIFyD66cbG2Y5AiE0YuJgezUEhz4qtq8eqdqgqLpbUgByoo6TrEGmDfv5N5z/FWLdWrcMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG34qHUBhIMKwO0wnMDbyzJ5BW8GgyWVQasBYDDP3NKMBsiMEfte17P5muvsC+gJGH7MWnRGoc13CurTHrWIRgC5h8KV5w3BCue7CZ/oAAfASUaayquDp3VCzs02sgwMVTIYS2IkVdPZYy7Ncidy9Cf+564dYhzw6d/AMqj5Ow984kvnEBs+G+qL4GfWqwPn9d/cfhuWgzDAv7V71h3mJxAXrpTKo6oxatvUXXAnbpUH6ceIME23ZGTXb3SddIjAFNg0eZPkwUit3nCYGF+dZLmT5lGEKpUqGsYVdsRf0OvyLuLSYB6rV9wIqkDt6P4q6eToRDiTAOgEEH7JqTNOn7ZCWbfRaT7HAF2PUGZ8zDkgVxK/DEWQKlbM2fdqnTquXFhN1Bgl0UfaRfqlDl3ENvtM0+WAxd4Crzr0IeNEaR1X42MSx/oyMOyexzf4w8REP7KUZjQyqBpXBonaxfjsPg0VjvT8yC4/7ZoTg5AaBOAl73wqr0AHvMbhqSCi/Fwu7cvbMcODq0aIx+wg53MaAldyeRVaaboMHvV97tZC7cR8XohJt8zRTbrE84l+eVczk8rbDmZc6aYBDlZumaYu64hndoK8AvZ4EbcrYn/Fqt+FJMUMhrSKfAe7JJrtZERDHB2rz7+AS85opYsjsRiilulsbcqcwp9GxBk/bky611FLH5b+aJUk3AMWJisxb0kbGonIkIYBGUZ76MNuWLkkFe3oL1SaZf70EvCfK2ExaF9GpPbQ4G/YRqUo0fKgTUSf1awbtx4gexQ691t0kZwCWEIho6hjTDxKhunfEEaIrBcg65EbdQaILL1WV9dvdK1R5vYV71fUPpXxJ20A19XwdLZvDZLfCum4nm+w1rlf6lSSZ7qPuIWbyE3yxJ5ei5eG7wEQhaZwMts4OcT9QHlX9hKgLWUwRgXEqI28YpVE+KXou2E/KavnL5NhutUhJ1fBQUBRO08FlSXgBall+RZOvoVuK38R1tiftBPrTwCp5BNyAnHndDzcu19FXbvmG8AbdLZx4UI3NKKDgjgDZMta84CxQbZQKY/TphfTOFrvT3U/xVceYkaReU1xJqfTqyfRSwruynDw2fUXmEs/ReukqFtoNwmwMK6Q091Gz1TZf/o6HEyJWUtsFOkGjEAFAhcM9ZfEfwGqJIrxmL6gFFBFFFcoJrUKvtjnS6YndRV1bFe1Enn71tcSfR/coe6Rz93JlavhYl3RItWZ4VCLfbQzjbOOT//0I4CG2CbcXgriX3Sr6IggrDMW7qhc5/NM6G5q/mC2C6K7IyzsCnai1u4/BlqZYl+hoITribyruhKRXO+gnRpPlZEhkEJnhfHJ+LxnTqdKLzudoFBfgoyfRHkomK0XyacRalfOL9JOoKzHz371TAzOg++2mxjDWDAaL8gNszDJcuiMo21yEObi9H3x+WFcHQSy1OYjOBJK2AEqykYCL5vNRHSGtl3h84XXjtVtZK0dKiUC1iazVTfr04yS57GokbaKrwnNJaNd8HshEDsWXI1gHghmzoCI2C/bCpu5ashKLRoQNjbOwLaEF8D6IQwZ/cpbUoKDGgY8jSMK3xev6w20ENjHd9yocKY4jpOLIWJsKdE5gwLRMZ0oMToB6x9K1/cgNwQM0lVN8qFk09g1n2HE+PgIpTamQ5yRCKOctDBJvp/hC/gnfHEDDuojqRC0gV8Xw11LOEwGY3J8lv2u6/9SW8CRQPXx6ltqNbBMqZ6W9CdCGksBrRSuAHRg2dX88by0cRuW4L5woqgHB4L9pgkD/CIhODnG6nlVZ9hn4gxKIN9jbe5E78B4ch4D2cgdo+bIFFxqcaiRcHn1lc6GW4iNZzcYZbTx0LSgKgzKh3vDjx3VVWMh1Dsnx8ovTqOxLs9FplU1kh5HIlkp7XNA44X1TsZA61sJlomH5litvOwJ/ardXXcKhAxekJz3mFcsnGTXaeLR0Ld19/MfkXIcb8aEDnL8a+XiNj2SVE04Kx5+aH1mL+wmtoWJ6xMl9vk5evKsAIjGATACJED5alClAisxLAQIo3syceXAprMzaW+3+/XGQbpm1ctymOegLhZXox1i/mEyvHhZrmntVhs2z7twPen7jCAl6JeQyuXlMxDehzxtCurxMisBjzIRKz42K8bkzYJ0QYbFc0PY33+17eOp2FYHeHST87lK3SZI0QNHc4o80k1Y7R0PgAWI4rwgOglrGJ19+OPZzT7V66N3w22gzGnoJvHxu9eitMUJVr+eYIUUZSobTnFWlXajEg5yD0Np7T13cspez0sOvGjCgSY57nUws3/CJR3uTzKmGNvUEQ+X2j+HRTK3o0tKSI45asErtvo7VBCNR+wmh3b1wgDj+77y2MY1c1SLB5/AUyRIyQnzsnc5odvF/HwYLJ5wzKr7S5N17nskYpEI0LIPUDAloPCoMKdVrtQwS+74/TZI+B8tXS0JMXoxP+iA/quzikBxv3nl6bitgsqjw1J6BfI+YoTtdCmEuJQdeZM21EAKyAc9caoNhDnHaeJLQQNvQteIySD5mDNb8dCTaZJP3QNNSnUrG+6LqGklV4XHNBjzGJGGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2251,"cache_read_input_tokens":80819,"cache_creation":{"ephemeral_5m_input_tokens":2251,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d27a4a90-0f4e-47e3-b1b0-d0414390bfcf"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9M5K9g8kAvFfetyRJi","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlQQKpQEIERgCKkAWNFelj8zWsG0CjYC8jnWK0CbmS2wEhRCioRbqQRYS1141/3dCWZ6bV/hV7r+V3lIg6ocxODwytNbCjsrhfBkTMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBt+Kh1AYSDONeuBZBFAovUmHpVxoMJNjsGpS08bQuzNZ2IjAnfzIea1vFE2tt+3U16+upGsQM9gtQ7HtiUK4hQ1V4CPi2Dzo6RoQqu5IRGcGovEoqnAKU7ZA9A5yhfR9cFW5SUddrSPQFa2amF7L2EEtMtMeNUsBwnc/vNbJ98aQw8NQEIgGPsGF+C+wT51hBtpXQLdKMRM72zYdL41u3RlAN3xjtWhejdEGvI4RP9ZjRXLOvODNUWI6wwf5wJgswzViZyjkaXzXS3X7YJfqpE8BQ5RyRRZT6iVtGot4qr/p0Vej2xHeP9bjgEp/GxOzYc59/51cpjISIAXod9IWMdIvRbdja+ZiI+r/QGXoKCPyW9q7gez6gY3hNM17wzLadPJN9lfqm1EoTkrwPyPOLi7TuwZPmItSs3KcpRmbBkg2jkBpLtC68vh6EaBddaF/qAXSh7OKWRqZ/aAetTij9FqX1JIMMR0ZouLxLcJXduOutKxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2251,"cache_read_input_tokens":80819,"cache_creation":{"ephemeral_5m_input_tokens":2251,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"eb70b613-2a89-4313-8e22-feb05c7711be"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9M5K9g8kAvFfetyRJi","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019rxADTMWYo44HHRgZJyMZz","name":"Bash","input":{"command":"cd ~/apps/crea-ka && export $(grep -E '^APIFY_TOKEN' .env | xargs); .venv/bin/python - <<'EOF'\nimport os, requests, json, time, sqlite3\ntoken = os.environ[\"APIFY_TOKEN\"]\nh = {\"Authorization\": f\"Bearer {token}\"}\ndb = sqlite3.connect(\"data/creaka.db\")\nhandles = [r[0] for r in db.execute(\"SELECT handle FROM accounts WHERE platform='x' AND (metrics IS NULL OR metrics NOT LIKE '%x-apify_at%') LIMIT 40\")]\ninp = {\"usernames\": handles, \"maxTweets\": 5, \"concurrency\": 10,\n \"proxyConfiguration\": {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}}\nr = requests.post(\"https://api.apify.com/v2/acts/gorgeous_thistle~ka-x/runs\",\n json=inp, headers=h, params={\"memory\": 2048}, timeout=60)\nr.raise_for_status(); run = r.json()[\"data\"][\"id\"]\nwhile True:\n time.sleep(15)\n s = requests.get(f\"https://api.apify.com/v2/actor-runs/{run}\", headers=h, timeout=60).json()[\"data\"]\n if s[\"status\"] in (\"SUCCEEDED\",\"FAILED\",\"ABORTED\",\"TIMED-OUT\"): break\nitems = requests.get(f\"https://api.apify.com/v2/datasets/{s['defaultDatasetId']}/items?clean=true&limit=1000\", headers=h, timeout=60).json()\nfrom collections import Counter\nc = Counter()\nfor it in items:\n c[\"found\" if it.get(\"found\") else f\"err:{it.get('error','?')}\"] += 1\nprint(\"statut run:\", s[\"status\"], \"| items:\", len(items), \"/\", len(handles))\nfor k,v in c.most_common(): print(f\" {k}: {v}\")\nEOF","description":"Sample 40 stale X handles through ka-x","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":2251,"cache_read_input_tokens":80819,"cache_creation":{"ephemeral_5m_input_tokens":2251,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3c294975-1981-4828-832d-77db5ff7d7e2"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bt5mkiwz0","tool_use_id":"toolu_019rxADTMWYo44HHRgZJyMZz","description":"Sample 40 stale X handles through ka-x","task_type":"local_bash","uuid":"1267a22c-ee63-42f8-9f83-704f757e2982","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019rxADTMWYo44HHRgZJyMZz","type":"tool_result","content":"Command running in background with ID: bt5mkiwz0. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bt5mkiwz0.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"59a36238-0050-4506-9433-5814e8735607","timestamp":"2026-08-21T15:39:43.681Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bt5mkiwz0"}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9M5K9g8kAvFfetyRJi","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E2hrRYRWmceZ6StfYMhF4q","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"SELECT handle FROM accounts WHERE platform='facebook' LIMIT 4; SELECT handle FROM accounts WHERE platform='threads' LIMIT 3; SELECT handle FROM accounts WHERE platform='onlyfans' LIMIT 3;\"","description":"Sample handles for fb/threads/onlyfans"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2251,"cache_read_input_tokens":80819,"cache_creation":{"ephemeral_5m_input_tokens":2251,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e05d327a-e96a-4c33-a290-37a7d35b14da"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E2hrRYRWmceZ6StfYMhF4q","type":"tool_result","content":"100007739415409\n100092235819696\n1appparjour\n1djjfro\namipl_\ncl_blog\nevanvanya\n4therinecloutier\nalaskahotmess\nalchimiea3","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3395b89f-ef2a-485d-a03d-f46bb55fe5d3","timestamp":"2026-08-21T15:39:45.021Z","tool_use_result":{"stdout":"100007739415409\n100092235819696\n1appparjour\n1djjfro\namipl_\ncl_blog\nevanvanya\n4therinecloutier\nalaskahotmess\nalchimiea3","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7f0e0f4c-407f-4c23-b5b9-4ebf8811cf81","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9NzL4s4bAJyu1ajFAi","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WgycF2j1v8tdGKBJ1YN287","name":"Bash","input":{"command":"cd ~/apps/crea-ka && export $(grep -E '^APIFY_TOKEN' .env | xargs); .venv/bin/python - <<'EOF'\nimport os, requests, json, time\ntoken = os.environ[\"APIFY_TOKEN\"]; h = {\"Authorization\": f\"Bearer {token}\"}\ndef test(actor, usernames, extra=None):\n inp = {\"usernames\": usernames, \"concurrency\": 4,\n \"proxyConfiguration\": {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}}\n if extra: inp.update(extra)\n r = requests.post(f\"https://api.apify.com/v2/acts/gorgeous_thistle~{actor}/runs\",\n json=inp, headers=h, params={\"memory\": 1024}, timeout=60)\n r.raise_for_status(); run = r.json()[\"data\"][\"id\"]\n while True:\n time.sleep(12)\n s = requests.get(f\"https://api.apify.com/v2/actor-runs/{run}\", headers=h, timeout=60).json()[\"data\"]\n if s[\"status\"] in (\"SUCCEEDED\",\"FAILED\",\"ABORTED\",\"TIMED-OUT\"): break\n items = requests.get(f\"https://api.apify.com/v2/datasets/{s['defaultDatasetId']}/items?clean=true\", headers=h, timeout=60).json()\n print(f\"== {actor} : {s['status']}, {len(items)} items\")\n for it in items:\n print(\" \", it.get(\"username\"), \"found=\", it.get(\"found\"), \"err=\", it.get(\"error\"), \"followers=\", it.get(\"followers\"))\ntest(\"ka-facebook\", [\"100007739415409\", \"1appparjour\", \"amipl_\", \"cl_blog\"])\ntest(\"ka-threads\", [\"evanvanya\", \"4therinecloutier\"])\ntest(\"ka-onlyfans\", [\"alaskahotmess\", \"alchimiea3\"])\nEOF","description":"Test fb/threads/onlyfans actors","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":2034,"cache_read_input_tokens":83070,"cache_creation":{"ephemeral_5m_input_tokens":2034,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f7818ad3-1b0d-4830-8801-c365b03d4414"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"b0eerrll4","tool_use_id":"toolu_01WgycF2j1v8tdGKBJ1YN287","description":"Test fb/threads/onlyfans actors","task_type":"local_bash","uuid":"fdbaf1bf-2ffc-48f2-8c1a-513034efc2fb","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WgycF2j1v8tdGKBJ1YN287","type":"tool_result","content":"Command running in background with ID: b0eerrll4. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/b0eerrll4.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1b8f6275-7d23-47b1-9e5c-e6ec7851d30d","timestamp":"2026-08-21T15:39:57.088Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"b0eerrll4"}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"293cf6eb-8b3b-4711-81c8-ea62159ee9bb","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9Pswd3B94uuHj4bzUS","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzwcKpAEIERgCKkDImbSilzwyiRk50hSnHDwPV9ooyhZb0AV4PZQuCqLKI4XP7W3Y8Jn/U3L2/SHtjZ48qXXqo6UoigJl0GxC973FMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHY4qHUBhIMSu/bSudnaKsMX6ekGgx5B1yCF+i5REysb1giMElL3tSPeCN4broBcuhRd/QK5/w4244SJAmQKteCwgljLgko9qfnMyzdmjiUtd/Y4SrXBedHFDw7F0eKzs4LeVIcyhnbt/o/w+0qRHGjSnV6dUcN5+r4LYPi2xIDloPsu88YeJloFRy7s21YdOdSnubmTD6wt34OvRAn0cIloz+rSMkn4LXpeNJL0MRC2Mb57jq90rUF17sy1nqBfTQ22ZEd7TVcyOsjeBXmU2LinQnVkT/g+rFFI38gTwDjYipJphDyllHguwWfaaV0Hf3wraavXWirfKMmjNMJkcl4nNmNU4liFk/T8XZy3aMxP9ICzZ0IIMhUf6SnMivc0YGRevbWvg+soe7vBgbz+5BUGGKBI806BB4bxtQ9nX1HsvXRrZzyyop0Vx/FkpaA9QkMyEO6pO7As9xWPukqgygdNf3wo4kSXLyymUBEUJugtDnSO4uS+oRwYljogFTbrJb4AVACJx62S5ADqB3yQ2hrpvkNcLpw4HgP5MOt2WWthazbryHCu+xQNYjZln34kU1OMB1E931QmF0Ddr09qcGZkLlnn+pokBZvNQtQ7ntQRblNqIezGSdcIdbbggkoU0G67AeFYsFuZ9j39r0ktrRs7h2rmTZrsJfgcjlFhgTVG3bBikFga+15SzbiwO8ijex3YtyGk1a27j5amuW6sng7Gjm7XLIt0x0vUKl1c7QscPF4eytuIk3XMHiEe6fsVtATFhgkEw66mc8Fh0/XmzTQxI6mbz0BxW18/MEKdpZk8FU1YbK0zYUwqYvUPfhZIAHENoEonCKlvgtOWxp/Xc6R+IF2ERYyjg8xY72f4VzOAlt0EgaaQHtRmsex1uCKGDqddb6vWzGorG+MYiKdOqE0w2tLK1rp191Av52kiOdSohOneubN0BXXz5lzdFRVfMDEohRp6cnHvMSgsZxfzwqgL3DWwxwf0QdwksTZx7cTNPbYF8jcRUmSip+DomjNB5sFleuA5354Y8C/XiYxn3PodpbVO38Y2lqNRpNJ2QJeyy6amXmT0RHHEbMHs3EYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":896,"cache_read_input_tokens":85104,"cache_creation":{"ephemeral_5m_input_tokens":896,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b8f48522-d41b-49a3-90b4-d90899a0ed69"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9Pswd3B94uuHj4bzUS","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS5gMKpQEIERgCKkAQ96HpybZgMAC7FZf+ppQIcftDCENGCk1/DRab0hbQ5BaG6I+TUsKwViydKAY10Q0w0wyVWseLlN/QZhAvuzn8Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB2OKh1AYSDC7mWYcVLgWVPsN1hRoMPHLDULztTh0hCk+qIjBoW/KUUd6TMIrbD12QhSxRxjcPZs8HsyT3/FKIVkrX0tyVReic5TX9uP/eA7HX738q7QH+vCbFnZrp78RT3u2DXEwRmreWu7QD6rrrR7Evd+btzfKVUKJMdi3fGSqKc54+x3scem3D0fUD7r1Qxat+J9J1Ri8bg7K4B6SVLg4OVF4YxIvBUcdzmRMkpY8FR+nkL5L1SgZupeUVsScwL+sy5C0hxB8HhdX/HRSUg2llAJINLObLyt2Czpw30BW4avXrBwW94xT9empGREHy+gSC3F86IV4zIt4zFdbbhWrXMsFL8rNbAtJr36lzF6PqsgLfLq+s9jPGOY2jG+F6rs6edTdMVwJiN39tHRXoV7UpCDO3ftlsn/T/MK6YKT+bQvwYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":896,"cache_read_input_tokens":85104,"cache_creation":{"ephemeral_5m_input_tokens":896,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7a006bdb-36de-40e4-8906-0f7081bd33c9"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9Pswd3B94uuHj4bzUS","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EEQLHiCwgz9jeZa8TU6hzU","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sed -n '80,140p' creaka/ingest.py && sed -n '1,60p' creaka/connectors/base.py","description":"Read ingest save flow and base connector"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":896,"cache_read_input_tokens":85104,"cache_creation":{"ephemeral_5m_input_tokens":896,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4d821b58-72ad-4d75-bd62-dde0b2489e60"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EEQLHiCwgz9jeZa8TU6hzU","type":"tool_result","content":" batch = connector.fetch()\n else:\n batch = connector.enrich(load_active(con))\n finalized, dropped = _finalize_batch(batch)\n finalized = dedupe(finalized) # dédup intra-lot avant la base\n stats = db.sync_source(con, sid, finalized, started=t0,\n errors=getattr(connector, \"errors\", 0))\n if dropped:\n stats[\"dropped\"] = dropped\n if stats.get(\"alert\") and cls.kind == \"discovery\":\n print(f\"[crea-ka] ⚠ ALERTE {sid} : {stats['alert']}\")\n print(f\"[crea-ka] {stats}\")\n results.append(stats)\n except SkipSource as exc:\n # passage sauté volontairement (ex. clés API absentes) : log clair,\n # PAS d'alerte de blocage ni de run compté à 0 dans sync_log\n print(f\"[crea-ka] ↷ {sid} sauté : {exc}\")\n results.append({\"source\": sid, \"skipped\": str(exc)})\n except Exception as exc: # robustesse : une source ne bloque pas les autres\n db.log_failure(con, sid, f\"{exc}\")\n traceback.print_exc()\n results.append({\"source\": sid, \"error\": str(exc)})\n if not sources: # passage COMPLET seulement : archivage des disparus (§13)\n db.archive_missing(con)\n return results\n\n\ndef watch(interval_seconds: int = 86400) -> None:\n \"\"\"Synchronisation en boucle (cadence §14 — défaut : quotidienne).\"\"\"\n while True:\n try:\n run()\n except Exception:\n traceback.print_exc()\n print(f\"[crea-ka] prochain passage dans {interval_seconds}s\")\n time.sleep(interval_seconds)\n# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: creaka/connectors/base.py\n# Desc: Classe de base des connecteurs + backends de fetch (requests direct,\n# Firecrawl, Scrapfly) — calquée sur louka/connectors/base.py\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport os\nimport time\n\nimport requests\n\nUSER_AGENT = (\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \"\n \"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 \"\n \"CreaKaBot/1.0 (+https://www.crea-ka.com/bot; contact@spboucher.ai)\")\n\nFIRECRAWL_API = \"https://api.firecrawl.dev/v1/scrape\"\nSCRAPFLY_API = \"https://api.scrapfly.io/scrape\"\n\n\nclass SkipSource(Exception):\n \"\"\"Passage sauté VOLONTAIREMENT (ex. clés API absentes) — pas un échec.\n\n Le pipeline (ingest.py) le journalise clairement, sans alerte de blocage\n ni run « 0 créateur » dans sync_log (§16, §18 monitoring).\n \"\"\"\n\n\nclass BaseConnector:\n \"\"\"Un connecteur = un adaptateur propre à une source (CLAUDE.md §8).\n\n Deux familles :\n - `kind = \"discovery\"` : `fetch()` retourne la liste de fiches `Creator`\n découvertes (listes médias, hashtags, agences…).\n - `kind = \"enrichment\"` : `enrich(creators)` reçoit les fiches existantes\n et retourne celles qu'il a enrichies (profil, abonnés, liens).\n\n Le pipeline (ingest.py) s'occupe du diff avec la base de données.\n Chaque connecteur documente en tête son MODE D'ACCÈS (API / Firecrawl /\n Scrapfly / link-in-bio) et sa famille (§10, §17).\n \"\"\"\n\n source_id: str = \"\"\n kind: str = \"discovery\" # discovery | enrichment\n request_delay: float = 0.8 # politesse entre requêtes (§15 rate-limit)\n timeout: int = 30\n\n def __init__(self) -> None:\n self.session = requests.Session()\n self.session.headers[\"User-Agent\"] = USER_AGENT\n self._last_request = 0.0\n\n # -- backends -------------------------------------------------------------\n def _throttle(self) -> None:\n wait = self.request_delay - (time.time() - self._last_request)\n if wait > 0:\n time.sleep(wait)","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"08432a79-1f71-4ee9-97af-73fcfbd0445b","timestamp":"2026-08-21T15:40:08.981Z","tool_use_result":{"stdout":" batch = connector.fetch()\n else:\n batch = connector.enrich(load_active(con))\n finalized, dropped = _finalize_batch(batch)\n finalized = dedupe(finalized) # dédup intra-lot avant la base\n stats = db.sync_source(con, sid, finalized, started=t0,\n errors=getattr(connector, \"errors\", 0))\n if dropped:\n stats[\"dropped\"] = dropped\n if stats.get(\"alert\") and cls.kind == \"discovery\":\n print(f\"[crea-ka] ⚠ ALERTE {sid} : {stats['alert']}\")\n print(f\"[crea-ka] {stats}\")\n results.append(stats)\n except SkipSource as exc:\n # passage sauté volontairement (ex. clés API absentes) : log clair,\n # PAS d'alerte de blocage ni de run compté à 0 dans sync_log\n print(f\"[crea-ka] ↷ {sid} sauté : {exc}\")\n results.append({\"source\": sid, \"skipped\": str(exc)})\n except Exception as exc: # robustesse : une source ne bloque pas les autres\n db.log_failure(con, sid, f\"{exc}\")\n traceback.print_exc()\n results.append({\"source\": sid, \"error\": str(exc)})\n if not sources: # passage COMPLET seulement : archivage des disparus (§13)\n db.archive_missing(con)\n return results\n\n\ndef watch(interval_seconds: int = 86400) -> None:\n \"\"\"Synchronisation en boucle (cadence §14 — défaut : quotidienne).\"\"\"\n while True:\n try:\n run()\n except Exception:\n traceback.print_exc()\n print(f\"[crea-ka] prochain passage dans {interval_seconds}s\")\n time.sleep(interval_seconds)\n# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: creaka/connectors/base.py\n# Desc: Classe de base des connecteurs + backends de fetch (requests direct,\n# Firecrawl, Scrapfly) — calquée sur louka/connectors/base.py\n# ==============================================================================\nfrom __future__ import annotations\n\nimport json\nimport os\nimport time\n\nimport requests\n\nUSER_AGENT = (\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \"\n \"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 \"\n \"CreaKaBot/1.0 (+https://www.crea-ka.com/bot; contact@spboucher.ai)\")\n\nFIRECRAWL_API = \"https://api.firecrawl.dev/v1/scrape\"\nSCRAPFLY_API = \"https://api.scrapfly.io/scrape\"\n\n\nclass SkipSource(Exception):\n \"\"\"Passage sauté VOLONTAIREMENT (ex. clés API absentes) — pas un échec.\n\n Le pipeline (ingest.py) le journalise clairement, sans alerte de blocage\n ni run « 0 créateur » dans sync_log (§16, §18 monitoring).\n \"\"\"\n\n\nclass BaseConnector:\n \"\"\"Un connecteur = un adaptateur propre à une source (CLAUDE.md §8).\n\n Deux familles :\n - `kind = \"discovery\"` : `fetch()` retourne la liste de fiches `Creator`\n découvertes (listes médias, hashtags, agences…).\n - `kind = \"enrichment\"` : `enrich(creators)` reçoit les fiches existantes\n et retourne celles qu'il a enrichies (profil, abonnés, liens).\n\n Le pipeline (ingest.py) s'occupe du diff avec la base de données.\n Chaque connecteur documente en tête son MODE D'ACCÈS (API / Firecrawl /\n Scrapfly / link-in-bio) et sa famille (§10, §17).\n \"\"\"\n\n source_id: str = \"\"\n kind: str = \"discovery\" # discovery | enrichment\n request_delay: float = 0.8 # politesse entre requêtes (§15 rate-limit)\n timeout: int = 30\n\n def __init__(self) -> None:\n self.session = requests.Session()\n self.session.headers[\"User-Agent\"] = USER_AGENT\n self._last_request = 0.0\n\n # -- backends -------------------------------------------------------------\n def _throttle(self) -> None:\n wait = self.request_delay - (time.time() - self._last_request)\n if wait > 0:\n time.sleep(wait)","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8fd4bb3f-cce5-4165-8872-b13f95d2f894","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9QkmYzCyWHn2qWp2jy","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlhcKpAEIERgCKkADjixtk0pIh1Rum7PC71t+KxgyQuYmyXRPyHL8nyKyNufGdS5DOf8xcab2sy9q3sMGOZ2Q5LECzI6oyq0Re15jMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHv4qHUBhIMA3sv7PeO2Em4+BJ7GgxKhZ9Uzt8QZ0su3o8iMM/uWg5XcN6JdUg71GRKjuHVdXyW0J9VVsGpEGNbMlnrKZ/DqejlIMRDanqeiyNPByqeFZyf72oB0jhyPaEzQwyCZO+2qcjQr5nVm8kWJ3uQsZ/XL9FdHssko4ogfsrvaGhsgyuOhOlSoemQhAjexYOdFRoAEV8O1mRBW6XJwj6z7MYC67umiORN+2+9G1pPh8FBl3hcWd/B6txUswzX4eLEn+QBXRsCJ7fIptpBIt2AucJJNxbSlZn8Y8ydszX8zbw5eiKe6cjd1fYGMUiyZFkF6d2YQHVfe9AquwNqq5etj+vC3VirhUSElfdxI+W7iF79AKD2BLPmnGt0Fk9MaiKueWaFU9JHiDBd24xsEwE6gzkPxKG0Hop3LYZWgRb/7nQ3IlnSGOo0FbT9C2vq9pzrYJgQQiNBZyJdbnUuwpFY+sBfjruYAl06pxZJLN5RS1INQYbeN91vkIXM4wKCfo++EOA4e4t/3GoAdDwCXY004YoDK7UOT0VC4wLevprX0yXNolLfNfjEmSAcjudsJ7arjZ5lYjwofFUw6DDjjzFIupSqp1zVfDR706BblURAved3atCzJEimIYXd0qdhnCX0izPx8opRQSdVIW0gEgbjEqac9vEJXeObsZlLQbg8r1TMJJ9IEzuR3fBPJ4P5g9u2sZXM6cUX4q43LdRwhfoiSlviDomhCkPD6jYk8BnIzrfJlTGsK4O2AKJUz8nvLtuzVmkh3vMZbTjrjXjHK92i/R+Tnq79Y1iygue1mW3X/Z5rzt+VUlsMYvPB1RJOiVy0xdAtHi0t9U4SS3VNlYWc8CM+eK+eeXr7ZB/WZFkuMVgnk0gAPSr4VUQ/XZC1RTpdDaqxEulG8MFNtXpSUugKEtMuR/rjBH54GZG0kzERdW18SwYA+zt9cnAi6B712y3izKHGIxsUWQA0goP4nrKBbXVWh7SxbdEnsupXZ7wSLTJmZrFZRKBiZxxLqKUm2PySN25D6UxgNSydm26DU9HcFg5FJgI2zlNQS9SCof4XgTR+qiMybtMa1Tx4VQlQBse/MOuuzvA36qclO9PZOsL5Mx51zxujAkSDFQjzVuYmTgQKR3YW90gQuW1U8qFVfQVD1s4AxelpSUGNzQSke/sapLmZP+uvNmcqlUzDsDXDNNuc2QgK4piSgef6W+m12YMuwwT0xkCUg3xM1MjhGMo9Xgm8SXEuE/d1k/CdaZtLL74pK6X9M5LO5a+k7fwOCSt3uLJsXjuRAxzgb3LFYtTAdpkfJ3SIAFu3g4P+1/2qGiMN3H7/RzqoscpNJOKwe6xQKVKC6u2+lovhFzA/4OhZVIHxKpfCwEoChDrOnqCkpGiVHBvufvvU2tXoMFbtRuJ7JCyyFDBFqQ7F3MH0weitXMFv1NEmh1JZ3bfqXtVgzyOXrpG3ZEhXHu8gYNR2ZOQk31evAVxCsvzT8mU/OGXEb+FebMYLdlx3weIKopKIOqMLwLMHSbtbFGNkmTg8RvfJR1ucWyZqftHHfPPMji516RyvlHInb8bedh3yiZwWX382eUZnaKMCHS8qZ/0mIua+GDojsw1twUCdbqLRXQzd4hBtm19TRS40FfT0H8mrIwVWUFZ7UnUuS10tkljLTD+/4zqOEeoYkIRPM3iBrHa9M/3dMX437FfTImpdDkSNH7gGoHbjTAJBjtnxrPI23OVkyrbwJ7kTM5o+HeCeve4GLeutsvwEneLkqmGafCoTVXz2jXaQ8XTR2+l/dgh/qM+HT1mYQPLmQBruA67hdIMWxlCIN44vKch5pQawNLAn5RWU/EkH44CXL5tvMApn+PpEqIJlqLNjPAJ4ba8leqbV98WlY5lZi6g3rSmMLJ/KI+49/3LC38+XqNSS96DoYoLKzFrLugFTG9fLr6u+STYWk31PQ1EkolwTQyV84uhWEGZV6fWF1I/Is2D4C3abzATrg+e8XfsNIToyvHHf0torEoLJLR0dAMpkB7DsPCjFyChn7j6RwW4ezTYqC4JXtTAZxVLHtQAxd+dOZIM7AIZYSpTYp9jd8OJW34KvouMcuAjR8/N8/xJe34FvD/qYBHMx8IucxAcFti4CfL+5DzNiDe0k/90DVWeG05n/TP9wVbjPgN4ikCEfNA94ilV1dfM15pBZKhpeJrMLjnO74nw9evwehfK8TseOCXq1blXwEn+GhhIxUPswe9uphJSIRuRPAaQVhKGeg3o6cP957BKJBgJK5aUgyoUWI7PiQwsg026uUj0/XA/c2+Q7zjDdtAZS+orSkLUoaNUY77FOZAGw1iVbv0pTWGFEsST75huk4n77t+A+f3LD++h22FW/+5+aFxjYBn1c8IFRtZoRP+XqdFq4MIR5GhL8NwIeHUNlthgTexgffQ7JxCnEezufFyZDVbCDjEamqUCpx7ywn8Atl6JD5ZWLInYgkiBXbm6wwZ/iU2OxBuT/WtwgSsI666ZjtC6QZZ/QW3p4achDaMkq1Iv8++XP+AanQ2C31gychqmZ+ttcX5P/4Bqjf/OSSi290eDYzuq1sIaYLbdpNp1mxSNlzW0Ojj49FbKm4i0IW8Gt/WlJtvtD61jo8bujL1onqqqcd6n4TD/Q1aOHngYckW/oI2kHY0oIxtoMJTc2Y636xEGgIsD1H2Nih3FWpoQxJztx0P8Q7y7EHuyJjJ6aw/qeY3xNqhiFoM8diPJTGNLFZ776R4+azo05ndpv9zweiah+dHlO02sLItBaU9opqAYnnwu/oO9trpws8auzdHGod+9DPepLFOh8x7vFtnsVhqFv1/M0FxsUD3DBrts6fU0Kfw1QhBXsDikxCM4cIfINRPa6y1SwOktJ97zCFo2ayZlKFZFmPbWL8VhohlMdIRVQ9YHgCm9Q6MRwFlVg0G3L+rWbVvdPmf3XzwJ9CbiFP03hezIY+pWfak1BvkK2wo3Mh2yB/RuvfUE1bfOYkFBj0Lqmj149FEopBg+jdFwkM+2QXxDb7elI6yAzcLW7IpRduro5IrjTK+D75LS5k2uUK8QYav3vAeBczou41y1XVdJaPtDZDFohWHRY9tt5LQ7pB5by+e4QwvxO5+TsMy+tGnMUayfE+IX5fBU20Di/LDnBf0Hzc6Do9zd33ESGqFx5sbLhR9O2J3pwimCtgtj3b4yg12JO6s8mtmN+lxwRmJq6NqG1nawxnswM2BFZR9fjg5JatTH49LBHCR1re/iTu9pnIjkz9SCELOj04aKJ7jIZYzIDG3rcPm0goPTARG5cO/V77O0IrI4bWu7RC2vuFjvQFAfNZ7HheFIH58iqKGjCXzure65tK4VxYhpiTbsn64eL+0nnhwWZD8Dlm55enPucOpfzma1UTOk1fBVnPy5/fH98J0+dJFnmjQmsOwDE86g38WaMXUUgrn1AiRxgGPvD1MoyWBVaVDYzHqpvNkpBYSN8o6tSjJ4MdxEqZEfdc/BtjGkLXY5L37AFOrcIpHNVTJhz0FHdPewkX1her7VtzTtT3PlqZ/SL+3mIWPfz5A4IHALLeKY2b+y2PKDeUj1cz+V7VXEx3MHEN7fLFASstiyxGyBCs6SndkRB0IdZEnjfR6Mw+STvJG+qnesqEUyGG6kcnsE3U5NgEOcKoVk89BCCH5wEZSnYLZ7IPuMY9+fdkXFmpLFBtR7ABC8QGSdfN/PwY2jO67mu3DkinvAFMg0HveBLRalYdWw5rxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2056,"cache_read_input_tokens":86000,"cache_creation":{"ephemeral_5m_input_tokens":2056,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e3bd8411-f6df-4bbd-a399-ade3f3cee3d0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9QkmYzCyWHn2qWp2jy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012CEhjfx2vESvjSA1ishj6F","name":"Bash","input":{"command":"cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bt5mkiwz0.output 2>/dev/null; echo ---; cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/b0eerrll4.output 2>/dev/null","description":"Check actor test results"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2056,"cache_read_input_tokens":86000,"cache_creation":{"ephemeral_5m_input_tokens":2056,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8fdd189e-1ca7-47d2-9a90-8a7f68a3c5a2"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012CEhjfx2vESvjSA1ishj6F","type":"tool_result","content":"---","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6f5f6af7-be36-421e-91ee-90bd1949893f","timestamp":"2026-08-21T15:40:34.652Z","tool_use_result":{"stdout":"---","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e9d1fddc-679c-4125-a8ba-db067b4441e5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9SfeHS4orCPoWYG2Cw","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS1gIKpAEIERgCKkDU678waY8k6Abpd7lVsjdX0eyKnOGAsZ1xkd9sDsfKYAh2vXl6gyDJgbWQpF+Jb9JKqm5M10kX/iIi0HHxMP28Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH54qHUBhIM+nRJuKcILZ2F5kLLGgwA0hMB9DXG7AAOi0MiMK1qs9kZ9KhZKZ4yAw4MtZzvKcuIC+I6qP33kXo1wJ9AOA+CIMmr09I2gl8IdaGC5ypftMBfsB1lToDzN5+OpFZ/Ju1nJWwgWeZeqIaTGQKVBWpAt+KCrg+7/FR0FZ1LjTQeZ3dlrGGlbz0L0BMZDdCNoroBqC7Z1bIAIJ/7/eURjb14SdoB1pnBry1wX+ZmWEkYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1425,"cache_read_input_tokens":88056,"cache_creation":{"ephemeral_5m_input_tokens":1425,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"13256b64-faaf-426f-ac64-d4da24f930fa"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9SfeHS4orCPoWYG2Cw","type":"message","role":"assistant","content":[{"type":"text","text":"Les tests tournent encore. J'implémente le mode forcé en attendant."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1425,"cache_read_input_tokens":88056,"cache_creation":{"ephemeral_5m_input_tokens":1425,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5ceb7208-fc2c-429c-bc49-dd96a939fca1"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9SfeHS4orCPoWYG2Cw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01C1xbEK6cpT2d59rPmQPJhh","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":"# 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\"))","new_string":"# 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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\""},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1425,"cache_read_input_tokens":88056,"cache_creation":{"ephemeral_5m_input_tokens":1425,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c2963f64-88bd-4abe-bb7a-d39a354c4fae"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01C1xbEK6cpT2d59rPmQPJhh","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"41d335fc-2e0c-4acf-a01e-86741e34982c","timestamp":"2026-08-21T15:40:45.940Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":"# 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\"))","newString":"# 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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":57,"oldLines":6,"newStart":57,"newLines":9,"lines":[" # rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis"," # REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j."," REVISIT_DAYS = int(os.environ.get(\"APIFY_REVISIT_DAYS\", \"7\"))","+# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le","+# bassin de chaque plateforme en un seul passage (rattrapage de couverture)","+FORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\""," "," "," def _token() -> str:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"6e7d3d72-874c-4f95-927a-b6bef9f7672c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9TV26CmTpgKh6EkR6m","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019maBYNsMLJgeMmt6rBxBd1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)","new_string":" def _stale(self, acc) -> bool:\n if FORCE_PASS:\n return True\n stamp = (acc.metrics or {}).get(self._stamp_key)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":618,"cache_read_input_tokens":89481,"cache_creation":{"ephemeral_5m_input_tokens":618,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7cbbe4f6-7984-45c3-b114-c4d2fd7c1ae0"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019maBYNsMLJgeMmt6rBxBd1","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"46dfdded-bebb-4433-a6f7-1e538800f77d","timestamp":"2026-08-21T15:40:53.120Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" def _stale(self, acc) -> bool:\n stamp = (acc.metrics or {}).get(self._stamp_key)","newString":" def _stale(self, acc) -> bool:\n if FORCE_PASS:\n return True\n stamp = (acc.metrics or {}).get(self._stamp_key)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":181,"oldLines":6,"newStart":181,"newLines":8,"lines":[" return f\"{self.source_id}_at\""," "," def _stale(self, acc) -> bool:","+ if FORCE_PASS:","+ return True"," stamp = (acc.metrics or {}).get(self._stamp_key)"," if not stamp:"," return True"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"aa568e42-cf87-49d6-9fc3-f8846313c610","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9U2AbK2tmo48tn2Shs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DKtYnotjcNkDRmTTgQvexb","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break","new_string":" targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if not FORCE_PASS and len(targets) >= self.cap:\n break"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":659,"cache_read_input_tokens":90099,"cache_creation":{"ephemeral_5m_input_tokens":659,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f5156b9f-7dc2-462c-b5bb-06208237b1bd"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DKtYnotjcNkDRmTTgQvexb","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"abe0afe9-f88e-41f1-a72b-6fc0a5dd57e8","timestamp":"2026-08-21T15:41:00.376Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if len(targets) >= self.cap:\n break","newString":" targets.setdefault(self.target_of(acc), []).append((cr, acc))\n if not FORCE_PASS and len(targets) >= self.cap:\n break","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 if FORCE_PASS:\n return True\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":214,"oldLines":7,"newStart":214,"newLines":7,"lines":[" if acc is None or not self._stale(acc):"," continue"," targets.setdefault(self.target_of(acc), []).append((cr, acc))","- if len(targets) >= self.cap:","+ if not FORCE_PASS and len(targets) >= self.cap:"," break"," if not targets:"," return []"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"578068c2-7855-480e-a50d-454902f10e76","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9UYMYL2AquCS8hTFug","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrwcKpAEIERgCKkC2bbyEngK0n4eW0UIx9YC1R0nQAlDBYadRZRdrW/ygJOSFw7zGVrHAb3A4MT8rjWk96uE47255R7MleQ2nxKyGMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGV46HUBhIMtil4eFG8W+PfjtaOGgz0Alo+n6KHvHP5imciMBMSLATp/s9sTzPVV726jkDYU4PrA4OC71FkI/6M2NtgxfuW93OUU2QBZKgwyw0Cxyq3BVCC8uEjUdMk4YAB4K3qegHWpjQ/ElzcLWLjDzctuCHFytxAli4EGQZ7q4UTF5g1dHvz8aNhenQLm7iQ8xGz7D3AujBF2Stz63bXozINVY8ni1TpH4WPJV7nROryNEqFSi8TjD4OKFLB8mtL92juy4N4PjFHbyFO2SfBkvYy8ZqpD1FuSFjpLqrnODx3wN3TOCPqPU04Jb5Y4PlU6YxtalQm6T001rtZqcchCylzZiXT0M0f9qQSvohcM+SmXVKjwo9q2g60gUMZRxLVPxRNfVNmTCCOckfD6Fq9HOIBnH+SRbd3ZTkCgCfgsMOvZUYfDUMPWHXeOOkjnTc46pHJ2KP5oPmw912aq6SPK+09ED5+uZFOf7LsLkWP+tcX5s2k3D/eKGvqbW2r0fIYgbGo24OaLIG29n0JWEXx0cTqLumk3BIVSdZAoNLgZAS/OvkcWQnxv+8DX555FWujjVPiRibRCogIh36MF5L8USnwBEYIi4Z+sNfAn30F2+m/zctxCQJFxRftVU/YDL86C428zUdmOKu7mpSKFe6HYqzGTRq6Jqxh/YbrAQLGQ7uC96rHtuKL3d3baYxEGCA4NpIX4dBOOc/I7zxes3TMZN17q9n9cBEUoI8YYbzKxFmcynz2hdJGywGX4vC0JizcEa8s/vAS5qn/r4uKN7+XZ29fLKE8RFD6tBhJ6rAILU3sETKSBkVQ2ZarJDMPXSAhIOzvDwKWEqir03GabEOAow5oVCvtMbSzFJKdloa1AwnN7I13u7KzOF53YdoMAP26iqLSwUvQz6kz1cMqUqKzCYg1M9ZR1cqmqveSOp4/Y7Dl8VudBk4HM4+eqIkcIJtjiWP+6v06TbiGdLtK7s/HsgRIx856PBe7LFFexea5emjD06817H2l8Cn8S9X4NruLyGQD7Ytxh0304ctXGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":307,"cache_read_input_tokens":90758,"cache_creation":{"ephemeral_5m_input_tokens":307,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0204c2f2-0473-4736-8eea-7e6fd28358c3"}} {"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"b0eerrll4","patch":{"status":"completed","end_time":1787326871153},"uuid":"c8115de3-bc8c-4849-bf64-9a9e6938bd01","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9UYMYL2AquCS8hTFug","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01QjG6wTcZwDFZtf8qKNxzKo","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))","new_string":" items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":307,"cache_read_input_tokens":90758,"cache_creation":{"ephemeral_5m_input_tokens":307,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5d5722e8-11cd-49d7-b365-2fae892063b6"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01QjG6wTcZwDFZtf8qKNxzKo","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9e6aca2c-a2b0-48b4-97df-fb18cccd81c9","timestamp":"2026-08-21T15:41:12.829Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets))","newString":" items = run_actor_sharded(\n self.actor,\n {\"proxyConfiguration\": RESIDENTIAL,\n \"concurrency\": RUN_CONCURRENCY,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":223,"oldLines":7,"newStart":223,"newLines":10,"lines":[" {\"proxyConfiguration\": RESIDENTIAL,"," \"concurrency\": RUN_CONCURRENCY,"," **self.extra_input()},","- sorted(targets))","+ sorted(targets),","+ # passage forcé : tranches plus grosses (bassin entier / 32 runs)","+ # → délai par run élargi en proportion","+ timeout_s=7200 if FORCE_PASS else 2400)"," by_handle = {str(it.get(\"username\") or \"\").lower(): it"," for it in items if it.get(\"kind\") == \"profile\"}"," enriched: list[Creator] = []"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"785a9714-52b2-492d-abf2-9108caaefb9a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9VUhJQg7BBVcRQYpXP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YVKx5W6MqeNGpWHwCjU4d7","name":"Bash","input":{"command":"cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/b0eerrll4.output","description":"Read fb/threads/onlyfans test results"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1037,"cache_read_input_tokens":91065,"cache_creation":{"ephemeral_5m_input_tokens":1037,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"adb672db-a38e-41ea-8816-290b4b709ea4"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YVKx5W6MqeNGpWHwCjU4d7","type":"tool_result","content":"== ka-facebook : SUCCEEDED, 4 items\n 100007739415409 found= False err= login_wall followers= None\n 1appparjour found= True err= None followers= None\n amipl_ found= False err= login_wall followers= None\n cl_blog found= False err= login_wall followers= None\n== ka-threads : SUCCEEDED, 2 items\n evanvanya found= True err= None followers= 1724\n 4therinecloutier found= False err= shell_or_200 followers= None\n== ka-onlyfans : SUCCEEDED, 2 items\n alaskahotmess found= False err= wall_200 followers= None\n alchimiea3 found= False err= Failed to perform, curl: (7) CONNECT tunnel failed, response 590. See https://curl.se/libcurl/c/libcurl-errors.html first for more details. followers= None","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"90c09af5-e35f-43b6-ad48-86940af7ad23","timestamp":"2026-08-21T15:41:18.559Z","tool_use_result":{"stdout":"== ka-facebook : SUCCEEDED, 4 items\n 100007739415409 found= False err= login_wall followers= None\n 1appparjour found= True err= None followers= None\n amipl_ found= False err= login_wall followers= None\n cl_blog found= False err= login_wall followers= None\n== ka-threads : SUCCEEDED, 2 items\n evanvanya found= True err= None followers= 1724\n 4therinecloutier found= False err= shell_or_200 followers= None\n== ka-onlyfans : SUCCEEDED, 2 items\n alaskahotmess found= False err= wall_200 followers= None\n alchimiea3 found= False err= Failed to perform, curl: (7) CONNECT tunnel failed, response 590. See https://curl.se/libcurl/c/libcurl-errors.html first for more details. followers= None","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"45fa8b3a-475b-4ec9-8c3e-7c885a3b4ba1","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9VuAiC14VQGqkfcV4J","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISogoKpAEIERgCKkD3zs5bnUD9zgCo9bh5CZ2pg9Uy0ji9C2Fo168IcqIW4jJB6l3LG8P8K/tjcbVB+mHsW0I4Ss5Ru1HH24kex1u/Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGs46HUBhIMsfXtq730QUD54BGOGgzJ/pvrCNbnL2CKUZ4iMAI6JuESHFHI2WPwGkcWKgZ6hJIJtYQrnXjXnudw0S60/CzcnSU8M1yHa9a+dv9TFiqqCKwWuhUpyiErI2FrukUyINFT6Hm+TvVXByudqcR9GV0Iz9gevmkwzjAzn2qTPivtxBOcu+TzDfa4DlHIOZDjY0M7rgXuTzjgFS90Z5XdRJ4iTuvChzaQCqLMqYnMhrECPr72FgYn2wnMRJ/0vBaWmqPbEVcqB2CpVTU2NYl0OLANTr+0y/XSnlG3AZTLHA6PQ0/I2n4SL0JJ+vBv108J3H6M0s5o9IRjCZQ92BhtMkMQY+UUGh4xvHMayP7ZDFDL/9cigf6MEYC388U6Zd5TGjXB1aGKH75TeVr/l4W626dMOXV4EC1zkFm1ojc7ybWdQfCJ8kn9tELrLGNG/0Z+CE4AH4H+nKqb9jWJidrDNOQurZKxO41Nq+ndk9oWecbWm1ENdQMv+a4iTRrpfqMVbn/TsHcltAprGhG08UIi14uwJJdV2788luwoVUezOOntw0HD2HGWNmgL4dZ6p1XAf+utbXOGVJHmv+z4+H/FVFnF/LlZHeJtGDiQ8HdCTvikJzivkD1ucYjBlBJn31SazlyUyuJrlEbnOolWqV5pmsemRNnBkJaXyXoJjVuzrIjaBr4X/fHe8vE5DQV3UE5enB5Qi7APVbpliFwr0QgqtESTPYzqLWCp9ddKDScuk9QdiBVhvXuRwKBA2ed1SQbw5CXoXJQZFxb6pmiJkn6on5YEv1+yaU5RgoHWhgFmcqQm8H+WCXeq3PuBHzr5NldbNf0iP7Lmn5ggLABjdIY5fOSnnfTvfd+Gj+PkyuwQlKm657ioRQhb6J0/FvLUpKpcKIkJLupOmgWllvHzVwLtAKPxoX47nXKVueIjha0mfcURssV9eb6z7/w3Fydd4+YGK2eRfeLT5uKAhaY1lkHD/eq6dZdC3YSc0fZ06VnD28Wka6k4WuiS8CwehtHT9snmFyYCtldIw4zqiWJJ1Rdaj/IwSKKSSGhlKsCL5DLDD3ihe60fvV39VEDXlViMj5ZTzGNmsK+Y0JmCQq7WZsAOdEvZdMZRh17qxedUHcJ8E0ePZcdZPzoTqR+naHguartgN671oYktJw0OxMOSrsVp9SdKJntyCe32pGSJvuVs50e0d/9Ldvx/vFHfvvSB615Q2q5GvbexML0QbZOGp4vLbiTIBlO3QA9lU6MvlOm5EyrOzcxsZf0cq7YwGSzQMGVLNKC4U4maPn8zCzuLv8QFRzi/U4LkpBX/vAFjM72UiPIfw0mnLWNVlhwT4qkgB9E2gmWYkHrJcDYM5uh3bTBTP9fQKQBTbzGce1myaIa8aKj+Ktvm/kAKNLyF8f3/N8Z2K4C15PuIyMwSq3ZP/nl0rM7YLXLhHVnpm8n6UU+bi5iJnZ55ksAOdNr/A4Y8hvm4ryfR7E0WS+pxcMHlAGKdPdXG3fAeFtQAXl8tXHG4XldGZMYaKDzVSGggWnUYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":448,"cache_read_input_tokens":92102,"cache_creation":{"ephemeral_5m_input_tokens":448,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"268d88b5-2d79-46bf-ac6b-89fc5f628d2a"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9VuAiC14VQGqkfcV4J","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS1AUKpQEIERgCKkARsOk1kGqO5sKSH0UPFDK7P0ZMQN6SklfJ0H/adipOCfi7vd4qdmBN2wTwcZrWmej6cBM+doQ31ZkelKG+NGf5Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBrOOh1AYSDBe7Eo4jRJbHLgKeYRoM4e80cOzFM8tRZ/jDIjA1WBZ7YiN7FesqFAdNGrHx/RIp2CnlUvmqc5h4l2I6mVa+DhDQW96OjUmPy9+aZwEq2wOTaT9/+QJMDJKbrvm/5MkpDUFJwUrlmvnRg2H5i1peQ+GpnbGTcXZsBHZXNkFXvTAhzdJ3JOxiwtdVfwaQDj/jJ8CMAof8QQWKQfPJxIqpSpWcsRkqjEfJaFL1316v6hWNfJN1tgVdXpneQfWaPFkZyRtecnV8nYhM+AxRxNF/snR+KLk/R1jhelQ83a7kIcpm+n2Pl+vxDaBiC5vJWstnGipClkX+RQws+9rsengVs1Xl1WzHbvcEXsf3DygEXHSgb6N2GsyAFz0hxbE6RiNfAr6POSza3jG7FTsQuA90GoJe5vsutnKuu2Fa92MqOobovzVROGfXQ3+O4IqWi3IYL4kwJRV+83oNWGrbatVGh+2KpE6EpBIsBXtaUE33NhIs4kSHw8BJTuPaIoB/SmIORz2McYR86tHbVlagthAnn/2OMXZz7D3CVy7g7WgzrcRwxWFM5Y0imSCJsQWq2mwbNevZsPpLsOld9snplqqqZdaXTBP2EChPQGN0AJitq4kUgqYUuZod2W5FdXCrOr54NbTcHkoUP1OHye2Ksa28MJAL81IeQqmbpMYdV0NmGJ6f35vWOLN7JUoNZHA1nQ85jyMd7DEIyGsFt2xbHMecCdk5f/H0D6buC3EoGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":448,"cache_read_input_tokens":92102,"cache_creation":{"ephemeral_5m_input_tokens":448,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fd38c76c-9414-47ea-b21a-e2b34e389be4"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9VuAiC14VQGqkfcV4J","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BrvVbHeeWcCc5k5nPn6FQW","name":"Bash","input":{"command":"until [ -s /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bt5mkiwz0.output ]; do sleep 5; done; cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bt5mkiwz0.output","description":"Wait for X sample results","timeout":400000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":448,"cache_read_input_tokens":92102,"cache_creation":{"ephemeral_5m_input_tokens":448,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f43f940c-3dac-4e87-8c86-e9aa44cc7a71"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bssqt467m","tool_use_id":"toolu_01BrvVbHeeWcCc5k5nPn6FQW","description":"Wait for X sample results","task_type":"local_bash","uuid":"f4fa38fd-9127-4dba-b220-13b7eddc83f6","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"bt5mkiwz0","patch":{"status":"completed","end_time":1787326920295},"uuid":"281ada4f-0eee-4641-98b6-7748cfa4bf1a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bssqt467m","tool_use_id":"toolu_01BrvVbHeeWcCc5k5nPn6FQW","status":"completed","output_file":"","summary":"Wait for X sample results","uuid":"578da7fe-3b0f-4d15-94d8-506eaefe3a9b","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BrvVbHeeWcCc5k5nPn6FQW","type":"tool_result","content":"statut run: SUCCEEDED | items: 40 / 40\n err:user_not_found: 25\n err:http_429: 13\n err:Failed to perform, curl: (7) CONNECT tunnel failed, response 590. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.: 1\n err:http_522: 1","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"097d49f1-d221-435f-b419-cab58f6613c1","timestamp":"2026-08-21T15:42:05.016Z","tool_use_result":{"stdout":"statut run: SUCCEEDED | items: 40 / 40\n err:user_not_found: 25\n err:http_429: 13\n err:Failed to perform, curl: (7) CONNECT tunnel failed, response 590. See https://curl.se/libcurl/c/libcurl-errors.html first for more details.: 1\n err:http_522: 1","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c90a9114-a803-4347-9525-1d2c770562c4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9ZKo8CBtH9szpWcEu2","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISghYKpAEIERgCKkCqO1cEMhDbRGc9hQDBhbUkUHdKwJ+vmSO2h6OpoOTsmj7ZSQGuSbTpC2KERxH0LN46r7KzhZ0ZdOFSRBO764xEMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHk46HUBhIMzwfHZTy95vBhxpryGgyVFU5Iy+Bu01Ua9LIiMK+3ZaMnMExM1V0Yu2kxDV3BIE9fD34ityAjWGxR5SefZY3kR8nj9wPM+UFvLjNjBSqKFAFvEvDk3MZCb3LvXLuXWiwtN9o/bLE3cPAlwT5xUu4v5tVO29W5939H3Le7ku+2xxrwEuhxifOPKRiYo5vbQ77PfQR/rvHuPg3h6o5IgbgScyn1MLKp7V5xIKQVRbkdPoMOKBYzG7lzhZ1E+xHUMNhfh7EdoawtLt/VH5m0ogYSHVk2Mar4d1VUeSkobKSB7QMHI0cyCEmzldQaxnw/DSmkf411fRC6p1evOcDWESFy5VqR9YxC+acm4xcgKlsNQMSY97tFovkARV9Zv8bq89FYLTNqITAsLGuWuK8HMC1WXTB5+F4R3f/Qu3hGgkS5ExtJ3CFp+AbOA3Lety4fO5gX3dLDEbd1Q6S3+yndxQ3UY6w2tek5JukqSStjRVWOX3K7MbJvrWVb+wzQFeNVPJh3TxV+D1BsrOdzInMAa3fX2bqqDUQaHE4iuyvxOAMfChL9IFK0wRLDSDqvfZkCFnb4/DOSQooS/+IGFI86A+/zLyUiOs1vzRSjsDr7yHddEJNQ8ArdFt4ZWEy8EkBmqa4iRi6CaVtYWxvq8TqxvDUy+4PHfCU/9qvn6iJMqiEWNS6WoYQmi+gb/3//CVWgDTHQte3IoSCl8nN93xNbplmd2llr0hbmiOcJTXdHsukxFGQKGrnn8WuXZqX0/LzgG9R0RPnv188e1eYoU+1ktRDVjqtmIR5/3TpdAyaDcynEls3clDMgJ1B798BlXpkERcoeqUtXmEkvOUu71/bu0tk0m7pi5ig94jF33G4vsMsCU+WICQ13T5VwLtBKlJSCFwfzX1TIUyKCA11hUUTv1eCkhgX3/s+JtumO3JGEMun+sbM92BvaXTa10i4OJQXMXYhy48OOfzqC9emn8LxpulWNIA+VziwQB2nNNcUAuZDgigx11Gk40A4BOC8IdyHKo31CDD5WuvO15iENmFmfX1LVfyPrFTY3km8VGiKVc7EWAfaK79awepCBLu3KXYAZK3PWwYPgavHKvpc94mTIr+aT+2b7ppKxnW6jytRUmrLnkgC3fxrdFusSClF0AEibwtBUU2bJB08hxcB/p3NNrdDXWcgEkEh8ciKdQM64BG2CFVxyDiXlYZGHlCEIzc4HyH654UH/2TH6vHukHach52Q5WLwNELJteEDSXNJSyfQ0shezXAxevJNKmGQ2LTDnd3tbsuJZZxv0kR2jNJU2mr24bmmnEQlOTqMCOUlU9O41ttyMtXZfczFQcumeXO+LeiLO7OgJJrEEcGt5B0syahe+kcb0AKKxgvVLGHKe3o1CoIXXvkftfuXoVof02TITUn0wkKSkPj7x1XezaF0PgSDgEDVaxR6wjyqCLMDxfoYGEuHgPIqglVw7gO5JSWCTAdw/TtHgo3Wsd/82ODJ2G8/pKAoJfNeopXEMODgyDI+iCMUJdVlYTn4UBHehU0jgnu6qonukxtj6HycyC1spRAbckEiuvFrzdpM0gTna5Hj5vxhHx85sX6OSVUH8PdgHM20wO9ORzhOvKkA+zal6yIrteEKLobMAjrbU6+OlZH0r71Idzuz7wN9OW0x5Js1V/gnoVb5VCMB4XdBlX8z2032HRlwWX63f2pNcLFqxgUjudW2Xr+QvG2EKzKqC9G6rbieGOOi3ZpIPXINJW0c0nypjZ59rgp+92L0LIEl88qSkn5hDWjlUP996vEZLxEw+4vEtvB1S1c5wbcI5wv1iYOs1gSCoLSd6hSOiQguKpDQnDU3WqMMsxspLXnMncPYKp2bG28tU0DDvDU2/CmReRYyeOd1rTappB4RdrVNm8QzoZoKYBrEuyFRczM76rS5KhzcRdDKws7rAY4tfnNGkBpLIuCR67vM83+yRAdqrZDOMIhOlfO8rneZow7oe+N9JCxBneUj+bYTBcaHveuQv2QVp9VLpQz1XP7Zn2d3Rg1cWaJ1dRIl/iZRQcH5gG7zYpkTScuH0QtL0p9gu/CGAdcsuxPXmbhlLSm5zXHE3ypl366sNWak+ksJ092t4kXl3Hrq4FjpnWKDgTdqN0vThptl2/6D4AAKvfHk3soB9HqfKjhISZX7Qy3p0eKV1Vkf55Ej3DM6D9G/dYBm1js77Cdr1MTzImKKhigJbqOaXD5BH/rxLledFUnxGh5KYptQrJP6Y1o8Ho1n4YuDD2wZqVhr7LFP3oaRniaGois7FvMxRts+4f+YXiasWG3wIC4ugPhIe2mohQGdcRV/xYJscXrogf20o86EhaVYOHwsWF7SS9gLJAWnEUu0ryA7G3U4rit8oC26DTzq/i9rqlBHhzu3anqh2daGc4dZ0s4zBscPTru80fbDxHJZjL0x0CZRO1YncYqH5wNaFlXI3AqIGRZTB65omFKZTSUP1gdwd/WHFPxyMipvVEKQxdanlka8xur6SbP/hNeN5XZJGxOXpw7QLhlOiogHNwiYO/nOYX3y/MgaOFsgBDss3rkLi2gtjPtj13aVydK6nBU94F+/tRdgh6Voc8CtcbKi6qmHuhuiF+LN4QukVQ3VgPqaBVJDNJwD/lmxkY/T74kKHjJqUtzZk378nRFgD4TDEZgzsON/Rkyz+Qvhi5OpYIItfcDkO/kZQTOkv7iF546wG4SijU4mphx3rwqzsBCqXU2ycE1DHGXc67roEs/b/p9Y3EhMKd7S/u3TJcYhQ+Zi04zCyBJjQ0HmnnQ2SseR1aA6vTqMAGD0zJmx0hy91r77IwXpphssTncUQjgMjMBQ0taWRIXqCv+fa68zXHvNePBObJt2xBaTNmdwcu3bi3UybTlq/Gsapxz23CrD88zq+SiRpp4QJWFtUgihzx9MTvuyqRj+MjecWHCFhdbElbyLQypwj4/r3Fqu/5cDRljreQ7Mz8ea11Pit/BaGi/TbRaFoVKdYxehGezoCZutmgZc9XswlBpYWtcsEMaklzShU3gVm8OdqnL2xh0HZdZKp/ju4Ye2u1ChgO7rt3uUEet2FEOhcEUr/OYs0ZhtcSJRW0Tr+xnDsJRLKvf1bFv/rpOVLF6ZfDxfyRuPA2vYdHqpm31hZIv40ZUj27OEk9yU08FiWV5AbwY998YCdmXctGg3sbTAo2K3MmZc3tKGJMAETHfTjCK1pUoc5+959y7OJXYaVn/QzITJaAUkq6pwlXMY4t6Mnb+ddgnfc2+NluyHW/E41YSjJkSA6ovFHJfNo91RG18htmOn+BywobLh5gHaa7p0Ig1n40om8u7hkfrY0SmdYuL4F6Tgs5rwTNYaEi8dvhmjffA4psEzGV4XIiedsegfoj9w82sEHjfjDsj2cln4LDVEZYS1ARdxopYUqPK40iVjufFRAgj5RO6N7EfQAe5F5rmwVHHjrf/NlVu5vR+d8QwMRW9AMEeVmCi7Kt8Q/vTf6bCNcO+xmecCdLTbdc9f/xPhf85yyDoscMdMmLuxxsWxY+RNgJg8txHHcGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1308,"cache_read_input_tokens":92550,"cache_creation":{"ephemeral_5m_input_tokens":1308,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"58b1b268-f4d4-42c2-8c6d-f3f6b76a8cc0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9ZKo8CBtH9szpWcEu2","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3AQKpQEIERgCKkA3OXw/l8l2KLx+V/TyLfbQHaDac2jaUCawk/NEENQ6Z0RbRJ8KlzxI9vuyQRBU0AqOKY4RwPibLvPaZFmrA+7IMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB5OOh1AYSDK72VpthApevKELJaBoMZI5XatLj+HgV2nkKIjCPtRIVxkDid3zZ5IGTqz7umVyW0J0PO74Dl5PlMZ06SWK+pWU8AMB6+sSRksepsZEq4wKwLr/IGZ6MP64DzGDaW0/soh4/TondaOSm2DR1T7vDlZOogmJJXcR6iUvDF3rSgtZLBfeQ/5YTsZClxxREQuIWnK094tKRCUE6bEJai40LvH9SdfwX4+Vq9a0kixDsYS/c0RK5ARhBDDiFUiUfXS9x+alQ+5D4Od237X8zzDrDtuep9zY+PpHfVKR82h+rNj4rKP0b60OlnmzcoWHFS4V+moVonZCWOsDoiZ8H5lcp/jZSOZeY58YhpQGuw85LTfg+5ZC1Z/YvTS/P+o3YNLV4XSOz0JlAzH+t2HkhOGA5oYMOn+Y9UnqkGP2MgTq1tyJcPb8jaw7AVltYFeFb5J3gTkUZRuDxakMbOi+pjIzChn+e8o31iZC23M8k7Nt6DihBuWrIvwiLSC4xWOE9S+gtLraMWUZ+WdglzIVX0A8uWzut/rpmDFVn0OYIPtJ9NisW6OHM63/9byUCKyHBff0ZJk5CGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1308,"cache_read_input_tokens":92550,"cache_creation":{"ephemeral_5m_input_tokens":1308,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a263de3a-0697-409f-a2ec-76d65b875e68"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9ZKo8CBtH9szpWcEu2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TpXkDwqDuHjcVnitDrm81y","name":"Bash","input":{"command":"cd ~/apps/crea-ka && ls actors/ && ls actors/ka-x/ 2>/dev/null && grep -n \"429\" actors/ka-x/src/main.py 2>/dev/null | head; find actors/ka-x -name \"*.py\" | head","description":"Inspect ka-x actor source"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1308,"cache_read_input_tokens":92550,"cache_creation":{"ephemeral_5m_input_tokens":1308,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"60afbfab-be90-449f-9825-ccbb93c758a3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TpXkDwqDuHjcVnitDrm81y","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\nrequirements.txt\nsrc\n8:# curl_cffi évite les 429 réservés aux clients python-requests.\n115: if resp.status_code == 429:\nactors/ka-x/src/net.py\nactors/ka-x/src/main.py\nactors/ka-x/src/__main__.py","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"49129bf7-d940-4c06-abff-d8ef48c62190","timestamp":"2026-08-21T15:42:29.574Z","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\nrequirements.txt\nsrc\n8:# curl_cffi évite les 429 réservés aux clients python-requests.\n115: if resp.status_code == 429:\nactors/ka-x/src/net.py\nactors/ka-x/src/main.py\nactors/ka-x/src/__main__.py","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e8576011-197b-4568-9567-5d4b4e7b16bf","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9b7wYcQYTxcHctRBMk","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014EWM2if5Xkfsx3y6JsSHHZ","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sed -n '90,160p' actors/ka-x/src/main.py","description":"Read ka-x request/retry logic"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1459,"cache_read_input_tokens":93858,"cache_creation":{"ephemeral_5m_input_tokens":1459,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"17c23808-d3ab-499f-89ab-f8cadae5d232"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014EWM2if5Xkfsx3y6JsSHHZ","type":"tool_result","content":" 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:","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c8727757-47b7-425d-aaed-402aced7f475","timestamp":"2026-08-21T15:42:34.299Z","tool_use_result":{"stdout":" 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:","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"344f0bce-7453-40c7-b015-66acf1fb64c5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9bU7k91N9s5uRrPKXR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISwBAKpAEIERgCKkAYrko6OALAZp91ZzMFi+NauK334IjWIZLd0Tf3pus2NerA3KdNgF4ocQYX4Ajz19/4mONx1au4TWJKshdWalbjMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH446HUBhIMXWHHupeNCM6XBBt8Ggxn/7jvuly61ljPv7UiMC3p2E7beQIJHKhnQAvYDV6DptN4J/kG220xVAeYnZnNY21qSb1K6foYxXX/Y1p3GSrIDtDRBQe9Pgi3QmUT42BhZDBr2pIrzZf7nn20NvI5EkJrs9qRzINnHa1F+6UEQrVozD94fUmL6CCqsdbyJnpC8bkBp9na2lf5qvCGvGvB1RlpS5ZhxcoS3+OV147aW4bPFDUz5uWWuU5OEAqRGerTiTVHgVeJpSe5bfhi2syQm8EpNYlm3KZM6L1iIMaUBdtQHJoLl62Ikj3NUsNVhWY0bQAhTT2K82dcV6Jzq8XbsKox61EKX2WqfUXJLvpKiq8WmDJ/kZ23CO3wp5Z/QPC8qGXbczrjJRjW1+ope5whN6cufkTKN6q5tlkXGRLNfcX9+OSd08UqPbEVIX0tHe99YagRP9bWEgamHXOjV+io4LRIZloEEpUrQ9LL1iA6V1rEUwKcdodTVkiHoA9/Jhjk7E48cjEC7jsbP9MMvMl3bhuvKIIhlWvO9qeyWgk/ckyb5/n+mH0MHOAYLHIu3eRH57xYa492aN7T/4+xS0rnfyxInXSDniDyJ2T4zhcMq3Yyl5o4Jr1Hb63q0FStIWvJCjtICo+UN6Q4IaFawIhhd6zlM9mzyAXyOZWaMqMtRPmQJQpgvs44gwpEdXbhLK+mIkLYwHBsC7CR+WFnh6w/JUFUr81zjebAcwUYfBllZhSj6JyhS3pD8IGh7BGmh03f64thVKxjhvLQ173ssL457yH4mK4Vp5xihIHhQP71/y/cL+PsFXUhIIdyWJk3VOsSmAKrgsixcUkth4gDfJWYoGgu1hDdUqlBF/EEHErp8bEv4TAElqtK+cwqmieRTr476tvOB0QRcWiH/KoOW85dfX1JAK0gS9SS1TerYgjoNLO1gPI2I89cmnPhx3qMVGle9ALutT9UPKOb3OwuuMlvpY6X8VI9YZjO7dTBjmtJzmzOjjb5nr19bCvrFHrJQwk+3jgItbSUZUo7NtOewLEY0VnQnqiiv9jqW8kAj7QI+/sRFR37iFUAG0cUE4chUA8ssr//SEEQSXpEjUTG3XlG3zH85OTeU/EhiBWCs82dVNsriVYDvTqWKTJHBOt23w4HxiaUBq1nbbpsfKYuytJHGal++s5EI73k/Xk1BvipjJswkc+EYLV2DrNRYF2r9uo04eGiXzrFMrcD6esJPzRUk6Cj3Vsq5wshU/CLLn82Gg8TV5wGpwghhQn3eC2tPowcB52gkFte2SKcoqa26HNsIOAtyPsA3cU/mvyC942FUIWYez/cdzlazclMqVNfZkFSN59zDFYpPgNenFIMKDypFPesepjDHls6cPb/N47Q1jnq0o9b43VLD2rnt/RqHARn5bKch1pzgan55R01IsVE2Cf4aDa4lwNmCqLb9TyzV/hLAFk/xIm4BW57fjEaL0adbkGherhirGYDIcIVSxbMSJ60D1ZFt2sQeW9JLg14W1rb9osfuVa0n9aU/PRZV7UXasa5yGjMwZZYDn+1otTB6ECQmMKw1HrmXylAiRGA+HMzd4cSrZeSvVcxQDMMrHX+fiJVBNwlk8ZpoI2P7a/WCl0ieaAynwULQ6aaAMrOjVfyCVOonYFHND1q3GbQZhnh5dH5BKwTD4LIKJHdw8jDihhDlweHXQRz2q+1U2fIS5jL4uBIajcmGPbEPUQqSQtmC2LbQLSbF4NVrO5NwmVn5yJIWpfbgfL8ODhYM69IabB92ZrBCSD/LAbZ/XNuU/eh+wS9aUw3iF3kIJSDHBOyeb4jtnlvX67aJnqxPqvQna1ARMs4JXor2tH1aOQ0Z7p1BNuBdEGfbOZJofyRAyvKdYc4kOFucuME8QsP0Ky7bqNaG7LuStZMpB6Gq1PZ+CKWwygU6FQQxkuunAKLAss4JEDlSMy41KqKt/jQXxTxX8807Lvaq0Adq3a4kWxxgzu0CnRH5/4+t2MNZjFCWSy5uFyfaXhnuW+fT/zhX5hGixeXXLoetlfLhBrxPvcLrnzeykWrsLS52U32uBOUYkWRduxF3SqdsIv2acJvDYaHxDUO14i5/fjQsIqH8hpwJ4BXheiiXnOxBiByc0IB5PpYGBiG2sOSjSRChHMx+GDkFVrTNqsq6fWjuscIBcpl5EjUrDIY8OP7Z6kOw4gobPLx4tXPqRqzgBHFaH+Bs/k5ql7IfHynUpSDH8ZtsmDfEl4r2P+hpWcmwUlVPreQaM1Mg1izsL3lXmx1sxokuB/P/LyDK8aBOlTDszDXy1FGkSeTk9sK3OUNlI71P59XpmhN8dOoNuCKZH6jLozV1w4GYgizz0sE63JSsBDmuR/NFufYHSC2G2CO1C6I12Jx4SNhXmXpXxj1KMRfRGLsX9h6rG/6oYiSlXxl+D7F5XF5s4guejfL1JoUjWv+i78bWySK12G/AFpYixUFk9k2fFzIq7i1Vtu/o08ucHkiCnzh4wdsG9xGuv+Q3C0dXd4myKd6nJmF6+6d7czUChrVZIvrbEH1SozkVQshyEjojARNJS4y9L3EqB+X+gAOi7aMD0LfkBbDqceRP4CYz7sYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1317,"cache_read_input_tokens":95317,"cache_creation":{"ephemeral_5m_input_tokens":1317,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2cbdb541-5fbf-4209-896f-61cacd9a3e84"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9bU7k91N9s5uRrPKXR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISqwQKpQEIERgCKkCKE89LtcqBD7KaY+hwNIxFppcXE4CV/5GM/8ntIOaxVCbENHLXyVzTJsJJ0B4FVYi+9+lHzuW8P5vczwcuA4ZnMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB+OOh1AYSDDPKyT2ZchCNRZ+w8BoMUcAuZR25JD6GJbY/IjChpftewhZeyWQSjahO85X/UTdduGZrreS++I76qclD6onOvpblG2jOkGBiW3zUPCMqsgLbBnMTuISaQ9Myky0MGFsocferV5VxtcJDL38VGWZ6zUdhWPG5+4Rg3M4UoDfJk/TnCD2Li63JiKdbOWvHK0ia/t/UqzxyfgGaq7Psk4JUouXVkBaE3ICXAM/v8dVT8IyQoKOW6Z0ert7Flk6wu1bYjyWBoIbqIFcLPi3bWFL/nXfV8yFqECayIEjJD60XK5EJITHIvj+WfnGnRuST9P37HOfrGkfmwnHUmnwsqxB18k67clxx/NhWQCIIfQSYvV7Fe/pPM0P9s2A0yDPcTHJD/14Wyet4048ficXYekpNPRqYGuKA5SNy+qvJrVabS0iKE3az+6jBCU36pn4FvQD8vuVf/U8jpg4/kxxWx1Tk1vW4u3odxwblMoqwYjMox8fK5tE2MRiElOIHMqbdZsJ92ToYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1317,"cache_read_input_tokens":95317,"cache_creation":{"ephemeral_5m_input_tokens":1317,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"10169479-f07a-4413-8979-356f47c9a53b"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9bU7k91N9s5uRrPKXR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AunRmgWtJncrrQjxxiuGqq","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -rhn '\"error\":' actors/*/src/main.py | grep -o '\"error\": \"[a-z_0-9]*\"' | sort | uniq -c | sort -rn; echo ---; grep -rh 'error.*=.*\"[a-z_]*\"' actors/*/src/main.py | grep -oE '\"[a-z_0-9]+\"' | sort -u | head -30","description":"List error codes across actors"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1317,"cache_read_input_tokens":95317,"cache_creation":{"ephemeral_5m_input_tokens":1317,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"03034cce-da4b-4d00-81b3-2a1f8ca1bcbb"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AunRmgWtJncrrQjxxiuGqq","type":"tool_result","content":" 5 \"error\": \"http_404\"\n 5 \"error\": \"bad_json\"\n 1 \"error\": \"user_not_found\"\n 1 \"error\": \"private\"\n 1 \"error\": \"not_found\"\n 1 \"error\": \"no_profile\"\n 1 \"error\": \"no_channel\"\n 1 \"error\": \"login_wall\"\n 1 \"error\": \"invite_invalide\"\n---","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"eafb1ab7-be0d-4bf9-b63f-c469524e01a6","timestamp":"2026-08-21T15:42:50.346Z","tool_use_result":{"stdout":" 5 \"error\": \"http_404\"\n 5 \"error\": \"bad_json\"\n 1 \"error\": \"user_not_found\"\n 1 \"error\": \"private\"\n 1 \"error\": \"not_found\"\n 1 \"error\": \"no_profile\"\n 1 \"error\": \"no_channel\"\n 1 \"error\": \"login_wall\"\n 1 \"error\": \"invite_invalide\"\n---","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"1750c2e6-d7f0-491b-af8e-3b8d9c37d099","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9cenDvqeyT3vq4ejt3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018dBiAz7ahA5Zu5cQZpSJXD","name":"Bash","input":{"command":"cd ~/apps/crea-ka && grep -rhoE '\\{\\*\\*miss[^}]*\"error\"[^}]*\\}|\"error\": f?\"[a-z_0-9{}\\.]+\"' actors/*/src/main.py | sort | uniq -c | sort -rn | head -20; grep -rn \"wall_200\\|shell_or_200\" actors/*/src/*.py | head","description":"Find remaining error code variants"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1110,"cache_read_input_tokens":96634,"cache_creation":{"ephemeral_5m_input_tokens":1110,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"03affa0c-db05-4ae9-b5bc-216ef9bf3e5a"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018dBiAz7ahA5Zu5cQZpSJXD","type":"tool_result","content":" 9 {**miss, \"error\": str(exc)[:200]}\n 8 {**miss, \"error\": f\"http_{resp.status_code}\n 5 {**miss, \"error\": \"http_404\"}\n 5 {**miss, \"error\": \"bad_json\"}\n 1 {**miss, \"error\": f\"wall_{resp.status_code}\n 1 {**miss, \"error\": f\"shell_page_{resp.status_code}\n 1 {**miss, \"error\": \"user_not_found\"}\n 1 {**miss, \"error\": \"private\"}\n 1 {**miss, \"error\": \"not_found\"}\n 1 {**miss, \"error\": \"no_profile\"}\n 1 {**miss, \"error\": \"no_channel\"}\n 1 {**miss, \"error\": \"login_wall\"}\n 1 {**miss, \"error\": \"invite_invalide\"}\n 1 \"error\": f\"shell_or_{resp.status_code}\"\n 1 \"error\": f\"not_found_{resp.status_code}\"","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0a952abd-0966-4a05-9470-bbb0acb447a9","timestamp":"2026-08-21T15:42:55.962Z","tool_use_result":{"stdout":" 9 {**miss, \"error\": str(exc)[:200]}\n 8 {**miss, \"error\": f\"http_{resp.status_code}\n 5 {**miss, \"error\": \"http_404\"}\n 5 {**miss, \"error\": \"bad_json\"}\n 1 {**miss, \"error\": f\"wall_{resp.status_code}\n 1 {**miss, \"error\": f\"shell_page_{resp.status_code}\n 1 {**miss, \"error\": \"user_not_found\"}\n 1 {**miss, \"error\": \"private\"}\n 1 {**miss, \"error\": \"not_found\"}\n 1 {**miss, \"error\": \"no_profile\"}\n 1 {**miss, \"error\": \"no_channel\"}\n 1 {**miss, \"error\": \"login_wall\"}\n 1 {**miss, \"error\": \"invite_invalide\"}\n 1 \"error\": f\"shell_or_{resp.status_code}\"\n 1 \"error\": f\"not_found_{resp.status_code}\"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"c594c21d-8ba1-4de6-87d3-595e4175da9f","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9d4YU18pfbrEjNquiQ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS1wcKpAEIERgCKkAxewJmA4eKEaDYV+irrVVqxdgbvDrsSQ79INnft4w77/BH9GGCsM50jDy6MmDJxKQr0FZFBkk3i6zEw1lsGNQVMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGH5KHUBhIM69XejpaAL5z8HaXfGgyoFPO/6BudM1K7EakiMLKgMruUvKcxACLYQPYIASZF5cnn5mUxP1hCgpUZQmB5bZxrMdRFdgf0LvWAskd3CCrfBfrodFlHPe7jRhxl/NxFMklV+3tbrliWAuZjBryO8UOXixNLTFiFF5ERAiSmDLaEhrMjdV/UK2xpzJkMbxmbd+fEkg9jD5NBadZ9kI2nFz6FtRRsB/FziZntuE3TomVrwx6JkVwObyczmb+tXCaIJs7laopjDvWKR6fT2Hr6UY9nvIDz6mWeQ7HCbtnzWZ/OHvrBxSAooWgK2/NzzhpQxGaq28E0njaamLFRj15rYmx/+LeMRImn9mx1TAsdxtGtkVUWls36071ef5FgBRcoIp06BoL3C2iHrTBVyOPHtXYxPQS3xQ4JLygEo+qVD0CSavwyOyURUEz4jFO8ya0PCQiue6MHU72tyGaEQkG7i2w6+gnONAhKxEfLJPBp2PsCXhq2KzqLn2BqPeHiNpipZgFMVLO8elyRPl5RcDh7G+9q/Uym423ISIHhaxD1OQjeuLqfx/1nuV1Qh7SHhzCUTs4v5LLP0pBw3RMjNaGIYRX1i2t+vHcqdJX/+tUfVym5Dh2G/XUgj5J9Z5hEyAu2imOXWwvJX/2xbQeqtthku3+gPy6urQWaYsrkn5iB7zP/7CwJEG7to92u7hJZvadctqvW1y/9BY0epFKT4/FausgLuPwXmezxC+MUY/4r00WYOaQooPEAQWESCAz8RDbmBGjblsaqtCbig5I5jKw8ewJVVWMi3nQnKvxu8wUznjw5BxG3sJX8PpYcuBsTXPQkMwtUSc/UEYeg5KDGOmT22S2EVv4k/Y3sqLAuABTSZga1jSc6sbXmlRzAbdhJCfP9jHfd5HUXlLLrplqA0DuleAvqFWNTjMUF53Au8z7YTGOXR9eg7vNkvZmXszMQSwuUhpGbU8FoX0UorWJb8vNeRZOgS4L4MquW9V0fIgqtnzQEW0tKyDEQwYEOb/ZmbvE7Sek2pC0FdLdmIRNs+C2MXsb0Cq7EKRrT6/FG/7mnHL7lEIeGac7qBPOq/V50GkjTUxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":520,"cache_read_input_tokens":97744,"cache_creation":{"ephemeral_5m_input_tokens":520,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fc5a711f-3836-4473-a7cc-02c2e63f7098"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9d4YU18pfbrEjNquiQ","type":"message","role":"assistant","content":[{"type":"text","text":"J'applique le tamponnage des « miss » définitifs dans le connecteur."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":520,"cache_read_input_tokens":97744,"cache_creation":{"ephemeral_5m_input_tokens":520,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"76cf86bd-dc42-45f7-8f33-03bf8dbafa49"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9d4YU18pfbrEjNquiQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HXdLniG6qg7XQ4iAksCSvr","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":"APIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}","new_string":"APIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":520,"cache_read_input_tokens":97744,"cache_creation":{"ephemeral_5m_input_tokens":520,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1bd811c6-664e-4d42-a078-9b21ce0fef42"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HXdLniG6qg7XQ4iAksCSvr","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"88765d14-0197-479a-a893-6042a002eff9","timestamp":"2026-08-21T15:43:08.711Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":"APIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}","newString":"APIFY_API = \"https://api.apify.com/v2\"\nACTOR_OWNER = \"gorgeous_thistle\"\nRESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}\n\n# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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 # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":44,"oldLines":6,"newStart":44,"newLines":14,"lines":[" ACTOR_OWNER = \"gorgeous_thistle\""," RESIDENTIAL = {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}"," ","+# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :","+# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation","+# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs","+# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.","+MISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",","+ \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",","+ \"wall_\")","+"," # --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à"," # 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de"," # mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue."]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"97863f06-73b8-43a7-b4f4-33203af6fc21","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9e19MWchHxybwBwxvW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Wz9qXAMBvnCfTE6ko4QvnW","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue","new_string":" if not it.get(\"found\"):\n err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\n continue"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1301,"cache_read_input_tokens":98264,"cache_creation":{"ephemeral_5m_input_tokens":1301,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e0bf9c34-3fce-4d6f-b78b-bde7fd7b00eb"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Wz9qXAMBvnCfTE6ko4QvnW","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7c7c53ef-cabe-4d65-acaf-2e5e86ff1440","timestamp":"2026-08-21T15:43:15.299Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" if not it.get(\"found\"):\n if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):\n self.errors += 1\n continue","newString":" if not it.get(\"found\"):\n err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\n continue","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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 # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":244,"oldLines":8,"newStart":244,"newLines":14,"lines":[" if it is None:"," continue"," if not it.get(\"found\"):","- if str(it.get(\"error\", \"\")).startswith((\"http_5\", \"shell\")):","+ err = str(it.get(\"error\", \"\"))","+ if err.startswith((\"http_5\", \"shell\")):"," self.errors += 1","+ elif err.startswith(MISS_PREFIXES):","+ for cr, acc in pairs: # miss définitif → rotation douce","+ acc.metrics[self._stamp_key] = now_iso()","+ acc.metrics[f\"{self.source_id}_miss\"] = err","+ enriched.append(cr)"," continue"," for cr, acc in pairs:"," self.apply(cr, acc, it)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"10813dcd-062a-4b05-8569-cdf285c122be","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9eVVvb8VTGr6Rqxp68","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISsAQKpAEIERgCKkBQmniHBPkIrY34PMwYGQnH+oofgH8dEr0FV3OTF+G5u/1Secnk7n0pkbK6j9loxfIPjJpiYS4pnaH91Eu/72NPMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGY5KHUBhIMuUetubnzIQrr3WeRGgxf4B+Xh1B+7RHE0yYiMKvti+p1oQeMPlOPUCJTxN3SUgL1X+7gUku7cwyKOGgWkOnmmYzKslS7yhc0cpywOSq4AhuUiua/YcoT01mqmJ3j5Ua5QI1OMivGyArhhRr17im/uKIZirGC2VeVBWZB4qmB3lxwC5uUQZhty7bd7F8EVGz2HeTvC2G8u1uo3kZdE8VN9nDOocGdAhQnaP5MRSedbE7c+Ld2UBGNzeLBXoOgZ0+V+sHaYFFhVJKFBjhC65Y7+1d+7N91HndzkEfvWP+gyBkk1zYJlxuXwALni8Ed9ErIdvomy5YPwsjpk62fiWUyWq3ldfZqPClOiSPQ+RNFzo6la+6e2JKPPr7yEnKQsxphUUklDuL1IXAv136XNSLyZJ4qOpGXkcg0/fIaP0/jSDNUEeuKL2ZPCXahtI2GMpc29MQI282VmE0tJt7pvH1u3ZvRvirdaqIAZrK77qIXaxU+ti7OyWyJ/IMT4P53l2eUcjp4NP7CABgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":425,"cache_read_input_tokens":99565,"cache_creation":{"ephemeral_5m_input_tokens":425,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5c927e6c-59fd-4a8d-ae6e-f439dad74469"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9eVVvb8VTGr6Rqxp68","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017uhFdkjUiG33azaNWTFWm2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)","new_string":" metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.pop(f\"{self.source_id}_miss\", None) # ressuscité\n acc.metrics.update(metrics)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":425,"cache_read_input_tokens":99565,"cache_creation":{"ephemeral_5m_input_tokens":425,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e1b37126-0b98-466e-932c-83e589d9c617"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017uhFdkjUiG33azaNWTFWm2","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"01ac9ffd-8e52-4c8d-ac3f-c921dab05032","timestamp":"2026-08-21T15:43:22.675Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.update(metrics)","newString":" metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n acc.metrics.pop(f\"{self.source_id}_miss\", None) # ressuscité\n acc.metrics.update(metrics)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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 # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":274,"oldLines":6,"newStart":274,"newLines":7,"lines":[" if it.get(img_key):"," metrics[img_key] = it[img_key]"," metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre","+ acc.metrics.pop(f\"{self.source_id}_miss\", None) # ressuscité"," acc.metrics.update(metrics)"," # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la"," # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4a351c2c-7559-452a-ba7c-b90925f98e1c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9f2s4iKZCfLQEMe7HQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018eXF2m5TYt69yCnzV9eA1e","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" def extra_input(self) -> dict:\n return {\"maxTweets\": 20}","new_string":" def extra_input(self) -> dict:\n # concurrence réduite : l'endpoint syndication de X rend des 429 en\n # rafale au-delà de ~4 requêtes simultanées par run (constat 2026-08-21)\n return {\"maxTweets\": 20, \"concurrency\": 4}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":455,"cache_read_input_tokens":99990,"cache_creation":{"ephemeral_5m_input_tokens":455,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ff24e3dd-bf63-4548-876c-4e5286b22617"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018eXF2m5TYt69yCnzV9eA1e","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3faa4517-298a-45b0-8ccc-69a785aa1102","timestamp":"2026-08-21T15:43:28.233Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" def extra_input(self) -> dict:\n return {\"maxTweets\": 20}","newString":" def extra_input(self) -> dict:\n # concurrence réduite : l'endpoint syndication de X rend des 429 en\n # rafale au-delà de ~4 requêtes simultanées par run (constat 2026-08-21)\n return {\"maxTweets\": 20, \"concurrency\": 4}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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 # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":385,"oldLines":7,"newStart":385,"newLines":9,"lines":[" \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}"," "," def extra_input(self) -> dict:","- return {\"maxTweets\": 20}","+ # concurrence réduite : l'endpoint syndication de X rend des 429 en","+ # rafale au-delà de ~4 requêtes simultanées par run (constat 2026-08-21)","+ return {\"maxTweets\": 20, \"concurrency\": 4}"," "," def bio_urls(self, it: dict) -> list[str]:"," return [it.get(\"website\") or \"\"]"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ca04e08d-ec11-4330-97d8-8d2989ce9738","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9fSRABkZPuRiUR6DT5","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISpgMKpAEIERgCKkCSxtv7Nr3hoz0eWqdqRD+X7zfLWz+vMaxSeMOtLMuH+E6EjRs0EXg2FwIwfqL7IJ26IB+bAjOWfhwfibxetDAuMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGm5KHUBhIMmAvmE69p4BxxxK5SGgw25WB3jkQtBxqU6iEiML9XBrWL+WbRsf4oMkEqqiTs5BFQ+SDsi4GpHgrmqq2S7K3RYa1pXjD3hQVpWXGHlSquAQRB+1oTNcCwryIeuD4VhNLvoUNb7qgu4sE4wIsIWxy+y2meswtBXMUT+Wn9viweVUMEtq43JW1oSZW8nXzC3OhyxjOC7yZpXl85DeSBoo0TzeqsOLZFr1XTcW6YXzTb9NcM8XPlg12XzVROjTvDOh4j1c5AdTtBW9OgtQuyGc0YiC/mECvJS1YTAi9QjVSi3qLfiCDC+ypqrUheUW8+rYohCtULlhmVGNdQP4fNERgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":335,"cache_read_input_tokens":100445,"cache_creation":{"ephemeral_5m_input_tokens":335,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4baf2785-3244-4960-b71b-3c07f2deae74"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9fSRABkZPuRiUR6DT5","type":"message","role":"assistant","content":[{"type":"text","text":"Maintenant le retry 429 dans l'acteur ka-x (boucle avec backoff + nouvelle session proxy)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":335,"cache_read_input_tokens":100445,"cache_creation":{"ephemeral_5m_input_tokens":335,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ab06467a-c58a-4a44-9286-ed3c1bf54d2e"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9fSRABkZPuRiUR6DT5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0173Va6DFMMRnN1kDzxfnKKz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" 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","new_string":" try:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=u, impersonate=None)\n # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return"},"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":100445,"cache_creation":{"ephemeral_5m_input_tokens":335,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0ced6fc5-6919-4ba0-a5e3-9f43cac8090c"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File has not been read yet. Read it first before writing to it.","is_error":true,"tool_use_id":"toolu_0173Va6DFMMRnN1kDzxfnKKz"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3e393a34-e39b-4f5c-a584-0d2a71daf796","timestamp":"2026-08-21T15:43:39.221Z","tool_use_result":"Error: File has not been read yet. Read it first before writing to it."}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5fd18fb1-60f7-49d0-8936-a7b5ed4d9c37","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9gFMvgaj2MQ4Bc4RgG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01S74Jg2av2xkPCErTt2TrHP","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","offset":100,"limit":30},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":785,"cache_read_input_tokens":100780,"cache_creation":{"ephemeral_5m_input_tokens":785,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f92d6b8e-b299-4faa-a503-5c3303db4fac"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01S74Jg2av2xkPCErTt2TrHP","type":"tool_result","content":"100\t usernames = [u.strip().lstrip(\"@\").lower()\n101\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n102\t max_tweets = int(inp.get(\"maxTweets\", 20))\n103\t proxy = await Actor.create_proxy_configuration(\n104\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n105\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n106\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n107\t\n108\t async def one(u: str) -> None:\n109\t async with sem:\n110\t miss = {\"kind\": \"profile\", \"platform\": \"x\",\n111\t \"username\": u, \"found\": False}\n112\t try:\n113\t resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n114\t session_id=u, impersonate=None)\n115\t if resp.status_code == 429:\n116\t resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n117\t session_id=f\"{u}b\",\n118\t impersonate=\"chrome\")\n119\t except Exception as exc:\n120\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n121\t return\n122\t m = _NEXT_DATA_RE.search(resp.text or \"\")\n123\t if resp.status_code != 200 or not m:\n124\t await Actor.push_data(\n125\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n126\t return\n127\t try:\n128\t data = json.loads(m.group(1))\n129\t except Exception:"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8038c2d1-5c61-4fc2-8ae7-1d2977620ead","timestamp":"2026-08-21T15:43:43.792Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","content":" 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:","numLines":30,"startLine":100,"totalLines":218}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"1508e055-8b55-4d2d-a8fc-f545ffc18414","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9gb94EDHDXT1ei2mAf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017AAfHAShr9LYLpiDW63DfD","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" 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","new_string":" try:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=u, impersonate=None)\n # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":718,"cache_read_input_tokens":101565,"cache_creation":{"ephemeral_5m_input_tokens":718,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"39e8bd03-8c32-4712-90ca-cf1060b84488"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017AAfHAShr9LYLpiDW63DfD","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"54790f47-ebd6-44ab-abbe-0a857bce0b54","timestamp":"2026-08-21T15:43:53.055Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","oldString":" 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","newString":" try:\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=u, impersonate=None)\n # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\n except Exception as exc:\n await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n return","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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'', 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 \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],\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":112,"oldLines":10,"newStart":112,"newLines":16,"lines":[" try:"," resp = await fetcher.get(SYNDICATION_URL.format(u=u),"," session_id=u, impersonate=None)","- if resp.status_code == 429:","+ # 429 : retries avec backoff + NOUVELLE session proxy","+ # (nouvelle IP résidentielle) — une seule relance ne","+ # suffisait pas (13/40 handles en 429, constat 2026-08-21)","+ for i, imp in enumerate((\"chrome\", None, \"chrome\")):","+ if resp.status_code != 429:","+ break","+ await asyncio.sleep(5 * (i + 1))"," resp = await fetcher.get(SYNDICATION_URL.format(u=u),","- session_id=f\"{u}b\",","- impersonate=\"chrome\")","+ session_id=f\"{u}r{i}\",","+ impersonate=imp)"," except Exception as exc:"," await Actor.push_data({**miss, \"error\": str(exc)[:200]})"," return"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5d29a195-413d-481a-9563-095508ffc8d6","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9hJUyDys1bpCWNLikP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISowsKpAEIERgCKkA9bGTAEBJPQISpMbcbw67pS7yQdKOmq1A/KumApKBrWX48et6iJocF2ttX7vUJ50dWEi7QcdS3B5Q/LBaTqopxMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHF5KHUBhIMpWA9tBoYjIQ3ONdZGgyy4l0peQ018p23lp4iMD/EhOTHaFBu72IxM+bVAmbM/pWhD3KwGeZk/7oR5Bc4MFwzf0z9UAWGIh/v+JydhCqrCeDhqtj99HSTLDaVCzc+3EqZOOEKszBkbGQa7sXJBFEIci5IS3v8L3le9h/46NsBc4vtuP/9TwdVQ/bx6M8/r51t9XHudiGtQ/3hD0CHxVafOKYFRzWuzNdHuBwJ528w8nckF81gKIuIyffeU1qdBk0D684OtNW6MVFHWCtAAtsnt1kBkqV9RhWBaNnjfDVjrNne6Tb9hm5wL/bTwS3xVhy/3bybRZ2KN8qvCM1a9iPBx8IoH3O3cbZfdyoFXtyh4KjMOXtjULIm1cixrH3DFCGDoK0EgSjqkgvMaIIQR53c5msPJUhyRZkhCEZsZDSEN/p108bKh3VjiqzZpp23r27qbHdXhwlgCaqp0Gu9ef4poxkwJeLAgaNh6vhShFBlLitP1PbPs6Cxj++9dX0XXoWpV2TNLjtAoQk6jVzPlQiDd6bQbIc/TVcb21WWnmhdViM5Xt4zRIHaF42S7vRT+xVNsHULy2AKQhyk1GxarX22boRYrIvkwkAo4pD7VFqXGrRRglvOmMy2KoWFtwjZ95N0hh11rC3WgXvQrejZ7XPpun3UJ2SNP0pFQOEbsC/1xdQdjhmtaEMIt6tmXi9LFFOxU9zx3hSygel6TXSEMZelP7bowtDlfHhycAelW5HpM/c51ehQCcS4SsasSKj0nK2hculGKJP5dy3MXLkj+Z+WFJEVjTE7FJpVDpxulBuRl9/Vx1n6A33LKLAbmrxNcWsWdV7wfT8M40mwudD+z1p5oS+yHXMBy+oLiy8oIBD9+LaQV+alrtx55A/rwUN8+wTHxbktf+lhorn8U5GH3bypoYb3ihvEu4f8nvhHSD16IssYATr+s0JygA5uebJ0BL1MUcRoAuNNPZoYkprrauL8w8Jfhkg9hq1YBDCI2C7dIfuMnL6gskl/7jaRty5yAYUXfJ+s/zneECNyGvK7DgX8wrqFLDeqaz7lgsv+D3uIedxHlXtAZJYD2MyluhLwDs41CHENdX/GOW7RUPe7xb0I2T5KzJ+dq6wBRACrJ9MCVOFAfQd+8hp/gYrT4WYKwb5LM9xmjMStMj/FtJOnJvm8dgrQxXgHxovNQ7bdQFQssg2heJcWxnaBMskMsg/IIfpctIbB9lBCgk0jXbD/OSJOVWTlTa8xk0f71Qa/7v/bianM3N2qF3La/zA6DIKV6rmGun7YeQ2o6HPbzhQMzHshD6FSrVhmkGXihsegTbwXe2FcQ7TFL6anYIUjTmtmU0LSVeaqxXnzYBc8rkZjNMB4s9spUKhAFhMmL4C+T6H2ksFxH1r5Ttktz820WtA8nlMNN28sz6dihmRC5LR3+7rJQ53GCPMp3nOg1hl8SzntJ6gMp+6QcspACe8ESTKoIocldTYgTckpWxtgaLyjfT+XI1GNmTUQzp/n0Z1q74VO5YoFJLCPPMqSbL5xH/WQ7dQzN+ELE2t2swchvDJ03xFCk0ZwrDJ3AARUtVgWQJ9cQaPyP2jRoEBkxIPVLWFnXOq7VZWMlP1U2egvqCqCQz/a8WUY6IUX2Op+SRPlqTy1GpPv4dyzPjeX7wOZSwZVuNFrgCNJTVDKaXI/GZV+ywx/8ceibo8pth9PUy8YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":680,"cache_read_input_tokens":102283,"cache_creation":{"ephemeral_5m_input_tokens":680,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5516c043-f271-4cf0-85b0-0957b3151109"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9hJUyDys1bpCWNLikP","type":"message","role":"assistant","content":[{"type":"text","text":"Je pousse l'acteur ka-x corrigé sur Apify et je lance les tests du repo en parallèle."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":680,"cache_read_input_tokens":102283,"cache_creation":{"ephemeral_5m_input_tokens":680,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d772ccd0-a1af-4fb9-b75c-6d0eea482905"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9hJUyDys1bpCWNLikP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Tvw1V5VdarGLMAKiDKikXU","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors/ka-x && apify push 2>&1 | tail -5","description":"Push ka-x actor to Apify (build)","timeout":300000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":680,"cache_read_input_tokens":102283,"cache_creation":{"ephemeral_5m_input_tokens":680,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9e173b76-02b9-4265-b81a-2943a69c5371"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9hJUyDys1bpCWNLikP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UEUikF3pT85AbrH7ipS4kf","name":"Bash","input":{"command":"cd ~/apps/crea-ka && .venv/bin/python -m pytest tests/ -q 2>&1 | tail -8","description":"Run repo test suite","timeout":300000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":680,"cache_read_input_tokens":102283,"cache_creation":{"ephemeral_5m_input_tokens":680,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3e0fdb36-bfd5-433e-8604-da3b9430424d"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bo0hiop5w","tool_use_id":"toolu_01Tvw1V5VdarGLMAKiDKikXU","status":"completed","output_file":"","summary":"Push ka-x actor to Apify (build)","uuid":"333b84c8-d771-404c-b548-fa5ead1ae273","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Tvw1V5VdarGLMAKiDKikXU","type":"tool_result","content":"Build ID: nesWUh9wsOYuUuJAb\nBuild number: 0.1.7\n\nActor URL: https://console.apify.com/actors/UMjMYceVVxkE2ravb\nBuild URL: https://console.apify.com/actors/UMjMYceVVxkE2ravb#/builds/0.1.7","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"693d434c-1562-46b7-ae9f-0122d2a7707c","timestamp":"2026-08-21T15:44:26.425Z","tool_use_result":{"stdout":"Build ID: nesWUh9wsOYuUuJAb\nBuild number: 0.1.7\n\nActor URL: https://console.apify.com/actors/UMjMYceVVxkE2ravb\nBuild URL: https://console.apify.com/actors/UMjMYceVVxkE2ravb#/builds/0.1.7","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bo0hiop5w","tool_use_id":"toolu_01Tvw1V5VdarGLMAKiDKikXU","description":"Push ka-x actor to Apify (build)","task_type":"local_bash","uuid":"37da3441-20e2-4484-aa8c-e080f958875c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UEUikF3pT85AbrH7ipS4kf","type":"tool_result","content":"................................................... [100%]\n51 passed in 0.12s","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"82c9e17d-7175-462a-b99b-4abac610241c","timestamp":"2026-08-21T15:44:26.834Z","tool_use_result":{"stdout":"................................................... [100%]\n51 passed in 0.12s","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8f497a02-cbe9-4b20-b2ea-34f5e6ebe707","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9jm6RmMGVideYHJKZF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JrHRL1NzhQ3ioJncsHdV3d","name":"TodoWrite","input":{"todos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer ka-facebook / ka-threads / ka-onlyfans (murs plateformes, miss tamponnés)","status":"completed","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Vérifier le build ka-x 0.1.7 par un run de test","status":"in_progress","activeForm":"Validant le build ka-x 0.1.7"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"pending","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1328,"cache_read_input_tokens":102963,"cache_creation":{"ephemeral_5m_input_tokens":1328,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"55e17f70-a40f-416f-956e-671b6ac4a5ae"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JrHRL1NzhQ3ioJncsHdV3d","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"bf157ac4-25a1-4a09-b854-2472cdbed524","timestamp":"2026-08-21T15:44:34.020Z","tool_use_result":{"oldTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"in_progress","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer et réparer ka-facebook / ka-threads / ka-onlyfans","status":"pending","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"pending","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"newTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer ka-facebook / ka-threads / ka-onlyfans (murs plateformes, miss tamponnés)","status":"completed","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Vérifier le build ka-x 0.1.7 par un run de test","status":"in_progress","activeForm":"Validant le build ka-x 0.1.7"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"pending","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"677b76f7-d81c-4e9a-a0fe-4d7be4a0d0fd","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9kJ4kwDtEB3BiXd7Gv","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS6QMKpAEIERgCKkA7z/fDeX7H39xo6LGH4D2yurZnxabb34haAw7ep2XrK6fLFIux7vst6xjiKeshL5L3sCJqltpSdmEkb3Ko7oFXMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHn5KHUBhIMsL9RnWpOKQ0FbP7+GgycrkwDF9htEKzsmbIiMP44/V04Swz/2RDqGqm3vXoIuRx+otK7pwlHyCGOXV5MebboX6XTchULIQaT/cZDMSrxAenPSnK87tzP0M6+UhEXn9+qogjWMs2Xyw9584LHbw5YVfD2e7gzWpQTmp1pFxB9PdvYScSSb2z4RfmMCVgqGhNeJGslEChMaHuJg0MNN1j/lYcHpSDNbL4wqMLMZndhLDIDMxTeU6WKs6RQlmNBqNf6WN4ejgTIuQlNnjmd1bRBnPjGx9JzayKiBXB+maya3aYcXTfKjoVf8kwlVHLAi1N/YXrDdKyIluC75RbB0fzb976m6M39sCCu8KW7WeTv4tj3y/Ir2Z48kk2eNlD1kCLCvTCazTqzPqunC/8aUodn+YUPjIdDzZlXYpjHaXInxYwYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":554,"cache_read_input_tokens":104291,"cache_creation":{"ephemeral_5m_input_tokens":554,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"21e5bf93-c3fc-4314-8dcf-95fb83143d80"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9kJ4kwDtEB3BiXd7Gv","type":"message","role":"assistant","content":[{"type":"text","text":"Build 0.1.7 poussé, 51 tests verts. Je valide le nouveau ka-x sur le même échantillon de 40 handles."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":554,"cache_read_input_tokens":104291,"cache_creation":{"ephemeral_5m_input_tokens":554,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2db8ffec-eb18-4cbd-a175-9ae2680b4bfb"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeG9kJ4kwDtEB3BiXd7Gv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019jb6MzzCPe9fE9ueyRqpGM","name":"Bash","input":{"command":"cd ~/apps/crea-ka && export $(grep -E '^APIFY_TOKEN' .env | xargs); .venv/bin/python - <<'EOF'\nimport os, requests, json, time, sqlite3\ntoken = os.environ[\"APIFY_TOKEN\"]; h = {\"Authorization\": f\"Bearer {token}\"}\ndb = sqlite3.connect(\"data/creaka.db\")\nhandles = [r[0] for r in db.execute(\"SELECT handle FROM accounts WHERE platform='x' AND (metrics IS NULL OR metrics NOT LIKE '%x-apify_at%') LIMIT 40\")]\ninp = {\"usernames\": handles, \"maxTweets\": 5, \"concurrency\": 4,\n \"proxyConfiguration\": {\"useApifyProxy\": True, \"apifyProxyGroups\": [\"RESIDENTIAL\"]}}\nr = requests.post(\"https://api.apify.com/v2/acts/gorgeous_thistle~ka-x/runs\",\n json=inp, headers=h, params={\"memory\": 2048}, timeout=60)\nr.raise_for_status(); run = r.json()[\"data\"][\"id\"]\nwhile True:\n time.sleep(15)\n s = requests.get(f\"https://api.apify.com/v2/actor-runs/{run}\", headers=h, timeout=60).json()[\"data\"]\n if s[\"status\"] in (\"SUCCEEDED\",\"FAILED\",\"ABORTED\",\"TIMED-OUT\"): break\nitems = requests.get(f\"https://api.apify.com/v2/datasets/{s['defaultDatasetId']}/items?clean=true&limit=1000\", headers=h, timeout=60).json()\nfrom collections import Counter\nc = Counter()\nfor it in items:\n c[\"found\" if it.get(\"found\") else f\"err:{str(it.get('error','?'))[:50]}\"] += 1\nprint(\"statut:\", s[\"status\"], \"| items:\", len(items), \"/\", len(handles))\nfor k,v in c.most_common(): print(f\" {k}: {v}\")\nEOF","description":"Re-test ka-x 0.1.7 with 40 handles","timeout":600000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":554,"cache_read_input_tokens":104291,"cache_creation":{"ephemeral_5m_input_tokens":554,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"aed1da2a-96e8-431c-ab65-6eda856ee3b4"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bxob2ew0w","tool_use_id":"toolu_019jb6MzzCPe9fE9ueyRqpGM","description":"Re-test ka-x 0.1.7 with 40 handles","task_type":"local_bash","uuid":"be7ba168-252a-46f1-9f1d-ebd8409be2f6","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bxob2ew0w","tool_use_id":"toolu_019jb6MzzCPe9fE9ueyRqpGM","status":"completed","output_file":"","summary":"Re-test ka-x 0.1.7 with 40 handles","uuid":"ed6878ec-2625-4ca7-9bc5-d712ba1010e6","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019jb6MzzCPe9fE9ueyRqpGM","type":"tool_result","content":"statut: SUCCEEDED | items: 40 / 40\n err:user_not_found: 34\n err:http_429: 5\n err:Failed to perform, curl: (7) CONNECT tunnel failed: 1","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f61c44c5-4b5b-44f4-a939-71800e92aea7","timestamp":"2026-08-21T15:48:03.062Z","tool_use_result":{"stdout":"statut: SUCCEEDED | items: 40 / 40\n err:user_not_found: 34\n err:http_429: 5\n err:Failed to perform, curl: (7) CONNECT tunnel failed: 1","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"fe433b5b-3f2f-48da-a4dd-10198649257b","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA1kuThkr8qrtb93Aea","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9A4KpAEIERgCKkDJ7G0Xl3toB57kXDZJQVm1KOMjPYFyLDNXwmShEaaNlLrJC04f9VKkNvMs7Ct5BrtXEjaLpKJtjeRwdsDCcZgiMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHG5qHUBhIMysnWO9KdiUQy9LOOGgzMi0fIwpBbuY6zR3YiME5RppZxLPDPFoG8A4nFJXsckiQugozzCYK441Z15/2YzdQ1nyUO2/wjHrOie3n71ir8DChg3Ky0gwZDDbeGMZ2BGmqx1X7AhzurQ4K5qhUU91roQenzcJA9ZupOeVBDbmvPcCPzrNhAeNLdRVpmbj+Knqr7wCjdm115bOvVDTf3fBLXEv2zdxJhC2JQHea++HB8yeoI6iGJ4OaAO86fWzZqFA3mmP25Az2QLgwORFcOT+mTmGAltdNT6phzGruMlh4pVhdQJ5FSPR84Bz1cLYJU1cQWZ/eAqZpQGWrlZBerOVHLxt5lAINSB3eomWVUBhte5/gQ/KGPgMR428tgvne/Ai2dZnh+WbR8A/fbImiSCFcab6qgq+fsAbetjdwG/eW/ZMVUhlOgaBLQZ74ACChOEp4XcksMrd3BlkFz0Kr5uQ+oTK3qZdoL4KmH/+jibPR6dPEuzl4eo8E08zrVVe0N1PZYd0PLNynhH/xVpfMsf0cuaT3oYPx5adnDX/eQBFXBikUbeMiHPTjJQHOXMxGOvfxAQg2MBkmkUPwd4FX1Gl2wHhK08eGPSeUgE1Vngn7jg7WxLhX88KmGAPUW5yBlxUSBY33SWpZWUf7C6tFjBpjlnag5phMVCwpzxeTnO8wxzkIWRlkdTchPPmWT+w/Bv+q0hFNhG4hsGI8wag78k4SCx9H5bVNI+7nQL2jgG3dYmIT77j3NF5iLmnPOCjpKPwgxAZDSRh9YSi+V4qRxbCCwYia11KQOJ3GP+JhJb1BM0kxJVX9TCH9anXyyZdMmHoDRM8ktTiWFujw9o+NAoaSeayeoIBwKml09p2JpXop55DCfQXieWBs5hIcff98XCF9Yt9zFL7WcRpLYA+FhKMdyz/tDT9w63KtTjMdPmo9sO9kPsOCQRzdQVfg7w5DkZsVSUuFKeAvIpfdtQJzV7HZC0XsBVSF4VOEZZ0xlRvsqRP2Jc92rdd7/IyzmBaMFB0FD9u3Kv2NuMA6GvEweRZSSWJumONkQwyCN2oh7fmzjpXvAyz7NlU9fVJnBMe4FSeETFpNvQiz5n2VIkvTG6CgYeFzG1DzIMNsL771hwMdsCr1khNb0pNnVQdY26SDBJ9+fsQ5M1sl2qjQ2LuhrviuMLdSSP0A0suT136fPHqnjO5aXeFs6fP0a3XfeM7C+foI+oKtEQlaC49fElAo7J0Xn8W0qPQJMKe53me430dwhykZWWdy01qYhqR/Li5J2jpcCusHpFdveZP6RBwm2J1dukDvLQ2SoW4u98TFAUDTf9QdcEe4yt6U9Wd751ZoOYqRuWrAzbW4k3tqAR+ii1O7EH8f6YVN0WDSTJLpmwDkYC3CrKKw4UYxDhM6jX4JHNzg4cmT8vbXILslAsPTb3JGGAsS77yF+2yhJU5m9bmAiHquiMipbfvCHblm7wC5bAfcJBpDV7NRbIiaztWS4+JuLh2tlQWU9kXlbXQKGI5uNuamXtkH2CAD3374mqV4z/ueyXTgNxGstX29PsBs4fo/6l4sVJp0MYOLmsP8Sx/di5z2bfvGCd0jvYQc/asZgLI8fEde6katzayk3EZ5vmyDcUciBcWh98sper3UEAA1EOXXkHc5Ba+oEdck8SIWio7FuAsMTdTHiAhSfY1c8vo2YuSCqbpS0B5iekUh83jhvt1zq3Og9ak5xepioSgKMcaVQry/Dlt3i5F4Co/N5QquwqENRsnIlZNkVuapfI0qbob7XFO5gyFCOzL7Z0EhV3z87HwWD02O7dNg3H8HVfba9cmaJs++BlZZhh3jU+m8Nq1TDJz+5xRPvnd0ojQEZA1UuoE/23fDrZolFg6xC+mp6rCieH6J3JFQ1VqElVGbKdN7eZmBButlk9wyoOaRzU42yc9gmHHXeLBN2pdHfd16MsdGR53SiSES9DUTI06PKC45SD0PydkA5GTvIW4DWUd7DikJHsp2Ph+pMpZwmEVq92Zz9Ggkdzm8EP5R4zmvMgw8moOJQ5/plcQXozm8yItYzA90uaE4HDsvvqswyCTWiEuutU9jqI/QI6Crd/qCedH7OmB5FCGvoiG2wlGuldBCw61W+yjdx5cZ/ay1gMqZg0EH2OW0kvXTyZoDucs0ClDGrul2dIKKHV5n7GPJNJhq3XjFzMy4iPEToGKNTd1SRqugUY9f6aFW9PDPp0FNzewIeRJJTzvPTmh+dSwC0Y9ek0vXrOF5BBWrB7mKWE9e1H5/H/4ALVwvNauBNMrLcZfTN9yWvCk9+wKU5IP4LzFswEeP9WY6N/fsxNToYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1000,"cache_read_input_tokens":104845,"cache_creation":{"ephemeral_5m_input_tokens":1000,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c9aa10b8-51ff-4361-9dba-33861a45ba00"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA1kuThkr8qrtb93Aea","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIStQQKpQEIERgCKkBYb5MR8KttYhhqgvdgn0cJ6H+rF8ZpHJRyUjBWTdW740607D374HnvPcbSvY7OXe/v2CR3MS8FnsRnyfiIFgk5Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBxuah1AYSDDUHM1XrhgfFkGRTSBoMJHPta7itxGwUr07BIjD9Con5F+4EgYQi6JW4bLAs1pluk6IN+dM6w1Xi2NxLlmAqocmGXjPEMI3zICL09aYqvAKqHmTkHrxi8DdFI2mAxPQfJl3+zFxINP2f42RNxkFuydHrf/rj6WUF1sExZqxVzmquta2hJuBTEdBlyF8i1RtA2KnVctFTwAc+gIRvF+vO7okJ8VX0sCFzhpstBQpve6I6aRowBUVppiegKGp0rrXD9J04ULAByXTaPmcBiIZPTJIT6xF9ijGOgjF2jGIjyv1PujhV6uOQPuVQlPndK1qi4AK6gGEbjFnoDG1Du1XVpg9ls3EUv3tAabIQUabrIgoRsMSJhlMuRed1G7uopsZFpD/OLjMCg+lPyaqxithTHG8Zs86yKL8tNJr0I05Kg+XT19dCexm/xur1nhaCeSAZRohPe/34DSBUcBzg/W3MXLRfhAf/sZ7aTb8V4c9hy+sFaBCnkTJDK9hQXa4uu6/Zo1J0WDOsDVVr0Q9BGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1000,"cache_read_input_tokens":104845,"cache_creation":{"ephemeral_5m_input_tokens":1000,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"632b9a3e-ab6d-4c41-99f5-78518d97ab50"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA1kuThkr8qrtb93Aea","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012XjqELYGfA7m5krpmouMUV","name":"TodoWrite","input":{"todos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer ka-facebook / ka-threads / ka-onlyfans (murs plateformes, miss tamponnés)","status":"completed","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Vérifier le build ka-x 0.1.7 par un run de test","status":"completed","activeForm":"Validant le build ka-x 0.1.7"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"in_progress","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1000,"cache_read_input_tokens":104845,"cache_creation":{"ephemeral_5m_input_tokens":1000,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d40b10da-3ec7-4c64-a938-be1a9fc19832"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012XjqELYGfA7m5krpmouMUV","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2be61345-9746-4e0b-ab53-a794e82c8845","timestamp":"2026-08-21T15:48:26.715Z","tool_use_result":{"oldTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer ka-facebook / ka-threads / ka-onlyfans (murs plateformes, miss tamponnés)","status":"completed","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Vérifier le build ka-x 0.1.7 par un run de test","status":"in_progress","activeForm":"Validant le build ka-x 0.1.7"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"pending","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"newTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer ka-facebook / ka-threads / ka-onlyfans (murs plateformes, miss tamponnés)","status":"completed","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Vérifier le build ka-x 0.1.7 par un run de test","status":"completed","activeForm":"Validant le build ka-x 0.1.7"},{"content":"Forcer un passage complet Apify sur tout le bassin, toutes plateformes","status":"in_progress","activeForm":"Forçant le passage complet Apify"},{"content":"Vérifier l'impact (jauges multi/complétude) et resynchroniser","status":"pending","activeForm":"Vérifiant l'impact sur les jauges"},{"content":"Rebuild frontend, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA1kuThkr8qrtb93Aea","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WFSuPdPmPxWvcXz29arej9","name":"Bash","input":{"command":"cd ~/apps/crea-ka && APIFY_FORCE=1 nohup .venv/bin/python run.py sync instagram-apify tiktok-apify x-apify facebook-apify threads-apify snapchat-apify youtube-apify twitch-apify kick-apify fansly-apify patreon-apify discord-apify onlyfans-apify link-in-bio bio-liens region-bio > /tmp/creaka-force-pass.log 2>&1 &\necho \"PID $!\"","description":"Launch forced full Apify pass in background"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1000,"cache_read_input_tokens":104845,"cache_creation":{"ephemeral_5m_input_tokens":1000,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c6badb6b-6adb-4461-946a-f53ac581e56e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WFSuPdPmPxWvcXz29arej9","type":"tool_result","content":"PID 39401","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"77105466-f6ed-4a2e-84d8-bc1a86dcc0d9","timestamp":"2026-08-21T15:48:28.302Z","tool_use_result":{"stdout":"PID 39401","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"84eb18ab-59c2-49ac-a255-72c568380fec","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA3ZSwQMJabmih8X789","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TxCaJaXAJ22J9e7mdcCMP8","name":"ToolSearch","input":{"query":"select:Monitor","max_results":1},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1651,"cache_read_input_tokens":105845,"cache_creation":{"ephemeral_5m_input_tokens":1651,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ef6fc57d-893e-49b3-9d43-9a5224feee48"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01TxCaJaXAJ22J9e7mdcCMP8","content":[{"type":"tool_reference","tool_name":"Monitor"}]}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"34e28a92-9227-479b-8e6f-68cdaa6a425d","timestamp":"2026-08-21T15:48:33.568Z","tool_use_result":{"matches":["Monitor"],"query":"select:Monitor","total_deferred_tools":17}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"78e90f8f-7eab-43a7-8af7-f2a99678d33a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA3xhBMfz8YuqHTf4by","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ha41HF5AzTx5fHMeDa1MGY","name":"Monitor","input":{"command":"tail -f /tmp/creaka-force-pass.log | grep -E --line-buffered \"sync |source|créateurs|creators|Traceback|Error|ERROR|échouée|ALERTE|sauté\" | grep -vE --line-buffered \"^\\s*File \"","description":"Progression du passage forcé Apify crea-ka","persistent":true,"timeout_ms":3600000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1898,"cache_read_input_tokens":107496,"cache_creation":{"ephemeral_5m_input_tokens":1898,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"294b11b6-76ea-402b-a9f5-9c46cc17ac0f"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bhiryuk78","tool_use_id":"toolu_01Ha41HF5AzTx5fHMeDa1MGY","description":"Progression du passage forcé Apify crea-ka","task_type":"local_bash","uuid":"9418895c-17a6-4e6c-8749-1b405ec26ba4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ha41HF5AzTx5fHMeDa1MGY","type":"tool_result","content":"Monitor started (task bhiryuk78, persistent — runs until TaskStop or session end). You will be notified on each event. Keep working — do not poll or sleep. Events may arrive while you are waiting for the user — an event is not their reply."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2816e273-8f80-4f7a-bfde-7127c48d19a7","timestamp":"2026-08-21T15:48:45.236Z","tool_use_result":{"taskId":"bhiryuk78","timeoutMs":0,"persistent":true}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"fd4e7d3a-1570-4404-8b96-f35abba5fc81","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA4p15Jbcttap45MGKg","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISwgMKpAEIERgCKkBiLSSlcfM98OFOrBXhiBBYeFavBnBOCxKq1d2bI+VALxwq4OZx2c3EWhfmnYgiObkjseTwUjc7a949nra6qhzRMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHl5qHUBhIMC9iZxrzKYTRLOraVGgzL/9Qe639eFucb/fkiMBZdtK0lwQBGgMXrUm7F24C3XNtUgMu1E5KCuEWlZNeRrtM3X54K2pLSb82ILC2gxCrKASAJ6Oqgnf3u3/V4mEvsM70BSmM4LLRDOzMtizBDciNkMR4TeQke1o24tKtXei4DoOC7n8VKZPVsKpE11Pi29tQAxIrszZv/4dJimwoMo77BAe3ISdVCPFhhjoiBfgS6qFQ7m5STvAUYtBcy63Uq3/eNh1mEiQGimfmjXWMqAZolGonX1lALhNIsnuv8850nbCd9yYlxYHeSiwpCsbVnCLuVppSuGLarwRpa/SWDnli1h+OmHkt5XfTucYd8TUBkjnSWAOuVVn2jUNYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":308,"cache_read_input_tokens":109394,"cache_creation":{"ephemeral_5m_input_tokens":308,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"01cc432b-5580-40e4-979a-213d181ebce3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA4p15Jbcttap45MGKg","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjwQKpQEIERgCKkBWrCs3Qpffszzccdqp/b+es1WQ9wq3lnNW9fhzwRuJtJmxQoqL7Nl3obONCxzzNQQ27B9qJJgl1gvZve3SRbi+Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB5eah1AYSDLimaUrJehkPZD9leRoMeagwgKHS83K9u7i2IjCJKC7+T2VBM5g7T48L/P6a4loSsbnLopJCNJoj7rFuVz+bcj7tGpfrUnYjkYMMtagqlgKmCxuc7t+Vh0x5C21cHCQo/CiXc/hTcGyCRtPeWHr9g/CD2zyCFjVrCzxtxSVFbx8jb5vCSxx/kopM188Lhc88zpIqox8OAtryoS0eDGaxT0fxmdXxiwvFvDLMTEQHl+U/GJlGrrQXk2amdpxOrHErdBvZVhmohV+p7LdsHo61iL9+5WIghuaKBbm8SlybpBqsDc5qgmTX80FEdQLiCMTX26r/TND4ggoiuzpA5dkCGhtVOp4JNzFNhckh8gEOAvNK9IfNNGeJfrqfp8g/aCU0HmRZ292O/aKGGeEEpxofiEmNiVUfdASyqY875wVlGMfMedt+S0o44VJcI0hZCDDozsDo0GHUgCHXQuoJW6g0e+IoRCV6whgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":308,"cache_read_input_tokens":109394,"cache_creation":{"ephemeral_5m_input_tokens":308,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f056de4f-2402-4ab9-a499-c6bdf4522b79"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA4p15Jbcttap45MGKg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NDBVDdMdfVnhHRuk6opDfc","name":"Bash","input":{"command":"sleep 20; head -20 /tmp/creaka-force-pass.log; echo; curl -s \"https://api.apify.com/v2/actor-runs?status=RUNNING\" -H \"Authorization: Bearer $(grep APIFY_TOKEN ~/apps/crea-ka/.env | cut -d= -f2)\" | python3 -c \"import json,sys; d=json.load(sys.stdin)['data']['items']; print(len(d),'runs actifs')\" 2>/dev/null","description":"Confirm forced pass startup and Apify runs","timeout":60000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":308,"cache_read_input_tokens":109394,"cache_creation":{"ephemeral_5m_input_tokens":308,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"aa18090f-f5f0-416c-9d93-052da9765119"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bt05ez9tr","tool_use_id":"toolu_01NDBVDdMdfVnhHRuk6opDfc","description":"Confirm forced pass startup and Apify runs","task_type":"local_bash","uuid":"ff4d73cd-ecd2-4447-80b4-50d63b21124d","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bt05ez9tr","tool_use_id":"toolu_01NDBVDdMdfVnhHRuk6opDfc","status":"completed","output_file":"","summary":"Confirm forced pass startup and Apify runs","uuid":"9dbee6ab-8111-4e3f-9c66-9fc1e9c90256","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01NDBVDdMdfVnhHRuk6opDfc","type":"tool_result","content":"32 runs actifs","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"15f8f907-564c-4a16-92ed-f448f53ab10d","timestamp":"2026-08-21T15:49:15.671Z","tool_use_result":{"stdout":"32 runs actifs","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"1f326da4-26da-4f81-b314-4e320ea56fb8","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA74HrsrfdS2AEWcA4K","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8w8KpAEIERgCKkD80gt18fYmtyTlgGU8VPCB3kyCuPjAQzQBknjAZ4rrX4T4uZZ2ZZoowtJQRpPy9T3k9M2qbyGURbwf9i/Om219Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGM56HUBhIMIcrnLkkBDuYfuZIzGgyDzARio6RZk6ofDmAiMHQ6Cd10DTUAhcOr57xy6lZbspPME9M05glmGxiMQEDzRJBi213aVSLa3X4FP8se3yr7DcinEEv8uoV7vTc9vcFQWVj2toKkeWAMh2wOjypP1pf/J5gZLjEObuWTFDhsqUratXa5ehfB8QOtvWjvpJBXTzS3tdBGenfr26CpWiPIzOwy+RADUnGylE5E2x8MJva7vDaZpEiSkAV0Psxa0CdZ44+dxE8BChsPGql2yJnedStCICQ48y8J5Pgq8KioGUPnvnlnR+7coPettOBZwx08nkjJD2uhXbwJzc6gdOm5MOk4XvTPZ2ffJdwl1IJCqnjnurclgFERxOESBHmvsF2JLG7RgJEBCKXf81c5cbRxCuEnSK9dabQUw58z19axULEkSODUdRGrdwIuDST+nIZ0UR3LQdOwwpi8dmDhH8QGvtIUIIPvZg0RvywLbPph14RHSVApdlLYAbIexVlgPRCaJ3U2xBSE2BURYOI0k7XES/SOQOlXMgcokqhqyfrU8i23QpNNhLkoIdG9lizcg3TyPQTX286C+SHq1hcFe1bGsqSuEH9UCMzy5Q9lcwspOBc9DpZB4Pc9q++W9T66qwC+EgJoGYtPZRCnXr6i/Cw9tTjh0HadVcOe2PYPLuQwQBv0LoQNq3LPjLqxNnZodHdlHvpKBTr+gqnW3VXKxeDdnVQkqnhXiPd0MTDbfnf7pHvJ6ABtpdYO68cHkwV8vtC4BcJ6/Tsg2JbL5I94SL2nA+TaFqidQPts1VGWzQ+4llRQCdc6lyakuEEZ/Nwc28KgGedlBD+TfiBNFc8COR7jPq65uqPJdIxW2+2mkWBYw4AH0mF6wEMxeolIhz2IRPhDcDJLB4Rq+hOrutQbaJKjy2AeBRUR6hzQFxMkRzmgMSIuYjMA1LoVUvzm2U7MiQkQNN6xukfp8Fb3WojNB5R5RZpFQooZW8Rs81aihhpEcY0chEzdnTAFVp7EglpUh/yap3VCTzimFpE+uMhrE2lnBd/mTxS7+YFjANXJvczNoL+cHEddqFf7VIagQZ7BchyIYwhuc8v474oFL1h4w+CEMUmxgyX0l2Gyoj5hzJdG8zFFse+lIU2DgmbExhEwn4an3hPTPGn9uAZ5TRScHxrzPqvI+Q4PHIOZ0qPVGDdbKGzn7XWTJzaX4Jpj4SGgcsmN0Yai5Ppj+OPJ5rZxnxxUY3EiM1dCzauXcQyddrr1vE0gFqCDh2aOYFHNGllTt0p/rvTJ/ekpcc6P5xwZYxA0AQOHBKgR1V1eHCz8EtP0kICFybE2WzxxY6Vc/4XtuIZZz5veQqMvOgvkhcP3PPmYpWoBzY/ACw+4U7FHSMiuzmvrNYmwfcI3ipdtEJEw8NIHh2IprPGMSgkby3FHpG3ii/o6SeAZ8wEXYFkz4Ly6/0uANyUfPYaCZCk9VgFWAado5uotaopW6iSlvbg8++1dqoqjfCXbM5f716WrELNmijasJRJx50mG+0LMANskTEj9aRMD4+Exm4xLKYdK6A4t1ssL+yuO+7XCn4rpJU+xWtbj3nDgrFzUKGIpidfxQ/P4lbSy4NnL4myzMCqScJQ1nR8/RKheyJmQDOequju2pzSXr/jCDAIgI79Y+MB6L2Uf+JchiodzyQKi32lCZOaWNp5QMllzC2R128Ouxnssk7f9MTDOUKeg4bq/g9cV00BsXdo+MNnCkXIcc8tHbkurmviKWjMRZ9NzIY300Ku3KxaiSXZv9meGpigtpY6bcGEhEJP4xUp4d+kHjXm2akZQapkW27WAAVBgmE5yfNmOQABa0rEiqVyLbOb2/bm536LvMTBZY6dYLtRJZOv4BUGTP7c8hrLI/C+m0JRhEk1nCbuOAnTXaEz8dDc5B+9SXimR5t0sh/eEiKvQPWOWn9MgNn2iyqx2e1XGh+ZZlWQ4n8dNb88r1ktPAt+ZeZAujR42gIPtvyWbGoXwnWfCJt9yebq0+LaaLxrj6l53MOp5l3MW++KjNMgUUZf6jJALYGpRzQg9SQ+vTuw/HTR9hrcqfNC2dx7dd1HC18K1S4BSqjyj14Ct14MEx7JqWq8l52P/gEEBz4egFkdYPZxKOhywWAYMhxKp9btXsto3IN6HzxXBzsSD0foz1xnsvy769FDZocU/obflNPkuingjWY7g2grc66hWCAbBWcwvGPimEiwnkK+baSibRf7+0T93g60swX10SNaKgLmq3s6iUmw1Ah7tKaSrX03LFgxARIqltcqpF8uhH9rrbE4auLeljBi9IDz7Tydxjlr2gDZIrUcGX0YnBlUy26FNEn84L9jcgxINX8MRz39wzgkgoM+hsFN2fwL5CB5RoYWKN/8PDjy7ochV/AEXLUJun3QgfP47QYoB6435a4plFkPooN5PvCB1jfkpEk4cVhaZfZIWyyZRZNvm9Bi4Zg5AA14+HfrhVxgYD1qV6JX0k6c0n0XsGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":478,"cache_read_input_tokens":109702,"cache_creation":{"ephemeral_5m_input_tokens":478,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3b927556-f1a6-45b2-87a9-c87102841096"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA74HrsrfdS2AEWcA4K","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS+QMKpQEIERgCKkDhkoqA/atwQJtQjhoLRSonNb43RlmPCIsvahUQiQV+CI+0VpVZgplw6+CI++OcLA9F103hpCg8snxR5ZvZumD5Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBjOeh1AYSDEeXIlRMX9PSmW8gmBoMgR4L3w8E4Ojeu4mkIjCbUm9Y1eM+FxOTWPBVRK9PNDpdEbj2skuvrGnVsUrfSmj/r4XpWHTf/zeU7brD0/4qgAK2/knKOBdHJfkHznwZIOW/s2Dw57FtTPtQmnVCOZWjB5hA1eimtw2V0Ora4zc0iZqLSIrNGxPTXva1wLQx1AsMK99NoNNABdaA4AMkPlbPVQZZDjceCDfny6W6kuY/0qWQaH6U4xEIs6G9Rtujuf05sa3ltz6xl3MU0E5tZqCqjAszmVDEhrMgbxSglzesQYyPR+T8CHAi6oZSJvzEUM6Uzz23yw4JanIaNHH1IRh9M6dsjw5rPG6k4mIUMxsDSX25BtonlB7DrAgnePG2C3jHC5sJlLy0JTAh4hnCbdDw/8moqmprzFrXsx/EhwlIlpnvx4x0B6lNOHF8BJGppVCnGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":478,"cache_read_input_tokens":109702,"cache_creation":{"ephemeral_5m_input_tokens":478,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2be2e588-2195-4ad4-ae77-754f9c7a2cc1"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA74HrsrfdS2AEWcA4K","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JMznCuXA6a23wKG4xoreAz","name":"Bash","input":{"command":"cd ~/apps/crea-ka && git status --short && git check-ignore data/creaka.db && echo \"db ignoré\"","description":"Check git status and db ignore"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":478,"cache_read_input_tokens":109702,"cache_creation":{"ephemeral_5m_input_tokens":478,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"926f76c9-23b7-44a9-8878-a3bc8ed8f386"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JMznCuXA6a23wKG4xoreAz","type":"tool_result","content":" M actors/ka-x/src/main.py\n M creaka/connectors/apify_social.py\ndata/creaka.db\ndb ignoré","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7107ca19-7a82-4f96-8c61-13d73c23c8af","timestamp":"2026-08-21T15:49:33.462Z","tool_use_result":{"stdout":" M actors/ka-x/src/main.py\n M creaka/connectors/apify_social.py\ndata/creaka.db\ndb ignoré","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f627ff77-0c1d-4f98-b834-f826c2ea723b","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA8N8wJSZ4ZNDEmZbR6","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EQvrPzhv9ebjy3QxSJviGW","name":"Bash","input":{"command":"cd ~/apps/crea-ka && git add actors/ka-x/src/main.py creaka/connectors/apify_social.py && git commit -m \"$(cat <<'EOF'\nCouverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n\nCo-Authored-By: Claude Opus 4.7 \nEOF\n)\" && git push origin main && git log --oneline -1","description":"Commit and push connector/actor fixes"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":860,"cache_read_input_tokens":110180,"cache_creation":{"ephemeral_5m_input_tokens":860,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a9acc28f-9dfc-4429-9eb5-9df1c351f206"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EQvrPzhv9ebjy3QxSJviGW","type":"tool_result","content":"[main cac232a] Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n 2 files changed, 38 insertions(+), 7 deletions(-)\nTo gitsrv:srv/git/crea-ka.git\n 8fa08f4..cac232a main -> main\ncac232a Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f7537e7f-02df-42f2-88c2-c0baeffb7745","timestamp":"2026-08-21T15:49:40.503Z","tool_use_result":{"stdout":"[main cac232a] Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n 2 files changed, 38 insertions(+), 7 deletions(-)\nTo gitsrv:srv/git/crea-ka.git\n 8fa08f4..cac232a main -> main\ncac232a Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9f47b81c-2c99-4f16-879f-4aca2406d170","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA8tL8wwMyJU3S4qs8M","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SHZK72P3n47VkrzPAqak8n","name":"Bash","input":{"command":"pm2 restart crea-ka-sync --update-env && sleep 3 && pm2 list | grep crea-ka-sync","description":"Restart sync watcher with new code"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":691,"cache_read_input_tokens":111040,"cache_creation":{"ephemeral_5m_input_tokens":691,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2c095065-8b3e-4093-9276-d527e6c02419"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bmkiojvqj","tool_use_id":"toolu_01SHZK72P3n47VkrzPAqak8n","description":"Restart sync watcher with new code","task_type":"local_bash","uuid":"feea3a8b-2b2d-4a07-9cd4-9fbb9c9ac1af","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bmkiojvqj","tool_use_id":"toolu_01SHZK72P3n47VkrzPAqak8n","status":"completed","output_file":"","summary":"Restart sync watcher with new code","uuid":"9ac62f02-4071-4c4f-a50a-71410854064b","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SHZK72P3n47VkrzPAqak8n","type":"tool_result","content":"[PM2] Applying action restartProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 16D │ 0 │ online │ 0% │ 171.7mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 173.8mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.2mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 70.8mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.5mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 122.8mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 208.7mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 827.7mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.2mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 40860 │ 0s │ 8 │ online │ 0% │ 5.8mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 14h │ 27 │ online │ 0% │ 145.6mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.7mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 338.5mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 110 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ 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.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.2mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 520.9mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.6mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 912.8mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 39h │ 65 │ online │ 0% │ 513.1mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.0mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 39h │ 18 │ online │ 0% │ 136.2mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.1mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 40860 │ 3s │ 8 │ online │ 0% │ 41.7mb │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"11fb43d5-668f-4898-b254-3659f922fc24","timestamp":"2026-08-21T15:49:47.967Z","tool_use_result":{"stdout":"[PM2] Applying action restartProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 16D │ 0 │ online │ 0% │ 171.7mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 173.8mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.2mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 70.8mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.5mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 122.8mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 208.7mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 827.7mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.2mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 40860 │ 0s │ 8 │ online │ 0% │ 5.8mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 14h │ 27 │ online │ 0% │ 145.6mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.7mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 338.5mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 110 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ 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.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.2mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 520.9mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.6mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 912.8mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 39h │ 65 │ online │ 0% │ 513.1mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.0mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 39h │ 18 │ online │ 0% │ 136.2mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.1mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 40860 │ 3s │ 8 │ online │ 0% │ 41.7mb │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"69496b27-64d9-441c-83d2-e309e4eaee42","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA9TiaB3SKPfCNAR7HQ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS6wkKpAEIERgCKkDvI8xSphqo0kKaG7TyG89YVBjYstvKpILkOsbv7sqzwRLem4hb7zIyKr1WCO00Z6jhxrGTXAWwzF9i2P22gLeDMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGm56HUBhIMC5oVg2oY2JO/ZTicGgxjh7clhSrd5A8UjPgiMHGNmeDvk2eyyrE85zykdLB02L/EV4mbHhLwVXVfBR+4EwyjupRwyVAXuinJJ5L+VCrzB+mgvniUdpNlbeBOLKYGSjqY2uLbo9KxKMKydmc+dF8YbpwtHMGeSEFYGCvigec5QjMf8fot4U6k90Ztk+K4geIAfNbLgR9Lsq9038BfDWD5AQSZi7dHNeIDVQeJ9A1k5j2pKoPVdM2tSaUgaB6lKP8MZUdj/lKtVsqLJguO/+Z1ApDtyA1v5mTKcVT5A+Fy0M30Q1k+HEkhxeT6KqAaTOiAx64+8U/auoLi39tgyzlGz06YKpyteBZBRkDfpFC2OgDF2Nk/Ad+UK7VS7iLKsaAq7Y7C0YtNHMKg5OxZfiwgpSMf2rdl5aIcAHxUriuuwy+bCgJ9ozoadJHoteABnMftMo3xAMRNEnFfTNp8Cb/so8ot6OFoqntk8VhEZVLC/KpGLeYfHOaT6clCEa3dJlCkGdYLy7+uaZ4Km64/8E/axDJ92fVRq7R8Ub8wYmRvrCYxY7C5DQuY44Dg+ee5FyKe6buyAABIR2XeTNtgRYYFXgClb36794vZoG2GeCtGq9b0JZavetb8m7EgGcqDGQxDbHOSc+jrkgxcT+ZCyMd2GrIvajnlCmPNQhaBolDBagSOH1eiTZn9+dATgKDerSlmNjxtKjUg7gFgr5dwae3nSHNmsQ88RRf+AodmjXDfIOX47INgDcL7jA5gNIUh4ENDMenWE/Aeijs0EZAesROSw64RfbIkEZipYaKBYdQw7GRROqspHGJQFzd+McGqlG5XpPBkmvFxyxaTEHB0uVteYpecADtCXapCvMunjj2eZ/blRTTJRpIXrJZxn/aLh+HbBwEnGg2U1dhszxuXGp3u32M88f+GHWZjwSRPDd4nwcBrs45qrcnkNp6XXT4tp1P4Wn1vW43jEXX3YMAmB3lPyjw3p9/K3QTNniF4afar9WhBQDy3j8gace0XhLKtAZyjXV0tzAlGlw3tE8Erdso7ZWwPWcfiNmq0/KWAF9ZBi4Bh2QvAfMTNoOjmNyIaMMWtvvtETGs2tmhpiDguBczzt1nw0T6RAN0D34x4/YT6pdbE64UWeTJtVqpHAQ2hxWwmckQn9qLP+dImCnslhjcDG9h4XhrxFhXuUW5IePsc11IfG+2agWBOMNEt0r2EQwnHRnhAsZu5VH60ikgjC0nf6/zB9+HY6NMrunnGXsQdicaMd5SzeGaH+U69iZmoqOx2ec7VhpmrNdwjd/RL92Sx4jCky3wlHHKox03sm74Kg++MigaqI+J4LbHAMlVimQjQxZRr4kGg0lHHkWxp+W43S4mj82x2zDcn7WffxzFl51KNFj3ARWDtmzTHlFlh+oU8mlA0L0SWCaxRhIAZEzQ1qwxTtfKV/a+cYqZMRX7r4i87GRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4294,"cache_read_input_tokens":111731,"cache_creation":{"ephemeral_5m_input_tokens":4294,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"05347915-ef0d-4e50-a764-873f31b63209"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA9TiaB3SKPfCNAR7HQ","type":"message","role":"assistant","content":[{"type":"text","text":"Attention — le restart de `crea-ka-sync` déclenche un passage quotidien immédiat qui entrerait en collision avec le passage forcé. Je le mets en pause le temps du passage forcé."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4294,"cache_read_input_tokens":111731,"cache_creation":{"ephemeral_5m_input_tokens":4294,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3b0fc0df-0210-4552-a7ac-744c52b8de08"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGA9TiaB3SKPfCNAR7HQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KoN48XtwbFrmsEYwwZjYe4","name":"Bash","input":{"command":"pm2 stop crea-ka-sync && pgrep -f \"run.py sync\" | head -3","description":"Pause sync watcher during forced pass"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4294,"cache_read_input_tokens":111731,"cache_creation":{"ephemeral_5m_input_tokens":4294,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fa77b34a-a27f-4be5-be26-7e32357d9fd5"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KoN48XtwbFrmsEYwwZjYe4","type":"tool_result","content":"[PM2] Applying action stopProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 16D │ 0 │ online │ 0% │ 171.7mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 173.8mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.2mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 70.8mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.5mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 122.8mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 208.7mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 827.7mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.2mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 0 │ 0 │ 8 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 14h │ 27 │ online │ 0% │ 145.6mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.7mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 338.5mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 110 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ 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.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.2mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 520.9mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.6mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 912.6mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 39h │ 65 │ online │ 0% │ 513.0mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.0mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 39h │ 18 │ online │ 0% │ 136.2mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.1mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\n39401","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"10cf7cf6-583e-49d6-9bc8-6b4a735f050e","timestamp":"2026-08-21T15:49:59.268Z","tool_use_result":{"stdout":"[PM2] Applying action stopProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 16D │ 0 │ online │ 0% │ 171.7mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 173.8mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.2mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 70.8mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.5mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 122.8mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 208.7mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 827.7mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.2mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 0 │ 0 │ 8 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 14h │ 27 │ online │ 0% │ 145.6mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.7mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 338.5mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 110 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ 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.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.2mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 520.9mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.6mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 912.6mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 39h │ 65 │ online │ 0% │ 513.0mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.0mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 39h │ 18 │ online │ 0% │ 136.2mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.1mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\n39401","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b6db5c8c-4897-4011-a8f6-8da0a13c4973","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGAAGTBqeuf1Knufc1CJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS+wgKpAEIERgCKkBDn7+/p3xpGWM93MYfHEhpZ7/cIbmbqkD2KbEi8ssTrSlw+PldqXLa0i2dXaOC2koiMLyDYgeTYBPKqyV5x04+Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG556HUBhIMIF7GdNjBZlnheUr5GgyaWWIV9QUy1XbEdRkiMDiOUlxKvMPZW/oPgXkXT2+ty0sIC9BlzCUoCR8mESDxCir42JWevooUlQigdxysdSqDB4tOWCdAwMreNH4/Woy+fAoZyUGJGrBxKDpIzH5y+YboDrs1JvzLSfSBWK2ziWmjKjIQ3t7e7+u/HpZTXVX0LRZz587T/Ev+5Ny5PsSZbs9e2PGU1WqNT/VJIKBMqV0/YG4eBnK1GB1wj5LsslsvQek99IwUoKReGH6isNd/LDUycKwbq0BeL90a5AGkkRZTcrDKzdv8+op4iozI5FNZ7yvH+hfBIXsr9Mxi3h+lIN55uH5F2Lh6uc1ijQL90jiQnjNuLfVIcIHsr7cyHxK7qrUvuFiOnOiFmzZbD3bWahavhZzQjW/zXfGkj1JlzdVUk5MWQABpMYgPSBW6cLsCj+7/G0KO0btpDyccvzyScRshTfxwH7txhYbx7ncawyvfYMqLB1OZRVf1vs7oHG71ylcJw/POpMUpqQIfbe6EWbmYDT0QQBj0OAsu5z5hvMVD+FjohTwdVqJJD3bkNiKmVcbXN4ecPnhcSrHGbS9CXEaqZabDjmNfrzE8vz7YN6vjkMUIVgmtRP8Vvv2SaVphlm9xRgrfx113TVS2EHDPlQ9CKREDynO1WUPf1f5f9lQC54WFTgijWWFtwrzQY/jJmO+ZA/iKj1X6By8XybjfRv5aai58y6uO54gIJQw6v8xN2UIdgoW+w8/SKgUbedINwrcHg6irMt0ULUz2Tt8g1JfZi/jsi7TT5m+i9/7uM4dbBjnJe4cC4Zr396gyKYqoodAtUTA5p8QGFv5p32H5OrZdRikCA0M83zUQIBMAcf6HJ5y49EtOClQsHgHH71MnCDyHYpmOcmcJ1sWz+K9s6IYpvT0jaSEIH4gApQDSHw/cuN1WmxTO9IeyGS8bPsWHgagZ8sbJTYL4jJtz1iJ50xJe0Bxf1Iy5OcUkh9lH15bXF8aHcS4vKP66kIH4GEGAvCsFTuxnUq5403m/xambXm2yZltoXtGen+YWYt2ClbX/lrS8ej2IunZpMKKBFF4QNK3wCT+91qAxSgDPKaBEDO9QQ7q6v3XWgl172JD8GY+d1tZOP0TnwRszF0x+j+JBi0m+81kC4zvhLTGKiL4wR3C5FIh0cy4JX3BbsxsWx9Pyd8123R7JiXkLZ80TmZcmK3VFIJeb8Y8jvntydf5Ti5qDM16X8zME7IMH2V4IWd+Dw2Q8d7kr6YstfRffud+Cs/F59aWEPbwtjtHDvUs7pI9VaUsJGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4616,"cache_read_input_tokens":116025,"cache_creation":{"ephemeral_5m_input_tokens":4616,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f7a28919-a7e4-4697-8de3-501d599fd985"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGAAGTBqeuf1Knufc1CJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISsA0KpQEIERgCKkAtNK+cRwxJ100BH45PJ+2G2DzMRzCmUUKt/M5UxDIQw6lIbZ9aIsUS+JAhiitUyI5OPoMIE/vURgMb+iWExFr3Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBueeh1AYSDNgc5uuLSrikxoXA7BoMNZVVHxQ4DokADVKeIjCfXWYDx9ho44Msc9mj6SBAynGInVPdLGGBOI6MG5YDxgaJxXegwvDJInb/OkMP1toqtwso01b3dpriJy9Ya9qM4haxHa8fvjeW7VlSXAImqqQD42VRKDiWDuzkmVzekmYIT5Ye2vO36lOKSteAzWaisDMQlFHE7GpoN3SLAlHXknL1kPvmLFB9DBIkT7KkFO3QlRvastkJBTliQ23AJGeHxiH4p5NVs/jhRMFHOxI7NvEuw10Cnc5VWRSzOWmzXkQZOHZp2Zd1b34vZCGcbEaf2ddTgLKU9SrUgFndrZiQLEEd2UoR7EgQPb/5+PJxRet7ioJlD4S8tu1lzLRqDce1Jp0cw3BzS/C905X1Rv/CvUIMxXdrcyPsNE1i+tTyyrwvSdHqIViIEZCZqzmQ/6xYgMRVpSGylX26ZbqkQUgXCXI0hvMzpiMyDFCReJbo6ZoPaPaaoSYSozstPaDJvIgSPQ+6Gp68JtDyEMA/pJg8hQ5bhuc4pZ8t5tDFif9wbDLX0IjoJ5pJbpeY8Kps1OdqQa4UcR6QW6XTjSCjsjOJW1vvCBARgYBHDgEl40WnxqcBU3B9E5zwF/wM5Is0ljYWEzpE0vG9NsfIwzYXuIVWd8wFq7QkLWpvAHRTtyDsoiS1FZNpa+6MuGdW16lKp7S/tpkd43fykl3VMAqm+7oU4Fv3jeYyxNmxzUFb5+6VF12Wjdxr6Ae8MkWi3iDF8JJBEosbSOIiexihRpTPLbvyIRWCeC9QEKmbqvbzg0C8YZlU54PaCiledkyX3FvBjjlsB9/6DWUFvTL1FBJ9R5fZR2RZvdFbCg0csTMIIuzyWt74/ItkYxQnSstEtm48e2lCKLHa6OrPpAXaTkE59s+aKE++hlrpBJjdUZ6hwzhE70jnMcOVItn1ZbmijdmX1Avq931+7ARw8dIfEK+g/vsM0jxua5VneoSv2vqrfX4aPnyRtsCLj3N6PZyfy37IxjnMInL3eikseG62P7VZ5bisNV5GiNRaU8O5QoUn+13jTv2DdZ5xl8JxWMqts6OWjC3H8/5oNt4IBN/OIPU+TgdTRmbFcCr/+6tiUIy/VjafOTfIb3+vtgJlINVC/nK48hied+Sjv1VGh/2d9AD77B3i3RlfLA2OzdXaPPa1S6uy8ttkHQrAX47XSoWNJ6GQMVoN07dTasKRI92cdOGAgx+3yrAnsvWhSum+LCKPjpQGBOl+I5yqOGWayd9PjaIiG0X7gAZYCk4W+Z2/B2faeKgzxea4n3TVgGjhTLmVhqQOMIFoAfLKw3bvqzWOAtGfFlOc9kLlAgrOfW4SqhA2rWQUXV93DcOUGgPXOyZYhaGX7Pn9+5s5nfnqV6koW0nJntkGc4lDV2/AXVnnorvRzxia8cktcMS/+DhBqyKqOKMAn+uHYwhettS4/MGJYJFq8AIGKEQKecHhs38s8pPKBX1+RdiX2bc6lQBk/aD58as8DGrD/MLrRdpjOWgraiUoKTDpPZClNAdhOdslXtspP7Hulxu1t3cz6eMVwesMU328zAkmKLJ8mfGHAjVp1Vtn2WvZuSUrkTgcA/6JKlm4brZAAWaCdgt3viPkzE4s3REmSwNPpSSG6p7hGJT3F5BEFYXWbYbo0IS81UxQGfIe6R366YMTc2wL6HVdvponQjse8NWqhA81uxGf1Jjcc3R5PTQervcGuZMWwzPxj2xt3ynMKHRk8WEDhE4gxGt9n2hdmPaZXfwedAVNJEzxG3ip1F+ZP8bQaNqeLYC4vei8vFTBhlDX7oHCzdVFzRnoRbn4OpovhpEjwi3DMTHKtFA9OCOtenVE90wa/c0Dpi9obR1csW7XUTKvEOAsxq+eC+HW7KmsShcTcvFCzJmhuh9v/kvt+62K4M4nM059hrekhTu9m5kPACenxqHFDU0OCBXcx6+1ofsFfhdqJGGpkHWZl6xs0L9UT6JfNFuX3fA/NeGQkLosoIm5yD6+FAZJ2kjmLQgojacpWS1HbnmgaFscosWLJ/jkQcCHw+nWthgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4616,"cache_read_input_tokens":116025,"cache_creation":{"ephemeral_5m_input_tokens":4616,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6afd84da-1eba-404e-b56f-936b25554657"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGAAGTBqeuf1Knufc1CJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015tdMmgNzpLWJYAGdZ4o6fh","name":"ScheduleWakeup","input":{"delaySeconds":1500,"reason":"Vérifier la progression du passage forcé Apify (~14,6k comptes, plusieurs plateformes en séquence)","prompt":"Vérifie la progression du passage forcé Apify de crea-ka (/tmp/creaka-force-pass.log, PID 39401). Si terminé : vérifier les jauges /api/stats/dashboard, rebuild frontend si nécessaire, pm2 restart crea-ka-web et relancer crea-ka-sync, puis committer/pousser le reste. Sinon, continuer d'attendre."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":4616,"cache_read_input_tokens":116025,"cache_creation":{"ephemeral_5m_input_tokens":4616,"ephemeral_1h_input_tokens":0},"output_tokens":6,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c0032b56-e04a-4cf3-b237-30755c348481"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015tdMmgNzpLWJYAGdZ4o6fh","type":"tool_result","content":"Next wakeup scheduled for 12:16:00 (in 1540s)."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"12388c4e-4f9b-455d-ab5b-f5b60fc5bccf","timestamp":"2026-08-21T15:50:19.991Z","tool_use_result":{"scheduledFor":1787328960000,"clampedDelaySeconds":1500,"wasClamped":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"36674aee-166b-4191-81f0-cb543e4a2577","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGABpToiqmT5KE396UXo","type":"message","role":"assistant","content":[{"type":"text","text":"Le réveil est programmé dans ~25 minutes pour vérifier la progression. Le moniteur me signalera aussi chaque plateforme complétée en temps réel."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1644,"cache_read_input_tokens":120641,"cache_creation":{"ephemeral_5m_input_tokens":1644,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"92842fb1-9140-4b74-b085-db24909ce48c"}} {"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":923393,"duration_api_ms":630861,"num_turns":70,"result":"Le réveil est programmé dans ~25 minutes pour vérifier la progression. Le moniteur me signalera aussi chaque plateforme complétée en temps réel.","stop_reason":"end_turn","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","total_cost_usd":2.6318587499999997,"usage":{"input_tokens":112,"cache_creation_input_tokens":197005,"cache_read_input_tokens":4632930,"output_tokens":33490,"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":197005},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":59,"cache_read_input_tokens":120641,"cache_creation_input_tokens":1644,"cache_creation":{"ephemeral_5m_input_tokens":1644,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":400,"outputTokens":25,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.0005250000000000001,"contextWindow":200000,"maxOutputTokens":32000},"claude-fable-5":{"inputTokens":112,"outputTokens":33490,"cacheReadInputTokens":4632930,"cacheCreationInputTokens":197005,"webSearchRequests":0,"costUSD":2.6313337499999996,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"fd4e2911-9990-43b9-8885-7e65bea29adf"}} {"type":"user_prompt","text":"Ça couper peux tu finir la tâche demande","ts":1787331185064} {"type":"status","state":"running"} {"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/crea-ka","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","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":"4aed6b97-2590-4716-b3ad-318f8b7fe379","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/"},"fast_mode_state":"off"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"44590a7e-44f6-4a54-8a2b-935165fe6ec4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEyMECSeNjCR1xMrDC3","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8QMKpAEIERgCKkAihuqDcI7zBeagpzGsQOrpfZvikGvgK0Zg71evohocoehEUXjQdI4iRn5/ucZ0oB1NMK76Tm2YRbUuDKCKtynCMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH1hKLUBhIMAlzCFSGZ+7/4w+a4Ggwz7awed4ehVHyzWgYiMOJtiEwyIAQysGEdwuGRfJy7bSon7KxXZ5IMpcZPXXr/dTGCxsdRtAI8CvtTFZouUyr5AWFXI1SvD2tMbOZMtlQ6LA/WvgP4RgmCQ/Sj+TuEe2KO737oz11zf47s39RrFojaGkuk8KJRqkr0yqeCd58M+KNleyzjhachYu7zoaAY8JBnPYeTfKUNBRgRHsPBlDXMNRkPiWRrYpKpmUp7PqbXLdLtTplfUuFtsValXdc9SJ5jGTEWSSNDvI/jEc+2cfuGXBRRdy7JHD+RYVLcDMNPXZYGNPBQom3W/LOSsNoD84tGxKppNyM+lW1Mif4RqBpKwTylvVF0cripMN0LTKfNXp+5tHpjuX46fNkXZXWkH6gBOmbp4bEEX23bKYP0fdGQRLOiEN1DpuKawBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":122889,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":122889,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d448cb55-b356-4292-8a81-5867a0da48f8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEyMECSeNjCR1xMrDC3","type":"message","role":"assistant","content":[{"type":"text","text":"Je reprends — je vérifie l'état du rerun facebook/threads et de la base."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":122889,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":122889,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"bb891cb0-b029-400b-abaa-5512363f67d0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEyMECSeNjCR1xMrDC3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011LoKMdRn4jB9XWREeKcZ8P","name":"Bash","input":{"command":"pgrep -f \"run.py sync\" | head; echo ---; tail -12 /tmp/creaka-force-pass.log","description":"Check running syncs and log tail"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":122889,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":122889,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"51275d66-f753-4156-a29b-4b0d9a1c98d3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011LoKMdRn4jB9XWREeKcZ8P","type":"tool_result","content":"30573\n30579\n---\n[crea-ka] ka-facebook tranche 19 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 8 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 0 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 2 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 22 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 17 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 18 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 15 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] {'source': 'facebook-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 146.8}\n[crea-ka] sync threads-apify (enrichment) ...\n[crea-ka] ka-threads tranche 0 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-threads/runs?memory=2048\n[crea-ka] {'source': 'threads-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 137.5}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0216263c-45c1-44a0-a6df-472bbd144dcb","timestamp":"2026-08-21T16:53:11.550Z","tool_use_result":{"stdout":"30573\n30579\n---\n[crea-ka] ka-facebook tranche 19 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 8 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 0 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 2 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 22 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 17 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 18 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] ka-facebook tranche 15 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-facebook/runs?memory=2048\n[crea-ka] {'source': 'facebook-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 146.8}\n[crea-ka] sync threads-apify (enrichment) ...\n[crea-ka] ka-threads tranche 0 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-threads/runs?memory=2048\n[crea-ka] {'source': 'threads-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 137.5}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEyMECSeNjCR1xMrDC3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0154FNh9n7ZAF4gJANY11nVS","name":"Bash","input":{"command":"cd ~/apps/crea-ka && git status --short && git log --oneline -2","description":"Check git state"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":122889,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":122889,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d9c30a4a-b497-4a47-af13-6e33e05c5bdd"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0154FNh9n7ZAF4gJANY11nVS","type":"tool_result","content":" M creaka/connectors/apify_social.py\ncac232a Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n8fa08f4 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","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fc1bfb38-dfaa-41ce-b6a9-bb8a7e79c8f5","timestamp":"2026-08-21T16:53:12.590Z","tool_use_result":{"stdout":" M creaka/connectors/apify_social.py\ncac232a Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n8fa08f4 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","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b357331d-204a-41b8-8cb0-620f45a11020","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEytk2K2NQo9Wry1wsz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIShRMKpAEIERgCKkAz9ON2oGaZbLY8b7K7zlIqJ91unvgFgitfCNyMU0Ae9EPaK2iOhp6Offhuzv9eGNyb5T9UfzdTXXd2d/0AT4oDMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGJhaLUBhIMFQtQrW8qkyWQ/7FXGgz/0dIPms3AjRd3ZPkiMB686sVoccLLS1nRvSq4w94eDwPXyXcN9XMlQ2GfLGkXh+jGj9+S0D3OYJwEI32ijCqNEYEzGQUBWQgN7AG3EXNYwGkK8cS2FmwlHO1eyR+o7fMvHfJ1swZemsaZMYGp0Ph1im9+6qA4nlkrl2o4KTC8b3FCmufrSFB55ZDj3mpHTSBCS067El+GN7gNyNzkluSmHO/YDRg+GwPvDQM+pnbs51FfBshqwyXnEPeerKWy5X7I6mbo00ElkPKa3PRiLs1pAUVn2pPnHONAHXuYzqVi5ynlCJ08htYeQDLX0ntCqmYTGs5b79WXj+AKbh8JP/UjpQx9VIKu03OkSrcLIvSvq15SeoSBGccVR/nRI4gIGtBpeZZJD6UCxm12O/gh8fZRFZfGqlFH96+Q5aSgJb8qVbNlwLwmagnxu3cp8zfTyEGQfXsr++SX1VQ4rJvXMfi1fgEmW0Yz0sbt6e7VULiTXYOBKj6NofxeP3Sd6qPX+x/t9+fwS59NuCVV/U2m+lEQMDth8iC7L+6i0JLKEVdTYL9HIsar04alGZi18r2v/UPc8MfiQFztbMuOjcIOdWQV71w7ehLLhfGLTjCsw/sJY0WPCrdyakrwGCd8u6a3pf7D1l4+MpHi2C7tp7iJZ7k1OvcXzuZZwJGKfVyKAU5V1CZ3SBZxYwRVGpGNrR2UZDNeMrQGBzEt54uBF0i6+Sat/JHIPm9kaC9P0tBE/zJSKPgtlLDd+hTKmPgbFojLIsCxTAHpht6P1uFTk2HB1NvfuPkRB4nDT+ss7+qKn8GJ31e5Rl+8aYwXef/eF8QQzAVqzRJPf3aNc3wHh+gzzNRYxOnKwp05wgPok/aiakFIUDz0Syjf93fv3nkTBT2kfPD6k3PCKjaOcU+5GtFG0wBgmXyu+N2Cb0aBSjjR5VS405QNPSnydj6HqU2OrXH64PQLGaEpvrEADm/ufOXIsSGJertfM9kNPFIzz7eTw7dRIdAwO2F1CEnIWPbgjaVfBYph6ZMO8O4N03U1pJ74jWIFnnt5/U681hy+tD6/sJGrRDddBR57v0vNgHxtWG1hqGx3f/sMhwqzOMuYyN0gg9L4pg8In+jVh9tjT+p7xmjVxHE7W/nRcHcbH4+GocRuYkd71mBsh2kQ/8qWmjArEmVwLzc6uX5p3SeqFx5NbXPPwys95l4qGclQ8sutb3moQIHFKJIYDhrfm5ZRs7FjwmrwXd2Gqq2OSnb1yzcJF4tNeZsKODSandMyWmcCR1ciIHR4+OW/3yU2SUhei2337o1WenQWptn8F0AVSZI7Zco3SCMmTbkLx7soW0nM8iRpPuDFf3VvpNFPqBqiMFp/y9YBCoNcKmTdtJ8EbS8Acn1MPNV40wzOLGD0eCblD465mIyxMslNQhaaIVD0FSmPdwTSaIxcenX+1BBLwidfqhYOl9HwQ2aMk9p66mN9FdGRdJ/6PDv96pDLGbGeknl+73m7diTvBNdIhCVgl3IjfAWPOwS24B3OBcw+/GKZn9uz5UGQ9/1vVN1w3esuN8fpBtgNlrKr2fOXvsVZxO1zmy80PWiYfoBNbslvR4Tf7Hwpf+WnYNyNw5sEd6zKvbYdm5jog1m6YAAXT2YBFx489+XIwTg7PPBzTDP6Nsj/CCtTTEvHwd+kbIYeFGSdcu87J/DWo2hcJd8Q6XbQ1PdnflJqG0ofKpM+hz5m2Ose/1MxtJ0kFUxK+QXN2TpvCG0xxrGacLvyj+lsjd0CRN8pt9tYyPjh6fOhUsQfaKLizTdvU3bn8pAv7EOAVivP4ZGYZnw1nOPN0Qky/nfROyhp9ySwO6iDVYDaGuq3yFb4/41ksBCXbyka8EUjfOV6xUgI35S3k0ZYPvo6uxK12Qd9oDQJcpZP0jg+bCisie/vApkL5BpFx6nOQdbwX3CzLqKldyIkP1srD6PNErfWjfVaU3mIxbLjjgV6nJAImUnIeV71gpLvha5MQIHW3HRBxg8+zFz5R+hEj99LY0URqhlPMHeC3CDmEsEeHZKH3Gj9OtQaPTvsKqtIX4A3QgdJbzH9yByhlgXqz/x67k0sgZ2qc3rGBs2kdVEK1/43lQsQLW0hu+hgUGVOCrUFIvh81qSGufwjBPuLiaN9Wb4rfpbNXSaickb+GF8cu1FAmRemCcaiQNqrMhSm2hZYX0BFhIHmmtdZIoSkL+HPrdk/vf0QtqT6F0nqeNDweO2bgoyxlK46WQlfxVvC9MgKDCGYHVauY1MoTJhJi471FtATIBgIDNZbaU2qoF97STCCK/fa0dFvUuGtmw4ux/RhDENJoJC8gq04g63eTJAAxKdZuaDQBm1CE9zFghELcLcih5QdyIV2cDuRMRLppeg1g7Cl+QlTDr5d9OE0Zz3AqFwcHlgH+vI7asW3FQHccvPLYWNds3JpqLGqg3m+jaqHmtSlLaatRLE7Q+AwQfW+a3EGKok0emmgqO++C0nkpV5LZI2iXFs6Rios6gr6vkwfh5s+abnpfTxXRjLytTE2PWj3UrsHn/UHLNgBaBDYqzCtryuM+GQ2MyWlpYuB8tQn/zpMkgi/9gMkbaDrUroXVTu+Wck4DxOmaD4xkkXGBG7DOOWTE4Xpg7qywpXtcq8Pd54IcRizIG8RTLQndVqjPcLw+9bx2s9Jqd8d2DutFddUjjCzQ5miqjjyO2C9lmRJnssPs9DeGoza/j5rajUdzWrmRdUQO0PJ0DTLf45CMhREpp/9knux6EWMSo8rHKiVWj5ZJL8kgaoftEm7wYDZqkUVKiWbL3tyoIGlTH4uAFJu9emNaQcOxCh1nCuN/LHql0PxspBjNsVQh2AvuyIzFBBXPnGbGMND6/K7pl/GF85Npjk08pawlUuEV6aGSw9WLg5G2KPsddAsFbZqN2eUqPQwnebhjUGWqAA2lmJ2bFi4kbhKe0deIP3cCcOnWYxmbUqgYc6nWeXZYu7zzKj9w2GmjnPOETx2hFdUQkeqZzajfezMS4P3GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1751,"cache_read_input_tokens":139110,"cache_creation":{"ephemeral_5m_input_tokens":1751,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"555f1b15-400c-41bc-b635-3b0bdb81f271"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEytk2K2NQo9Wry1wsz","type":"message","role":"assistant","content":[{"type":"text","text":"Le rerun corrigé (PID 30573) tourne encore — les dernières lignes du log sont celles de l'ancien essai (sortie bufferisée). J'attends sa fin."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1751,"cache_read_input_tokens":139110,"cache_creation":{"ephemeral_5m_input_tokens":1751,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"11b3396e-de8c-435b-8ee0-48e034c078d5"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGEytk2K2NQo9Wry1wsz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RtUMjp5hEoZCwAAYwGQkoQ","name":"Bash","input":{"command":"while kill -0 30579 2>/dev/null; do sleep 20; done; tail -8 /tmp/creaka-force-pass.log","description":"Wait for fixed facebook/threads rerun","timeout":1800000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1751,"cache_read_input_tokens":139110,"cache_creation":{"ephemeral_5m_input_tokens":1751,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8f3b58a3-a27b-4e9d-b1f6-d36812ff24f8"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bg0steszj","tool_use_id":"toolu_01RtUMjp5hEoZCwAAYwGQkoQ","status":"completed","output_file":"","summary":"Wait for fixed facebook/threads rerun","uuid":"8fc33e67-04e3-4fa3-b096-82107efaefc4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bg0steszj","tool_use_id":"toolu_01RtUMjp5hEoZCwAAYwGQkoQ","description":"Wait for fixed facebook/threads rerun","task_type":"local_bash","uuid":"0fecff5a-60cc-4723-be51-49ac9bff94da","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RtUMjp5hEoZCwAAYwGQkoQ","type":"tool_result","content":"[crea-ka] {'source': 'facebook-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 146.8}\n[crea-ka] sync threads-apify (enrichment) ...\n[crea-ka] ka-threads tranche 0 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-threads/runs?memory=2048\n[crea-ka] {'source': 'threads-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 137.5}\n[crea-ka] sync facebook-apify (enrichment) ...\n[crea-ka] {'source': 'facebook-apify', 'creators': 1402, 'accounts': 4634, 'added': 0, 'updated': 1402, 'optout_skipped': 0, 'avg_confidence': 0.921, 'seconds': 183.0}\n[crea-ka] sync threads-apify (enrichment) ...\n[crea-ka] {'source': 'threads-apify', 'creators': 14, 'accounts': 89, 'added': 0, 'updated': 14, 'optout_skipped': 0, 'avg_confidence': 0.935, 'seconds': 106.9, 'errors': 1}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"87475e2c-4eb2-4faa-9491-d527781e665d","timestamp":"2026-08-21T16:58:10.486Z","tool_use_result":{"stdout":"[crea-ka] {'source': 'facebook-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 146.8}\n[crea-ka] sync threads-apify (enrichment) ...\n[crea-ka] ka-threads tranche 0 échouée : 400 Client Error: Bad Request for url: https://api.apify.com/v2/acts/gorgeous_thistle~ka-threads/runs?memory=2048\n[crea-ka] {'source': 'threads-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 137.5}\n[crea-ka] sync facebook-apify (enrichment) ...\n[crea-ka] {'source': 'facebook-apify', 'creators': 1402, 'accounts': 4634, 'added': 0, 'updated': 1402, 'optout_skipped': 0, 'avg_confidence': 0.921, 'seconds': 183.0}\n[crea-ka] sync threads-apify (enrichment) ...\n[crea-ka] {'source': 'threads-apify', 'creators': 14, 'accounts': 89, 'added': 0, 'updated': 14, 'optout_skipped': 0, 'avg_confidence': 0.935, 'seconds': 106.9, 'errors': 1}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5c9512e3-374c-4b13-af39-73ce0af2181a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFMqzCDwKndd9QLccBY","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjAQKpAEIERgCKkDJrNKdTMCQMzzsUG8WswccJ0zSlvtTum4ceR3I1CHS97aYFA1lpdh1wNqM9KUiX5JUSiZlVRna/UFVDXbLLTe1Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGoh6LUBhIMUCNuFLBLgeylD9gfGgxZcuzxyHLlhxMBNaQiMFKlTOugBXNWaQ36cvag1r5AZVyTgHRHPBwHRQ6DJU1Wkqk+L6rYqJWnKxKTFxtrOSqUAsk5eTNomO39MxDXjZ1qAWtX+ZZn0j6HlFK4FDP6ANIfSRXEt7fk9bOv15gJtXfDPoO0BvGOFqC8OtB+jzZlf57eTYw7ALQLPrH/a4gi0Lh9SUA8sWphImA6QmvFRymI1YocLGMX5sqJDk9BXixiCVjTG1xJ3tNpfUDSKOwGDPMSw3H0VswQ/esDQC+0LWKFjQSxdLfAIUNxYiEFbTRZBulDtgaNnRvIpPp7bJeL5xtuFagPG0cBa3IubvxPIrRoLQRB/81PwRfz5in0GE2hng8CQ/Yz1sbN2XNoAmL55aSnSj7APdQ8iKJC+gc5V/tnop+lka5c78JCTYidxNs1Mtje3UIKfaTHZlaFD0FwYz1IOYcYWhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2028,"cache_read_input_tokens":140861,"cache_creation":{"ephemeral_5m_input_tokens":2028,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d51816a2-422d-4a94-8e4f-d1d9997c0fc4"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFMqzCDwKndd9QLccBY","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjgQKpQEIERgCKkAs71yRAkoZ9p/nQE8tcpUsCv5QIpdspdT/BROmYVRp6rfLmlTkfYb0gMiXN85g2HN67Wv/zg6ESchiE2UGumjFMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBqIei1AYSDIJ17aafezBH18efDxoMxslETneM73kfdaSBIjCR2gqKXXX21VceDthZFqHuwaw4pUmV/LC8jxUO+m9Bgxf0d8CEwEuYf3N1Z/x627IqlQIwFX/HTCjVrFpw1mJTerLGmEdTNhOvOajDeYNYaqmmyPrOpBf1422RVEb3xFCqyd+4n2tjuVvRVD7zwcrCgyGS18xO8MVQ36SlFXmTqD6Nbhug8myQl5j5nW77M/NrfuVDEH9sBqSM5mUPrAdJitMqgv7bQFZRuPbBgbfRbje3qrYTJd5MGfDNpmzYLAh8PKkoFQk0PRbTJ/IPFf6qw/b08L5y0K3pXhVf5ga3RuYe396J5Giv/YOM25HTiV2JtDxwH3LV0/2RtokWzX/XRIzi0/QCCIN55kaKDPlbYw4fm2DXuDwBaG3Gfc/mNE9onfyXlXEchRiTn41T2VeMEyBeC2FFSGC45OOag4nXLSlQG4PkO+MBGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2028,"cache_read_input_tokens":140861,"cache_creation":{"ephemeral_5m_input_tokens":2028,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"691d9a92-3ada-4f13-b1d6-5b2bf61d4fd3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFMqzCDwKndd9QLccBY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0193xy915dxxUL3fB4tAjbt5","name":"TodoWrite","input":{"todos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Réparer facebook/threads : plafond concurrency 6 du schéma d'input (400) — 1 402 fiches FB enrichies","status":"completed","activeForm":"Réparant facebook/threads"},{"content":"Passage forcé complet toutes plateformes (multi 6,8 → 19,7 %, +3 900 comptes)","status":"completed","activeForm":"Passage forcé complet"},{"content":"Second passage (mode normal) sur les nouveaux comptes croisés","status":"in_progress","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"pending","activeForm":"Vérifiant l'impact final"},{"content":"Rebuild frontend si nécessaire, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2028,"cache_read_input_tokens":140861,"cache_creation":{"ephemeral_5m_input_tokens":2028,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a3b052d1-ccdc-4288-ba98-7728993e67a7"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0193xy915dxxUL3fB4tAjbt5","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a536de20-ec92-4bbd-92e7-06b6b320b023","timestamp":"2026-08-21T16:58:21.836Z","tool_use_result":{"oldTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Diagnostiquer ka-facebook / ka-threads / ka-onlyfans (murs plateformes, miss tamponnés)","status":"completed","activeForm":"Diagnostiquant ka-facebook/threads/onlyfans"},{"content":"Vérifier le build ka-x 0.1.7 par un run de test","status":"completed","activeForm":"Validant le build ka-x 0.1.7"},{"content":"Passage forcé complet (fait : multi 6,8→,19,7 %, +3 900 comptes) + rerun facebook/threads","status":"in_progress","activeForm":"Terminant le rerun facebook/threads"},{"content":"Second passage (mode normal) sur les ~3 700 nouveaux comptes croisés","status":"pending","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"pending","activeForm":"Vérifiant l'impact final"},{"content":"Rebuild frontend si nécessaire, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"newTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Réparer facebook/threads : plafond concurrency 6 du schéma d'input (400) — 1 402 fiches FB enrichies","status":"completed","activeForm":"Réparant facebook/threads"},{"content":"Passage forcé complet toutes plateformes (multi 6,8 → 19,7 %, +3 900 comptes)","status":"completed","activeForm":"Passage forcé complet"},{"content":"Second passage (mode normal) sur les nouveaux comptes croisés","status":"in_progress","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"pending","activeForm":"Vérifiant l'impact final"},{"content":"Rebuild frontend si nécessaire, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFMqzCDwKndd9QLccBY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01K5Pkzho14kkhKMGGcRu7xS","name":"Bash","input":{"command":"cd ~/apps/crea-ka && nohup .venv/bin/python run.py sync instagram-apify tiktok-apify x-apify youtube-apify twitch-apify kick-apify snapchat-apify discord-apify patreon-apify fansly-apify onlyfans-apify facebook-apify threads-apify link-in-bio bio-liens region-bio > /tmp/creaka-pass2.log 2>&1 & echo \"PID $!\"","description":"Launch second normal enrichment pass"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2028,"cache_read_input_tokens":140861,"cache_creation":{"ephemeral_5m_input_tokens":2028,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ce2fa472-acd6-461d-af1d-79e0c0a8ae9a"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01K5Pkzho14kkhKMGGcRu7xS","type":"tool_result","content":"PID 38151","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3d52924d-967f-4811-82b6-c196cc61e59f","timestamp":"2026-08-21T16:58:23.486Z","tool_use_result":{"stdout":"PID 38151","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8cab32d2-6339-4e51-9feb-ebcc11f83a38","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFNoqjXBBfvoA8hnWm3","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FvcbGReAeTWsZZyQn248P5","name":"Bash","input":{"command":"while kill -0 38151 2>/dev/null; do sleep 30; done; grep -E \"source|ALERTE\" /tmp/creaka-pass2.log | tail -20","description":"Wait for second pass and summarize","timeout":3600000,"run_in_background":true},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1066,"cache_read_input_tokens":142889,"cache_creation":{"ephemeral_5m_input_tokens":1066,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4847a964-cc78-4e0f-828a-f890777cc02c"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bg4yuv6im","tool_use_id":"toolu_01FvcbGReAeTWsZZyQn248P5","description":"Wait for second pass and summarize","task_type":"local_bash","uuid":"1a58ba48-ef56-48d1-ab5a-4bb988e9d881","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FvcbGReAeTWsZZyQn248P5","type":"tool_result","content":"Command running in background with ID: bg4yuv6im. Output is being written to: /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bg4yuv6im.output","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8263a976-4b49-4c55-bf83-dfaf1fd03ba7","timestamp":"2026-08-21T16:58:27.786Z","tool_use_result":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bg4yuv6im"}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"be6af200-a490-470d-ac64-ac19a8b1715d","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFP8mXLq2gehLvSv261","type":"message","role":"assistant","content":[{"type":"text","text":"Le second passage tourne (~3 700 comptes ciblés). Je serai notifié à la fin — ensuite : jauges finales, relance du watch, redéploiement et commit."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":289,"cache_read_input_tokens":143955,"cache_creation":{"ephemeral_5m_input_tokens":289,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"15f77746-702b-4b1e-8bc0-6362b4f454cd"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFP8mXLq2gehLvSv261","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019FqSiFzDAzZtLApSBQ6C7a","name":"ScheduleWakeup","input":{"delaySeconds":1200,"reason":"Vérifier la fin du second passage d'enrichissement crea-ka puis finaliser (jauges, sync, déploiement, commit)","prompt":"Vérifie si le second passage crea-ka (PID 38151, /tmp/creaka-pass2.log) est terminé. Si oui : jauges /api/stats/dashboard, relancer crea-ka-sync (pm2 start crea-ka-sync), rebuild frontend si nécessaire (scripts/build_frontend.py), pm2 restart crea-ka-web, vérifier healthcheck :8160, puis git add/commit/push origin main des changements restants (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":289,"cache_read_input_tokens":143955,"cache_creation":{"ephemeral_5m_input_tokens":289,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3541ae2d-2dfb-48a6-a3e3-0d8b022bfde5"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019FqSiFzDAzZtLApSBQ6C7a","type":"tool_result","content":"Next wakeup scheduled for 13:19:00 (in 1225s)."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b52d93cf-60c7-48a7-9d66-c0f1439e6304","timestamp":"2026-08-21T16:58:35.000Z","tool_use_result":{"scheduledFor":1787332740000,"clampedDelaySeconds":1200,"wasClamped":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3ddae1a9-6cbb-454e-89fd-dfc9fe368d1e","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGFPfnaWKKgNFiHnPHPS","type":"message","role":"assistant","content":[{"type":"text","text":"Réveil programmé dans 20 minutes pour finaliser."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":412,"cache_read_input_tokens":144244,"cache_creation":{"ephemeral_5m_input_tokens":412,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"09fc92cf-2aa8-4737-8361-f24cdeb2b79e"}} {"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":332660,"duration_api_ms":52338,"num_turns":8,"result":"Réveil programmé dans 20 minutes pour finaliser.","stop_reason":"end_turn","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","total_cost_usd":0.74296125,"usage":{"input_tokens":12,"cache_creation_input_tokens":128435,"cache_read_input_tokens":727280,"output_tokens":2874,"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":128435},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":24,"cache_read_input_tokens":144244,"cache_creation_input_tokens":412,"cache_creation":{"ephemeral_5m_input_tokens":412,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-fable-5":{"inputTokens":12,"outputTokens":2874,"cacheReadInputTokens":727280,"cacheCreationInputTokens":128435,"webSearchRequests":0,"costUSD":0.74296125,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"ca3f18d6-c458-456a-9465-c7ab28ba485f"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bg4yuv6im","tool_use_id":"toolu_01FvcbGReAeTWsZZyQn248P5","status":"completed","output_file":"/private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bg4yuv6im.output","summary":"Background command \"Wait for second pass and summarize\" completed (exit code 0)","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9468b526-1703-4399-93ae-f97d2ce42454"}} {"type":"claude","event":{"type":"system","subtype":"task_updated","task_id":"bg4yuv6im","patch":{"status":"completed","end_time":1787334329141},"uuid":"e58ccef3-7a99-4b04-bb42-2713e249bef2","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/crea-ka","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","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":"f419e51b-6bee-4ccd-980e-7c98efe703fa","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/"},"fast_mode_state":"off"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"52f4aca6-eecc-459d-aa40-99a8991def93","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJy7FYJmHwVNdB2Kqcn","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BaasBPPxHUyH3ZPhEy4jwr","name":"Bash","input":{"command":"cat /private/tmp/claude-501/-Users-simon-pierreboucher-apps-crea-ka/eb002d3d-a873-4cca-9361-cb2467df7353/tasks/bg4yuv6im.output","description":"Read second pass summary"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":128662,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":128662,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"94f32410-6cad-4582-9d7e-b2d1c9c8fa39"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BaasBPPxHUyH3ZPhEy4jwr","type":"tool_result","content":"[crea-ka] {'source': 'instagram-apify', 'creators': 606, 'accounts': 2238, 'added': 0, 'updated': 606, 'optout_skipped': 0, 'avg_confidence': 0.924, 'seconds': 137.8}\n[crea-ka] {'source': 'tiktok-apify', 'creators': 293, 'accounts': 1347, 'added': 0, 'updated': 293, 'optout_skipped': 0, 'avg_confidence': 0.925, 'seconds': 167.8, 'errors': 152}\n[crea-ka] {'source': 'x-apify', 'creators': 30, 'accounts': 104, 'added': 0, 'updated': 30, 'optout_skipped': 0, 'avg_confidence': 0.917, 'seconds': 1059.0}\n[crea-ka] {'source': 'youtube-apify', 'creators': 222, 'accounts': 614, 'added': 0, 'updated': 222, 'optout_skipped': 0, 'avg_confidence': 0.917, 'seconds': 122.2}\n[crea-ka] {'source': 'twitch-apify', 'creators': 10, 'accounts': 52, 'added': 0, 'updated': 10, 'optout_skipped': 0, 'avg_confidence': 0.928, 'seconds': 16.2}\n[crea-ka] {'source': 'kick-apify', 'creators': 1, 'accounts': 9, 'added': 0, 'updated': 1, 'optout_skipped': 0, 'avg_confidence': 0.948, 'seconds': 16.2}\n[crea-ka] {'source': 'snapchat-apify', 'creators': 9, 'accounts': 57, 'added': 0, 'updated': 9, 'optout_skipped': 0, 'avg_confidence': 0.931, 'seconds': 46.4}\n[crea-ka] {'source': 'discord-apify', 'creators': 10, 'accounts': 80, 'added': 0, 'updated': 10, 'optout_skipped': 0, 'avg_confidence': 0.941, 'seconds': 31.8}\n[crea-ka] {'source': 'patreon-apify', 'creators': 3, 'accounts': 15, 'added': 0, 'updated': 3, 'optout_skipped': 0, 'avg_confidence': 0.913, 'seconds': 76.7}\n[crea-ka] {'source': 'fansly-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 0.6}\n[crea-ka] {'source': 'onlyfans-apify', 'creators': 26, 'accounts': 67, 'added': 0, 'updated': 26, 'optout_skipped': 0, 'avg_confidence': 0.903, 'seconds': 258.9}\n[crea-ka] {'source': 'facebook-apify', 'creators': 26, 'accounts': 84, 'added': 0, 'updated': 26, 'optout_skipped': 0, 'avg_confidence': 0.913, 'seconds': 152.3}\n[crea-ka] {'source': 'threads-apify', 'creators': 2, 'accounts': 10, 'added': 0, 'updated': 2, 'optout_skipped': 0, 'avg_confidence': 0.916, 'seconds': 16.2, 'errors': 1}\n[crea-ka] {'source': 'link-in-bio', 'creators': 404, 'accounts': 1956, 'added': 0, 'updated': 404, 'optout_skipped': 0, 'avg_confidence': 0.942, 'seconds': 716.3}\n[crea-ka] {'source': 'bio-liens', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 0.7}\n[crea-ka] {'source': 'region-bio', 'creators': 11, 'accounts': 26, 'added': 0, 'updated': 11, 'optout_skipped': 0, 'avg_confidence': 0.924, 'seconds': 0.7}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f633a08b-8806-4a30-81dc-fbc4fbd1e650","timestamp":"2026-08-21T17:45:34.490Z","tool_use_result":{"stdout":"[crea-ka] {'source': 'instagram-apify', 'creators': 606, 'accounts': 2238, 'added': 0, 'updated': 606, 'optout_skipped': 0, 'avg_confidence': 0.924, 'seconds': 137.8}\n[crea-ka] {'source': 'tiktok-apify', 'creators': 293, 'accounts': 1347, 'added': 0, 'updated': 293, 'optout_skipped': 0, 'avg_confidence': 0.925, 'seconds': 167.8, 'errors': 152}\n[crea-ka] {'source': 'x-apify', 'creators': 30, 'accounts': 104, 'added': 0, 'updated': 30, 'optout_skipped': 0, 'avg_confidence': 0.917, 'seconds': 1059.0}\n[crea-ka] {'source': 'youtube-apify', 'creators': 222, 'accounts': 614, 'added': 0, 'updated': 222, 'optout_skipped': 0, 'avg_confidence': 0.917, 'seconds': 122.2}\n[crea-ka] {'source': 'twitch-apify', 'creators': 10, 'accounts': 52, 'added': 0, 'updated': 10, 'optout_skipped': 0, 'avg_confidence': 0.928, 'seconds': 16.2}\n[crea-ka] {'source': 'kick-apify', 'creators': 1, 'accounts': 9, 'added': 0, 'updated': 1, 'optout_skipped': 0, 'avg_confidence': 0.948, 'seconds': 16.2}\n[crea-ka] {'source': 'snapchat-apify', 'creators': 9, 'accounts': 57, 'added': 0, 'updated': 9, 'optout_skipped': 0, 'avg_confidence': 0.931, 'seconds': 46.4}\n[crea-ka] {'source': 'discord-apify', 'creators': 10, 'accounts': 80, 'added': 0, 'updated': 10, 'optout_skipped': 0, 'avg_confidence': 0.941, 'seconds': 31.8}\n[crea-ka] {'source': 'patreon-apify', 'creators': 3, 'accounts': 15, 'added': 0, 'updated': 3, 'optout_skipped': 0, 'avg_confidence': 0.913, 'seconds': 76.7}\n[crea-ka] {'source': 'fansly-apify', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 0.6}\n[crea-ka] {'source': 'onlyfans-apify', 'creators': 26, 'accounts': 67, 'added': 0, 'updated': 26, 'optout_skipped': 0, 'avg_confidence': 0.903, 'seconds': 258.9}\n[crea-ka] {'source': 'facebook-apify', 'creators': 26, 'accounts': 84, 'added': 0, 'updated': 26, 'optout_skipped': 0, 'avg_confidence': 0.913, 'seconds': 152.3}\n[crea-ka] {'source': 'threads-apify', 'creators': 2, 'accounts': 10, 'added': 0, 'updated': 2, 'optout_skipped': 0, 'avg_confidence': 0.916, 'seconds': 16.2, 'errors': 1}\n[crea-ka] {'source': 'link-in-bio', 'creators': 404, 'accounts': 1956, 'added': 0, 'updated': 404, 'optout_skipped': 0, 'avg_confidence': 0.942, 'seconds': 716.3}\n[crea-ka] {'source': 'bio-liens', 'creators': 0, 'accounts': 0, 'added': 0, 'updated': 0, 'optout_skipped': 0, 'avg_confidence': None, 'alert': '0 créateur retourné — source possiblement bloquée', 'seconds': 0.7}\n[crea-ka] {'source': 'region-bio', 'creators': 11, 'accounts': 26, 'added': 0, 'updated': 11, 'optout_skipped': 0, 'avg_confidence': 0.924, 'seconds': 0.7}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3927af80-f6f7-45e5-a386-e5974a8775a3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJyVZxztgRcvXC52xBq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISnwgKpAEIERgCKkC+BdvW69Cqj3fBeQnZXb9nhtRKRi3vJ095siFJG1XsdZLzxolES28ufQ17Xc1vHgMJrxWsjfLpCDigAb9fGC1sMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHInaLUBhIMCbSzp4+nkzOivq3GGgy6PwgSC6ER7alJUdYiMIHJ4k3Jh9a7d7kYny0n7H2i0p0wRnvSobTUub7n4/KDFWkSn8RVMRtZjecMc4tqGSqnBsdH6oaHczo862hriM2DxuPlMujcl71N2QPnyWr8IaO1xZC/P9bILpQMELg//GU+lcd32NCqZmMR+W/2DRmqxfzoyHq0nU+zbtQtwk9L1ECNmnI1iio+VWlSYAYE0yfP6Se5KwhSniUnBwtJfK5WZIBn235QH+X+aEN50oPMo4BaK0nt+Be3uq53NZh3OGw+dWAcfIrUZmIc/NNpKEDu22h0zFy3lboKmubsm0V7TjITgoxAgmAj9IIWMN7NxxrKwWUvGy1yfwj+MjyWUofN2Hz5jf+pZAktwVYBlvg9XnzsdLpUWzcLS+IJzMA5OqoeZEh71NwfXhjleJkyw5SdLZ/j8Aont8wn7EygVVWzV13IZV4ccP1KJPk3wQMcEo5XTdiixiHlDJL2XaMmnmK8PlFmh3BVwLver1Jet9l7NN5nXJPidyZCe3JjOseCnrgfemhRU1o8qugsjCj2FhDVCmYinfC5sqkTZIcBufChyc/Kki80ohaPGBaZVlhEIUv3G+TTkbHbsCpT+wh30+shNX2jTnZZkZiFNSCYeirOIs5jtGE2VMXJmFjB7U1uljmxcjEImd/JiIVHKzM/stePUZ9Qn0tRivPHkgwivj0vN198dj+hS1TCgN0BXobs4/i/CKxIotP+NpNn04Y7mnNKTxd6/lzLa0nZJEKuVJL/z9EV60SoYhJY+d7S+4ozgHxPCJWtXfXChUZA3oFea+ks2NRWVSo/59ldTQRQ6XHJzS68g5sgKklhEswqCN273y/VywPr53sJ7E0YUejZI6AYZJmJrt1PDrztlgGW4ejH2WoMy5mmHzSZqEHsy6cb/GAUOvcw7oP+h8Fmn96+5asXXI/qTRZlPHP3pkoWSnkVfgudMsdLFx8oCNFoHOmzUUbdmpLc3k7H+P4u4p3OkD9c9dyh+wofN+P7d+WphxHEzvNSRaH/4+KYZIg84oXrNtL+mH9X7VgXLI+RgaOVg3sdXcxosGxTiYT7kLFXkkaiEJYJ8ZnnanPDBqYzaVBm80vqMIfE9GGy4YAr3sdDafYgmSdo7bn/DwVlZ8eiTMr6NafjEuvMt9HwmBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1669,"cache_read_input_tokens":144883,"cache_creation":{"ephemeral_5m_input_tokens":1669,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d7169c4d-22dd-49e2-82f8-3da35ba19a59"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJyVZxztgRcvXC52xBq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS0QMKpQEIERgCKkCf6Z7bKNi9GiV9AhxkIdMHQ4Gm/lnhqAFaYotPVoka2xmfVXCpWuUBKtHLT+c9X+a7sLDO1DDucXEurgES3Bl7Mg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagByJ2i1AYSDDQRM7CTszMUhdD4BBoMt3Ivfdv4Ve3x/dDyIjBa5O0jf8XAb3Vsah4sLmvjaJe6PSxXCbmXSzjWmcge3tmcbUixQQo6dJKdjIs8loEq2AGaiBB2LTM+Yy0KS5ENCGwfsVUL+XVE9U3lYb4gOvEId68+7JB+Z0veddFETjJrVdHsDoc538GidB5WFktE4aVVyEQO8MNjS6xF3I0qg9MU6VE87Udmbl64HYglCjsKf+rjdGE/cIYW+JPy043GVszYhaD0T35BryQBUOl8pcWZQbf3BLA2osLmYjjaZdka1J53jckFNb3IQ2TswYqqIlrwmiHUbvbGxoZ34P6vGuQvlAhl0KliGPjd2qL09onXSKsCchfGFhrVUgZteiU2DBvV5c8u4gvmG1sYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1669,"cache_read_input_tokens":144883,"cache_creation":{"ephemeral_5m_input_tokens":1669,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"32d843ef-eaa9-4462-99d1-b1fe1d7401b0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJyVZxztgRcvXC52xBq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011wUpZ2KYGzLQX39BPB2JzW","name":"Bash","input":{"command":"curl -s \"http://localhost:8160/api/stats/dashboard\" | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nfor g in d.get('gauges',[]): print(g['id'], g['value'], '|', g['label'][:70])\nfor k in d.get('kpis',[]):\n if k['id'] in ('creators','accounts','multi','reach'): print(k['id'], k['value'])\n\"","description":"Final gauges"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1669,"cache_read_input_tokens":144883,"cache_creation":{"ephemeral_5m_input_tokens":1669,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"16586163-e85a-4475-b9d8-246c45c622a3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011wUpZ2KYGzLQX39BPB2JzW","type":"tool_result","content":"multi 19.6 | Créateurs multi-plateformes (2 comptes et +)\nbio 79.0 | Fiches avec biographie\nloc 14.6 | Fiches avec ville ou région déclarée\nreach 82.6 | Fiches avec audience connue\ncomplete 66.6 | Complétude moyenne des fiches (bio, lieu, audience, photo)\ncreators 12739\naccounts 18527\nmulti 2502\nreach 522802145","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ae50049d-7e9b-495a-8cc7-be36f4f786e3","timestamp":"2026-08-21T17:45:46.089Z","tool_use_result":{"stdout":"multi 19.6 | Créateurs multi-plateformes (2 comptes et +)\nbio 79.0 | Fiches avec biographie\nloc 14.6 | Fiches avec ville ou région déclarée\nreach 82.6 | Fiches avec audience connue\ncomplete 66.6 | Complétude moyenne des fiches (bio, lieu, audience, photo)\ncreators 12739\naccounts 18527\nmulti 2502\nreach 522802145","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJyVZxztgRcvXC52xBq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LfmS1beyyUrEKrrevBqKoY","name":"Bash","input":{"command":"cd ~/apps/crea-ka && sqlite3 data/creaka.db \"\nSELECT platform, COUNT(*) total, SUM(CASE WHEN metrics LIKE '%apify_at%' THEN 1 ELSE 0 END) enrichis, SUM(CASE WHEN metrics LIKE '%_miss%' THEN 1 ELSE 0 END) miss FROM accounts GROUP BY platform ORDER BY total DESC;\"","description":"Final per-platform coverage"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1669,"cache_read_input_tokens":144883,"cache_creation":{"ephemeral_5m_input_tokens":1669,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"31c5f239-4ef2-4c63-b177-67fc65c4d7f9"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LfmS1beyyUrEKrrevBqKoY","type":"tool_result","content":"youtube|9889|9501|776\ninstagram|3275|2006|552\nfacebook|1483|1407|253\ntiktok|964|760|11\nx|834|212|141\ntwitch|576|569|44\npodcast|508|0|2\nonlyfans|258|165|165\ndiscord|225|189|58\nspotify|197|0|0\nsite-web|185|0|0\nkick|155|155|0\nlinkedin|55|0|0\npatreon|52|18|2\nsnapchat|27|23|4\nmym|25|0|0\nsubstack|25|0|0\nfansly|19|19|0\nthreads|18|16|0","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f18d914e-4180-4c51-85eb-719071c39f0d","timestamp":"2026-08-21T17:45:48.478Z","tool_use_result":{"stdout":"youtube|9889|9501|776\ninstagram|3275|2006|552\nfacebook|1483|1407|253\ntiktok|964|760|11\nx|834|212|141\ntwitch|576|569|44\npodcast|508|0|2\nonlyfans|258|165|165\ndiscord|225|189|58\nspotify|197|0|0\nsite-web|185|0|0\nkick|155|155|0\nlinkedin|55|0|0\npatreon|52|18|2\nsnapchat|27|23|4\nmym|25|0|0\nsubstack|25|0|0\nfansly|19|19|0\nthreads|18|16|0","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d2985d4f-6c90-4f45-8b25-741fc4e675b5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJzXnwYxCk36tv4DLDR","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS1AkKpAEIERgCKkCRdsv3y64reSrySXrh+30zP3prJJTzRR0hjDWqNHDi6oqrQv5dwi5xu9sNXbdt+nDVsmEDdU9Ao08ytgSnio+zMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHXnaLUBhIMX2XKzTLRrUOE1j5yGgx9WaSaIB7nXvCmikgiMDFQ91tWsHYhhiRuGTmeeEQ3DXuhLSLR2cZXCryc8IrxxQc+SDA4jFbPR/X+IyQebSrcB64q6q2mI2H0Vlx853NNwIBOVgoWQ/NN8xaMrB3Gz7QUfn0Ae9twSGRsG846UkClFngz0Qu6xnUmZbvvvvZHI4c1lf3a45ufOcds7PUSHa6eVXdOV8+uGOM4y1ijcjs+pC6W+n0mLda74AtKmJygqa42PUnUqTzKrgkZVfNt0bFvTFxwsvE115f4RYjqYD4DAEWsXVdkL/12qIrX2lC04WP6HcIYlFTa9FoLRm20rAo50HyVfShQxDt/Q5QxvnBo618uzdRGY02oGMDiUFlbldLAKg+LLN0auJdm/yPGWAYbJUw/0biKljAdB63rImWc9eMAqwCAI0j8nFwz8+gHBdYP2sHq7Q6xSTg5txCmxoTdi348Zgn8XNIZRsGwOOUQWHI6SALwcff1lQaF33GInfO8V9RRn1vGQ1q/e/L90XExFVyvuZ/gQaTsVDJ3vJgy/Q9c40b4XsL4+UHcQE+dAFqo3bDlPNRpN89Yas4SMV4SuYi+rihGIxh862Xl1m42Alz02iaRlJduAfE4GSEZm7Fa9CqOeThF80b1GSP1kspnZqaL9qyHVgfegapl/Vg/5ZB7sIklwEuYGrSn+1QWDUPy3KyzbJs0J/dXG41w+SqZkiOCrIhJKv8SZ56e0OPyZm6QIJ+LvZQl2+HdMA9WlupKelbiwT6FiVmpFrgo9JgB9l/0Uu0aftahl0zFx+RGrnGb3nRe/6rRm0EkB7Mildxi6z30XXoDwU4lePnJKDvdQTrqnjHYc22pX89FirMyUi1f7MBPRcGfxfGH4zqjLYG5XcRoxm16Lpc/7TVcu/UQRwxQ95WZPaagsa3H3et8PkljNhhE2AkLoHzNjgDomo8Fmt5oSK0GL/0zMywKKpA39upNWg8aVf3LHrpXlNRu3xdyVvx307v5SdOOyvOb2lhfMj5WvQytpN/7OfHM7jLUpi5Q5PF3GD0LDyY9K5h8HJaq3z3VuTMRJDeouOSssMN8lxXNez14x1AyyAcHgDvXrTkZttHvt+MoE2fg0sPFebsMKuPmD/GaS8K4qmFLHdWG39yxff50DUSGwGfIeGnBz88pqNDhJFuxsbNsgSzms5YSI0r1gUZae6H50n/fvqC5rm+lsKQaW+/KVHpZ4RMNaxBxRh+ocul/xvns8uqdpL4aClggVB1zAbHwsKdsfasUBHNqMrHwLYVv4d2U6sShiThcEgeH0nw5huJ69AIqIKEDss1NjB2+O9apd2KHQT2KiM0mVKexGTJyg7olH5AdFANlTJc4Ow2NheVmPQIU+yAptmJwg6jTmo6FZRQUu3WpRckvdl0A/yjZ9uUYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1230,"cache_read_input_tokens":146552,"cache_creation":{"ephemeral_5m_input_tokens":1230,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e809ff57-c5f3-4208-82bd-31b2035c4c13"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJzXnwYxCk36tv4DLDR","type":"message","role":"assistant","content":[{"type":"text","text":"Couverture massivement élargie. Je relance le watch quotidien et le web, puis je committe."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1230,"cache_read_input_tokens":146552,"cache_creation":{"ephemeral_5m_input_tokens":1230,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6f0fa225-9a66-48be-980b-0a7646ac4f35"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGJzXnwYxCk36tv4DLDR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_018mgb613t4yuNVeX9k8VoDf","name":"Bash","input":{"command":"pm2 start crea-ka-sync && pm2 restart crea-ka-web && sleep 4 && curl -s -o /dev/null -w \"healthz: %{http_code}\\n\" http://localhost:8160/healthz && curl -s http://localhost:8160/api/health | head -c 200","description":"Restart sync and web, healthcheck"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1230,"cache_read_input_tokens":146552,"cache_creation":{"ephemeral_5m_input_tokens":1230,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"619249b4-f8fc-4a71-ab36-9f79c05795b7"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"b2hmbshml","tool_use_id":"toolu_018mgb613t4yuNVeX9k8VoDf","status":"completed","output_file":"","summary":"Restart sync and web, healthcheck","uuid":"af232b2c-8f70-41ae-b591-cd1850d5d5bf","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"b2hmbshml","tool_use_id":"toolu_018mgb613t4yuNVeX9k8VoDf","description":"Restart sync and web, healthcheck","task_type":"local_bash","uuid":"33766a40-b8ed-473e-a127-f702764133e4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_018mgb613t4yuNVeX9k8VoDf","type":"tool_result","content":"[PM2] Applying action restartProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n[PM2] Process successfully started\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 16D │ 0 │ online │ 0% │ 171.8mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 172.9mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.3mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 71.5mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.6mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 123.1mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 219.2mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 840.5mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.3mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5766 │ 0s │ 8 │ online │ 0% │ 3.0mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 16h │ 27 │ online │ 0% │ 164.8mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.8mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 340.2mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 113 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ simon-p… │ disabled │\n│ 1 │ immbot-ngrok │ default │ N/A │ fork │ 76976 │ 16D │ 1 │ online │ 0% │ 70.5mb │ simon-p… │ disabled │\n│ 64 │ khaelor-sh │ default │ N/A │ fork │ 13042 │ 10D │ 2 │ online │ 0% │ 43.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.5mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 556.7mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.8mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 848.3mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 41h │ 65 │ online │ 0% │ 492.4mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.6mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 41h │ 18 │ online │ 0% │ 147.7mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.2mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\nUse --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 │ 16D │ 0 │ online │ 0% │ 171.8mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 172.9mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.3mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 71.5mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.6mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 123.1mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 219.2mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 840.5mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.3mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5766 │ 0s │ 8 │ online │ 0% │ 41.3mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 5785 │ 0s │ 28 │ online │ 0% │ 496.0kb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.8mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 340.2mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 113 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ simon-p… │ disabled │\n│ 1 │ immbot-ngrok │ default │ N/A │ fork │ 76976 │ 16D │ 1 │ online │ 0% │ 70.5mb │ simon-p… │ disabled │\n│ 64 │ khaelor-sh │ default │ N/A │ fork │ 13042 │ 10D │ 2 │ online │ 0% │ 43.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.5mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 556.7mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.8mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 848.3mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 41h │ 65 │ online │ 0% │ 492.4mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.6mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 41h │ 18 │ online │ 0% │ 147.7mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.2mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\nhealthz: 200\n{\"ok\":true,\"creators\":12739,\"accounts\":18527,\"last_sync\":\"2026-08-21T17:45:23Z\"}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a5edb8a6-4877-46b8-942b-d20cf148d0fa","timestamp":"2026-08-21T17:46:06.106Z","tool_use_result":{"stdout":"[PM2] Applying action restartProcessId on app [crea-ka-sync](ids: [ 94 ])\n[PM2] [crea-ka-sync](94) ✓\n[PM2] Process successfully started\n┌────┬──────────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │ cpu │ mem │ user │ watching │\n├────┼──────────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤\n│ 6 │ airiskindex-ngrok │ default │ N/A │ fork │ 13980 │ 16D │ 0 │ online │ 0% │ 171.8mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 172.9mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.3mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 71.5mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.6mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 123.1mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 219.2mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 840.5mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.3mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5766 │ 0s │ 8 │ online │ 0% │ 3.0mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 3710 │ 16h │ 27 │ online │ 0% │ 164.8mb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.8mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 340.2mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 113 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ simon-p… │ disabled │\n│ 1 │ immbot-ngrok │ default │ N/A │ fork │ 76976 │ 16D │ 1 │ online │ 0% │ 70.5mb │ simon-p… │ disabled │\n│ 64 │ khaelor-sh │ default │ N/A │ fork │ 13042 │ 10D │ 2 │ online │ 0% │ 43.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.5mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 556.7mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.8mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 848.3mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 41h │ 65 │ online │ 0% │ 492.4mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.6mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 41h │ 18 │ online │ 0% │ 147.7mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.2mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\nUse --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 │ 16D │ 0 │ online │ 0% │ 171.8mb │ simon-p… │ disabled │\n│ 5 │ airiskindex-web │ default │ N/A │ fork │ 65729 │ 16D │ 13 │ online │ 0% │ 172.9mb │ simon-p… │ disabled │\n│ 75 │ anomaly-atlas │ default │ N/A │ fork │ 33650 │ 9D │ 14 │ online │ 0% │ 69.3mb │ simon-p… │ disabled │\n│ 76 │ anomaly-atlas-ngrok │ default │ N/A │ fork │ 61023 │ 9D │ 0 │ online │ 0% │ 71.5mb │ simon-p… │ disabled │\n│ 77 │ apika-api │ default │ N/A │ fork │ 13966 │ 2D │ 21 │ online │ 0% │ 708.6mb │ simon-p… │ disabled │\n│ 79 │ apika-ngrok │ default │ N/A │ fork │ 11978 │ 4D │ 0 │ online │ 0% │ 123.1mb │ simon-p… │ disabled │\n│ 78 │ apika-scheduler │ default │ N/A │ fork │ 58993 │ 3D │ 6 │ online │ 0% │ 10.0gb │ simon-p… │ disabled │\n│ 21 │ coinexplorer-api │ default │ N/A │ fork │ 4186 │ 15D │ 6 │ online │ 0% │ 219.2mb │ simon-p… │ disabled │\n│ 19 │ coinexplorer-indexer │ default │ N/A │ fork │ 54216 │ 15D │ 3 │ online │ 0% │ 840.5mb │ simon-p… │ disabled │\n│ 22 │ coinexplorer-ngrok │ default │ N/A │ fork │ 60713 │ 15D │ 0 │ online │ 0% │ 151.3mb │ simon-p… │ disabled │\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 5766 │ 0s │ 8 │ online │ 0% │ 41.3mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 5785 │ 0s │ 28 │ online │ 0% │ 496.0kb │ simon-p… │ disabled │\n│ 28 │ draft2027 │ default │ N/A │ fork │ 89898 │ 12D │ 0 │ online │ 0% │ 40.5mb │ simon-p… │ disabled │\n│ 29 │ draft2027-ngrok │ default │ N/A │ fork │ 89910 │ 12D │ 0 │ online │ 0% │ 57.8mb │ simon-p… │ disabled │\n│ 60 │ earthnow-api │ default │ N/A │ fork │ 87198 │ 11D │ 0 │ online │ 0% │ 79.7mb │ simon-p… │ disabled │\n│ 63 │ earthnow-ngrok │ default │ N/A │ fork │ 87239 │ 11D │ 0 │ online │ 0% │ 76.8mb │ simon-p… │ disabled │\n│ 62 │ earthnow-proxy │ default │ N/A │ fork │ 87227 │ 11D │ 0 │ online │ 0% │ 46.0mb │ simon-p… │ disabled │\n│ 61 │ earthnow-web │ default │ 0.1.0 │ fork │ 87210 │ 11D │ 0 │ online │ 0% │ 108.2mb │ simon-p… │ disabled │\n│ 30 │ groupe-ka │ default │ 1.0.0 │ fork │ 35282 │ 2D │ 69 │ online │ 0% │ 340.2mb │ simon-p… │ disabled │\n│ 31 │ groupe-ka-ngrok │ default │ N/A │ fork │ 26263 │ 5D │ 1 │ online │ 0% │ 198.2mb │ simon-p… │ disabled │\n│ 95 │ groupe-ka-status-tick │ default │ 1.0.0 │ fork │ 0 │ 0 │ 113 │ stopped │ 0% │ 0b │ simon-p… │ disabled │\n│ 0 │ immbot-ai │ default │ 1.0.0 │ fork │ 19793 │ 16D │ 6 │ online │ 0% │ 147.8mb │ simon-p… │ disabled │\n│ 1 │ immbot-ngrok │ default │ N/A │ fork │ 76976 │ 16D │ 1 │ online │ 0% │ 70.5mb │ simon-p… │ disabled │\n│ 64 │ khaelor-sh │ default │ N/A │ fork │ 13042 │ 10D │ 2 │ online │ 0% │ 43.4mb │ 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 │ 16D │ 0 │ online │ 0% │ 217.5mb │ simon-p… │ disabled │\n│ 7 │ llmindex-web │ default │ 0.1.0 │ fork │ 85142 │ 2D │ 19 │ online │ 0% │ 556.7mb │ simon-p… │ disabled │\n│ 10 │ llmindex-worker │ default │ 4.23.5 │ fork │ 44184 │ 16D │ 4 │ online │ 0% │ 51.7mb │ simon-p… │ disabled │\n│ 71 │ localvm-web │ default │ 1.0.0 │ fork │ 4329 │ 9D │ 13 │ online │ 0% │ 91.2mb │ simon-p… │ disabled │\n│ 72 │ localvm-web-ngrok │ default │ N/A │ fork │ 71734 │ 9D │ 0 │ online │ 0% │ 92.8mb │ simon-p… │ disabled │\n│ 27 │ lou-ka-ngrok │ default │ N/A │ fork │ 22790 │ 15D │ 0 │ online │ 0% │ 348.4mb │ simon-p… │ disabled │\n│ 26 │ lou-ka-sync │ default │ 1.0.0 │ fork │ 19953 │ 2D │ 31 │ online │ 0% │ 848.3mb │ simon-p… │ disabled │\n│ 25 │ lou-ka-web │ default │ 1.0.0 │ fork │ 76103 │ 41h │ 65 │ online │ 0% │ 492.4mb │ simon-p… │ disabled │\n│ 73 │ modelmap-web │ default │ 1.0.0 │ fork │ 23027 │ 9D │ 18 │ online │ 0% │ 83.5mb │ simon-p… │ disabled │\n│ 74 │ modelmap-web-ngrok │ default │ N/A │ fork │ 89389 │ 9D │ 0 │ online │ 0% │ 66.6mb │ simon-p… │ disabled │\n│ 84 │ resto-ka │ default │ N/A │ fork │ 92117 │ 41h │ 18 │ online │ 0% │ 147.7mb │ simon-p… │ disabled │\n│ 85 │ resto-ka-ngrok │ default │ N/A │ fork │ 35426 │ 3D │ 0 │ online │ 0% │ 107.5mb │ 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.2mb │ simon-p… │ disabled │\n│ 69 │ svgarden-web │ default │ 1.0.0 │ fork │ 34954 │ 10D │ 1 │ online │ 0% │ 65.3mb │ simon-p… │ disabled │\n│ 23 │ voyage2026 │ default │ N/A │ fork │ 43917 │ 15D │ 0 │ online │ 0% │ 39.6mb │ simon-p… │ disabled │\n│ 24 │ voyage2026-ngrok │ default │ N/A │ fork │ 43929 │ 15D │ 0 │ online │ 0% │ 58.0mb │ simon-p… │ disabled │\n└────┴──────────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘\nhealthz: 200\n{\"ok\":true,\"creators\":12739,\"accounts\":18527,\"last_sync\":\"2026-08-21T17:45:23Z\"}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"717d16c1-00d5-4e81-8000-ddc3809b14d3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK1pnuye4rHYBbtvBRJ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISuQQKpAEIERgCKkBmhRBOtqJ8pSWoU4Mhv6WY3bZ9Jo9tx/AjqIatyOLJVfNkgTr+Ukttiu9dOBFwLmsvaBS55sFei3PzLZeuy8boMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHjnaLUBhIM+gLe7H0J1noX64gBGgy7jGzRq06fOK9qr+wiMHuMOHnttns7ETE514MEioUlPjmvF+97rv7KfBCpUpFK0pGoEe9aBoZnc4+bJYhYySrBAo7mB5qCW6vUU4OLf7VM6c24Kp7KsVQ7rzCwROrf0qCKk+5oAWSst87iUjXwll2+vNWGW6SY/Uk1TRNpnBiF6rkco8rFiEZulZnh/T5PR18N7xCXsGEPRaxYRil9UiCXHqTQQaA8GOoI/aiXRc1khH84fi5DuYL+gnQs2x6kYm6bvvh7jS8g1YB4d3oDWltJ8g6Sy1dLNvMGzAGcia3kCz1So4ekyQWxdNGyNjOZgkBFy6nyfJAn9Vu5n538hp2Z+VTAzpdvMc2rrIo2WO6GjhM4Gk9nz4TKajzPMXPYIXqXOmVCYvOOe7FvJFWNBFmsFQHM3rxlvUbjYkjPyVXaYuGP113R/9k/mwzGx9BQZGnvSln+1cCQt9HmINhgZ1/lmCD/BgBy+4p3hGAYQQmJurdMhxWcID4rodqL5m0zKbKlNhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9320,"cache_read_input_tokens":147782,"cache_creation":{"ephemeral_5m_input_tokens":9320,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4c39dff1-6c87-4c9b-9b7e-7a9c39ba0299"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK1pnuye4rHYBbtvBRJ","type":"message","role":"assistant","content":[{"type":"text","text":"Tout est en ligne (healthz 200, 18 527 comptes). Je committe le correctif de concurrence et je pousse."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9320,"cache_read_input_tokens":147782,"cache_creation":{"ephemeral_5m_input_tokens":9320,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c8df5889-9eb1-4153-9e59-94613690fb84"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK1pnuye4rHYBbtvBRJ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01K6R3t9fRbbj5GJEhBUyqXS","name":"Bash","input":{"command":"cd ~/apps/crea-ka && git add creaka/connectors/apify_social.py && git commit -m \"$(cat <<'EOF'\nConcurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé\nEOF\n)\" && git push origin main && git log --oneline -1 && git status --short","description":"Commit and push concurrency fix"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9320,"cache_read_input_tokens":147782,"cache_creation":{"ephemeral_5m_input_tokens":9320,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e96ba576-7d58-4e5e-ba33-ee3f868f4804"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01K6R3t9fRbbj5GJEhBUyqXS","type":"tool_result","content":"[main 0cad882] Concurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé\n 1 file changed, 11 insertions(+), 4 deletions(-)\nTo gitsrv:srv/git/crea-ka.git\n cac232a..0cad882 main -> main\n0cad882 Concurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5a3abdfd-c6e1-499c-b569-a50e03d3cd9a","timestamp":"2026-08-21T17:46:15.597Z","tool_use_result":{"stdout":"[main 0cad882] Concurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé\n 1 file changed, 11 insertions(+), 4 deletions(-)\nTo gitsrv:srv/git/crea-ka.git\n cac232a..0cad882 main -> main\n0cad882 Concurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3c8535be-b3ef-4043-96ae-33820d977818","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK2XAJH7wZQCnKPRvPN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0163fdPEdGPq3QDM7ysTuS6U","name":"TodoWrite","input":{"todos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Réparer facebook/threads : plafond concurrency 6 du schéma d'input (400) — 1 402 fiches FB enrichies","status":"completed","activeForm":"Réparant facebook/threads"},{"content":"Passage forcé complet toutes plateformes (multi 6,8 → 19,7 %, +3 900 comptes)","status":"completed","activeForm":"Passage forcé complet"},{"content":"Second passage (mode normal) sur les nouveaux comptes croisés","status":"completed","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"completed","activeForm":"Vérifiant l'impact final"},{"content":"Redéployer (pm2 restart, healthz 200) et committer/pousser","status":"completed","activeForm":"Redéployant et poussant"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":799,"cache_read_input_tokens":157102,"cache_creation":{"ephemeral_5m_input_tokens":799,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2b8dcd38-716e-40fc-b767-a4520005a055"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0163fdPEdGPq3QDM7ysTuS6U","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"886b5942-ed14-469a-978b-4d76aaeca9ab","timestamp":"2026-08-21T17:46:23.464Z","tool_use_result":{"oldTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Réparer facebook/threads : plafond concurrency 6 du schéma d'input (400) — 1 402 fiches FB enrichies","status":"completed","activeForm":"Réparant facebook/threads"},{"content":"Passage forcé complet toutes plateformes (multi 6,8 → 19,7 %, +3 900 comptes)","status":"completed","activeForm":"Passage forcé complet"},{"content":"Second passage (mode normal) sur les nouveaux comptes croisés","status":"in_progress","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"pending","activeForm":"Vérifiant l'impact final"},{"content":"Rebuild frontend si nécessaire, pm2 restart, commit + push","status":"pending","activeForm":"Redéployant et poussant"}],"newTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Réparer facebook/threads : plafond concurrency 6 du schéma d'input (400) — 1 402 fiches FB enrichies","status":"completed","activeForm":"Réparant facebook/threads"},{"content":"Passage forcé complet toutes plateformes (multi 6,8 → 19,7 %, +3 900 comptes)","status":"completed","activeForm":"Passage forcé complet"},{"content":"Second passage (mode normal) sur les nouveaux comptes croisés","status":"completed","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"completed","activeForm":"Vérifiant l'impact final"},{"content":"Redéployer (pm2 restart, healthz 200) et committer/pousser","status":"completed","activeForm":"Redéployant et poussant"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b4d4b9f1-a0f8-459f-a8af-fb6cfef15a14","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK36vJdPANbukiixRN1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISjQUKpAEIERgCKkAsSAnuvVWskK5DxVkLcgoPlYDruUXU06X8GI25Lq1o63Gg7izNyvFWHiBvLOly4H9xbysCOSD5KKUr/mVeQnMWMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH1naLUBhIMJpT7f3ufZ+te+nm9GgyIBVjMijqQX5MXPZ4iMGEXtj7qek9XuoTO32qqj+F6A2aAmD9c6Q5UiXlz3PzzpMbsq0sIFbAcwDTQNO6aEyqVA5k2VoVn2OCeDEiXIFe4XY3NIdnzPXw7Jm6rOQhKNT5uqm56+K1QAZtN9vkL1weoyl878OuwbYGlntHSod/xdMVPfysd3Z8AAlao9Trp3r+TYXpIlT+TRhYBQ0sfU/AbUL3EgUUbPNQaG/yF4Cn/Eo4dDMDSu4uf8bChS2AME3PVgM1N9VPdjjofb+xJpHlL41UVch9Fh3NGET/2dR8bliwbvdoomlHrBiCAOSj6M+dAAP7ESScxyG24RehbBk/tXVtJjeUk5cYRLaPGtUNBeAh3rOBNE4XJOhKieRZhwI8XsUmcCtwb6JcLsDADUJan1ziBLiWIk9EH1d76EPLa6m/ybZecd7ETpdSHgp71mL9wjKFypjeRXIPwNKgsjaDSnOIhmwek1DIFi4cAmcpHqHZ8CvV+SFe8Dn/Y9MoIFYkzfUnTEETljVBDBNIdzxgUIzrkG7MhwdX/DWDVC1UaHacU9JupxQBAJ9nwCfWOaHNcoDyqc5GuiUOcDWvuGRrFzKkFEwdjmuw6XuUiN2wheLagJvOSDBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":579,"cache_read_input_tokens":157901,"cache_creation":{"ephemeral_5m_input_tokens":579,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7a722686-d8c2-4179-bf88-495adb36232b"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK36vJdPANbukiixRN1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01NLPtUzecTWtsme6Gy6Wn93","name":"ToolSearch","input":{"query":"select:TaskStop","max_results":1},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":579,"cache_read_input_tokens":157901,"cache_creation":{"ephemeral_5m_input_tokens":579,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"dd1cc6fd-352e-48aa-931c-15df5a8edb80"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01NLPtUzecTWtsme6Gy6Wn93","content":[{"type":"tool_reference","tool_name":"TaskStop"}]}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"81ad8e80-2cdf-4dc6-a0cf-d6d6789fe4fe","timestamp":"2026-08-21T17:46:30.179Z","tool_use_result":{"matches":["TaskStop"],"query":"select:TaskStop","total_deferred_tools":17}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK36vJdPANbukiixRN1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ENv9QkXghqcgSdqTjUhXqS","name":"Write","input":{"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md","content":"---\nname: Couverture Apify crea-ka — gotchas & mode forcé\ndescription: Plafonds de concurrence des acteurs (400 sinon), mode APIFY_FORCE=1, tampon des miss, état de couverture 2026-08-21\ntype: project\n---\n\nCampagne « couverture forcée » du 2026-08-21 (commits cac232a, 0cad882) : multi-plateformes 6,8 % → 19,6 % (2 502 créateurs), comptes 14 603 → 18 527.\n\n**Why:** l'utilisateur voyait « 6 % de complétude » = la jauge *créateurs multi-plateformes* du dashboard (/api/stats/dashboard), pas la jauge « complete ». Le sync quotidien (caps + rotation 7 j) ne couvrait qu'une tranche du bassin.\n\n**How to apply:**\n- ⚠️ GOTCHA MAJEUR : le champ `concurrency` envoyé à un acteur DOIT respecter le `maximum` de son input_schema.json, sinon Apify rejette le run en **400 Bad Request** (silencieux : « 0 créateur retourné — source possiblement bloquée »). ka-facebook et ka-threads plafonnent à 6 ; le connecteur a maintenant `run_concurrency` par classe. Si un connecteur Apify tombe à zéro, vérifier D'ABORD ce plafond avant de soupçonner un blocage plateforme.\n- `APIFY_FORCE=1 python run.py sync ` : ignore fraîcheur + caps → passage du bassin ENTIER. Arrêter `pm2 stop crea-ka-sync` pendant (collision quota mémoire Apify → 400 transitoires), le relancer après.\n- Miss définitifs (user_not_found, login_wall, wall_*, http_404…) : tamponnés `_miss` dans account.metrics → rotation hebdo au lieu d'un re-scan quotidien. Un profil ressuscité efface le tampon.\n- X (syndication) : max ~4 requêtes simultanées par run sinon 429 ; ka-x ≥ 0.1.7 retry 429 avec backoff + rotation d'IP. ~60-85 % des vieux handles X sont morts.\n- Facebook : profils personnels = login_wall (miss), mais les PAGES passent très bien (1 402 fiches enrichies).\n- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":579,"cache_read_input_tokens":157901,"cache_creation":{"ephemeral_5m_input_tokens":579,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"128eb906-d0ad-48a0-9806-e9e0f843cd67"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ENv9QkXghqcgSdqTjUhXqS","type":"tool_result","content":"File created successfully at: /Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0f087a96-7fdc-483b-890a-ee2d252a93ea","timestamp":"2026-08-21T17:46:43.396Z","tool_use_result":{"type":"create","filePath":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md","content":"---\nname: Couverture Apify crea-ka — gotchas & mode forcé\ndescription: Plafonds de concurrence des acteurs (400 sinon), mode APIFY_FORCE=1, tampon des miss, état de couverture 2026-08-21\ntype: project\noriginSessionId: eb002d3d-a873-4cca-9361-cb2467df7353\n---\nCampagne « couverture forcée » du 2026-08-21 (commits cac232a, 0cad882) : multi-plateformes 6,8 % → 19,6 % (2 502 créateurs), comptes 14 603 → 18 527.\n\n**Why:** l'utilisateur voyait « 6 % de complétude » = la jauge *créateurs multi-plateformes* du dashboard (/api/stats/dashboard), pas la jauge « complete ». Le sync quotidien (caps + rotation 7 j) ne couvrait qu'une tranche du bassin.\n\n**How to apply:**\n- ⚠️ GOTCHA MAJEUR : le champ `concurrency` envoyé à un acteur DOIT respecter le `maximum` de son input_schema.json, sinon Apify rejette le run en **400 Bad Request** (silencieux : « 0 créateur retourné — source possiblement bloquée »). ka-facebook et ka-threads plafonnent à 6 ; le connecteur a maintenant `run_concurrency` par classe. Si un connecteur Apify tombe à zéro, vérifier D'ABORD ce plafond avant de soupçonner un blocage plateforme.\n- `APIFY_FORCE=1 python run.py sync ` : ignore fraîcheur + caps → passage du bassin ENTIER. Arrêter `pm2 stop crea-ka-sync` pendant (collision quota mémoire Apify → 400 transitoires), le relancer après.\n- Miss définitifs (user_not_found, login_wall, wall_*, http_404…) : tamponnés `_miss` dans account.metrics → rotation hebdo au lieu d'un re-scan quotidien. Un profil ressuscité efface le tampon.\n- X (syndication) : max ~4 requêtes simultanées par run sinon 429 ; ka-x ≥ 0.1.7 retry 429 avec backoff + rotation d'IP. ~60-85 % des vieux handles X sont morts.\n- Facebook : profils personnels = login_wall (miss), mais les PAGES passent très bien (1 402 fiches enrichies).\n- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n","structuredPatch":[],"originalFile":null,"userModified":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"cd2e8155-dca5-4098-94c5-d9508587ae01","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK4bNMfqERmbLJMXqHG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AzcyfEWbNcuz5gGxSY6sHZ","name":"TaskStop","input":{"task_id":"bhiryuk78"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1601,"cache_read_input_tokens":158480,"cache_creation":{"ephemeral_5m_input_tokens":1601,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1d352bd3-d423-48af-8b2b-26bf2efaa95e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"No task found with ID: bhiryuk78","is_error":true,"tool_use_id":"toolu_01AzcyfEWbNcuz5gGxSY6sHZ"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3e4afb83-62fb-4fcc-bd93-bb1332e703fa","timestamp":"2026-08-21T17:46:46.838Z","tool_use_result":"Error: No task found with ID: bhiryuk78"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK4bNMfqERmbLJMXqHG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P4njtpTgHekmDtLXGz1MUG","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/MEMORY.md","old_string":"- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl","new_string":"- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl\n- [Couverture Apify — gotchas & mode forcé](project_apify_couverture.md) — concurrency > max du schéma = 400 silencieux ; APIFY_FORCE=1 ; tampon des miss"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1601,"cache_read_input_tokens":158480,"cache_creation":{"ephemeral_5m_input_tokens":1601,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ab412c1a-9cf9-4a2c-b11d-e8b0de9eedbd"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"File has not been read yet. Read it first before writing to it.","is_error":true,"tool_use_id":"toolu_01P4njtpTgHekmDtLXGz1MUG"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"da287bb0-d89b-4364-8128-bdd892733218","timestamp":"2026-08-21T17:46:50.005Z","tool_use_result":"Error: File has not been read yet. Read it first before writing to it."}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b1d76972-296a-4a70-8c4c-85b0f9faaa57","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK54QJm7pyMBt9XUvjG","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XD4FhtD7hkNSWLwdYwG494","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/MEMORY.md"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":552,"cache_read_input_tokens":160081,"cache_creation":{"ephemeral_5m_input_tokens":552,"ephemeral_1h_input_tokens":0},"output_tokens":14,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3ba8f3e5-3f60-4539-9cd7-aea783796803"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XD4FhtD7hkNSWLwdYwG494","type":"tool_result","content":"1\t# MEMORY.md — index\n2\t\n3\t- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl\n4\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d74567e4-3519-41c8-b9e4-6439af9b4dd0","timestamp":"2026-08-21T17:46:54.715Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/MEMORY.md","content":"# MEMORY.md — index\n\n- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl\n","numLines":4,"startLine":1,"totalLines":4}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"00951e92-ad6c-493d-a5ac-851a02da1d9c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK5QbyxQDHmswfEywGf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CWyegbFMcPZnmxn8pTFQPq","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/MEMORY.md","old_string":"- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl","new_string":"- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl\n- [Couverture Apify — gotchas & mode forcé](project_apify_couverture.md) — concurrency > max du schéma = 400 silencieux ; APIFY_FORCE=1 ; tampon des miss"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":195,"cache_read_input_tokens":160633,"cache_creation":{"ephemeral_5m_input_tokens":195,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"dae6abb5-bfc0-4f1c-a345-6249f8ea1018"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CWyegbFMcPZnmxn8pTFQPq","type":"tool_result","content":"The file /Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/MEMORY.md has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ba406b4a-c488-4081-a609-6aa36514705d","timestamp":"2026-08-21T17:47:02.496Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/MEMORY.md","oldString":"- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl","newString":"- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl\n- [Couverture Apify — gotchas & mode forcé](project_apify_couverture.md) — concurrency > max du schéma = 400 silencieux ; APIFY_FORCE=1 ; tampon des miss","originalFile":"# MEMORY.md — index\n\n- [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl\n","structuredPatch":[{"oldStart":1,"oldLines":3,"newStart":1,"newLines":4,"lines":[" # MEMORY.md — index"," "," - [Campagne moisson seeds 2026-08-20](project_moisson_seeds.md) — workflow + règles qualité pour élargir la base créateurs via qc_creator_seeds.jsonl","+- [Couverture Apify — gotchas & mode forcé](project_apify_couverture.md) — concurrency > max du schéma = 400 silencieux ; APIFY_FORCE=1 ; tampon des miss"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4ed99f7b-ede4-4343-849b-00d03ac15bc5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGK5ysEUUFsHgfbu2ybk","type":"message","role":"assistant","content":[{"type":"text","text":"Tâche terminée. Bilan complet :\n\n## Résultats\n| Métrique | Avant | Après |\n|---|---|---|\n| Créateurs multi-plateformes (le « 6 % ») | **6,8 %** (869) | **19,6 %** (2 502) — ×2,9 |\n| Comptes publics reliés | 14 603 | **18 527** (+3 924) |\n| Complétude moyenne des fiches | 65,4 % | 66,6 % |\n| Fiches avec biographie | 77,2 % | 79,0 % |\n\n## Couverture Apify par plateforme (comptes enrichis)\nYouTube **9 501/9 889** · Facebook **1 407/1 483** · Instagram 2 006/3 275 · TikTok 760/964 · Twitch 569/576 · Kick 155/155 · Fansly 19/19 · Threads 16/18 · Snapchat 23/27 · Discord 189/225 · OnlyFans 165/258 · X 212/834 (le reste = handles morts ou 429, désormais en rotation hebdo).\n\n## Ce qui a été réparé/livré (commits `cac232a` + `0cad882`, poussés sur spbgit)\n1. **Cause racine du blocage Facebook/Threads trouvée** : leurs acteurs plafonnent `concurrency` à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter **tous** leurs runs en 400 depuis des jours. Corrigé (`run_concurrency` par acteur) → Facebook débloqué d'un coup (1 402 fiches).\n2. **Mode `APIFY_FORCE=1`** : ignore fraîcheur + caps → tout le bassin en un passage (utilisé 2×, plus un passage de rattrapage).\n3. **Tampon des « miss » définitifs** (handle mort, mur de login) : rotation hebdo au lieu d'un re-scan quotidien en pure perte — ~2 000 comptes morts identifiés.\n4. **ka-x 0.1.7** : retries 429 avec backoff + rotation d'IP résidentielle, concurrence 4.\n\nLe watch quotidien est relancé, le site répond (healthz 200), 51 tests verts. La jauge multi continuera de monter : chaque passage quotidien moissonne maintenant les liens croisés de tout ce qu'il touche, et les nouveaux comptes rattachés sont enrichis dans la foulée."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":449,"cache_read_input_tokens":160828,"cache_creation":{"ephemeral_5m_input_tokens":449,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8cee3010-29b0-472e-b228-dd4403a75c6a"}} {"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":107996,"duration_api_ms":154783,"num_turns":13,"result":"Tâche terminée. Bilan complet :\n\n## Résultats\n| Métrique | Avant | Après |\n|---|---|---|\n| Créateurs multi-plateformes (le « 6 % ») | **6,8 %** (869) | **19,6 %** (2 502) — ×2,9 |\n| Comptes publics reliés | 14 603 | **18 527** (+3 924) |\n| Complétude moyenne des fiches | 65,4 % | 66,6 % |\n| Fiches avec biographie | 77,2 % | 79,0 % |\n\n## Couverture Apify par plateforme (comptes enrichis)\nYouTube **9 501/9 889** · Facebook **1 407/1 483** · Instagram 2 006/3 275 · TikTok 760/964 · Twitch 569/576 · Kick 155/155 · Fansly 19/19 · Threads 16/18 · Snapchat 23/27 · Discord 189/225 · OnlyFans 165/258 · X 212/834 (le reste = handles morts ou 429, désormais en rotation hebdo).\n\n## Ce qui a été réparé/livré (commits `cac232a` + `0cad882`, poussés sur spbgit)\n1. **Cause racine du blocage Facebook/Threads trouvée** : leurs acteurs plafonnent `concurrency` à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter **tous** leurs runs en 400 depuis des jours. Corrigé (`run_concurrency` par acteur) → Facebook débloqué d'un coup (1 402 fiches).\n2. **Mode `APIFY_FORCE=1`** : ignore fraîcheur + caps → tout le bassin en un passage (utilisé 2×, plus un passage de rattrapage).\n3. **Tampon des « miss » définitifs** (handle mort, mur de login) : rotation hebdo au lieu d'un re-scan quotidien en pure perte — ~2 000 comptes morts identifiés.\n4. **ka-x 0.1.7** : retries 429 avec backoff + rotation d'IP résidentielle, concurrence 4.\n\nLe watch quotidien est relancé, le site répond (healthz 200), 51 tests verts. La jauge multi continuera de monter : chaque passage quotidien moissonne maintenant les liens croisés de tout ce qu'il touche, et les nouveaux comptes rattachés sont enrichis dans la foulée.","stop_reason":"end_turn","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","total_cost_usd":1.7938501499999997,"usage":{"input_tokens":20,"cache_creation_input_tokens":145056,"cache_read_input_tokens":1410463,"output_tokens":5582,"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":145056},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":838,"cache_read_input_tokens":160828,"cache_creation_input_tokens":449,"cache_creation":{"ephemeral_5m_input_tokens":449,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-fable-5":{"inputTokens":32,"outputTokens":8456,"cacheReadInputTokens":2137743,"cacheCreationInputTokens":273491,"webSearchRequests":0,"costUSD":1.7938501499999997,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"855cec2c-05e3-4d8d-8c3b-2012afb10a8f"}} {"type":"status","state":"idle"} {"type":"user_prompt","text":"Peux tu faire un encore une fois un major upgrade de tous les actors apify , pour que ça soit plus\nComplète et encore plis robuste pour avoir encore plus d’information","ts":1787334978310} {"type":"status","state":"running"} {"type":"claude","event":{"type":"system","subtype":"init","cwd":"/Users/simon-pierreboucher/apps/crea-ka","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","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":"72262980-3d68-4913-b129-aa2cff961aca","memory_paths":{"auto":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/"},"fast_mode_state":"off"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5688516d-b546-40bf-a813-966f64e3c521","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKnzCDSdaDWmSXxAuXq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmR0KpAEIERgCKkCtpSepcCsyUywNZkrRMYrFl8h1gSZAHrrfKtWH9MZWW2lnvzy+jWoV6BAecMJ01hsyBymkKUVIp0DWpE/6lZbGMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHcoqLUBhIM6y3RLTifhZpyNE08GgyiIaRhQyE/OXchpJIiMAM5yvLMu9WjW2+VpPCwrfD+4Q24Uv8NmjJtqA3SjNbS1hA7Odp/VYYJrZQphTTquiqhGzdw3DFnyDu5eZQyIzejmiMkUXuvefAXFUdQAEIrNG8X+mKLbRR5fpY7jZy+hGF9QZrCHAnR6k0SkBrwIS7+35nKOOdvLn9hPVLwWRPVRt5j779xakMfsjQ7hMSi/ArUjRWXjJ93SSOUEPqaLiOqENb5lu7mlKWjPi0kqz/nJROhLlO4ue3JVVHezl2ICnf4sX4HzZpzKoq0sYSY8qGjdjrNtvxq0t/kVQko+P0vaP0Mn4dYHAOL70EHl0MPtaXf8mNZzBjRdtEA1N7SXGkpU7D/e7qGjZ9S6/q/TqmdLsI9Z9gc7vGDwgBj87RIEbXQBFMko3bCH7qOHS3OVScBAZTjo2/V5wrh9AvaZbVYMmAdZiex3OeUsV0g8Jl9ok1RQcG8yrI4NNDZb3D0F6JZqUf2VG46WTMZxsdm1IpqbwL18ir4siz4BVmI1ml3p0Ku5WcgdF9i0S9ewHPArEmMOsRbRtMqxRtSdsj085eVJObwr3nMa4IN0npZ2HyJdIli99Hl4FqQ3z/kpO1sMrl39h2999VjidYLMq92W5mbv4GyhuZe+qXIkVFzFBPd9t3pRSjnw9tKd3hn+b8Ot7T7SigFgKZ3c5JBNmF87tqqitvsfcjbZxpTQa+MSSW47+cAhKf9KsPAhXQ8hwHkiB8o8wjjUlzLGmWzKbDz1hTzqcABHjd40LycG/74S1v4zGNurTNiu3bwJEabBuhsV99j09L2qxvzUoeI2yZ71+wwIrdwHPERLNeoNu0Qmgrju4hzTWVcF+4DfYIou3gAztU+m079vPxXHeDTlq1En1vXkt3X24b74Dr1HGS9dVdzIl1V8CYR3kfIpvAnD0zpH1o0s+8Y0MgPA/dV/A+JsGAO8Y5ExPTaliP1cGvYjPzOpS+U8rW/yHVxBa0eUopQCGWGji6dHYcf55sac8JsEt4iYHYW6WoAoCh71Vn/PHP+CMGZUMP5TvgkLvmk9etY0lkSPoYI443V4U4tJdTamd8RPIrROS3UQh3hymk+d2NtINNbTwGYGtfhZ2SUlp8oTUhlH3Ek10p77nANvuMPXRUMmOrLhW9pApXHRum5mGJZkpnanjYlEDuDR7dKfL3KG+/90IRnmRRofMzgdEFfbqvVRn/gMaf6RKnKmDBUxwG/4JKruiCrWwKJR+yPa9/tF4Jzkv2Ys/aMC1Bng9XbLTm/xo+s3BlbcBnbTKv3G4Iq5iawHQCjI3TuGFz1LyVdAwqyXEqa5LHoMV7cWUfIf+VKKeLLGu8axJmNX7vrPl/Cccd7hVV/wFfTz0OJivKvNZVTOoRT0wkql601D5K7IYlmUVy62KKreXpFhyojH2G+hFQ7mnJKIXbNU4EPkqdw6Lu/xMrbWKo08S049iiJibtmqy7rlLCA/KBKDTMTOBrc4ZRFIzkWAmS6SwMu9z/WNWN59ZhSAtG8IftgG3eovtAPv85K/m44hQ3UYep9ILXqbbeHOxSj2xJTK2C0jXRQWNcx4r16gDLzLr34wDtUarP7pH7pfWNT/krDvVyDWjNZ5pBzkvKz8C7wwqTXf5gjEzrmgbuMSsqkbM0cTvepLMVMgxTSOnVZ5XXukSYQ/YE57P0pm5UkhOC0260go8yfpeUeg0GrVjTa3BO5rkAS9PWT3Qhzb2Kl0ILUHu3jhGIG4qN9jJrhn0diY4kUjAjcCyFLyubA0X88TAnsYIQKW27zkCA5x1S2LSiOQbsmhYjQy4YqpmGFmfDlNoCA/qzput2dajAqZTYjhQAQC+7vT1j74Z+SKo3C9qQ6kHr2W3SC71pGqQHdWtGNgi6r2DALNIMLwUVh9qtY5w7wgfG/fbdNNXM7JBJ39hABu56sU8QXkbiR9krtz0Rd55JreC0A0aLZBKt2PyHW9PPxphETD7FiqXGpHlRsMrW03JH0qrujqipdjE2HE7N1gNhZM28+koB6v8lmaFNeb7jnBLvBv+KdXsFA4MG/iz/izc7NfO69c6DxOZnfMMDNPpIKw3Jdgy3ALlHCqIJvegTFaR6f6QWGWE7d6kAhW+e5cOPt910oNIWBrrrNHo8H+PyGKWmfD42RGoQJVfBQU3wVx2x/KtBVMhuiaSahT/3KiV/XCbIEbEOufeeXh0Il4GbEVajrmJ/cUtSpsbTwHAv1dsCG4/rDHtfBSAxqPFzK/477NN8qmskSjaWBIR1K72JGbTyijdaRiWkfuDNWZh85dxSkfht3m6ReX4XLm28mS/LzJh5KHM4xbkU7xeBySRbrFlI7ConaMugKZzgvO+ENa3eArt1gU6zP/QUpLF6Ew+pBxbqdIsS9j1UqGHLS/VFCLqzpdNTuYFmT4+TnXDHIOpzCTuFh7uZGmZ3cYhHfrRAN/EnPwizDHoFj31v2Y0TLqArBfERglqIKqJbpubzl3lAZYsietD8IZjm8xYNMMD39s8cOCbQeJAdAbt1amgDWRj/MP7yWSdANMWHJycrBO074pArgqa6UEW1/wjWD4hDbdVypOVp0MV8ynJgI66RLGADCP8osIh586zinrZdfyhHJnaCUkDwEAb1JCGdsZ60RSayGXc/nvdtizBeUz6FUI5KpLL6Mmo52744dNazZ4stfReQYRviAW6tPKLMwZdvjqLu/v1CCTb5nnMgwo5W+mpT5NY9m81Bqxs+1K6WR0CSYKXc/rrNjlAS/MFlD16gMt+orODHQZBQWkrvnwPKwxpMjSeG2ABmgn+wdjZW3GPglY4OzvSK5uH8Db/vuRzbU8uEfXboBVDspDVxI9Skh/sfctsHERDrwUZ65vFJNpHvx5DJMa7EQDov/MrDvf0UIiLukGJdRikoIgulbZAkhjJALS6Qp2eNXaaVrCpb/Sohtpn3t27X+/r7UoU/yHZ5C9phYXKHyFItg7kO8pjkKgC0NebzJZDv5rUbuJaMYG5Sr4pySE3IF2P8MP8RRnPyfp9aiK/EPC8eWw6h+YElYPpbOYZdKPgvp7u//lafbihgmBVoej0C8vWMB5HUlglUndvjetc8AAZHtyrqtEF/lTb2ZBa9CJ+ayTRnrYUN8PzeB5M6dlI8yiYUCoYFcFXOp9/98pIUJb6hfGNy6kVqGqdlub+WgBmYMQUIpn9Rlm6ZGtdJ3gslc73UT83WxYSAq+436K6llWzR97vnXEBGCJdew54Hy6Hs2uWf25nvJBZuC+EeMqzRsscxlbCgb5TldO5PXGYZNi+EEn2qReqj/SeH0TX0OKUVueZOS9yNEXxaH5f/24h16ebvnaT1GU7I1NNg01ysE40GPAlUJOD8UXJlSo3F91p1Y8g6pgQ2FgDpE47Im7GW61Zg5dQONi8O+0PJ3dd8MG8TbjCp6aFYloL1CxIimSJaft3yv5zDAAc5lyusOM1QYBF7+DSG54QimRYwULVb8lXacmFsNe6XrzPQxQqVcF+JVd+X39xUI0K6NNZQZjsMV+3FyRMcNH66g8dAUq6lWKGvLT6JOboFfrk23gM2voQeoplKOKQD6NU7pBOcMjhn55clXlD0EzKhPE07Vp/wlEa4Q1RHc0SyVqLnEXBysv6LgX2HwqqLZcoAsA0+edIBd7MvbSQndpo5eHU20j3SJ62rNtZs03RG5YI5n9i6zEaYthft7M4zFQwAzVgCphi7icaS8suQ9QCCC4faScbo1Mr3Sbg2/T+VW4Rsp6QXwvRd4WBUnCEbJBK+uVW4xRb0TBsEBsUhfmJkK2KTK5BVC6BLpwZ4btfZZu0t9KyyatSkfbMlGV8LXV2P6Hdk1sjRxGC1kMZ9OhQF5jsJ8RUwI1TabYUzEpEAhNZkObkmBCM3xsKU7hwQZa8/h8T8GAJoCPrEbXGuv46jhifIVyvoxlS2Uxy2Mc18KriM/sxos19P8uWPi5gS7RnZC5YU8udkLX8Ia6VProstswaBcMViBpg4xT7zOagd0DalEYHqZ7zE2Ua0m/nMl7YzENHkFiltBgbWtnSoBPGDU2Zu+BUvi549+/no2gfE5MsTNGVPunIJNrXupPBH1RhkuCUjNxZgKJ19CZYJ0Vv+aSxBgW+0yZFX+mBaUBYlrBj0PlGg/LBBeNivofNv2j4zEInzGkG8UePrKSN53Rg+yatc2OTge1T/qVljIVgs0K4XiI/TckqUWOhBbeWrXl+KavU5+Rw5gctSAq7NcQpRWG9y513IpVbzoSIvtvAzMNlcvxu/qBAn+p3y9jbuCJ8q49+gdBMlNyRX8EDmnC7jgaJli3EKRJqI0sgyCkzvyKDnNjE8oztQkx7rzBc2CICfT7/LTO6N005go12PwUyYsPwhHNWBdNk50NmzVai7rLvzvJuiKsPgBh2VAK4eRtIWHHQRH6DjLneBLa3qmd9zMWsFw1duVMm4OABOFiNr5eEkGrBWgJUNrIXm1Qe7ScP2sGmr7K4scwVm4iZl3C4qexUSE98PygVDB7wm1Ww1+ozxt+FNEJ5qUKkHBbw4zMoUb4UIFohJQ06O0S+L+UvvuiMbVrGkIQW59/2hrV51VB2KHMgpZi9Ui/pN+eqF9tS5hG/bO7//dZOU3/a/9CP3VSBlAFBuomAb/IsKTucXaWpBfiCQm03QkgZDJBu9tY0VgjNWgEayTEd9YNUUYKtQelTFP8xLLfxLzW/uiU35alECLQhoD47kaypT5HE9wbyo7zLUUrk0vqBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":140216,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":140216,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c591db89-f53c-4cba-a789-d77e38bb0177"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKnzCDSdaDWmSXxAuXq","type":"message","role":"assistant","content":[{"type":"text","text":"Gros chantier — un « round 2 » d'upgrade majeur des 13 acteurs. Je commence par inventorier ce que chaque acteur extrait aujourd'hui et l'état de leur couche réseau, pour cibler les ajouts (données + robustesse)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":140216,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":140216,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d56ca7ef-4500-46bf-91ba-1d29f705f56e"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKnzCDSdaDWmSXxAuXq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01FFFiewrBuMbrfEqUyNJ3JN","name":"Bash","input":{"command":"cd ~/apps/crea-ka/actors && for a in ka-*/; do echo \"== $a $(wc -l < $a/src/main.py 2>/dev/null) lignes main, net.py: $(md5 -q $a/src/net.py 2>/dev/null | cut -c1-8)\"; done; cat ka-x/src/net.py","description":"Survey actor sizes and net.py variants"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":140216,"cache_read_input_tokens":16221,"cache_creation":{"ephemeral_5m_input_tokens":140216,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a440c475-d029-4e2e-994e-e603d86a0a40"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FFFiewrBuMbrfEqUyNJ3JN","type":"tool_result","content":"== ka-discord/ 87 lignes main, net.py: 53da597c\n== ka-facebook/ 121 lignes main, net.py: 53da597c\n== ka-fansly/ 93 lignes main, net.py: 53da597c\n== ka-instagram/ 243 lignes main, net.py: 53da597c\n== ka-kick/ 103 lignes main, net.py: 53da597c\n== ka-onlyfans/ 112 lignes main, net.py: 53da597c\n== ka-patreon/ 114 lignes main, net.py: 53da597c\n== ka-snapchat/ 121 lignes main, net.py: 53da597c\n== ka-threads/ 99 lignes main, net.py: 53da597c\n== ka-tiktok/ 228 lignes main, net.py: 53da597c\n== ka-twitch/ 158 lignes main, net.py: 53da597c\n== ka-x/ 223 lignes main, net.py: 53da597c\n== ka-youtube/ 303 lignes main, net.py: 53da597c\n# ==============================================================================\n# Author: Simon-Pierre Boucher \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}\")","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fbc31a26-869f-4629-988d-babc9e8acbbf","timestamp":"2026-08-21T17:56:47.361Z","tool_use_result":{"stdout":"== ka-discord/ 87 lignes main, net.py: 53da597c\n== ka-facebook/ 121 lignes main, net.py: 53da597c\n== ka-fansly/ 93 lignes main, net.py: 53da597c\n== ka-instagram/ 243 lignes main, net.py: 53da597c\n== ka-kick/ 103 lignes main, net.py: 53da597c\n== ka-onlyfans/ 112 lignes main, net.py: 53da597c\n== ka-patreon/ 114 lignes main, net.py: 53da597c\n== ka-snapchat/ 121 lignes main, net.py: 53da597c\n== ka-threads/ 99 lignes main, net.py: 53da597c\n== ka-tiktok/ 228 lignes main, net.py: 53da597c\n== ka-twitch/ 158 lignes main, net.py: 53da597c\n== ka-x/ 223 lignes main, net.py: 53da597c\n== ka-youtube/ 303 lignes main, net.py: 53da597c\n# ==============================================================================\n# Author: Simon-Pierre Boucher \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}\")","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"86ff5869-d46a-48e7-99c2-c63e958a8d46","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKq6NqwLacjN9Eh6huV","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIShwwKpAEIERgCKkCzU9f0gRO4/jvir14tgZC75nzSodf/rR3/IvNXEY0Oqz49P8X30JxLuZKEx3ISsZFZotfTUX81AGZ0yz+ELTS7Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHroqLUBhIMa4zPbPuvk7fkoK9eGgx5KN7yEQf3V/kzv+giMCJYfrvFf2VlE+zNyNgqNsOR0fGOPhrN+0JMhTMi1rWIHyJxr155X6mDFbUwn1OiaSqPCgs3b7FWaIRlnUjHoZhY+JaUIdqhnfUMMK/WoGdrfF7GEW6poKirnQGNC6sc5D82KTevz/xt3IVkn8Yc88cxXweJd+q2lFFTfZNrqEGOjkH3135QFtvilUZzztbFO/YliwNnfDGkrJSNJyvv7+bkiOM7ENrRITbo0wEiaE+nZn5QQ5LUrNtAgC6LuNg7iwW9oqkby3/Pdj0zXolXjBNIVneRkJCSGnX2o9zzE2Qkz1NmL+1ZA1ym8LVymNwCoUJclPT+VeMVzAWSbHLeMm8VaH38lZ2o60nnvH+MOte3FgIM6nnSSCAYuBCiwaRkPUiQK7I/UdQ7A187MZPrDB/pojfgHR0lW5q/4TcGt36YaNJNEIkI/YrxlR0abDPlh4FT0FIkz9sqfbK62Mse7pbb1qODENzudCMltlMN5x6sm/5GpanIcaxo9dgD3EyvkOy77WIDp7/De89ZVrhkKRLThol+Ed87IHNtUM4B5b2f3xzs5ZLHKmDRwSgvB/JGKIwj5J1c5bmttegWEJdXYATGMFmBEOSTgmljT2jVOrBOd6f2bU8/RkXH1Yl15jexaD6B1119/Uc0ozyReKBaKiKnCRxv3cQ13td9ubhrAU+a2IeJT/5VA0c7LqLrXkdoqKGuIOZ9F6s8rtAByLCz8vUB+/dMi/2DQbpycWNV92Bs4efAO7mTd9bumxkaETLiQE/LeLYjCqcnLJLE1V+fE5xMjIekoC7cf4zDpWldjF9UfqWZj3lsAbHHtp+aTXn9FNWWOrqpcwDYLZURREt06qXXPxcJbx8G47I5xU2mqwA1oZBiAzBBWRN4GSZ60m6hMjs2P/SgO0BkUOJLV9xWYqRCA39VHnbyE3jeAxT353CU1D7BnxBE/tEayVLTtsxmF9+617XSKKpK1iQrDVnxiC47jsrRqekFJxjGbwr8HMZewrcKtXW9kbH/pqmNbe4meErJnz0OSIbvr5CGOIrwAtwbmk7zITcU55dZW6RnpiIQ/F9SG/dTSMNkQnhFQnEeGwYIyBbFf6noawmR9pEZdxkw1D803P27tajQM3ML5lFkRrnuoeKyntGbIJiwlVZYCeoxAVSXgflraKrlD9r5sE7VWQHAlcz7F3+cnUNl1PNsBxggbmOOYy+iPVJr+1VgIfFLqgiL0bjrITmRo7kArX9xpSIgxO/i3x0g+XN29OA75fZXLwzhSnbBC1cZXeKRCjY0o87MkBbgcVy6NoBvTZMgk4OoMSCivi9IxhM2LA0wkdhIQc6sW69zxrp6CaWt0GKqOKF528jtFFCwQsPkwcT3oAjjjr8cxduu8n8ujyHlhZPJMLO1mBoIqfqNMbaFZOatPc+McJWthkoPn7dc9bUxlMW7ddUNQEmCVi+dC993lAHB7tYf4J78MfSsEQ43bZMrInQEQEln0EFcAqbh7q5Y/HXQWXglpJ0WwE3gIAk0KzHJ6gtVATo9YtrWqRlHQeeB+5jJneHW+EKf94aqWyYCwcyKQO63cTxcrytK61he+Q7cFCJZsV1gGHqH8qAgi3Ox5BSgNVnXQqLueYhK7zumtda+YuiVBfH5X5awjYwgaQvAiINEPENqQhwpzWALGAGARtQ8PoeUo8wOtx0EUc5Qx5MxuWJP9L92zBjKBWohs/mTVS7TlA5L4ZIX6ddEDSfoaE5vQY4uxHVh6rNEDgrAuPZP1CXHa5CBVyq1WpQ9+aXy1m5U2ki+uFHOKCUVVCr7GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2988,"cache_read_input_tokens":156437,"cache_creation":{"ephemeral_5m_input_tokens":2988,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"778d936f-182d-4202-9cd5-29e3bc4271cf"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKq6NqwLacjN9Eh6huV","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS5QMKpQEIERgCKkC3EkRQihwS55WBPa3F6VNdbrQWt9W/l2pirBK/9Ni8RCqjJw5dBSz2M+nUbpwXmCv5n02OrBeLNzLUHgMG0szZMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagB66Ki1AYSDIrpby8QbjUOBu4GcBoMtzDT6NevtQI+MAzgIjAMskYxxFcTkj3MebDY7ZPjIuIepcEweTTd3oNO0w7I05HaPUCPi5wFWe8FfzZdPKUq7AEnPERcFzIoxJ5aMLvmZf7n3oIhG9lIYO4YeKDAoOrH/f7hspQzwO0XgvDCNJdOwefwcvQATFQrCz9TkahxjPj9c/hRkm8xI1uSBJ0XX2lUaOgJn/Q4KYH43I56bRB05ZEIEF09vt0kVi2QoPlwkPWebOzSN0L1RPFnMzcuJHhtW2eUPtBfyGUCXAj53We0Rfkgptq5VKc2TCrB9W9Un+r3qIaid17jjKk/g/nSHkp+K9oB3gBWKmcpSPaxkD9CNHEfl5LFgLJixXSnK9p5hdX4u5PjKDkznXVi2lui5lTXEGpqtn5J52CsBnefQxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2988,"cache_read_input_tokens":156437,"cache_creation":{"ephemeral_5m_input_tokens":2988,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5b24361e-cad1-4a24-9dd8-ac9d4f9df768"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKq6NqwLacjN9Eh6huV","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0174bYU7SwQEyGxKeyKdfmZP","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":2988,"cache_read_input_tokens":156437,"cache_creation":{"ephemeral_5m_input_tokens":2988,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"294ee8ee-a422-48c1-b393-0d736ac34ae3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0174bYU7SwQEyGxKeyKdfmZP","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"17837eae-d230-4f58-920f-82eb22720a66","timestamp":"2026-08-21T17:56:59.935Z","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 \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}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d0b71402-d1aa-49db-bcb5-ac5076b1326f","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKr2aB8PzrjzzSRnuMq","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrRIKpAEIERgCKkBGQ1Qajds6/IxwykA/0QlOakUxjgZim4KSMsBeUZhcX2S1cRBikHxxPZdmtE8QHgfZgOp1pLBm9ffsuBcqob+tMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH9oqLUBhIMQN+PmUooOBkU68cmGgz1CyAOun0vsNPlcZwiMNJsx7E4zVmvaXipAHnRMAInWE8xHfI3qXHyzy2xRk8Jao+08XnvGBV2H4chFu8yICq1EPAs81ZjNMSchvwrxdnLraEbtqD3LS6XtHULNNmC1W8dg5SC4CwX8ua4X+ayZRILcg1N8iue9vJKlYY8WwTOj3McKGViy0Kdy5n6kd8VAvq1x2wrumMs9leKtpDeE72DtQ+b1lbcwsxY4zeo7ypGpoKqtMcObBqf4qGxant6ucSsBgclABYXbbqj9G2LDsW8D/Y4eBMje0/AjeHsnfXu1zutfwM5xn5ZbMWKul0X0FcXOtfHZ7+aEEAoMw3IvwViDtJN0rx+ahTy5n8kKz20KRka9P35XXWTuSBXEkCf9LbBHYDW85J5lFkm8hdM1s5BK2uDN33u5+iLvkrB1Y19fNk+d+X0eEMNa/0ECu/XGloURkPfi+7XDgs5iJHDaWE7ODe6T49cPEtV9Ay502RYuNgn9T70SXqz48xOEsSRPJlxd3UAqyaUtInLvGavgxSWiL2Bg3l1TjMtawGgQCu3AKhEVnmWE4GRsDESiULDGDXDCX7kCLhvv9eo9KqBMq8D6EHkfpbBh2vY7++8TipcQ9lAtXVWtQcq5B0G/FdKW066rg/b6hafr38lsIu57njc65POr9oO9lZn3NxyPa9ZEKINs1nUNSbL618LWkvgSfqgVg9bW+2PPzkXKVWG30jm/bxlRcRkLnhYueXjrVM4uJroAAC71g8XBM0JLxVAdx2WTpoUxtSuIids0JNNXym6LupEMfv1DV+X4OUOGx0xzd/2c2ekzUO4818QKiG1M58XxxjmNiInfWAjghK70edgVNsUZwZNaD2p+EmvLjJ/1F/jU1IJxoDVVO0dIK9eFvaxC165L5+MClYm1wjUVOR+o15ycFCcVw3/Jy6+xFfMGInZF/jQ5GKQ1TDqfU2M0MTAiK6SBMlhM0cfQjqx5KQiqg6gErAQOMQTyERFOFS9MZJ4aWtTpJbBpyrGSPNA+YTSP2ALWA6kSydyVI+PWjYbz8slPkoqc45SmoPqt5t2BQUHyXOojskF4a5tHgPrKnmSNY3E8IpvmJG9PVcyo8VhHK2lWYokZks7+qbtMn1905fTaAumrqp0BzCMa0GcNN2o9Ms1JSxZUEWNJhywXR+7EOaBCi4Ttc4TFsp4Ps9t+fmU/RKozBmgs9H+k0LvBOjNOvPp7BlYSFvz+3B6Xj8aQdA+F6OMBkwqyykEQLyxqMPIVNW+C8Hf9Llwaca8eO1plf1CLfU9WUkZ/T8zf8ia7QSxUHELuwP8HnXv8OwZq0kRsOiNxnzNiIXK6fbQZOAeVQcRQ093voIf0ptjbT0RZOBL5wobKpb9Tup1zA4QGnz+k4+lS71jZn8MLqCXU7v5Y0vja6f0SrhlSVpEZk0E8gqp/SmneGVRCY7EBsJpqhDKbR53p08SlQZbnOfvF6aD6nVjiZQsNkIyMwzi3iXrbWnpVkQK8yz5Li3OO7irW0vfVXc3ZvqT4SZR0uwBDc88VUKvhsKxereiYrUxvkiQOSy9jX1AG2dSzIi/YroxfIzUpHKRBz8xksWQs/CmQXYbAvhIde2X/d4mtw2mUYMbHSGPmPb8Ol3fXO+7d7AHlBk+xU5oc64DBKWiv6hXxVuwK9Wba60JrMp96yZIk+oJkg5992BL1oyYaIdMJQKTg4rVGbhkO718zJSDLQ9jvszvlqZjDqPVhd74IXCvwhIypFQvGNwYUDTJXsn52LzooySCDA4LH98+ynGK/0jJbbXtQNbhNo9WG8x0InX8/ofswMynSPI5Nwx3+kFPjyfZfYe/6aDA0hdKH9ZfEznyqF9Fx3O8S/CucMaWisEa2OBJlnNMlpQYigrw7Vvusr56uPElQ5AoAvRya9fHATXY9vttgnDfDpIr/kWB/chKTJ5ixIE709cRkphlvk0KLJ9e9REpQIn0J6yIdaG/SUqBkwuJKSgxCiilBz+lVWYVuW8WedVL64T1t9FPV0l8Hkz+estZwNQcTa03MUQYxYfE2pskiyD3V2MxzCXzzLzurtJbgQIMBA5qXKRzd5wXRMGQcF7LMJO1vPKlUMe/ik7Zc4WrrCPkgWBHOXqpxMNXhduWg95aBOumMKs6u3036hPmP8a2z0STq4aptmo2iCPnh9efDJMPmYrHnztdjVfNdCqiwvnujIK6A2W6z8q3acok3PHAuzbrwovKssrJQTq5gy2x7wnUC6xcxBVr1WytHYwS8GmJhwaN9dx/QAxUjr5FQBUQz8ag3YRuUxUHfbD0VgB6Rv9JUe8OToOT3jKTWg08SpMuYj03+zlFgHvgFuBI6Nbw2whswNYx0CHbNRFf9QEkF7W7I8taFIzqzErver3XVWpJWND/Mo9BcBYqZKftHIOzVLx3krwphM3oOK5vX3L5m8pOoOiQF6vYf/76+swXB8FTL4Ss3fRcs4VwkHddU/NQvpOfwCOhHZX1rWt71IYt8/jG4uQpkRNQOOqbnlTe4WtSHx+fTKlSKd6eVgdvTxYCxkpHdQoB/BDKhDb74tN6mE392bFyrJgqlRJmKSCg3phfOIh4G8nuqC6WpwlcmxP+kAfQK81G4XPbZBeEgUirv6VBzik+3CCB0v8CfocKywLCaLFMtJd01AC8WV+68q5MzUbkmTNPLVYZWEJ2rlZbMiZH6IGy0C4eG6QKMAKG3zcy00sN66gG2wPEWk9eF5NfQFT/hhyqjFOo8pvUuC0RlI/chskDEBNOKS+MqPmkNseBcuj7Vm3iSI7gdDdKpNuy3QUSFwFiWUjpjJAH3+WdLNOyYNyGXt5Wx+eILiK6YUak7G7aXHJ+LfuuFyf3777kG3MbpJm6sLV9MtsewwTB7eVgqH4YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":5179,"cache_read_input_tokens":159425,"cache_creation":{"ephemeral_5m_input_tokens":5179,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"34ff07fe-62da-4621-9f10-a7f806f406bb"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKr2aB8PzrjzzSRnuMq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LDWN3gvHWYrYdpPD1f24M5","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":5179,"cache_read_input_tokens":159425,"cache_creation":{"ephemeral_5m_input_tokens":5179,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"503468f0-dbd6-4765-804d-99bf1d53e4a9"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LDWN3gvHWYrYdpPD1f24M5","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \n3\t# File: src/main.py (ka-tiktok)\n4\t# Desc: Profils TikTok publics ULTRA DÉTAILLÉS via la page @handle : le JSON\n5\t# SSR __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes\n6\t# (abonnés, cœurs, vidéos) ; vidéos récentes avec COVERS (miniatures),\n7\t# durée, hashtags, musique, épinglés + cadence (videos_per_week) et\n8\t# top hashtags quand TikTok les inclut dans le SSR (ItemModule/itemList).\n9\t# ==============================================================================\n10\tfrom __future__ import annotations\n11\t\n12\timport asyncio\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.tiktok.com/@{u}\"\n21\t_STATE_RE = re.compile(\n22\t r'',\n23\t re.S)\n24\t_ITEM_RE = re.compile(r'\"ItemModule\"\\s*:\\s*(\\{.*?\\})\\s*,\\s*\"[A-Z]', re.S)\n25\t\n26\t\n27\tdef parse_videos(html: str, data: dict) -> list[dict]:\n28\t \"\"\"Vidéos récentes si le SSR les inclut (souvent absent — best effort).\"\"\"\n29\t items: list[dict] = []\n30\t raw: dict = {}\n31\t scope = data.get(\"__DEFAULT_SCOPE__\") or {}\n32\t detail = scope.get(\"webapp.user-detail\") or {}\n33\t for it in (detail.get(\"itemList\") or []):\n34\t raw[it.get(\"id\", str(len(raw)))] = it\n35\t if not raw:\n36\t m = _ITEM_RE.search(html)\n37\t if m:\n38\t try:\n39\t raw = json.loads(m.group(1))\n40\t except Exception:\n41\t raw = {}\n42\t for vid in list(raw.values())[:20]:\n43\t stats = vid.get(\"stats\") or vid.get(\"statsV2\") or {}\n44\t author = vid.get(\"author\")\n45\t handle = author if isinstance(author, str) else \\\n46\t (author or {}).get(\"uniqueId\", \"\")\n47\t video = vid.get(\"video\") or {}\n48\t music = vid.get(\"music\") or {}\n49\t items.append({\n50\t \"id\": vid.get(\"id\"),\n51\t \"url\": f\"https://www.tiktok.com/@{handle}/video/{vid.get('id')}\",\n52\t \"caption\": (vid.get(\"desc\") or \"\")[:500],\n53\t \"views\": _num(stats.get(\"playCount\")),\n54\t \"likes\": _num(stats.get(\"diggCount\")),\n55\t \"comments\": _num(stats.get(\"commentCount\")),\n56\t \"shares\": _num(stats.get(\"shareCount\")),\n57\t \"saves\": _num(stats.get(\"collectCount\")),\n58\t \"timestamp\": vid.get(\"createTime\"),\n59\t \"cover\": (video.get(\"cover\") or video.get(\"dynamicCover\")\n60\t or video.get(\"originCover\")),\n61\t \"duration_s\": _num(video.get(\"duration\")),\n62\t \"width\": _num(video.get(\"width\")),\n63\t \"height\": _num(video.get(\"height\")),\n64\t \"is_pinned\": bool(vid.get(\"isPinnedItem\")),\n65\t \"hashtags\": [c.get(\"hashtagName\")\n66\t for c in (vid.get(\"textExtra\") or [])\n67\t if c.get(\"hashtagName\")][:8],\n68\t \"music\": ({\"title\": music.get(\"title\"),\n69\t \"author\": music.get(\"authorName\"),\n70\t \"original\": music.get(\"original\")}\n71\t if music.get(\"title\") else None),\n72\t })\n73\t return items\n74\t\n75\t\n76\tdef _num(v):\n77\t try:\n78\t return int(v)\n79\t except (TypeError, ValueError):\n80\t return None\n81\t\n82\t\n83\tasync def main() -> None:\n84\t async with Actor:\n85\t inp = await Actor.get_input() or {}\n86\t usernames = [u.strip().lstrip(\"@\").lower()\n87\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n88\t proxy = await Actor.create_proxy_configuration(\n89\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n90\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n91\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n92\t\n93\t async def one(u: str) -> None:\n94\t async with sem:\n95\t miss = {\"kind\": \"profile\", \"platform\": \"tiktok\",\n96\t \"username\": u, \"found\": False}\n97\t resp = None\n98\t m = None\n99\t for attempt in range(3): # coquille sans SSR → nouvelle IP\n100\t try:\n101\t resp = await fetcher.get(PROFILE_URL.format(u=u),\n102\t session_id=f\"{u}t{attempt}\")\n103\t except Exception as exc:\n104\t await Actor.push_data({**miss,\n105\t \"error\": str(exc)[:200]})\n106\t return\n107\t if resp.status_code == 404:\n108\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n109\t return\n110\t m = _STATE_RE.search(resp.text or \"\")\n111\t if m and \"webapp.user-detail\" in (resp.text or \"\"):\n112\t break\n113\t info = None\n114\t data: dict = {}\n115\t if m:\n116\t try:\n117\t data = json.loads(m.group(1))\n118\t info = ((data.get(\"__DEFAULT_SCOPE__\") or {})\n119\t .get(\"webapp.user-detail\") or {}).get(\"userInfo\")\n120\t except Exception:\n121\t info = None\n122\t if not info or not (info.get(\"user\") or {}).get(\"uniqueId\"):\n123\t await Actor.push_data(\n124\t {**miss, \"error\": f\"shell_page_{resp.status_code}\"})\n125\t return\n126\t user = info.get(\"user\") or {}\n127\t stats = info.get(\"statsV2\") or info.get(\"stats\") or {}\n128\t if user.get(\"privateAccount\"):\n129\t await Actor.push_data({**miss, \"error\": \"private\"})\n130\t return\n131\t followers = _num(stats.get(\"followerCount\"))\n132\t videos = parse_videos(resp.text, data)\n133\t views = [v[\"views\"] for v in videos\n134\t if isinstance(v[\"views\"], int)]\n135\t likes = [v[\"likes\"] for v in videos\n136\t if isinstance(v[\"likes\"], int)]\n137\t comments = [v[\"comments\"] for v in videos\n138\t if isinstance(v[\"comments\"], int)]\n139\t shares = [v[\"shares\"] for v in videos\n140\t if isinstance(v[\"shares\"], int)]\n141\t avg_views = round(sum(views) / len(views)) if views else None\n142\t avg_likes = round(sum(likes) / len(likes)) if likes else None\n143\t engagement = None\n144\t if followers and avg_likes is not None:\n145\t engagement = round(\n146\t (avg_likes\n147\t + (round(sum(comments) / len(comments))\n148\t if comments else 0)\n149\t + (round(sum(shares) / len(shares))\n150\t if shares else 0)) / followers * 100, 3)\n151\t top_video = max(\n152\t (v for v in videos if isinstance(v[\"views\"], int)),\n153\t key=lambda v: v[\"views\"], default=None)\n154\t stamps = sorted(_num(v[\"timestamp\"]) for v in videos\n155\t if _num(v[\"timestamp\"]))\n156\t videos_per_week = None\n157\t if len(stamps) >= 3 and stamps[-1] > stamps[0]:\n158\t videos_per_week = round(\n159\t (len(stamps) - 1)\n160\t / ((stamps[-1] - stamps[0]) / 604_800), 2)\n161\t last_video_at = None\n162\t if stamps:\n163\t from datetime import datetime, timezone\n164\t last_video_at = datetime.fromtimestamp(\n165\t stamps[-1], tz=timezone.utc) \\\n166\t .strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n167\t tag_counts: dict[str, int] = {}\n168\t for v in videos:\n169\t for h in v.get(\"hashtags\") or []:\n170\t tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1\n171\t commerce = user.get(\"commerceUserInfo\") or {}\n172\t bio_link = ((user.get(\"bioLink\") or {}).get(\"link\")\n173\t or \"\").strip()\n174\t total_likes = _num(stats.get(\"heartCount\")\n175\t or stats.get(\"heart\"))\n176\t videos_count = _num(stats.get(\"videoCount\"))\n177\t await Actor.push_data({\n178\t \"kind\": \"profile\",\n179\t \"platform\": \"tiktok\",\n180\t \"found\": True,\n181\t \"id\": user.get(\"id\"),\n182\t \"sec_uid\": user.get(\"secUid\"),\n183\t \"username\": user.get(\"uniqueId\"),\n184\t \"full_name\": user.get(\"nickname\"),\n185\t \"biography\": user.get(\"signature\"),\n186\t \"bio_link\": bio_link,\n187\t \"followers\": followers,\n188\t \"following\": _num(stats.get(\"followingCount\")),\n189\t \"friends\": _num(stats.get(\"friendCount\")),\n190\t \"total_likes\": total_likes,\n191\t \"videos_count\": videos_count,\n192\t \"avg_likes_per_video_lifetime\": (\n193\t round(total_likes / videos_count)\n194\t if total_likes and videos_count else None),\n195\t \"is_verified\": bool(user.get(\"verified\")),\n196\t \"is_organization\": bool(user.get(\"isOrganization\")),\n197\t \"is_seller\": bool(user.get(\"ttSeller\")),\n198\t \"is_live_now\": bool(user.get(\"roomId\")),\n199\t \"commerce_category\": (commerce.get(\"category\") or None\n200\t if commerce.get(\"commerceUser\")\n201\t else None),\n202\t \"account_created_at\": user.get(\"createTime\"),\n203\t \"region\": user.get(\"region\"),\n204\t \"language\": user.get(\"language\"),\n205\t \"avatar\": user.get(\"avatarLarger\")\n206\t or user.get(\"avatarMedium\"),\n207\t # agrégats sur les vidéos présentes dans le SSR\n208\t \"avg_views\": avg_views,\n209\t \"avg_likes\": avg_likes,\n210\t \"avg_comments\": (round(sum(comments) / len(comments))\n211\t if comments else None),\n212\t \"avg_shares\": (round(sum(shares) / len(shares))\n213\t if shares else None),\n214\t \"engagement_rate_pct\": engagement,\n215\t \"videos_per_week\": videos_per_week,\n216\t \"last_video_at\": last_video_at,\n217\t \"top_hashtags\": sorted(tag_counts, key=tag_counts.get,\n218\t reverse=True)[:8],\n219\t \"top_video\": ({\"url\": top_video[\"url\"],\n220\t \"views\": top_video[\"views\"],\n221\t \"likes\": top_video[\"likes\"],\n222\t \"cover\": top_video.get(\"cover\")}\n223\t if top_video else None),\n224\t \"recent_videos\": videos,\n225\t })\n226\t\n227\t await asyncio.gather(*[one(u) for u in usernames])\n228\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n229\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"aa102c2c-cd1f-49bd-80a2-4debc11184ff","timestamp":"2026-08-21T17:57:18.642Z","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 \n# File: src/main.py (ka-tiktok)\n# 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).\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'',\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","numLines":229,"startLine":1,"totalLines":229}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKr2aB8PzrjzzSRnuMq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WezhQdJVez8C7Prx16xk1R","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":5179,"cache_read_input_tokens":159425,"cache_creation":{"ephemeral_5m_input_tokens":5179,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"67c99881-a7bf-4c42-8b74-f13c2f39e383"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WezhQdJVez8C7Prx16xk1R","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \n3\t# File: src/main.py (ka-youtube)\n4\t# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData\n5\t# de la page /videos : abonnés, nb de vidéos, description, avatar,\n6\t# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,\n7\t# date relative) et agrégats — plus un 2e passage /about (vues totales de\n8\t# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).\n9\t# Accepte @handle, handle nu, ou id de chaîne (UC…).\n10\t# ==============================================================================\n11\tfrom __future__ import annotations\n12\t\n13\timport asyncio\n14\timport json\n15\timport re\n16\timport urllib.parse\n17\t\n18\tfrom apify import Actor\n19\t\n20\tfrom .net import Fetcher\n21\t\n22\t_INITIAL_RE = re.compile(r\"var ytInitialData\\s*=\\s*(\\{.*?\\});\", re.S)\n23\t_COUNT_RE = re.compile(r\"([\\d][\\d\\s  .,]*)\\s*([KkMB]?)\")\n24\t\n25\t\n26\tdef _url_for(handle: str) -> str:\n27\t h = urllib.parse.unquote(handle).strip()\n28\t if h.startswith(\"UC\") and len(h) == 24:\n29\t return f\"https://www.youtube.com/channel/{h}/videos\"\n30\t if not h.startswith(\"@\"):\n31\t h = \"@\" + h\n32\t return f\"https://www.youtube.com/{urllib.parse.quote(h)}/videos\"\n33\t\n34\t\n35\tdef parse_text_count(text: str) -> int | None:\n36\t \"\"\"« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier.\"\"\"\n37\t if not text:\n38\t return None\n39\t m = _COUNT_RE.search(str(text))\n40\t if not m:\n41\t return None\n42\t num = re.sub(r\"[\\s  ]\", \"\", m.group(1))\n43\t unit = (m.group(2) or \"\").upper()\n44\t if unit:\n45\t num = num.replace(\",\", \".\")\n46\t if num.count(\".\") > 1:\n47\t num = num.replace(\".\", \"\", num.count(\".\") - 1)\n48\t try:\n49\t val = float(num)\n50\t except ValueError:\n51\t return None\n52\t return int(val * {\"K\": 1e3, \"M\": 1e6, \"B\": 1e9}[unit])\n53\t num = re.sub(r\"[.,]\", \"\", num)\n54\t return int(num) if num.isdigit() else None\n55\t\n56\t\n57\tdef _walk(node, key: str):\n58\t \"\"\"Premier objet portant `key` dans l'arbre ytInitialData.\"\"\"\n59\t if isinstance(node, dict):\n60\t if key in node:\n61\t return node[key]\n62\t for v in node.values():\n63\t hit = _walk(v, key)\n64\t if hit is not None:\n65\t return hit\n66\t elif isinstance(node, list):\n67\t for v in node:\n68\t hit = _walk(v, key)\n69\t if hit is not None:\n70\t return hit\n71\t return None\n72\t\n73\t\n74\tdef _texts(node) -> list[str]:\n75\t \"\"\"Toutes les chaînes `content`/`simpleText`/`text` sous un nœud.\"\"\"\n76\t out: list[str] = []\n77\t\n78\t def rec(n):\n79\t if isinstance(n, dict):\n80\t for k in (\"content\", \"simpleText\", \"text\"):\n81\t if isinstance(n.get(k), str):\n82\t out.append(n[k])\n83\t for v in n.values():\n84\t rec(v)\n85\t elif isinstance(n, list):\n86\t for v in n:\n87\t rec(v)\n88\t rec(node)\n89\t return out\n90\t\n91\t\n92\tdef _lockup_video(lv: dict) -> dict | None:\n93\t \"\"\"Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé.\"\"\"\n94\t if lv.get(\"contentType\") != \"LOCKUP_CONTENT_TYPE_VIDEO\":\n95\t return None\n96\t vid = lv.get(\"contentId\")\n97\t if not vid:\n98\t return None\n99\t md = lv.get(\"metadata\") or {}\n100\t title = ((md.get(\"lockupMetadataViewModel\") or {}).get(\"title\") or {}) \\\n101\t .get(\"content\")\n102\t strings = _texts(md)\n103\t views = next((parse_text_count(s) for s in strings\n104\t if \"view\" in s.lower() or \"vue\" in s.lower()), None)\n105\t published = next((s for s in strings\n106\t if \"ago\" in s.lower() or \"il y a\" in s.lower()), None)\n107\t overlays = _texts(lv.get(\"contentImage\") or {})\n108\t duration = next((s for s in overlays\n109\t if re.fullmatch(r\"\\d?\\d:\\d\\d(:\\d\\d)?\", s.strip())), None)\n110\t return {\n111\t \"id\": vid,\n112\t \"url\": f\"https://www.youtube.com/watch?v={vid}\",\n113\t \"title\": (title or \"\")[:200],\n114\t \"views\": views,\n115\t \"published\": published,\n116\t \"duration\": duration,\n117\t \"thumbnail\": f\"https://i.ytimg.com/vi/{vid}/hqdefault.jpg\",\n118\t }\n119\t\n120\t\n121\tdef _collect_videos(data: dict, cap: int = 15) -> list[dict]:\n122\t out: list[dict] = []\n123\t\n124\t def rec(node):\n125\t if len(out) >= cap:\n126\t return\n127\t if isinstance(node, dict):\n128\t lv = node.get(\"lockupViewModel\")\n129\t if isinstance(lv, dict):\n130\t v = _lockup_video(lv)\n131\t if v:\n132\t out.append(v)\n133\t return\n134\t vr = node.get(\"videoRenderer\") or node.get(\"gridVideoRenderer\")\n135\t if vr and vr.get(\"videoId\"):\n136\t out.append({\n137\t \"id\": vr[\"videoId\"],\n138\t \"url\": f\"https://www.youtube.com/watch?v={vr['videoId']}\",\n139\t \"title\": \"\".join(\n140\t r.get(\"text\", \"\") for r in\n141\t (vr.get(\"title\") or {}).get(\"runs\") or [])[:200],\n142\t \"views\": parse_text_count(\n143\t (vr.get(\"viewCountText\") or {}).get(\"simpleText\")),\n144\t \"published\": ((vr.get(\"publishedTimeText\") or {})\n145\t .get(\"simpleText\")),\n146\t \"duration\": ((vr.get(\"lengthText\") or {})\n147\t .get(\"simpleText\")),\n148\t \"thumbnail\": (f\"https://i.ytimg.com/vi/{vr['videoId']}\"\n149\t f\"/hqdefault.jpg\"),\n150\t })\n151\t return\n152\t for v in node.values():\n153\t rec(v)\n154\t elif isinstance(node, list):\n155\t for v in node:\n156\t rec(v)\n157\t\n158\t rec(data)\n159\t return out\n160\t\n161\t\n162\tdef _s(v):\n163\t \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n164\t return v.get(\"content\") if isinstance(v, dict) else v\n165\t\n166\t\n167\tdef _banner_of(data: dict) -> str | None:\n168\t \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\"\n169\t b = _walk(data, \"imageBannerViewModel\")\n170\t if isinstance(b, dict):\n171\t srcs = (b.get(\"image\") or {}).get(\"sources\") or []\n172\t if srcs and srcs[-1].get(\"url\"):\n173\t return srcs[-1][\"url\"]\n174\t thumbs = (((_walk(data, \"c4TabbedHeaderRenderer\") or {}).get(\"banner\")\n175\t or {}).get(\"thumbnails\")) or []\n176\t return thumbs[-1].get(\"url\") if thumbs else None\n177\t\n178\t\n179\tdef _about_of(data: dict) -> dict:\n180\t \"\"\"Panneau « À propos » (/about) → vues totales, date, pays, liens.\"\"\"\n181\t acv = _walk(data, \"aboutChannelViewModel\") or {}\n182\t if not acv:\n183\t return {}\n184\t links = []\n185\t for entry in (acv.get(\"links\") or []):\n186\t lv = entry.get(\"channelExternalLinkViewModel\") or {}\n187\t url = _s(lv.get(\"link\"))\n188\t if url:\n189\t links.append({\"title\": _s(lv.get(\"title\")), \"url\": url})\n190\t return {\n191\t \"total_views\": parse_text_count(_s(acv.get(\"viewCountText\"))),\n192\t \"joined_date\": _s(acv.get(\"joinedDateText\")),\n193\t \"country\": _s(acv.get(\"country\")),\n194\t \"external_links\": links[:10],\n195\t }\n196\t\n197\t\n198\tasync def main() -> None:\n199\t async with Actor:\n200\t inp = await Actor.get_input() or {}\n201\t fetch_about = bool(inp.get(\"fetchAbout\", True))\n202\t usernames = [u.strip() for u in (inp.get(\"usernames\") or [])\n203\t if u and u.strip()]\n204\t proxy = await Actor.create_proxy_configuration(\n205\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n206\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n207\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 3))\n208\t\n209\t async def one(u: str) -> None:\n210\t async with sem:\n211\t miss = {\"kind\": \"profile\", \"platform\": \"youtube\",\n212\t \"username\": u, \"found\": False}\n213\t try:\n214\t resp = await fetcher.get(\n215\t _url_for(u), session_id=u,\n216\t headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n217\t except Exception as exc:\n218\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n219\t return\n220\t m = _INITIAL_RE.search(resp.text or \"\")\n221\t if resp.status_code != 200 or not m:\n222\t await Actor.push_data(\n223\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n224\t return\n225\t try:\n226\t data = json.loads(m.group(1))\n227\t except Exception:\n228\t await Actor.push_data({**miss, \"error\": \"bad_json\"})\n229\t return\n230\t meta = _walk(data, \"channelMetadataRenderer\") or {}\n231\t if not meta.get(\"externalId\"):\n232\t await Actor.push_data({**miss, \"error\": \"no_channel\"})\n233\t return\n234\t # compteurs : le nouvel en-tête n'a plus subscriberCountText ;\n235\t # on lit les chaînes « N subscribers » / « N videos » du header\n236\t header = _walk(data, \"pageHeaderViewModel\") or {}\n237\t header_txt = _texts(header) or _texts(\n238\t _walk(data, \"c4TabbedHeaderRenderer\") or {})\n239\t sub_text = next((t for t in header_txt\n240\t if \"subscriber\" in t.lower()\n241\t or \"abonné\" in t.lower()), \"\")\n242\t vid_text = next((t for t in header_txt\n243\t if (\"video\" in t.lower()\n244\t or \"vidéo\" in t.lower())\n245\t and any(c.isdigit() for c in t)), \"\")\n246\t if not sub_text: # ancien en-tête\n247\t sub_text = ((_walk(data, \"subscriberCountText\") or {})\n248\t .get(\"simpleText\") or \"\")\n249\t subs = parse_text_count(sub_text)\n250\t videos_count = parse_text_count(vid_text)\n251\t vids = _collect_videos(data)\n252\t views = [v[\"views\"] for v in vids\n253\t if isinstance(v[\"views\"], int)]\n254\t text = resp.text or \"\"\n255\t is_verified = True if (\n256\t '\"BADGE_STYLE_TYPE_VERIFIED\"' in text\n257\t or '\"BADGE_STYLE_TYPE_VERIFIED_ARTIST\"' in text\n258\t or '\"OFFICIAL_ARTIST_BADGE\"' in text) else None\n259\t about: dict = {}\n260\t if fetch_about:\n261\t try:\n262\t resp_a = await fetcher.get(\n263\t _url_for(u).replace(\"/videos\", \"/about\"),\n264\t session_id=f\"{u}a\",\n265\t headers={\"Accept-Language\": \"en-US,en;q=0.9\"})\n266\t ma = _INITIAL_RE.search(resp_a.text or \"\")\n267\t if ma:\n268\t about = _about_of(json.loads(ma.group(1)))\n269\t except Exception as exc:\n270\t Actor.log.warning(f\"/about {u} : {exc}\")\n271\t await Actor.push_data({\n272\t \"kind\": \"profile\",\n273\t \"platform\": \"youtube\",\n274\t \"found\": True,\n275\t \"username\": u,\n276\t \"channel_id\": meta.get(\"externalId\"),\n277\t \"full_name\": meta.get(\"title\"),\n278\t \"biography\": (meta.get(\"description\") or \"\")[:1000],\n279\t \"followers\": subs,\n280\t \"videos_count\": videos_count,\n281\t \"is_family_safe\": meta.get(\"isFamilySafe\"),\n282\t \"is_verified\": is_verified,\n283\t \"keywords\": (meta.get(\"keywords\") or \"\")[:300] or None,\n284\t \"country\": about.get(\"country\") or _walk(data, \"country\"),\n285\t \"canonical_url\": meta.get(\"vanityChannelUrl\"),\n286\t \"avatar\": (((meta.get(\"avatar\") or {}).get(\"thumbnails\")\n287\t or [{}])[-1].get(\"url\")),\n288\t \"banner\": _banner_of(data),\n289\t \"total_views\": about.get(\"total_views\"),\n290\t \"joined_date\": about.get(\"joined_date\"),\n291\t \"external_links\": about.get(\"external_links\") or [],\n292\t \"avg_views\": (round(sum(views) / len(views))\n293\t if views else None),\n294\t \"top_video\": (max(\n295\t (v for v in vids if isinstance(v[\"views\"], int)),\n296\t key=lambda v: v[\"views\"], default=None)),\n297\t \"last_video_published\": (vids[0][\"published\"]\n298\t if vids else None),\n299\t \"recent_videos\": vids,\n300\t })\n301\t\n302\t await asyncio.gather(*[one(u) for u in usernames])\n303\t Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n304\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1bd7617a-06fd-4ba4-a84d-1ed6f87960c9","timestamp":"2026-08-21T17:57:18.663Z","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 \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*(\\{.*?\\});\", 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 \"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 [],\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":304,"startLine":1,"totalLines":304}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2cb9f298-056f-4ffb-9a40-94dd5f3cb7b5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"compacting","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2207751a-1e0b-4798-8ac7-69e1f03234f3"}} {"type":"claude","event":{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f0480e23-52a8-469c-8832-e4bb43178e7b"}} {"type":"claude","event":{"type":"system","subtype":"compact_boundary","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"cc3409dd-97bf-4437-9f5a-5bd2da01548c","compact_metadata":{"trigger":"auto","pre_tokens":171964,"post_tokens":16226,"duration_ms":94755}}} {"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\n**Request 1 (COMPLETED)**: User (French, Quebec) reported \"une complétude de 6% des créateurs sur crea-ka\" and asked to make sure data exists for everyone (\"de quoi pour toute la gagne dispo\") by forcing coverage with Apify actors for every social media platform. Diagnosis revealed the \"6%\" was the multi-platform gauge (6.8% of creators had ≥2 linked accounts). This was fully resolved: multi-platform went 6.8% → 19.6%, accounts 14,603 → 18,770.\n\n**Request 2 (IN PROGRESS — current work)**: \"Peux tu faire un encore une fois un major upgrade de tous les actors apify, pour que ça soit plus Complète et encore plis robuste pour avoir encore plus d'information\" — perform another MAJOR upgrade of all 13 Apify actors so they are (a) more complete, (b) more robust, (c) extract even more information. This is \"round 2\" after a previous major upgrade (commit 8fa08f4 \"Fiches légendaires\").\n\n2. Key Technical Concepts:\n - **crea-ka**: Quebec creator directory; SQLite DB (`data/creaka.db`, WAL mode), FastAPI web (`run.py serve 8160`), daily sync watch (`run.py watch 24`), pm2-managed (crea-ka-web, crea-ka-sync, crea-ka-ngrok)\n - **13 custom Apify actors** in `actors/` (owner `gorgeous_thistle`): ka-instagram, ka-tiktok, ka-x, ka-facebook, ka-threads, ka-snapchat, ka-youtube, ka-twitch, ka-kick, ka-fansly, ka-patreon, ka-discord, ka-onlyfans; deployed via `apify push` from each actor dir\n - **Shared net.py Fetcher** (identical md5 53da597c in all 13 actors): curl_cffi + Chrome TLS impersonation, Apify RESIDENTIAL proxy with session rotation, async throttling, retries on 403/407/429/502\n - **Connector architecture** (`creaka/connectors/apify_social.py`): `_ApifySocialEnrich` base, sharded parallel runs (SHARD_SIZE=60, MAX_SHARDS=32, memory 2048MB/run, RUN_CONCURRENCY=10), per-connector freshness stamps `{source_id}_at` in account.metrics, REVISIT_DAYS=7\n - **CRITICAL GOTCHA**: Apify rejects a run with 400 Bad Request if input violates the actor's input_schema.json (e.g., concurrency > maximum). ka-facebook and ka-threads cap concurrency at 6\n - **APIFY_FORCE=1 mode**: ignores staleness + caps, timeout 7200s — full-pool coverage in one pass; must `pm2 stop crea-ka-sync` during (restart triggers immediate watch run → collision/quota saturation)\n - **Miss stamping**: definitive errors (MISS_PREFIXES) stamped for weekly rotation instead of daily re-pay\n - Platform data sources: Instagram web_profile_info API (x-ig-app-id 936619743392459), TikTok SSR `__UNIVERSAL_DATA_FOR_REHYDRATION__`, YouTube ytInitialData (/videos + /about), X syndication endpoint (max ~4 concurrent, 429-prone)\n - venv required: `.venv/bin/python` (system python3 lacks requests)\n\n3. Files and Code Sections:\n\n - **`creaka/connectors/apify_social.py`** (modified, committed cac232a + 0cad882):\n - Added: `FORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"`\n - Added MISS_PREFIXES:\n ```python\n MISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\n ```\n - `_stale()`: `if FORCE_PASS: return True`\n - enrich(): cap bypass `if not FORCE_PASS and len(targets) >= self.cap: break`; `timeout_s=7200 if FORCE_PASS else 2400`; base input now uses `\"concurrency\": self.run_concurrency`\n - Miss handling in enrich():\n ```python\n if not it.get(\"found\"):\n err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\n continue\n ```\n - In apply(): `acc.metrics.pop(f\"{self.source_id}_miss\", None) # ressuscité`\n - Class attr `run_concurrency = RUN_CONCURRENCY` in base; `ApifyFacebook.run_concurrency = 6`, `ApifyThreads.run_concurrency = 6`, `ApifyX.run_concurrency = 4`\n - Connector classes with metric_maps: ApifyInstagram (cap 1800), ApifyTikTok, ApifyX (extra_input maxTweets 20), ApifyFacebook (cap 400), ApifyThreads (cap 60), ApifySnapchat, ApifyYouTube, ApifyTwitch (cap 1200), ApifyKick (cap 600), ApifyOnlyFans (cap 100), ApifyFansly, ApifyPatreon, ApifyDiscord (target_of extracts case-sensitive invite code from URL)\n\n - **`actors/ka-x/src/main.py`** (modified, pushed as build 0.1.7): 429 retry loop:\n ```python\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=u, impersonate=None)\n # 429 : retries avec backoff + NOUVELLE session proxy\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\n ```\n\n - **`actors/*/src/net.py`** (read, IDENTICAL in all 13, md5 53da597c): Fetcher class — `async def get(self, url, headers=None, retries=3, session_id=None, impersonate=\"chrome\")`; retries on status in (403, 407, 429, 502) with `await asyncio.sleep(1.5 * (attempt + 1))`; proxy session `f\"{sid}r{attempt}\"`; UA Chrome/126. **Target for shared robustness upgrade** (add 500/503/504, jitter, exponential backoff), then copy to all 13.\n\n - **`actors/ka-instagram/src/main.py`** (read fully, 243 lines): web_profile_info endpoint; parse_post() extracts likes/comments/views/music/location/coauthors/tagged_users/pinned; parse_user() extracts followers/following/posts/bio_links/pronouns/category/business flags/highlight_reels/related_profiles + computed aggregates (avg_likes, engagement_rate_pct, posts_per_week, video_share_pct, top_post). Also topsearch discovery (currently requires auth — SkipSource in connector). **Identified upgrade candidates**: `biography_with_entities` (bio @mentions/#hashtags), `has_onboarded_to_text_post_app` (→ Threads cross-link!), `business_email` (public), `edge_felix_video_timeline` count.\n\n - **`actors/ka-tiktok/src/main.py`** (read fully, 228 lines): SSR `__UNIVERSAL_DATA_FOR_REHYDRATION__` via `_STATE_RE`, fallback `_ITEM_RE` ItemModule; 3-attempt shell retry with new IP; extracts user/statsV2 (followers, hearts, videos), commerce info, bioLink, region, language, roomId (is_live_now); parse_videos with covers/duration/hashtags/music/saves; aggregates avg_views/likes/comments/shares, engagement, videos_per_week, top_hashtags, top_video.\n\n - **`actors/ka-youtube/src/main.py`** (read fully, 303 lines): ytInitialData regex from /videos page; `_walk()` tree search; `parse_text_count()` for \"1.23M subscribers\"; `_lockup_video()` for modern lockupViewModel cards + legacy videoRenderer/gridVideoRenderer; `_banner_of()` (imageBannerViewModel or c4TabbedHeaderRenderer); `_about_of()` (/about second fetch: total_views, joined_date, country, external_links via channelExternalLinkViewModel); verified badge via BADGE_STYLE_TYPE_VERIFIED string search; accepts @handle/handle/UC-id via `_url_for()`.\n\n - **Actor line counts** (for upgrade scope): ka-discord 87, ka-facebook 121, ka-fansly 93, ka-instagram 243, ka-kick 103, ka-onlyfans 112, ka-patreon 114, ka-snapchat 121, ka-threads 99, ka-tiktok 228, ka-twitch 158, ka-x 223, ka-youtube 303.\n\n - **`~/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md`**: consolidated memory (gotcha concurrency/schema 400, APIFY_FORCE mode, miss stamping, X ~4 concurrent max, Facebook Pages OK/profiles login_wall, OnlyFans partially works now).\n\n4. Errors and fixes:\n - **\"6%\" ambiguity**: Global completeness gauge was 65.4%, not 6% — live dashboard check revealed multi-platform gauge = 6.8% was what user saw.\n - **ModuleNotFoundError: requests**: system python3 lacks requests → use `.venv/bin/python`.\n - **facebook/threads 400 Bad Request on ALL shards (the big one)**: Initially misdiagnosed as transient quota saturation (a lone manual test with concurrency=2 succeeded). Monitor showed ALL shards failing again → checked `actors/ka-facebook/.actor/input_schema.json` → `concurrency` has `\"maximum\": 6` while connector sent 10 → Apify input validation rejects with 400. Fixed with per-class `run_concurrency`. Facebook then enriched 1,402 creators (its \"login_wall blockage\" for daily syncs was actually this bug).\n - **pm2 restart crea-ka-sync triggers immediate watch run**: caused collision risk + Apify memory-quota saturation (transient 400s on launch) → `pm2 stop crea-ka-sync` during forced passes, restart after.\n - **X http_429 rate limits (13/40)**: fixed via actor retry loop w/ backoff + IP rotation (0.1.7) and run_concurrency=4 → down to 5/40; remaining are dead handles (user_not_found), now miss-stamped.\n - **git \"nothing to commit\"**: commit 0cad882 had already been made during a ScheduledWakeup turn — verified via git log that fix was committed/pushed.\n - **Duplicate memory file**: a wakeup turn had already created `project_apify_couverture.md`; deleted my duplicate `project_couverture_apify.md`.\n - **Edit tool \"File has not been read yet\"**: must Read a file before Edit.\n\n5. Problem Solving:\n - Solved: full-pool forced Apify coverage (2 passes: forced then normal for the ~3,900 new cross-linked accounts); multi-platform 6.8%→19.6%; accounts 14,603→18,770; enriched accounts 15,040; facebook unblocked (1,402 fiches); all deployed (healthz 200 local + crea-ka.com), committed (cac232a, 0cad882), pushed; memory saved.\n - In progress: major upgrade of all 13 actors (request 2). Survey phase done: net.py shared (upgrade once, propagate); read ka-instagram/ka-tiktok/ka-youtube mains fully; identified concrete field additions for Instagram (bio entities, threads flag, business_email); remaining 10 actor mains not yet read (ka-x partially read earlier, plus facebook/threads/snapchat/twitch/kick/fansly/patreon/discord/onlyfans).\n\n6. All user messages:\n - \"Il y a juste une complétude de 6% des créateur sur crea-ka peux tu faire un effort et faire en sorte que lon ait de quoi pour tout la gagne dispo force la couverture avec acteur apify pour chaque média sociaux\"\n - \"Peux tu faire un encore une fois un major upgrade de tous les actors apify , pour que ça soit plus Complète et encore plis robuste pour avoir encore plus d'information\"\n\n7. Pending Tasks:\n - **Major upgrade of all 13 Apify actors** (current explicit request):\n - Phase 1: harden shared `net.py` (add 500/503/504 retryable, jitter, exponential backoff) and propagate to all 13 actors\n - Phase 2: per-actor extraction additions (more fields from existing responses; e.g., IG: biography_with_entities mentions/hashtags, has_onboarded_to_text_post_app → Threads cross-link, business_email; equivalents per platform)\n - Phase 3: `apify push` all 13, validate each with test runs on known handles\n - Phase 4: extend `metric_map`/`bio_urls`/`cross_links` in `creaka/connectors/apify_social.py` to consume new fields; run `.venv/bin/python -m pytest tests/ -q` (51 tests); redeploy pm2; commit + push\n\n8. Current Work:\n Immediately before this summary: executing the survey phase of the actors upgrade (request 2). Just completed:\n - `for a in ka-*/; do wc -l $a/src/main.py; md5 $a/src/net.py; done` → confirmed net.py identical (53da597c) across all 13; printed full ka-x/src/net.py (Fetcher class, retries on 403/407/429/502, 1.5s linear backoff)\n - Full read of `actors/ka-instagram/src/main.py` (243 lines)\n - Full parallel read of `actors/ka-tiktok/src/main.py` (228 lines) and `actors/ka-youtube/src/main.py` (303 lines)\n Stated intent to the user: \"Je commence par inventorier ce que chaque acteur extrait aujourd'hui et l'état de leur couche réseau, pour cibler les ajouts (données + robustesse)\" and \"La couche réseau net.py est identique dans les 13 acteurs — je peux la renforcer une fois et la propager partout.\"\n\n9. Optional Next Step:\n Continue the actors survey then implement the upgrade, per the user's request: \"un major upgrade de tous les actors apify, pour que ça soit plus Complète et encore plis robuste pour avoir encore plus d'information.\"\n Concretely: read the remaining actor mains (`ka-x` [full], `ka-facebook`, `ka-threads`, `ka-snapchat`, `ka-twitch`, `ka-kick`, `ka-fansly`, `ka-patreon`, `ka-discord`, `ka-onlyfans`), then implement Phase 1 (hardened shared `net.py` — add 500/503/504 to retryable codes, jitter + exponential backoff — copied to all 13 actors) and Phase 2 (per-actor field additions, starting with the identified Instagram wins: `biography_with_entities`, `has_onboarded_to_text_post_app` → Threads cross-link, `business_email`), followed by `apify push` + test runs for each actor, extension of connector `metric_map`s in `creaka/connectors/apify_social.py`, pytest, 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/eb002d3d-a873-4cca-9361-cb2467df7353.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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3af25bba-b082-4ce4-a818-18774ffa7475","timestamp":"2026-08-21T17:58:53.443Z","isSynthetic":true}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISqAQKpAEIERgCKkC0Ia+AlX+qyPibUd23xvMIRt/2LhfFF8Ya00y4PSIKGEUV5idobeJf4WJrtGJ74qr99scZZ0Zwr2poujcXe/2kMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHio6LUBhIMofkVzWG64AICQciWGgw95b1WrZFgAZysoIoiMK1pTYrfLHV5KWnvhNBOzUXw4aXCdsLdV8hTkaRJ3EzGOt+p+RprKfPxjEJYdOfoWCqwAnzWktMULaScZ+zn/bXchhXTIsoG//3TpmrTnZjpX+epFbRTCWDCKv5XgscjC8YynxK7DHhSPuS0TxdYTb86jpLjQ+sIrPQ2ryetq42AnlS/8NezaYA5/7kEOQLMWtrMnqmfkJGmE2sDIBhmLVX2IuBCIsbPno65k7GAsILxvOruIl0QOIFnpsxpPIxsIQKiP2ODW+bGohkZhwS3c2QP47HgW6nVO/Q8cSB6dsBUHsqM9nXtMNx99OaZZdJOdPzIjxnnK24YQ+B8BXjY3L2GlgDpTwXTOh87AkzOva9Rx2O1RxuVl3vs01rHqUIL74tRHUzzWxCnT9B5renWBG7KEAQu69yOL0saMxOxJvHp1ihlXWHPL7dsHCLyet2hE95UHNvdCG+A6P4uV1v35L4uaU8YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"77871936-381e-47df-9dd2-4146d05d4a2d"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"text","text":"Je lis maintenant les 9 acteurs restants pour finir l'inventaire avant d'implémenter l'upgrade."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d2a84400-746e-4d1b-b0b4-647d633266d4"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Ss7CB6wCihwxpK2im5jz9M","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":2,"cache_creation_input_tokens":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5f3820b6-9d71-4c43-86ae-ac9a42dd0074"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Ss7CB6wCihwxpK2im5jz9M","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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' 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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b5a2f62e-7e37-4fac-8740-4e20d8564661","timestamp":"2026-08-21T17:58:59.146Z","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 \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' 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}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KfomawnPjzTNXPDLYXksDF","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":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b92f5878-dd33-4584-a45e-2aa77c9faba1"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KfomawnPjzTNXPDLYXksDF","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"52a8b458-ec56-4eb5-9eb3-8a410aa810ed","timestamp":"2026-08-21T17:58:59.854Z","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 \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}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YBKzpWyJrV4t2nz3tpcp4f","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":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9e741a54-c058-40e7-b679-35eca25abaab"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YBKzpWyJrV4t2nz3tpcp4f","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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/ : 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'', 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 story_previews = []\n78\t for s in snaps[:6]:\n79\t if not isinstance(s, dict):\n80\t continue\n81\t urls = s.get(\"snapUrls\") or {}\n82\t prev = (urls.get(\"mediaPreviewUrl\") or {}) \\\n83\t if isinstance(urls.get(\"mediaPreviewUrl\"), dict) \\\n84\t else {\"value\": urls.get(\"mediaPreviewUrl\")}\n85\t url = prev.get(\"value\") or urls.get(\"mediaUrl\")\n86\t if url:\n87\t story_previews.append(url)\n88\t spotlight_previews = [\n89\t h.get(\"thumbnailUrl\")\n90\t for h in spotlight[:6]\n91\t if isinstance(h, dict) and h.get(\"thumbnailUrl\")]\n92\t subs = _num(info.get(\"subscriberCount\"))\n93\t await Actor.push_data({\n94\t \"kind\": \"profile\",\n95\t \"platform\": \"snapchat\",\n96\t \"found\": True,\n97\t \"username\": info.get(\"username\"),\n98\t \"full_name\": info.get(\"title\"),\n99\t \"biography\": info.get(\"bio\"),\n100\t \"followers\": subs if subs else None, # 0 = masqué\n101\t \"is_verified\": str(info.get(\"badge\")) == \"1\",\n102\t \"category\": info.get(\"categoryStringId\"),\n103\t \"website\": info.get(\"websiteUrl\"),\n104\t \"address\": info.get(\"address\"),\n105\t \"avatar\": info.get(\"profilePictureUrl\"),\n106\t \"has_story\": bool(info.get(\"hasStory\")),\n107\t \"has_spotlight\": bool(info.get(\"hasSpotlightHighlights\")),\n108\t \"has_curated_highlights\": bool(\n109\t info.get(\"hasCuratedHighlights\")),\n110\t \"story_snaps_count\": len(snaps) or None,\n111\t \"story_previews\": story_previews or None,\n112\t \"lenses_count\": len(lenses) or None,\n113\t \"spotlight_highlights_count\": len(spotlight) or None,\n114\t \"spotlight_previews\": spotlight_previews or None,\n115\t \"publisher_type\": info.get(\"publisherType\") or None,\n116\t \"subcategory\": info.get(\"subcategoryStringId\") or None,\n117\t \"related_accounts\": related,\n118\t })\n119\t\n120\t await asyncio.gather(*[one(u) for u in usernames])\n121\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n122\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"67b6a0de-e031-4ffc-9fbc-d157089bce00","timestamp":"2026-08-21T17:59:00.562Z","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 \n# File: src/main.py (ka-snapchat)\n# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page\n# snapchat.com/add/ : 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'', 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 \"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,\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":122,"startLine":1,"totalLines":122}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Xu8VfsvGxqSJKHVcahVbr7","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":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7c4205e2-f2b9-4fdf-8b98-4f21475aca83"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Xu8VfsvGxqSJKHVcahVbr7","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \n3\t# File: src/main.py (ka-twitch)\n4\t# Desc: Profils Twitch ULTRA DÉTAILLÉS via le GQL PUBLIC du web (Client-ID\n5\t# public kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) :\n6\t# abonnés, partenaire/affilié, live en cours (titre + MINIATURE),\n7\t# dernière diffusion, jeux récents, vidéos récentes avec MINIATURES et\n8\t# vues + LIENS SOCIAUX auto-déclarés du panneau « À propos ».\n9\t# Pas de clé requise.\n10\t# ==============================================================================\n11\tfrom __future__ import annotations\n12\t\n13\timport asyncio\n14\timport json\n15\t\n16\tfrom apify import Actor\n17\t\n18\tfrom .net import Fetcher, UA\n19\t\n20\tGQL_URL = \"https://gql.twitch.tv/gql\"\n21\tCLIENT_ID = \"kimne78kx3ncx6brgo4mv6wki5h1ko\"\n22\t\n23\tQUERY = \"\"\"\n24\tquery($login: String!) {\n25\t user(login: $login) {\n26\t id\n27\t login\n28\t displayName\n29\t description\n30\t createdAt\n31\t profileImageURL(width: 300)\n32\t bannerImageURL\n33\t followers { totalCount }\n34\t roles { isPartner isAffiliate }\n35\t primaryTeam { displayName }\n36\t channel { socialMedias { name title url } }\n37\t lastBroadcast { startedAt title game { displayName } }\n38\t stream { viewersCount createdAt title\n39\t previewImageURL(width: 640, height: 360)\n40\t game { displayName } }\n41\t videos(first: 10, sort: TIME) {\n42\t edges { node {\n43\t id title viewCount lengthSeconds publishedAt\n44\t previewThumbnailURL(width: 320, height: 180)\n45\t game { displayName }\n46\t } }\n47\t }\n48\t }\n49\t}\n50\t\"\"\"\n51\t\n52\t\n53\tasync def main() -> None:\n54\t async with Actor:\n55\t inp = await Actor.get_input() or {}\n56\t usernames = [u.strip().lstrip(\"@\").lower()\n57\t for u in (inp.get(\"usernames\") or []) if u and u.strip()]\n58\t proxy = await Actor.create_proxy_configuration(\n59\t actor_proxy_input=inp.get(\"proxyConfiguration\"))\n60\t fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))\n61\t sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))\n62\t\n63\t import random\n64\t from curl_cffi import requests as cffi\n65\t\n66\t async def gql(login: str) -> dict | None:\n67\t body = json.dumps({\"query\": QUERY,\n68\t \"variables\": {\"login\": login}})\n69\t p = None\n70\t if fetcher.proxy_configuration:\n71\t p = await fetcher.proxy_configuration.new_url(\n72\t session_id=f\"tw{random.randint(1, 999_999)}\")\n73\t await fetcher._throttle()\n74\t resp = await asyncio.to_thread(\n75\t cffi.post, GQL_URL, data=body,\n76\t headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n77\t \"Content-Type\": \"application/json\"},\n78\t impersonate=\"chrome\", timeout=45,\n79\t proxies={\"http\": p, \"https\": p} if p else None)\n80\t if resp.status_code != 200:\n81\t raise RuntimeError(f\"gql http_{resp.status_code}\")\n82\t return (resp.json().get(\"data\") or {}).get(\"user\")\n83\t\n84\t async def one(u: str) -> None:\n85\t async with sem:\n86\t miss = {\"kind\": \"profile\", \"platform\": \"twitch\",\n87\t \"username\": u, \"found\": False}\n88\t try:\n89\t user = await gql(u)\n90\t except Exception as exc:\n91\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n92\t return\n93\t if not user:\n94\t await Actor.push_data({**miss, \"error\": \"not_found\"})\n95\t return\n96\t vids = []\n97\t for e in ((user.get(\"videos\") or {}).get(\"edges\") or []):\n98\t n = e.get(\"node\") or {}\n99\t vids.append({\n100\t \"id\": n.get(\"id\"),\n101\t \"url\": f\"https://www.twitch.tv/videos/{n.get('id')}\",\n102\t \"title\": (n.get(\"title\") or \"\")[:200],\n103\t \"views\": n.get(\"viewCount\"),\n104\t \"duration_s\": n.get(\"lengthSeconds\"),\n105\t \"published_at\": n.get(\"publishedAt\"),\n106\t \"thumbnail\": n.get(\"previewThumbnailURL\"),\n107\t \"game\": (n.get(\"game\") or {}).get(\"displayName\"),\n108\t })\n109\t views = [v[\"views\"] for v in vids\n110\t if isinstance(v[\"views\"], int)]\n111\t games = [v[\"game\"] for v in vids if v[\"game\"]]\n112\t stream = user.get(\"stream\") or {}\n113\t last = user.get(\"lastBroadcast\") or {}\n114\t roles = user.get(\"roles\") or {}\n115\t await Actor.push_data({\n116\t \"kind\": \"profile\",\n117\t \"platform\": \"twitch\",\n118\t \"found\": True,\n119\t \"id\": user.get(\"id\"),\n120\t \"username\": user.get(\"login\"),\n121\t \"full_name\": user.get(\"displayName\"),\n122\t \"biography\": user.get(\"description\"),\n123\t \"followers\": ((user.get(\"followers\") or {})\n124\t .get(\"totalCount\")),\n125\t \"is_partner\": roles.get(\"isPartner\"),\n126\t \"is_affiliate\": roles.get(\"isAffiliate\"),\n127\t \"is_verified\": bool(roles.get(\"isPartner\")),\n128\t \"team\": ((user.get(\"primaryTeam\") or {})\n129\t .get(\"displayName\")),\n130\t \"created_at\": user.get(\"createdAt\"),\n131\t \"avatar\": user.get(\"profileImageURL\"),\n132\t \"banner\": user.get(\"bannerImageURL\"),\n133\t \"is_live_now\": bool(stream),\n134\t \"live_viewers\": stream.get(\"viewersCount\"),\n135\t \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,\n136\t \"live_thumbnail\": stream.get(\"previewImageURL\"),\n137\t \"live_game\": (stream.get(\"game\") or {}).get(\"displayName\"),\n138\t \"last_broadcast_at\": last.get(\"startedAt\"),\n139\t \"last_broadcast_title\": (last.get(\"title\") or \"\")[:200]\n140\t or None,\n141\t \"last_broadcast_game\": ((last.get(\"game\") or {})\n142\t .get(\"displayName\")),\n143\t \"avg_video_views\": (round(sum(views) / len(views))\n144\t if views else None),\n145\t \"recent_games\": list(dict.fromkeys(games))[:5],\n146\t \"recent_videos\": vids,\n147\t # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →\n148\t # cross_link fort côté crea-ka (§12.1)\n149\t \"social_links\": [\n150\t {\"name\": s.get(\"name\"), \"title\": s.get(\"title\"),\n151\t \"url\": s.get(\"url\")}\n152\t for s in (((user.get(\"channel\") or {})\n153\t .get(\"socialMedias\")) or [])\n154\t if s.get(\"url\")][:10],\n155\t })\n156\t\n157\t await asyncio.gather(*[one(u) for u in usernames])\n158\t Actor.log.info(f\"terminé : {len(usernames)} profils\")\n159\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8cd0f51f-0293-41fb-9962-fd86c03f8c94","timestamp":"2026-08-21T17:59:01.290Z","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 \n# File: src/main.py (ka-twitch)\n# 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.\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","numLines":159,"startLine":1,"totalLines":159}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzPVjeMJBGwNmotF4i","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01A3o4nXJuujfuxfErJXEMAX","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":2,"cache_creation_input_tokens":45934,"cache_read_input_tokens":22155,"cache_creation":{"ephemeral_5m_input_tokens":45934,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"213dbd61-07b1-4af6-a7da-582061b27c67"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01A3o4nXJuujfuxfErJXEMAX","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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 \"live_thumbnail\": ((live.get(\"thumbnail\") or {}).get(\"url\")\n88\t if isinstance(live.get(\"thumbnail\"),\n89\t dict) else None),\n90\t \"live_category\": next(\n91\t (c.get(\"name\") for c in (live.get(\"categories\") or [])\n92\t if isinstance(c, dict) and c.get(\"name\")), None),\n93\t \"vod_enabled\": ch.get(\"vod_enabled\"),\n94\t \"avatar\": user.get(\"profile_pic\"),\n95\t \"banner\": ((ch.get(\"banner_image\") or {}).get(\"url\")\n96\t if isinstance(ch.get(\"banner_image\"), dict)\n97\t else None),\n98\t \"recent_categories\": [c for c in cats if c][:5],\n99\t \"social_links\": socials,\n100\t })\n101\t\n102\t await asyncio.gather(*[one(u) for u in usernames])\n103\t Actor.log.info(f\"terminé : {len(usernames)} chaînes\")\n104\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"bf05e61f-b425-4e0c-94e3-a9dcbbc6ad62","timestamp":"2026-08-21T17:59:01.743Z","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 \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 \"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":104,"startLine":1,"totalLines":104}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"0b60bd86-e96f-4f23-ab18-70ee7516c8b0","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzzsyefZZsJpG9J4J2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LqUK835a2Ar3Ma2XMLY7rn","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":11478,"cache_read_input_tokens":68089,"cache_creation":{"ephemeral_5m_input_tokens":11478,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"45b1fcc1-e8f1-44f1-bb36-a8d15a463a46"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LqUK835a2Ar3Ma2XMLY7rn","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1edcbf7b-835f-4189-b813-cb74df723759","timestamp":"2026-08-21T17:59:06.465Z","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 \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}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzzsyefZZsJpG9J4J2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DpXMrztDkQr4MbqQW6Nv1a","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":11478,"cache_read_input_tokens":68089,"cache_creation":{"ephemeral_5m_input_tokens":11478,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"baaa6158-2d52-4c3f-b76c-6131a2146e36"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DpXMrztDkQr4MbqQW6Nv1a","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \n3\t# File: src/main.py (ka-patreon)\n4\t# Desc: Pages Patreon publiques — la page / 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' str:\n41\t try:\n42\t return htmllib.unescape(json.loads(f'\"{raw}\"'))\n43\t except Exception:\n44\t return raw\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 async def one(u: str) -> None:\n58\t async with sem:\n59\t miss = {\"kind\": \"profile\", \"platform\": \"patreon\",\n60\t \"username\": u, \"found\": False}\n61\t try:\n62\t resp = await fetcher.get(PAGE_URL.format(u=u),\n63\t session_id=u)\n64\t except Exception as exc:\n65\t await Actor.push_data({**miss, \"error\": str(exc)[:200]})\n66\t return\n67\t text = resp.text or \"\"\n68\t if resp.status_code == 404:\n69\t await Actor.push_data({**miss, \"error\": \"http_404\"})\n70\t return\n71\t m = _NUM[\"patrons\"].search(text)\n72\t if resp.status_code != 200 or not m:\n73\t await Actor.push_data(\n74\t {**miss, \"error\": f\"http_{resp.status_code}\"})\n75\t return\n76\t\n77\t def num(key):\n78\t mm = _NUM[key].search(text)\n79\t return int(mm.group(1)) if mm else None\n80\t title = _OG_TITLE.search(text)\n81\t img = _OG_IMG.search(text)\n82\t desc = _OG_DESC.search(text)\n83\t monthly = _MONTHLY.search(text)\n84\t nsfw = _NSFW.search(text)\n85\t cover = _COVER.search(text)\n86\t creation = _CREATION.search(text)\n87\t socials = {k: _dec(m.group(1))\n88\t for k, rx in _SOCIALS.items()\n89\t if (m := rx.search(text))}\n90\t name = htmllib.unescape(title.group(1)) if title else u\n91\t name = re.sub(r\"\\s*\\|\\s*Patreon\\s*$\", \"\", name).strip()\n92\t await Actor.push_data({\n93\t \"kind\": \"profile\",\n94\t \"platform\": \"patreon\",\n95\t \"found\": True,\n96\t \"username\": u,\n97\t \"full_name\": name,\n98\t \"biography\": (htmllib.unescape(desc.group(1))\n99\t if desc else None),\n100\t \"followers\": num(\"patrons\"), # patrons = « abonnés » KA\n101\t \"patrons\": num(\"patrons\"),\n102\t \"posts_count\": num(\"posts\"),\n103\t \"is_monthly\": (monthly.group(1) == \"true\"\n104\t if monthly else None),\n105\t \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n106\t \"creation_name\": (_dec(creation.group(1))[:200]\n107\t if creation else None),\n108\t \"avatar\": htmllib.unescape(img.group(1)) if img else None,\n109\t \"banner\": _dec(cover.group(1)) if cover else None,\n110\t \"social_links\": socials or None,\n111\t })\n112\t\n113\t await asyncio.gather(*[one(u) for u in usernames])\n114\t Actor.log.info(f\"terminé : {len(usernames)} pages\")\n115\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ffaaf137-73a0-41db-b2be-8ea9128fb9c0","timestamp":"2026-08-21T17:59:07.191Z","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 \n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page / 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' 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 \"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 })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","numLines":115,"startLine":1,"totalLines":115}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzzsyefZZsJpG9J4J2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EeZEAvPg47YnMt2eFAsJb3","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":11478,"cache_read_input_tokens":68089,"cache_creation":{"ephemeral_5m_input_tokens":11478,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f528be00-40ca-41f8-a006-e6649a0faaac"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EeZEAvPg47YnMt2eFAsJb3","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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 banner_hash = guild.get(\"banner\")\n59\t splash_hash = guild.get(\"splash\")\n60\t await Actor.push_data({\n61\t \"kind\": \"profile\",\n62\t \"platform\": \"discord\",\n63\t \"found\": True,\n64\t \"username\": code,\n65\t \"guild_id\": gid,\n66\t \"full_name\": guild.get(\"name\"),\n67\t \"biography\": guild.get(\"description\"),\n68\t \"followers\": d.get(\"approximate_member_count\"), # membres\n69\t \"members\": d.get(\"approximate_member_count\"),\n70\t \"online\": d.get(\"approximate_presence_count\"),\n71\t \"boosts\": guild.get(\"premium_subscription_count\"),\n72\t \"verified\": (\"VERIFIED\" in (guild.get(\"features\") or [])),\n73\t \"partnered\": (\"PARTNERED\" in (guild.get(\"features\") or [])),\n74\t \"channel\": (d.get(\"channel\") or {}).get(\"name\"),\n75\t \"avatar\": (f\"https://cdn.discordapp.com/icons/{gid}/{icon}.png\"\n76\t if gid and icon else None),\n77\t \"banner\": (f\"https://cdn.discordapp.com/banners/{gid}/\"\n78\t f\"{banner_hash}.png?size=1024\"\n79\t if gid and banner_hash else None),\n80\t \"splash\": (f\"https://cdn.discordapp.com/splashes/{gid}/\"\n81\t f\"{splash_hash}.jpg?size=1024\"\n82\t if gid and splash_hash else None),\n83\t \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n84\t })\n85\t\n86\t await asyncio.gather(*[one(c) for c in codes])\n87\t Actor.log.info(f\"terminé : {len(codes)} invitations\")\n88\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"28053fb5-be39-4f1f-a015-637ea7ae597f","timestamp":"2026-08-21T17:59:08.001Z","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 \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 \"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 })\n\n await asyncio.gather(*[one(c) for c in codes])\n Actor.log.info(f\"terminé : {len(codes)} invitations\")\n","numLines":88,"startLine":1,"totalLines":88}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzzsyefZZsJpG9J4J2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BCgo16zgUVSLLpav9VQAzG","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":11478,"cache_read_input_tokens":68089,"cache_creation":{"ephemeral_5m_input_tokens":11478,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"999e8f67-b163-468c-bb94-22c18d7c8158"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BCgo16zgUVSLLpav9VQAzG","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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' 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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4265f43e-e2b1-4c49-8eae-80ad60a49a98","timestamp":"2026-08-21T17:59:08.558Z","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 \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' 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}}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGKzzsyefZZsJpG9J4J2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01N37N7MqpcaKUCu8bpXizE1","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/net.py"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":11478,"cache_read_input_tokens":68089,"cache_creation":{"ephemeral_5m_input_tokens":11478,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"af8faf19-4604-41f8-81af-749260887f71"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01N37N7MqpcaKUCu8bpXizE1","type":"tool_result","content":"1\t# ==============================================================================\n2\t# Author: Simon-Pierre Boucher \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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c21a285f-2b7f-433f-ba49-338ff4aa6a52","timestamp":"2026-08-21T17:59:08.626Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/net.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4a9ef340-d5c9-47e8-8843-b55474452054","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL1WukZM97UnaqeZVVN","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4UkKpAEIERgCKkBScK5RYDJk9gisK8OtRiwAnmtazht+wv0+Ap8aCJ5v24uv0Sp3zDr9WyxNGeAeQKtKigXNfVjPmbr6AsivHcfhMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGqpKLUBhIM6jnAUdHNubUil723GgzUd2R5vqWh8P69FEIiMEVGzL/4ZeXE7A567GAr+tXFmRcNt/P/X9plPnzQ8l2/R+ouWiW3kmJPKdfHqReT/yrpRznI65jgyQihYAlyvT0Fl/LfMXbfGZw7nSgzQvEyOdhDbSHQeHTI6MWHVP2XXuRGKCcy4SytpMZguPJupV6Oz6mR7ma11EcE7As9et9CHzZfFFcdVLvyhE467g/ZsFmQqXYheALIm/wZycLUGHraMp3P3awe+1vPKvLf+0BRiePjJwtMdWhnttXerXSWBkV4jPUifghB7FcpA8VBJuVgFr61PO3qal41l763tmyMZQzMn/Ixe6Fv2hejiLiLJisGbkVqVFeb+p9lavtfSTtOwrdFpX7nvtZJPSzkn8wR9IujPvENEFtgsIydjvKphfko7P0XiFUn9F6TRFieSIDOxhAcPA/cRgcJX5zVdYTsZqD48zz8IJN2yVnhmLNYVVmps3TCox3qvKvvZi3EL0/RTBAS+2ReP871G28DI5l6RGbMGIh/2vj1oWv64kKXsAJeNqhfWwVUo6IVjUrss67U7/AAq4rX1m+Pzw1LmfRqIOBJfMCbpwbwBancMXD3jc6wYEPqhLa7lphCnZsTaZDmeRKIqGe0yef2f1RFgzYnBpe2ma45xN5icdMBwlIiH+k9nNSgI1IbUd0g0IS3ejd8/bRTNzhmtWz0V3pkPl95OLvbcypioq+XMnVw452afJxXckgxShRoEJyKxRyNyvJqtdObSEWf/VXuSj0uphyT+C+dzGwLw4KOCiJSegGIrt1FWY6dwtljGYvX+g8oPTdWpz+Z/pF1MsMdYRYt/isKReV2Qe8G5Cix4ScKhYyzmDlavOEGpHm0POmD+3khuQoYSZq6cXbsXyhXYuEN4r7BpWrQDO8TcWrL6j3r8xIwfqjP6jrLHpYnmzAe7/bT1vNuiTu81P5P6vtf7BmlDQXT7x7ex7RHIl1ky/mP0SS0kqI+qg8ZuDEsqqfGqrsjiiHx+tfGEckS/UvdQfDtw2SzxpcSUmT7yRuOkzmptMeZIY/60vRHAUzx4oxzRd05HUKhrLGE8fXjghX/0w151KsJ+1VyngoDCsYNKXbOhnK2yo6/8/wAr5UxSi9dVGKJ/+5jURWrtNxqZHZC7E66NfBrmJDmeovfAPcBVau583GSlQE0rNBNkKRQQLdJS7Rx/8VAjhXtG3y0J7J3OAyJqs9ffzHeERib7EwwBYfVBdzPQEINQvRgPllsEmYa/5WN4rY7O3l4vt27iwTX/GkSLQHIxdR51aWJTZaZR179sw4Y7nk8ypE7yzmn4cL8wg1LoATGRdDu1Cbes2D+vhfjiGYsxD7UVLCzDGep4yxUV7Dac17EzFeq5OpPwAI0lfliWhm4SkQ8le0jEBuwEg33Uov3bRGaaKhADzn/QVz5kKQRAvkEtQ/8PSPeaBzmuVzTOI8yAoVNJ74zvs98lNPI8zGCJ9sqpBDyj1j2iC5HpEBBaPnqmimj7QA33LafJciSHsY2woVtfQmzktWB3yqHkOCVOtKRQZ8A7+N4n3dvS3ikjcPSels2rxERPpegUc0RkCwzYca48QorWT66jgMce4ieVOzRVbSPOOYu/KPp5ae2vrlsKUP8UJ9mw3qjDO5kohCCJnpM3LxTWuCXUAH82lOo/mTqzt/lqdSElz60V/wZhaznAEjgWw0CQ0wsLCVnBhItEgUBMBVDqBW1DjRZNYhtHoJ2ZEs4NY6GLy0/DIuuwD1aq1TRDRf9gGnso/LEnJNWW8u/tzwJJfk7fjcEyIzoBlz0UQ3c7V1JfT0aAfg+RlmxeQvw8eO4VOCBSUcnVxcwTDrNBQUtW+BbhqSSLPV4nElLp1NTHO6YnMqTMPjaPtbDTTXs5AJF3x4zDUCD5v0/rudBITUKhup8Dp84ikPKW/2x+yn2C+7rHJxYIyyV21beWHVX2TTAcsl9+SNn0lMMwWBZal9S/PjCUsAzx2mzmZMedCq6wxFIVvHuzxX1NZsIyn1y3aiurTln/hstnmcjf+9IamlnEjUF2dDGA5+Gm6NEGkvZI0Zd7ov6GSs3dNIcf6RdyYp8YhEfu04oOsMm0SqE7DDOHh4YDIfKXI8HcgArTTozegaWO7flL/DUalb+lndIUZ8e9T3q42etCcXKeSE8LgSnuQwU4XA9t5HCFfJ4QY6WIhhgoOPadeulSD67qO+sPAMdUpd70AJvn43o5CY6OMpGEjugaG5C8+DiT41Civa8OoLTWX1It9jn2m1oXtHkbVedgsnZARThoIs9xP8G24JMD2x/Yob7EJcCIxtJk2rqQZIc3+kiRq/RxAQ+G0ziVFFrW5scfoln+0sWejr2ZlxxKyESUyKa3IpAEdpdMYyECcgg4As1fISwngo0yBK6lNXDbYFexYfTSnguNWh7meZ/pyiHprzEsfCCD0MLRpJSxZQTygsQho/XGOs8vxBiaQJuR8kjxWepbhgnouQIHc4vJzLPPYENi6xNvbONZ1bvm86Is5b8/OtVLhiGQDiHLO+V9kjqgr6qDKHatjLfl57B+ZHypT+oGiCzr67jBtXnw1Kg/3cQPPZ6RuAUCgG+OONCK5GrM9jQddnOy0NFZF1tfjpapWGFiPdhPyGtJ6meb9BYy6312UXoWpWRBM815V+cNywqkXWJNh19k6rGkFjgzIfVOJxEIaX4+2/+Rg2T6CzOY1v42UE+L7i7T4tk8LPJKdtOrpSco1joVp1m1UpF9BoiyZQFt58MAGIe9i0EtVkjhjeSl2nohjW3pDI+PGYXRAIfdI0MpJrGNbchOVKppzJVLQBhMjLyY4/1WY8x6kmMzaZUw53REJV35AtyY8eiZ0DWl98hhDDk1WYIl93a1fMa/1PTea26xPu/MgXJ53rB8AA98cY4HfRdG5LFW7p2c/dpvJZ8MeBII/V36S5d2Jhyg72v9RzCZQnBiAcU7l5D0K9xwEbJtmMIKgXU/b3tBFZD/XBtvWO9h3LNudEo1HJ1onzA9tUQJWKDinoyZkjXfloRS+dLKukcjGjs+6lhAg6pFBLrx/IV/mDhx0nC2V8P1wgr+OMVigcLx4cWgtyUCngbsia+TzA8epkWJXFq2GVG+9q2k7XFx7+lzgbD/LiukEDKFTJNCSYGnAUhx3crNufYMgk6eb163XS6jrTHQNzOv3m4M9vq83y1aA2QEZgPmU9Vf7urNziR8piUsUFqRNmLFP+Jcx5377DtJrl6VZJ0ESLQvPro9mP5/I8/yccKtp1wcCGKEzLQU3jbN3mpOpOEzdYoTwAD0bFOxdF221xaJCOFq8GbFI5yNhwEfZ2LJD2hcOsMQFJagOTLLyMMk+hgf1y6QyvpenOA4gG8wMtq/D55PXUFVlMqt0RGqsHrmGQbEPhJsTHqpdgIX4wd5NQgS4HKyb6d6VqWx4FWnOZYgOTCR5atlaVgj503eHH+9Ncgbx1KoQTABzLIncZ/zAJl9kY9YrOV9pQCBoGTM1uKhmDvPSGq5gv8fvKGXj1ON2H6YUvADWgLKBD6WPs3Udf8lgJ/1cXj8n4btU6R33uArC5iJOPVkFDO3soUEYoSkVJcwlcAwnLHzvWhpgMQahsJzFmQu5tLYRECak6P1F5U3+AQ6fC9RIB7C9Nx8vTXZxowFk9ccaaoaKZ8178CGFL9K4YaqjRDhV7191fBYe0LGU5PQZSvxeTJn20sliMK1O2l8mxc0xzW5Y7WHRNuRA6vt8mPKTKdGzCawxn/ZTbW7DpsIADKzKRMMdbuZdpHOWm5mdBMCNWXiWNEVe71anobSbFoQeN5JQqYq2uMDeegTj2zCgc29XmmLUUpYzlFL1F1TRUgzQ86R1n/w7maxQo82881wG1scnAvi/BJP1YKA2/MAYJSvyPZ+tPfctgXwQ5zC6J4mNG082n4Q4fBUlvJzxuF0lWMULxxH6+U4W+p3n9HEePLPklPmzoiFPt5FmpstjiCiHPXB/pHfdW6VBPWEbSKlm1C6B2iTy/0E91Br2x9ERn5Zbv5tLhYG6uOfTXLX7HN5u+U4Q7NzO4PflJ20rEPv7AMILtgXK609afkUNdyLLcNtDLrD4Aekg9LF4SVyv3CcamG7KIiZh5HyZmErtkNuOvXoE2+UadyxHjPFEFVjyRNgHQfBQ8x5e0S6CEAbcuv3w/MqC1hfzFK2bIEfaoMbaLF25HESB4nKaG5g5JDU+R4LENnrtZqZ3s3MQBkz135LilodI5yXUcF0l+1KT49mOXrm4WjMrN6l1RrsfgyAMXDOg5lEtd37aMOsrEhYVReNhMUXpIEh4+EDCgkkRk326gec1W5VbgGduM1B3q1o3WRiSwhWw1kQd3xvmjabNjzFdMQhQQLqDs5trY7asWlUI+BD+kKjl3XEmFjbcPVG2rG5QZrZkfxh52DTPO0D81J1Gvek7yDtk9s5fXjxjnWL6u2567yA/wh8nydxN5yOjhNtkaSMK6Px3LpCyF0oHDGGLHU+B4kDtlJr9gbDtYWjaMe4amLCGQ0/ZMvgKlYz0gYkiXxwrFmqyksKpewSc0jhE1bPGIl+K/QEd2XEI35CNPUqj0EdmwZ6E3tUx7dNslmEfxwhoeG7ZjaRV7SofSDlpdGT9LU/cnM4iWWaJYgbCWli7ruiKFJXv+5v8MzqPACqqZdIir9Nv0R7OAS2YgUuyzPLXpq21p6lLAwbV0kznSXyD5pRDv9w+BFoA8XSHD22+o8EdhOiEhHd12eQuopcGZPt7/3nGB0uaNO7OST7SO5aU2Y/Zs5R2oWB+uiY47z9axIFGxck3HcGWvOlx1jbsXFgj5V4NktNneTfT4LhbyEtnZQtD63DU1Poc56kRrCHyQdhDMLV6xCedikx3boDWlQlPbI2WOwn1PkFa4iIzMlfghlQTaIhnbsSVsG025G0BJE2ktkTb5DWIfSFMx2ePz44dNFpD3RcEnmIWzrRwPJEtDyjUcUfwpsw5Voa+mEndRQpTPbZ28Uyndh15kKgubOSWSl0XPH1+u+BpLw+Neaeq5Vq7stQ6svi7ghnNNug64PNV+S7qVqqB7jaKK4QnlF6Ot09ikZb8XOsl72i39KNzQ13JH2Ab6tVw7l2tE7Se73dCRVFAyj2xHx6q1r+d5DC61FEvK8cZYvoo85YfjUk0EAjTDKERRZYtIerF37src+OKwEDu5wvseCxPQyPezS4AkhuFskGUQ7wsQgLejLEdxb/FH6THCksnGlaNMb0qle3jpvQJEWP3aaBJtp/T4zIeVJpUOwK5KESRtWRwqCDS4ItsgICBWoqa+XVI1izliOMGwVo4OWoAKzxWdNSBLXBaIOYPQdZ5+oOrgSkSg89BhUHmKwnroXcL1Qsakm0En1AsxeGd9OdDTabVaRDRoohr7nMQkvsyJeqipcSGvtQJTs30qKVNrf9vHxrfV0Y1RQW2gIat83s41GOrMIIx+WBVGW+kibG7VsRG8neJnQIn25x4GDk/rjnem5Fbe3jBHKoxXT8kCtaQawS+r7OsRbEjHU9gy+RszRDyeGPhzOyUoOSVElVmzImx3gJg7BWonzv3is0u0Qgw1Q1+7JjWMWn0R5RZ3yib8FuqHN1NHeCx8PwyJy5zY0F81QYJgEsQ9CCdqUiRFkGEm8UrYl23DwbnXinXE5Uq7TK6WwswZNAh+nMEpHEHBMNTixpoE04tHoC2yP/LiXyoGBFjsuz1g+eOcSUMXwUD0M0SjFmzaaBCZ/oRmrEcL+lhSX7MxuLzmRcG6LCIkeHXOhBMJYSng1nRyazogHEcFDrvzzVfXVLbSv9wo+DnxYsDJeW5652ptVckDcNRqRSDJTzJpkffw09JJV55QvxnTHVpOA1Ax+1IDXjLH/vJBepLjmbHwvZ+szYvivUuhkZEhzX/87qoDWI/UspkAlR1dEApQ+mz+vjqV9F1ZGSQJH2lBMhu6xvy/MFvJC93qO7AnKJKOYFxA7bcqVDLxr/E3LRVVhOZcLmbhdXurIV+/rtTasMrc+LEgzgEX5Z8IRM9g/Z7dXzYbUIAYP8lGwxUus6B/EPl67nUsjHhkEDCBfC/DdmgVdZ9wpdu0w0eWlc+HaxLcF8C/DPUdXHgOZLD2mg2Q3zkJUYWWx6G4raLGX7UZkyeN7CpSmVFof+FG1e2s7RW4OOxamf1CCrEqoPaEDFWaWO2QDnAaHkzbHpABQrM4prOG+D30R+cAF8fFx/Kj3rx3wnaMtsIPqM6ZGBZlWzJOBwDJoUwnNajMfFwWKn3l5ALBtVdnXR9GW1gsRgf15JTO81r3kfLn2b5/Jy9tKY+lEqV0166EChgX2z3QsZRXXGh2ZlOek+3xQAbNaCtpFGQd04ucYfQ0VN41f6gulP7rUyWUDvfUGH0ONToCi326MI+Tzi9ugCSaRplrwoh92OEg9KXS/b7Ezz6rjsr0qsHORdU6plUMAu2db5QD9rG5nBW5QEkW1lAnkNDXzPSUuWWbANKiPr1gyw/4myisbD+/csK7eWbOOA44Y41OlOVUCboNAR6nFD1p6Pd6IEkRT4a7yiQttNFhrF08Fh/rWyHi2ozj6NnxsnS6oJCMA47J8Fb6cpJIdieMpTwrIXagjXo1mixBKr1KzpFwsUSSvllSK4R8gXoxI1zUy1TdBiGAG0BwraewQiX8zBPipG0BXVUJ+HfKfHaa1enCX2qEzAcJM47PQsRYADm+iMYDkBF44uHoYF/Qic2YKkGeYDp4XccPZ6xgPuwtc2aCudl73giUFsSgs7BX26j+csEL58za/FVnr8yLZrNrn4hqGboAXIMMuPL6mEZ2BrHA4AMBmEiMgSiY9fiLWJOBMUsmDxXmjiqGo3ZcZhkJv9fr3KknuOWCFyTkHLXWyu/KR6jUffOfKjVH1tWoOf6NjGz9VG4lKtSHZtXvN+9DytWsaS9E3id9r+yOrkP61ZC+ozGaUlRsQEMKIyqVu2k8yPL8XNoSiKIFm7YXDIh+KbhniBBCs6nYYW0fa8lpxFNJOc/EJUeylXBa+iIdYDoLl+zEGx6P6SI/6dqaKgWYMstA+Os9hiClHa+5CE0y2a4sGIrCxQBmjWZqEnu24Dctk+5MT/QOA5y1/2u33XVzzTM3Y6j9pfvyuUKKo0Wok1biyEJYkx6HxSmTWWAadJ8hMyleUzK+UxUTvhp6gIM7CEDbsVHHIVYw5MzrPAT/qYRoBSscIXLcLllXBMiJFbl/poA6XjKD+hX2rWO+b5CUTK6LkvJan3hMExmjBTWockvmWOjXKpcThCkVC9oHxYp5eOgLOXhTdJ33BiJ/bn9B0Upi8NqxZdCLuinz8TWoQfeiYQ5h3H5jjWU4OUJi/t5a1UYzEfGRziR+NEnPZ1JcXricOBvaNZe7oPYZSPheSpWmZnk30rKln+Ts1tLYyiXOAO8OrbXxnSvbyKEaUQsPocsI431usmcLcsbrJnvfn2Qn0UFhtUuPuUyOi0ZEPCr+J9gt4wb/myyLerpFGF+UX7y9CD+Axf0/mbwEo37J1DKhuHPuS7V5E2Q49DiMtG9NiJfTWEQgSzyRhSBnYEZ8s1VZYfXvdxtTW60DMlKbx/WXQZUZ0IyxHLbGi0yT99oQChZXmy2yPorj8OLKoWZ+NLXfS5m9rZF+qRHvQ6ZvOb/t/V6l6yHyXn4COwgEvHehM3TfK5YB08woAMTHBQV2Rz9/8MRrZ8MkKh88iF47VhUyPoEMDqEgsJDpqpslUudcKcooCFliJ10UuQEVfGVAbr5LAlDF9gi3AFaFCfZeI0aynZXRyN8b/CcFizjLrlRoXeJSpRD1LokHGp8VV0iPryZPAa2u/C1ufxR4phU8XMK3dzJ3nDzNUD0qlTDWaBZbxX/57CWGxqjK5j9Q9QTJ5beqfDsWAR6UkYiLUw7t+mzCt1ptGx5uCC8bX4Z4wFdVaKr6Kh3nqMq8417DktZdYG7I+2BdEKj1LwVgeFORVMJRJsfInGrQxhs9CcKC9gXMx5q3/J9QdjI+WrP07MwguMSRyGj2fo5MhwfKKS6YKBfuZqbx+w0pEg/ro4uEuYLzQOxMvjdZoLLTxHC6ovzTVePET7HN9m0+dsZqum+Vq4AzP4vyMFN1Q3JdLmT/49TrmdjxszGYVdevWdaj4+wD5X+2Z2qte4w5UIoFie4ZOQxgBCSl2Rut8ojl1l5Pfa8EC32zMh7LqO+cV0gDIFJtyIvBN2gE1Tn0qhJqRE/rGYMwDbqDs4mXA51qcsHerbI3tUtBH4+iTBxZnVOEkBp6wFSRBQ6kYxiZHwfs6nL2UTVEWkS8YE/PgEtq2BbjErm3WZsaL/oH15derGZe/8CZ5JjaNdyAEaSE4ZUxgMChRuMr9WHNQlx25KZESTcMlIRtJ/p9gwsNMu/5ZHBgmYkIY31YCmm4py/XV/aBnWFf6W04/B1V4NW5XNr69tqCh1c7y54j087934X+ryZLfHdlZU/PJf2thmbWTg0spTWuBPMebUeqkB7qayakE7KW1Lc5OxpKHDYmFfQoDsuJ0DVgWaPRjH192phyviJ32PYNaHrFt4l8Huk9Miw37ZY4DFI+1KLJ+t4OccbOb7uhbxEek7MwQ0uGCYnco5+Z+S/UuE1lseXnOs9TH0JY0EIufoEWBrBTaLK+76jEJ3KCc2iAJbqnqu5eUCX8wNhn0Eev2aKtqsSziITdSdWGumq0pY9XBfznIRCe4IlWUIUTYc9dGz1abTZs1p0OFLDUl9smDYrj3L3yR12eKVM6H97KwHBSY3JDIZ1H8FziHOLzlinN4qQoRzZCdedM10P/TlDRFochi5AcmPwaOss+QnQ65XRRtbGXkEnLfQ3rccSJ7WAjtU4UxrPHJff4AAh+n+HYy7gDRm2xXYzoD4BFKw7JBEvWJTpv9XsYHBYbgDQdxJ/UEjp6/R01lvDDtAgHjQZ3q7J3ApgSgZ1q67Jn/qPPBnqQRLw7GIABEc8uSzXBmqwv11NqUuhGPJu477zP05Ve2ybOh620Q7vmPL6vk1wNS5nlJ/UfDIabs+/wg/hzoMiLtOFOAJfY9Of0W0dnfqK2InMgL92HCYwziIDJumxJoL7RJphUfEO+K9pu9wnLZRZwUTtQPVYUVzzWSnGURocm+1tSe15bAm/R9EWH90IQHLo0w/E5voZSAMkHQsn2FzBGENIhpGxOmVQrp7Ie5N/Y3Lh3WP8wClQo3Dy1wg9/BabZGZk6Wdk2uSuqNObQbU/sP2s4OTaki8yLP4t1LmyGpUMH+i7dM1z2oBFUmbDUhmetrxN4Yjid/C80ULz35bH8cZd7c+7gRaKMlSC3wMz0oVOM+gPhotJfiXKb/1sC+YTLq8hJOJmJRjnaUJBWdXDgf0uF8bpFKMo2GQV/poSV6oI+qXulwqC1PYug3NR650hBml/hMC4s7fH/K2+Lg0zgdnmcU+jOlxf+dT6O54hurqPw+TfkFRsm2NR6J59w0dEfNTN0yE44ihrGusGBAdUg5MHjTD69dozLXPYVMJjCyQ64koig9kRG7jAwDsjS36bi6JnVRnNQ9dwATiMwiWFvNjDJrXH6IPQ4nV0gUPH56eBoFKpgjvwo5kS+zZDKs5SsvRMI+IdahJPmJpYGM4WYwYDN14+9ec5FRBPRy1hnQfcLeQ87WTuomFVwxqbBNn1FSTKMQlm7p6a+eHpqFGt5Ch6eIhpzbVfy5OLGHUu/eEOrIxGLOWq/jxEoOc4QGYf0dI6Wmxw8DYmKEf95JZGEk4S2WtDFZLCsYGxeBTpotdBMosK4cqM0Pp+vXkqykvUM2JTiC31yXN5BBghwfjJbNNsk8Ug173+1U81zT/xPBQRjN+prxue+Zj3spqdIrgZuMwaTxfYc2eJNW/BrWHFXdnDatnzYYghBkgwf9irAkKR9aUrpdLISU/HNtUbQQ+vL3/fFLN5jQsNoFsYGV1ior0MF2C8M2EHaxnNwRgK2dLY+fk4GbDAVrLW/Fw3fuGBGsB8p5DEL+v07uOENKN74x3+ePf8jBsiEjbfVikfs1mzpPZlzsSYyc5ROhu/+r9AfbN+9yOoQUgi20Oc+Uu8wD4ORge2QCejo0qzPyxZZzYUM4qL1fq2BhFT5zg3CE0LAThikoO7fbgBOK5MmPx6Jl2RmoQq3Pi4/oh7cbSHI9NcSVs7dwPnh6c6gIzMgOMq2vdPJa2+gPCEaij8zLGnh7g7dRprGTKrv8hu1KA8SqZbGdlM0FRbJRFFcKfNAgduhaZuDF2F5v8QSgLtd5AhmKf/T3ru38CvJIRpDI8pxY6ruOxZdz6d9EsFs5+7iMkahQVZ4FFWTcc59QwwvsDMT2+suovhl4KuALoaRAXKTHxen7tsH27qm/3jgq7Gz0pToBBAlPfaKp1pG5wPTI6tgeGwtpuVAgmkW9eqPgtOCyvpI6fEQNPip75OtlkcwLcXxf//7BIMJI6yiOVQvKmJdeD+eGSF2SxFMF4qvJnDR6/mCvoVJKos/e42ELOAk9SO96EHVg+Jfq3lKFZJtSRpUpx1D/8DkMCz/d9txQQMoFkRVCUPBgSLKbLPjdTtWEh56vmJVm0RphikLl6XCIV2//Gp/Z3pTRi/wqkHnxYCxzjvNMHOLnJANQuL53KACDqb9KU8Vz++hFyIhgOtk3uv81+sLdvVm9F9DDHNJDgSbEYbxAvy4JpzGmfFK2iIGVhG9EueWJZcVQCqOX8BZO7bbynYafcAKAQqKWvNx9OqQCGsdGEzkfrZhUVcYZey788I+jKg+D8pDA8dTMIrYOD0Q7kf5147FTMfYeM1+YHZhq7I6owpPKxYCh0j+2BatcTwOYJbrYI4N9hI2QnDuu7OHGeBWe0RevxWY7/x19tvfdC5gMdlKPTIBaKZUkUBYIjrkcSMTpvD/7ZHzEjpHNtdh5NCDgEIbX54s/CjS5YT+xP/qfSMXd5aeV0uF7CsuyvJI0zHji9R3749HKcaNvvclv1+tRwk1zm6CmSxh3DcCG+qf6qbKQYTldaJ2Noi9bUzk2CMfnSmFYmCTDDMCNvqT9kmAvDQ8dP+7k7rVkhszYOetdCYXHm7KZ9Yb5fPjLbrzRDwKuKveLERYCatJdQAFVwH2e5wCl3KXKpEOnPevSy32u29NtA522q7gY8zE43RSXwmVdH/GkADT2AN/zih2L9ql1Rbhx1IBfb43+Z39rK29h/dG+JK/Ag2RTHtFEjkmU23jkosJrH8+adqcJzxlzjX7CRP6/TQvYLH4DpSXFOJaMagS51GI4EnguepJ5QcIysZzHeRDJpUz3f1rOZMYlsqlvtGuHvDrZ9nLhWj7gCZXzquzSboak0k79Dw+GtysYcyz91aKUwoLK0noLTTqUpRChyZdx+qreMGQMAmvMLgj8XBAocYEEDEApkT5jeLLy9+0O2Hcac0QWoBcQs9TTiRqYht18c3d2WfT2yg6yyTGhpGmX5Lsiuld1zikh13gHzP861IblI2dXQSrO/eBB2WuXDksPX6hBUSVbBQy/yqtgjcXmfS5GFB0hkQKacqLdqv738dS0oov6UMef42es8bdoHKuNWLuNt060Pp528om54iBdw70CyzMBoAIX3lBZFdEloZ2Edi6nvXiX+f1j8AFJWzd07d96w/LHRjhQ8W9jqbUNCvAHKKLAR6+XAtfs25ET1ti8heaImTKKx4m5cM+qjEMyTd36knnBwhKDpgr2pu5eYdwG8zGTOCOUq8ZaK+fgkSvOIftI1/LB5K2NpgQ5YET9o7govd+U2HqohWCqYOnqGOdsh0dhpwwFSgsiFnIG8kNt/0ZKsuFfaJwoKOE7OKUYcV57XJ1AzTp4ZlW+NXldt9v4DcJzoD2pOgB0CQB2z/YvAuWD/jKhSt67oYQq3hgbSiLTyNfZ8j4I0iGUizj8O8e+TXICSxe/bt1jwQH6mPLMvtIIKUZBwHoYsDZYNDw+Z0Py+g+6xW7MqsvPq8WwpwHYj4ARTBKyCsNNAeepHDe1saBD+AiBJ/9mAUgtvBeA5J3MQiI6o5dv47NiHOzPvj6Hi92sTp5JcT/m9iYa6Nkd1eaHgnLrlqGXwZUIcshs+JtIoUUdrJczZaj8dFE1y3LUXfWL2pE8cXsSraqvyUNRZ0PzajXOpI1+oRHgMdEFtsEo+a3boLtDgfe6ms+n/OZbjEkXoy3wfQgazSq8/BtJl2FbQPDPHO7lkJt0rRoGSgjQ96kabgM5fAqX2YFhSIbb7VWrPHchndCL1HJvaNx9G/fndLRUUjtFDnpOl/Li3OVCdlFcSD+Jlt5vUjW6iJhLLV0RAJhTCxUv47AyUA4yEn72pXJ2ZlfcINj3QvEqOSENPf/UbKbKK1PEfvahvHzB3JBBP1Cf/UhpUkyg6hxXHsH3f2jfo4l/5vhbve8YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9823,"cache_read_input_tokens":79567,"cache_creation":{"ephemeral_5m_input_tokens":9823,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"571bcf08-c518-42ca-9702-cd8a733080af"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL1WukZM97UnaqeZVVN","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISwwQKpQEIERgCKkA94goOFaZhgC1BsG97YEP2moAXJNZxYzfbw/XyfYG7m2tIrHNQn0VQYxeUZHeVhtSnkXOhjBMbw2CKU7pc+dBkMg5jbGF1ZGUtZmFibGUtNTgBQgluYXJyYXRpb25aJDZmY2M4ZjgzLTFmOWMtNGQzMy1iYTA3LTRkNGRmMzdkNjdmNXIQ9HhX+w9e52x5AjwcTVbSGogBAagBqqSi1AYSDLwLYOeb2YDdr5xNYxoMGebaSpCXLhdSdphrIjBMcqw6SYpe5vNPFp+eN6/hFW30/q/9GqleDRkEFnsS3EMZyNwbKShDXGeXzVlBTKcqygLMOi+ywWFtC13DiH5PLmStvuq5hVzYt87tLxC3SY3hfLUEmUvYpX1001o46BzBIVOXQYW1SHu0Q3BOePHq73LmKnEG8eQHNGlEtaPSQRwp+3nKSMZzH7j1l4KQNyzEhGzeDlRZaxdVG5fol8UoQuhfhqddeLTMamZCeFY7hlJjW9QccsqB240HFi8H0gxSrAeO0reflxTy4XORPEnZBHFNcSXBDr6wGrevH+TRkY1r5Ahnu4JfX7fiH+dk5jyqSm2Unm/Ylfi4JlWQOb+P4BE8tpq7gWBfGjZe+hfh4wogOxnUu6GjeLxuoxie4PiWi5dMjnE1/8Koy5I371CHqNQ63rLEVUpHnDSsXsKdE1a8pndHeXIg0J6VayaVfxyNIFZLO2DG2QEeVDMti+8wuM+C8EQl0aQcE5tbrKKf0MK4IYleAG5FSG02Rj0YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9823,"cache_read_input_tokens":79567,"cache_creation":{"ephemeral_5m_input_tokens":9823,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a2983789-5d84-469e-98ae-8c879adb88b7"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL1WukZM97UnaqeZVVN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CYNxB3mp3GyDBWEa6XArQF","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":9823,"cache_read_input_tokens":79567,"cache_creation":{"ephemeral_5m_input_tokens":9823,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"921a2407-eda0-4e19-9352-3a15fa2e59ff"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01CYNxB3mp3GyDBWEa6XArQF","content":[{"type":"tool_reference","tool_name":"TodoWrite"}]}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1db20517-8e27-45d3-8566-880931aa64ab","timestamp":"2026-08-21T18:00:10.838Z","tool_use_result":{"matches":["TodoWrite"],"query":"select:TodoWrite","total_deferred_tools":17}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL1WukZM97UnaqeZVVN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PAq6EQGrfmFEXoJf5Fs512","name":"Bash","input":{"command":"cat /Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/.actor/actor.json","description":"Voir le format actor.json (version)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":9823,"cache_read_input_tokens":79567,"cache_creation":{"ephemeral_5m_input_tokens":9823,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7a959e01-5f37-43ff-9a92-6068a50ebba8"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PAq6EQGrfmFEXoJf5Fs512","type":"tool_result","content":"{\n \"actorSpecification\": 1,\n \"name\": \"ka-x\",\n \"title\": \"KA X (Twitter) — profils + tweets récents\",\n \"description\": \"Profils X (Twitter) détaillés SANS clé API via le service officiel de syndication des widgets : abonnés, bio, badge, avatar + tweets récents avec métriques (favoris, RT, réponses) et engagement moyen.\",\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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"99dffcd2-3c8d-4ff2-b2e5-5c7acd44a79d","timestamp":"2026-08-21T18:00:11.956Z","tool_use_result":{"stdout":"{\n \"actorSpecification\": 1,\n \"name\": \"ka-x\",\n \"title\": \"KA X (Twitter) — profils + tweets récents\",\n \"description\": \"Profils X (Twitter) détaillés SANS clé API via le service officiel de syndication des widgets : abonnés, bio, badge, avatar + tweets récents avec métriques (favoris, RT, réponses) et engagement moyen.\",\n \"version\": \"0.1\",\n \"buildTag\": \"latest\",\n \"input\": \"./input_schema.json\",\n \"dockerfile\": \"./Dockerfile\"\n}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9dd7946b-2739-44e8-af8d-01af0a6b1e0b","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL6Artao9dRaNKRbJmv","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01LLvyasjEQJqNH1LtVe348b","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"in_progress","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"pending","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, playlists, cadence, SEO)","status":"pending","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":7720,"cache_read_input_tokens":89390,"cache_creation":{"ephemeral_5m_input_tokens":7720,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f2f74b39-269a-4a4d-960c-3772d082f1c4"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01LLvyasjEQJqNH1LtVe348b","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"81832c6c-e9d0-4685-a265-00282c903f32","timestamp":"2026-08-21T18:00:24.372Z","tool_use_result":{"oldTodos":[{"content":"Diagnostiquer et réparer l'acteur ka-x (0 résultat depuis des jours)","status":"completed","activeForm":"Diagnostiquant l'acteur ka-x"},{"content":"Réparer facebook/threads (bug concurrency 10 > max 6 du schéma → 400) : 1 402 fiches FB enrichies","status":"completed","activeForm":"Réparant facebook/threads"},{"content":"Passage forcé complet toutes plateformes (multi 6,8 → 19,6 %, +4 167 comptes)","status":"completed","activeForm":"Forçant le passage complet"},{"content":"Second passage (mode normal) sur les nouveaux comptes croisés","status":"completed","activeForm":"Enrichissant les nouveaux comptes croisés"},{"content":"Vérifier l'impact final (jauges) et relancer crea-ka-sync","status":"completed","activeForm":"Vérifiant l'impact final"},{"content":"Redéployer (pm2 restart web + sync, healthz 200), commits poussés (cac232a, 0cad882)","status":"completed","activeForm":"Redéployant et poussant"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"in_progress","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"pending","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, playlists, cadence, SEO)","status":"pending","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9c7e1478-c193-44a5-94db-b8c455969bb7","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL75pZ2nDViexnJcH6F","type":"message","role":"assistant","content":[{"type":"text","text":"Phase 1 — j'écris le nouveau `net.py` durci puis je le propage aux 13 acteurs."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":944,"cache_read_input_tokens":97110,"cache_creation":{"ephemeral_5m_input_tokens":944,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"483f3d1a-ddcd-4094-9e97-ecc97365bbab"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL75pZ2nDViexnJcH6F","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RwkZJue5ZeSQkYUriMtxfo","name":"Write","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/net.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504\n# avec rotation de session proxy (nouvelle IP) à chaque tentative.\n# GET et POST partagent le même moteur de 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# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur\nRETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504})\n\n\ndef _backoff(attempt: int) -> float:\n \"\"\"Backoff exponentiel plafonné + jitter (anti-troupeau).\"\"\"\n return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8)\n\n\nclass Fetcher:\n \"\"\"HTTP poli : proxy Apify + impersonation Chrome + retries robustes.\"\"\"\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 request(self, method: str, url: str,\n headers: dict | None = None, data=None,\n retries: int = 4, 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 # nouvelle session proxy (nouvelle IP) à chaque tentative\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.request, method, url, headers=hdrs, data=data,\n impersonate=impersonate, timeout=60, allow_redirects=True,\n proxies={\"http\": proxy, \"https\": proxy} if proxy else None)\n if resp.status_code in RETRY_STATUSES \\\n and attempt < retries - 1:\n Actor.log.warning(\n f\"HTTP {resp.status_code} {url} — retry {attempt + 1}\")\n await asyncio.sleep(_backoff(attempt))\n continue\n return resp\n except Exception as exc: # réseau/proxy : on retente\n last_exc = exc\n await asyncio.sleep(_backoff(attempt))\n if last_exc:\n raise last_exc\n raise RuntimeError(f\"échec après {retries} tentatives : {url}\")\n\n async def get(self, url: str, headers: dict | None = None,\n retries: int = 4, session_id: str | None = None,\n impersonate: str | None = \"chrome\"):\n return await self.request(\"GET\", url, headers=headers,\n retries=retries, session_id=session_id,\n impersonate=impersonate)\n\n async def post(self, url: str, data=None, headers: dict | None = None,\n retries: int = 4, session_id: str | None = None,\n impersonate: str | None = \"chrome\"):\n return await self.request(\"POST\", url, data=data, headers=headers,\n retries=retries, session_id=session_id,\n impersonate=impersonate)\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":944,"cache_read_input_tokens":97110,"cache_creation":{"ephemeral_5m_input_tokens":944,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ad97475f-61b4-43ec-aa1e-c6559b2bdcb7"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RwkZJue5ZeSQkYUriMtxfo","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/net.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8aee6174-21d8-4881-bd01-c8925a667fbe","timestamp":"2026-08-21T18:00:45.049Z","tool_use_result":{"type":"update","filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/net.py","content":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504\n# avec rotation de session proxy (nouvelle IP) à chaque tentative.\n# GET et POST partagent le même moteur de 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# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur\nRETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504})\n\n\ndef _backoff(attempt: int) -> float:\n \"\"\"Backoff exponentiel plafonné + jitter (anti-troupeau).\"\"\"\n return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8)\n\n\nclass Fetcher:\n \"\"\"HTTP poli : proxy Apify + impersonation Chrome + retries robustes.\"\"\"\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 request(self, method: str, url: str,\n headers: dict | None = None, data=None,\n retries: int = 4, 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 # nouvelle session proxy (nouvelle IP) à chaque tentative\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.request, method, url, headers=hdrs, data=data,\n impersonate=impersonate, timeout=60, allow_redirects=True,\n proxies={\"http\": proxy, \"https\": proxy} if proxy else None)\n if resp.status_code in RETRY_STATUSES \\\n and attempt < retries - 1:\n Actor.log.warning(\n f\"HTTP {resp.status_code} {url} — retry {attempt + 1}\")\n await asyncio.sleep(_backoff(attempt))\n continue\n return resp\n except Exception as exc: # réseau/proxy : on retente\n last_exc = exc\n await asyncio.sleep(_backoff(attempt))\n if last_exc:\n raise last_exc\n raise RuntimeError(f\"échec après {retries} tentatives : {url}\")\n\n async def get(self, url: str, headers: dict | None = None,\n retries: int = 4, session_id: str | None = None,\n impersonate: str | None = \"chrome\"):\n return await self.request(\"GET\", url, headers=headers,\n retries=retries, session_id=session_id,\n impersonate=impersonate)\n\n async def post(self, url: str, data=None, headers: dict | None = None,\n retries: int = 4, session_id: str | None = None,\n impersonate: str | None = \"chrome\"):\n return await self.request(\"POST\", url, data=data, headers=headers,\n retries=retries, session_id=session_id,\n impersonate=impersonate)\n","structuredPatch":[{"oldStart":2,"oldLines":7,"newStart":2,"newLines":10,"lines":[" # Author: Simon-Pierre Boucher "," # File: src/net.py"," # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +","-# proxy Apify (résidentiel par défaut), throttling poli + retries","+# proxy Apify (résidentiel par défaut), throttling poli, retries à","+# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504","+# avec rotation de session proxy (nouvelle IP) à chaque tentative.","+# GET et POST partagent le même moteur de retries."," # =============================================================================="," from __future__ import annotations"," "]},{"oldStart":16,"oldLines":9,"newStart":19,"newLines":17,"lines":[" UA = (\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \""," \"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36\")"," ","+# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur","+RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504})"," ","+","+def _backoff(attempt: int) -> float:","+ \"\"\"Backoff exponentiel plafonné + jitter (anti-troupeau).\"\"\"","+ return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8)","+","+"," class Fetcher:","- \"\"\"GET poli avec proxy Apify + impersonation Chrome + retries 403/429.\"\"\"","+ \"\"\"HTTP poli : proxy Apify + impersonation Chrome + retries robustes.\"\"\""," "," def __init__(self, proxy_configuration, delay: float = 1.0) -> None:"," self.proxy_configuration = proxy_configuration"]},{"oldStart":34,"oldLines":9,"newStart":45,"newLines":10,"lines":[" await asyncio.sleep(wait)"," self._last = asyncio.get_event_loop().time()"," ","- async def get(self, url: str, headers: dict | None = None,","- retries: int = 3, session_id: str | None = None,","- impersonate: str | None = \"chrome\"):","+ async def request(self, method: str, url: str,","+ headers: dict | None = None, data=None,","+ retries: int = 4, session_id: str | None = None,","+ impersonate: str | None = \"chrome\"):"," hdrs = {\"User-Agent\": UA,"," \"Accept-Language\": \"fr-CA,fr;q=0.9,en;q=0.8\"}"," if headers:"]},{"oldStart":48,"oldLines":22,"newStart":60,"newLines":38,"lines":[" if self.proxy_configuration:"," sid = session_id or f\"s{random.randint(1, 999_999)}\""," sid = re.sub(r\"[^\\w._~]\", \"\", sid)[:40] or \"s\"","+ # nouvelle session proxy (nouvelle IP) à chaque tentative"," proxy = await self.proxy_configuration.new_url("," session_id=f\"{sid}r{attempt}\")"," try:"," resp = await asyncio.to_thread(","- cffi.get, url, headers=hdrs, impersonate=impersonate,","- timeout=60, allow_redirects=True,","+ cffi.request, method, url, headers=hdrs, data=data,","+ impersonate=impersonate, timeout=60, allow_redirects=True,"," proxies={\"http\": proxy, \"https\": proxy} if proxy else None)","- if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:","+ if resp.status_code in RETRY_STATUSES \\","+ and attempt < retries - 1:"," Actor.log.warning("," f\"HTTP {resp.status_code} {url} — retry {attempt + 1}\")","- await asyncio.sleep(1.5 * (attempt + 1))","+ await asyncio.sleep(_backoff(attempt))"," continue"," return resp"," except Exception as exc: # réseau/proxy : on retente"," last_exc = exc","- await asyncio.sleep(1.5 * (attempt + 1))","+ await asyncio.sleep(_backoff(attempt))"," if last_exc:"," raise last_exc"," raise RuntimeError(f\"échec après {retries} tentatives : {url}\")","+","+ async def get(self, url: str, headers: dict | None = None,","+ retries: int = 4, session_id: str | None = None,","+ impersonate: str | None = \"chrome\"):","+ return await self.request(\"GET\", url, headers=headers,","+ retries=retries, session_id=session_id,","+ impersonate=impersonate)","+","+ async def post(self, url: str, data=None, headers: dict | None = None,","+ retries: int = 4, session_id: str | None = None,","+ impersonate: str | None = \"chrome\"):","+ return await self.request(\"POST\", url, data=data, headers=headers,","+ retries=retries, session_id=session_id,","+ impersonate=impersonate)"]}],"originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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","userModified":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7ac0fcb5-55f9-4eeb-a138-15ac24f3ab8d","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL8cUK3QynQEtNHLs4h","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01P12DouCMJv652JUo6vU9fq","name":"Bash","input":{"command":"for a in ka-instagram ka-tiktok ka-youtube ka-twitch ka-kick ka-fansly ka-onlyfans ka-facebook ka-threads ka-snapchat ka-discord ka-patreon; do cp ka-x/src/net.py $a/src/net.py; done && md5 ka-*/src/net.py | awk '{print $NF}' | sort -u","description":"Propager net.py durci aux 13 acteurs et vérifier l'identité md5"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1807,"cache_read_input_tokens":98054,"cache_creation":{"ephemeral_5m_input_tokens":1807,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a87f7022-6f7f-47db-9607-6e8bc779b8d0"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01P12DouCMJv652JUo6vU9fq","type":"tool_result","content":"5cb439fabb52de0e5c844da20963b050","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"935530cd-316c-4075-ad7a-073643ac380b","timestamp":"2026-08-21T18:00:50.443Z","tool_use_result":{"stdout":"5cb439fabb52de0e5c844da20963b050","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ad9d33e4-c150-446e-925b-0aaaeac2b259","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL91Vf45Y5NrFhnRMqa","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISnwMKpAEIERgCKkDD8RDNHifJbDZHadL85XNbaZB4S3E3PqC6qecQ6I/bfXk54AB/voYZa/MoMeyH2ZA39/uYJY/7WIOD7uaOi3oVMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHZpKLUBhIMUSJDW6QxSzaCzwfeGgwJYQ0I35reZdNA2d4iMK2VC2irPpocK8JHVVIuBBzVrfcfNEbVVA79FstcEG2udk8cD4KGt4sQaXhSQDyamSqnAe3Bzs2Rpn//r26HlTqolD+5mUWRTtTQmZyQVKFolbIFpoc98h3lEB8tp9FHU0mZAb/ZOkdk9y/5reJLmhBAsBiQHL69eemchpLD8TwXFkEgoG5OAxyHvq96hiLIglxtKgcRRbI4cKha6gCEbUEeU0j+1HqgEa8dhpYEervaha1V6ItVlrcEyODzp2f2979YJaOnSsmpga1u7mRJRv/319N0kYAMYxo9GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":248,"cache_read_input_tokens":99861,"cache_creation":{"ephemeral_5m_input_tokens":248,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7b9aa8b2-5a26-4384-ba36-f27f9c9831d3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL91Vf45Y5NrFhnRMqa","type":"message","role":"assistant","content":[{"type":"text","text":"`net.py` propagé, identique sur les 13. Phase 2 — enrichissement acteur par acteur, en commençant par Instagram."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":248,"cache_read_input_tokens":99861,"cache_creation":{"ephemeral_5m_input_tokens":248,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"10d3429e-70dc-49e1-a536-4ca71f3704c3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGL91Vf45Y5NrFhnRMqa","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CjR1x2bYfyZHUZqhm6jDuB","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"in_progress","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, playlists, cadence, SEO)","status":"pending","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":248,"cache_read_input_tokens":99861,"cache_creation":{"ephemeral_5m_input_tokens":248,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9f19b430-e12a-48ce-ae47-d1cbe9ab9851"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CjR1x2bYfyZHUZqhm6jDuB","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a594c328-be6c-43b4-9277-fe2273a8c48b","timestamp":"2026-08-21T18:01:05.322Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"in_progress","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"pending","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, playlists, cadence, SEO)","status":"pending","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"in_progress","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, playlists, cadence, SEO)","status":"pending","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"744dce2c-64aa-4872-9819-8ff53d6a6a83","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLA7DyLzzQNf8LY5YUC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019WYoT2tBSHBrPNnYkf6ZVv","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","old_string":"def 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 [])]","new_string":"def 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 bio_ent = user.get(\"biography_with_entities\") or {}\n bio_mentions, bio_hashtags = [], []\n for ent in (bio_ent.get(\"entities\") or []):\n eu = ((ent or {}).get(\"user\") or {}).get(\"username\")\n eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")\n if eu:\n bio_mentions.append(eu)\n if eh:\n bio_hashtags.append(eh)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1056,"cache_read_input_tokens":100109,"cache_creation":{"ephemeral_5m_input_tokens":1056,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7ca3e039-09c0-42cc-a121-9406f86afbbf"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019WYoT2tBSHBrPNnYkf6ZVv","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0eef71df-a28c-44a4-8676-222af3479d07","timestamp":"2026-08-21T18:01:12.671Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","oldString":"def 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 [])]","newString":"def 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 bio_ent = user.get(\"biography_with_entities\") or {}\n bio_mentions, bio_hashtags = [], []\n for ent in (bio_ent.get(\"entities\") or []):\n eu = ((ent or {}).get(\"user\") or {}).get(\"username\")\n eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")\n if eu:\n bio_mentions.append(eu)\n if eh:\n bio_hashtags.append(eh)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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","structuredPatch":[{"oldStart":90,"oldLines":6,"newStart":90,"newLines":15,"lines":[" posts = [parse_post((e.get(\"node\") or {}))"," for e in ((user.get(\"edge_owner_to_timeline_media\") or {})"," .get(\"edges\") or [])]","+ bio_ent = user.get(\"biography_with_entities\") or {}","+ bio_mentions, bio_hashtags = [], []","+ for ent in (bio_ent.get(\"entities\") or []):","+ eu = ((ent or {}).get(\"user\") or {}).get(\"username\")","+ eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")","+ if eu:","+ bio_mentions.append(eu)","+ if eh:","+ bio_hashtags.append(eh)"," followers = (user.get(\"edge_followed_by\") or {}).get(\"count\")"," avg_likes = _avg([p[\"likes\"] for p in posts])"," avg_comments = _avg([p[\"comments\"] for p in posts])"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3832ebe5-270b-431e-8ac3-dabc40e3bc5b","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLAh3D2wBG8pdpFL5hQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EBXNUTWwLq4NhrzKAFq2CE","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","old_string":" \"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\")],","new_string":" \"username\": user.get(\"username\"),\n \"full_name\": user.get(\"full_name\"),\n \"biography\": user.get(\"biography\"),\n \"bio_mentions\": bio_mentions[:10], # @comptes cités dans la bio\n \"bio_hashtags\": bio_hashtags[:10],\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\")],"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":575,"cache_read_input_tokens":101165,"cache_creation":{"ephemeral_5m_input_tokens":575,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9df82af0-db8d-46e4-91b7-7dac705221ba"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EBXNUTWwLq4NhrzKAFq2CE","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ebd553b9-6a4b-411c-8c06-27252ef18a6d","timestamp":"2026-08-21T18:01:21.152Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","oldString":" \"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\")],","newString":" \"username\": user.get(\"username\"),\n \"full_name\": user.get(\"full_name\"),\n \"biography\": user.get(\"biography\"),\n \"bio_mentions\": bio_mentions[:10], # @comptes cités dans la bio\n \"bio_hashtags\": bio_hashtags[:10],\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\")],","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 bio_ent = user.get(\"biography_with_entities\") or {}\n bio_mentions, bio_hashtags = [], []\n for ent in (bio_ent.get(\"entities\") or []):\n eu = ((ent or {}).get(\"user\") or {}).get(\"username\")\n eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")\n if eu:\n bio_mentions.append(eu)\n if eh:\n bio_hashtags.append(eh)\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","structuredPatch":[{"oldStart":132,"oldLines":6,"newStart":132,"newLines":8,"lines":[" \"username\": user.get(\"username\"),"," \"full_name\": user.get(\"full_name\"),"," \"biography\": user.get(\"biography\"),","+ \"bio_mentions\": bio_mentions[:10], # @comptes cités dans la bio","+ \"bio_hashtags\": bio_hashtags[:10],"," \"pronouns\": user.get(\"pronouns\") or None,"," \"external_url\": user.get(\"external_url\"),"," \"bio_links\": [b.get(\"url\") for b in (user.get(\"bio_links\") or [])"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b2797a16-49d6-4914-a18c-5317dc3e5f3d","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLBGgGj2Sf23M24qPpf","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CKeuNBFBTA7yj8FuZvMTv3","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","old_string":" \"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\"),","new_string":" \"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 \"igtv_videos_count\": (user.get(\"edge_felix_video_timeline\")\n or {}).get(\"count\"),\n \"mutual_followers\": (user.get(\"edge_mutual_followed_by\")\n or {}).get(\"count\"),\n \"highlight_reels\": user.get(\"highlight_reel_count\"),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":500,"cache_read_input_tokens":101740,"cache_creation":{"ephemeral_5m_input_tokens":500,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"38d49283-55f0-471b-9960-72328149c3df"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CKeuNBFBTA7yj8FuZvMTv3","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"318699c0-9130-4dab-933e-f491afc27987","timestamp":"2026-08-21T18:01:27.191Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","oldString":" \"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\"),","newString":" \"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 \"igtv_videos_count\": (user.get(\"edge_felix_video_timeline\")\n or {}).get(\"count\"),\n \"mutual_followers\": (user.get(\"edge_mutual_followed_by\")\n or {}).get(\"count\"),\n \"highlight_reels\": user.get(\"highlight_reel_count\"),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 bio_ent = user.get(\"biography_with_entities\") or {}\n bio_mentions, bio_hashtags = [], []\n for ent in (bio_ent.get(\"entities\") or []):\n eu = ((ent or {}).get(\"user\") or {}).get(\"username\")\n eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")\n if eu:\n bio_mentions.append(eu)\n if eh:\n bio_hashtags.append(eh)\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 \"bio_mentions\": bio_mentions[:10], # @comptes cités dans la bio\n \"bio_hashtags\": bio_hashtags[:10],\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","structuredPatch":[{"oldStart":142,"oldLines":6,"newStart":142,"newLines":10,"lines":[" \"following\": (user.get(\"edge_follow\") or {}).get(\"count\"),"," \"posts_count\": (user.get(\"edge_owner_to_timeline_media\")"," or {}).get(\"count\"),","+ \"igtv_videos_count\": (user.get(\"edge_felix_video_timeline\")","+ or {}).get(\"count\"),","+ \"mutual_followers\": (user.get(\"edge_mutual_followed_by\")","+ or {}).get(\"count\"),"," \"highlight_reels\": user.get(\"highlight_reel_count\"),"," \"is_verified\": user.get(\"is_verified\"),"," \"is_private\": user.get(\"is_private\"),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"2c00e850-8255-485d-bd4e-80c2245b6fac","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLBiMrx2D2ft9i7G96o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015vPyQFDYi32YRv5Zy4VtD2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","old_string":" \"category\": user.get(\"category_name\"),\n \"business_category\": user.get(\"business_category_name\"),\n \"business_address\": user.get(\"business_address_json\"),","new_string":" \"category\": user.get(\"category_name\"),\n \"business_category\": user.get(\"business_category_name\"),\n \"business_address\": user.get(\"business_address_json\"),\n # courriel PRO affiché publiquement par le créateur (§15 : public only)\n \"business_email\": user.get(\"business_email\") or None,\n \"business_phone\": user.get(\"business_contact_method\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)\n \"has_threads\": (bool(user.get(\"has_onboarded_to_text_post_app\"))\n if \"has_onboarded_to_text_post_app\" in user\n else None),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":479,"cache_read_input_tokens":102240,"cache_creation":{"ephemeral_5m_input_tokens":479,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fa5f6668-582f-4ab0-8b55-7e4c44744f74"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015vPyQFDYi32YRv5Zy4VtD2","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"713a2c67-0443-4b0d-b285-5edda7d87402","timestamp":"2026-08-21T18:01:33.893Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","oldString":" \"category\": user.get(\"category_name\"),\n \"business_category\": user.get(\"business_category_name\"),\n \"business_address\": user.get(\"business_address_json\"),","newString":" \"category\": user.get(\"category_name\"),\n \"business_category\": user.get(\"business_category_name\"),\n \"business_address\": user.get(\"business_address_json\"),\n # courriel PRO affiché publiquement par le créateur (§15 : public only)\n \"business_email\": user.get(\"business_email\") or None,\n \"business_phone\": user.get(\"business_contact_method\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)\n \"has_threads\": (bool(user.get(\"has_onboarded_to_text_post_app\"))\n if \"has_onboarded_to_text_post_app\" in user\n else None),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 bio_ent = user.get(\"biography_with_entities\") or {}\n bio_mentions, bio_hashtags = [], []\n for ent in (bio_ent.get(\"entities\") or []):\n eu = ((ent or {}).get(\"user\") or {}).get(\"username\")\n eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")\n if eu:\n bio_mentions.append(eu)\n if eh:\n bio_hashtags.append(eh)\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 \"bio_mentions\": bio_mentions[:10], # @comptes cités dans la bio\n \"bio_hashtags\": bio_hashtags[:10],\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 \"igtv_videos_count\": (user.get(\"edge_felix_video_timeline\")\n or {}).get(\"count\"),\n \"mutual_followers\": (user.get(\"edge_mutual_followed_by\")\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","structuredPatch":[{"oldStart":154,"oldLines":6,"newStart":154,"newLines":13,"lines":[" \"category\": user.get(\"category_name\"),"," \"business_category\": user.get(\"business_category_name\"),"," \"business_address\": user.get(\"business_address_json\"),","+ # courriel PRO affiché publiquement par le créateur (§15 : public only)","+ \"business_email\": user.get(\"business_email\") or None,","+ \"business_phone\": user.get(\"business_contact_method\") or None,","+ # présent = compte Threads relié (cross-link fort, même handle)","+ \"has_threads\": (bool(user.get(\"has_onboarded_to_text_post_app\"))","+ if \"has_onboarded_to_text_post_app\" in user","+ else None),"," \"has_clips\": user.get(\"has_clips\"),"," \"has_channel\": user.get(\"has_channel\"),"," \"has_ar_effects\": user.get(\"has_ar_effects\"),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ad04df91-8cb5-44fa-a9ef-5ea215a4b283","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLCDHexFy3WiomHPD4f","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISxQQKpAEIERgCKkCYrEB0pe+Tl0c34Y0n8Lc4UMaxL3iU4f/U//PlLcc3T+ulmksuIt1EKx2tKmlrp9eUG5G5BqXcxAhDOdEZlXlSMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGCpaLUBhIM5ow/1FC3bYNd/ab6GgwzF6m+aCaUQld6YLciMJHLZDXohqdMIhWdqLrwmuzNIe+JntkBMwkTX212qOB95OsHqPvZtqu6hfS7Ge6uZirNAqMWQQv6SX3k/zRmF57TFeSiuYf3x0ERRVRwoDhdEkv9xLC8SXCWUoe5bMm0ED0w/VU/CEEFoW04+Rjt4ugJsylPIj5TsMNz/Vwcu5Q0c5i6mFO7/8vsV8tKxS5I0usB2QfI21QCjE9uEqH39bLjngMEFBV/ZjQYqSvvtRRfclDZrOGsoTCoTu0S+wRe4SPhMfg9Tw9HA0IiX0RWLmdox3Fb8VGYRaJPbxWrcvcgToMjG5OeX30agKtYakMlMASOaKZxp7IG4pCK0FPWJ8iGyNMD9p0kSIfR8Y9gzmKl8tnBQFB7m0CEwbtT/rxx4x5hKAuklzb6C6SDgqIM6RvOq0rtCEoSQdv7Vg4PYloIzy4sKJxGFqnukzjonEFz1Um21XK5R/qA06xt2zm5wf5QU03uenoiU6fsUotIFtZ6MS96kRlBR4INstuFxyY+yxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":459,"cache_read_input_tokens":102719,"cache_creation":{"ephemeral_5m_input_tokens":459,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"83c6483d-81a2-4b13-863e-af1c1dc67ab3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLCDHexFy3WiomHPD4f","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01F21Tb4gbfSdydvjLCziBrX","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","old_string":" \"business_email\": user.get(\"business_email\") or None,\n \"business_phone\": user.get(\"business_contact_method\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)","new_string":" \"business_email\": user.get(\"business_email\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":459,"cache_read_input_tokens":102719,"cache_creation":{"ephemeral_5m_input_tokens":459,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"aeb9415d-aa61-4d7f-981d-eebf2f568bff"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01F21Tb4gbfSdydvjLCziBrX","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4b6820b2-bc01-443b-be99-87742fc5b1c8","timestamp":"2026-08-21T18:01:40.618Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-instagram/src/main.py","oldString":" \"business_email\": user.get(\"business_email\") or None,\n \"business_phone\": user.get(\"business_contact_method\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)","newString":" \"business_email\": user.get(\"business_email\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 bio_ent = user.get(\"biography_with_entities\") or {}\n bio_mentions, bio_hashtags = [], []\n for ent in (bio_ent.get(\"entities\") or []):\n eu = ((ent or {}).get(\"user\") or {}).get(\"username\")\n eh = ((ent or {}).get(\"hashtag\") or {}).get(\"name\")\n if eu:\n bio_mentions.append(eu)\n if eh:\n bio_hashtags.append(eh)\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 \"bio_mentions\": bio_mentions[:10], # @comptes cités dans la bio\n \"bio_hashtags\": bio_hashtags[:10],\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 \"igtv_videos_count\": (user.get(\"edge_felix_video_timeline\")\n or {}).get(\"count\"),\n \"mutual_followers\": (user.get(\"edge_mutual_followed_by\")\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 # courriel PRO affiché publiquement par le créateur (§15 : public only)\n \"business_email\": user.get(\"business_email\") or None,\n \"business_phone\": user.get(\"business_contact_method\") or None,\n # présent = compte Threads relié (cross-link fort, même handle)\n \"has_threads\": (bool(user.get(\"has_onboarded_to_text_post_app\"))\n if \"has_onboarded_to_text_post_app\" in user\n else None),\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","structuredPatch":[{"oldStart":156,"oldLines":7,"newStart":156,"newLines":6,"lines":[" \"business_address\": user.get(\"business_address_json\"),"," # courriel PRO affiché publiquement par le créateur (§15 : public only)"," \"business_email\": user.get(\"business_email\") or None,","- \"business_phone\": user.get(\"business_contact_method\") or None,"," # présent = compte Threads relié (cross-link fort, même handle)"," \"has_threads\": (bool(user.get(\"has_onboarded_to_text_post_app\"))"," if \"has_onboarded_to_text_post_app\" in user"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8ac11010-27f9-466b-a230-5af03980b923","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLChrdJk4qLm4kfyWbu","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrwgKpAEIERgCKkBtjPj5x5PzmMBbFA195xFbnU3YkeroKd9uqJ6DaJ91nO/Dy+ec38U/lpHTKWK7pBkG99idUnoyPukH57b8kDbaMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGPpaLUBhIMrhN0kcOAiIbKXK+wGgx8Fu4UImunN5CDByciMDy+YF9fyKzzJYZN3h0tc18l5FSNvQ55LB2gyY6bgpqHTEKTdeGg3Fp8OpPIgB+2Piq3BgiKOWj+5jFa0anReAlSsxXZBpytgSjVgajKppaS14D2ZGdUEdVYTLK5sws6qAD3ooIBmRfby+Nz9wv4QGhcO95LY2xU/hXka/0vrKoyyPtD3OJ3rb57kfKXCqhYO2aj6CD3rQMp690wqm+ArmMA6LHoNDh8UPK1GUaSpsyzGfjX4hEwM1lY5zrM0EJhqxx03VwA06ILh5XXWjFaXpKkVkBRi7F+75BrcGRnKTBwZ/AWh+b9r+BZ7XY16K6Mvbj5ZSfXw31ECu2kQZBf3xROBp8e7rlQys+/tSoEDT827fCUyw03r67qh+ci3rquqcLTMgl2m/VmpxpeCMHGqLcajr8pMOi0ZMNCxj+qE2z7vCCOBoHayLtoq9OXGDEWGRVnN37UmIw8jSf40TW7rRr+MS8yD7WMJ5M2lQBf9MzDWL9Qpt4DssmYk3LGQg4PKEeMabtFjlKgYP5OVqI87AyqdaSVj1fYZr6FuTD+r8M4ekH43qlTSABqrSLS1JIx5YDCOREcp/nHCUUYsE9NTulPGtZh9BwUke2o59UQVqh+LxdwF2+EO0uEjs/AtvvvR8m7PMC85wzGkhaAbu6/PfavB6Nemz5IoNMBbisrHmwipgyxLShYgUTqb4OPcHbVPrFuYH8ijnN9952v17rpBRKkoVMbFaWSSAo71mG8GwlCnXe+PszSpOm7FVEpynKMQ9EgaGBsu0RVWKu5wjmolhYp73hPoQHQUMS1J4TCXP4gziDU/76zVWqLYqzBN/bfl3acf06F79/Knj7hQWLb0wpTCnWBW/uvy/ec6SpPdZjHYx8i0/DEWPyq+qlo7aKReLvtJohMAVd/dpsnU63Q3vMd+S5yrxGba+9c1+NNKULhANX1lM14ZN92GjmANGjyNkAdZgb0g0qBVzR3zbVNb/z6/LyNh7LyUXDmx0d5HuWebDwbitOSg99kL75+y2hF20oiSkoT7u+rhckazww7iwgwivE/bCwfekR8oS8wnQfzr4uGOQB6bWUf5gvyeiYAjsXzfcJaBwMnWRmk5AWBB8nFWDMUzd6S4R7j1IEdlCAWkBzUNBgyIDh+E8Gz3hiDdokbuohaNJjRIKYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":423,"cache_read_input_tokens":103178,"cache_creation":{"ephemeral_5m_input_tokens":423,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ae213401-5467-46a4-9974-f401827cfa4f"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLChrdJk4qLm4kfyWbu","type":"message","role":"assistant","content":[{"type":"text","text":"Instagram fait (bio mentions/hashtags, flag Threads, business_email public, compteurs IGTV/mutuels). Passons à TikTok."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":423,"cache_read_input_tokens":103178,"cache_creation":{"ephemeral_5m_input_tokens":423,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0cc68f0a-da4d-4fa0-ad2f-e86378d92dce"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLChrdJk4qLm4kfyWbu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MC7mHdm1khmEh9ykbm74CR","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"completed","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, flag mineur, avg_saves, épinglés)","status":"in_progress","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":423,"cache_read_input_tokens":103178,"cache_creation":{"ephemeral_5m_input_tokens":423,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"19297c5b-79f5-4d6b-a30a-732c8659e0d5"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MC7mHdm1khmEh9ykbm74CR","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e4fb4e4a-965a-491d-8d4d-b7bdf75ded0b","timestamp":"2026-08-21T18:02:00.076Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"in_progress","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, playlists, cadence, SEO)","status":"pending","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"completed","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, flag mineur, avg_saves, épinglés)","status":"in_progress","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLChrdJk4qLm4kfyWbu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017BejxAV47GewW8tfVJfEyr","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,","new_string":" \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"likes_given\": _num(stats.get(\"diggCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":423,"cache_read_input_tokens":103178,"cache_creation":{"ephemeral_5m_input_tokens":423,"ephemeral_1h_input_tokens":0},"output_tokens":5,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a9cd0d38-c453-474d-9e00-45b8ba70504e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017BejxAV47GewW8tfVJfEyr","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"912a393e-25db-4996-bc4b-81e1f0910fc6","timestamp":"2026-08-21T18:02:02.531Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,","newString":" \"followers\": followers,\n \"following\": _num(stats.get(\"followingCount\")),\n \"friends\": _num(stats.get(\"friendCount\")),\n \"likes_given\": _num(stats.get(\"diggCount\")),\n \"total_likes\": total_likes,\n \"videos_count\": videos_count,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-tiktok)\n# 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).\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'',\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":187,"oldLines":6,"newStart":187,"newLines":7,"lines":[" \"followers\": followers,"," \"following\": _num(stats.get(\"followingCount\")),"," \"friends\": _num(stats.get(\"friendCount\")),","+ \"likes_given\": _num(stats.get(\"diggCount\")),"," \"total_likes\": total_likes,"," \"videos_count\": videos_count,"," \"avg_likes_per_video_lifetime\": ("]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4d192f59-7821-463c-b146-6b6601c890eb","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLEKnPBM2Cn2tg3ZEtD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015t1evQhpQdcs5Y1K3vGTaR","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" \"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\")),","new_string":" \"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 # drapeau mineur déclaré par TikTok → régime restreint §15\n \"is_under_18\": (bool(user.get(\"isUnderAge18\"))\n if \"isUnderAge18\" in user else None),\n \"is_embed_banned\": (bool(user.get(\"isEmbedBanned\"))\n if \"isEmbedBanned\" in user else None),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1792,"cache_read_input_tokens":103601,"cache_creation":{"ephemeral_5m_input_tokens":1792,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5ef1ea92-f84a-44e2-8268-bc98d303c34b"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015t1evQhpQdcs5Y1K3vGTaR","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d83b7b03-d00e-484e-9181-cb7a34079dc0","timestamp":"2026-08-21T18:02:09.005Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" \"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\")),","newString":" \"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 # drapeau mineur déclaré par TikTok → régime restreint §15\n \"is_under_18\": (bool(user.get(\"isUnderAge18\"))\n if \"isUnderAge18\" in user else None),\n \"is_embed_banned\": (bool(user.get(\"isEmbedBanned\"))\n if \"isEmbedBanned\" in user else None),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-tiktok)\n# 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).\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'',\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 \"likes_given\": _num(stats.get(\"diggCount\")),\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":197,"oldLines":6,"newStart":197,"newLines":11,"lines":[" \"is_organization\": bool(user.get(\"isOrganization\")),"," \"is_seller\": bool(user.get(\"ttSeller\")),"," \"is_live_now\": bool(user.get(\"roomId\")),","+ # drapeau mineur déclaré par TikTok → régime restreint §15","+ \"is_under_18\": (bool(user.get(\"isUnderAge18\"))","+ if \"isUnderAge18\" in user else None),","+ \"is_embed_banned\": (bool(user.get(\"isEmbedBanned\"))","+ if \"isEmbedBanned\" in user else None),"," \"commerce_category\": (commerce.get(\"category\") or None"," if commerce.get(\"commerceUser\")"," else None),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5e9ec43e-39c0-4647-ac5e-10dfab1c856d","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLEpACEYiVmmwqbG1Ub","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ViYjX52fj75MwfRyovTpm2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" \"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),","new_string":" \"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 \"avg_saves\": (round(sum(saves) / len(saves))\n if saves else None),\n \"pinned_videos_count\": (sum(1 for v in videos\n if v[\"is_pinned\"]) or None),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":507,"cache_read_input_tokens":105393,"cache_creation":{"ephemeral_5m_input_tokens":507,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7642992e-a371-45da-8d93-0236dd3ad34e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ViYjX52fj75MwfRyovTpm2","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"04c47b5f-ed63-40a2-8526-5867547ddb60","timestamp":"2026-08-21T18:02:14.682Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" \"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),","newString":" \"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 \"avg_saves\": (round(sum(saves) / len(saves))\n if saves else None),\n \"pinned_videos_count\": (sum(1 for v in videos\n if v[\"is_pinned\"]) or None),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-tiktok)\n# 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).\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'',\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 \"likes_given\": _num(stats.get(\"diggCount\")),\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 # drapeau mineur déclaré par TikTok → régime restreint §15\n \"is_under_18\": (bool(user.get(\"isUnderAge18\"))\n if \"isUnderAge18\" in user else None),\n \"is_embed_banned\": (bool(user.get(\"isEmbedBanned\"))\n if \"isEmbedBanned\" in user else None),\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":217,"oldLines":6,"newStart":217,"newLines":10,"lines":[" if comments else None),"," \"avg_shares\": (round(sum(shares) / len(shares))"," if shares else None),","+ \"avg_saves\": (round(sum(saves) / len(saves))","+ if saves else None),","+ \"pinned_videos_count\": (sum(1 for v in videos","+ if v[\"is_pinned\"]) or None),"," \"engagement_rate_pct\": engagement,"," \"videos_per_week\": videos_per_week,"," \"last_video_at\": last_video_at,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"29dd4664-15b9-44af-bbc6-20e3e4416b4c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLFDdKryR5mnKQBgEvX","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WuJPDYmopLiQKuZCdVuqqB","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","old_string":" shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]","new_string":" shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n saves = [v[\"saves\"] for v in videos\n if isinstance(v[\"saves\"], int)]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":424,"cache_read_input_tokens":105900,"cache_creation":{"ephemeral_5m_input_tokens":424,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"93e07bcc-4f60-444f-9d35-a3a5a9ba23a3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WuJPDYmopLiQKuZCdVuqqB","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3d259797-01db-44d1-b451-8b3326658e33","timestamp":"2026-08-21T18:02:19.506Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-tiktok/src/main.py","oldString":" shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]","newString":" shares = [v[\"shares\"] for v in videos\n if isinstance(v[\"shares\"], int)]\n saves = [v[\"saves\"] for v in videos\n if isinstance(v[\"saves\"], int)]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-tiktok)\n# 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).\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'',\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 \"likes_given\": _num(stats.get(\"diggCount\")),\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 # drapeau mineur déclaré par TikTok → régime restreint §15\n \"is_under_18\": (bool(user.get(\"isUnderAge18\"))\n if \"isUnderAge18\" in user else None),\n \"is_embed_banned\": (bool(user.get(\"isEmbedBanned\"))\n if \"isEmbedBanned\" in user else None),\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 \"avg_saves\": (round(sum(saves) / len(saves))\n if saves else None),\n \"pinned_videos_count\": (sum(1 for v in videos\n if v[\"is_pinned\"]) or 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":138,"oldLines":6,"newStart":138,"newLines":8,"lines":[" if isinstance(v[\"comments\"], int)]"," shares = [v[\"shares\"] for v in videos"," if isinstance(v[\"shares\"], int)]","+ saves = [v[\"saves\"] for v in videos","+ if isinstance(v[\"saves\"], int)]"," avg_views = round(sum(views) / len(views)) if views else None"," avg_likes = round(sum(likes) / len(likes)) if likes else None"," engagement = None"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"08ae141d-ae56-4247-9714-37654f8f3466","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLFaFK1kfinJAMiKAof","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2QkKpAEIERgCKkCLeP6yzxhi6H0CRwM60C1U2n2aXhDqVeqpj7L4wmBtN7r6Btp1jdgF6Y49XLhNWmKnM/1cJcfFrG6p7XyXG4piMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG2paLUBhIMVyHD/odjRHkct4rjGgwx+xrxRBE321/2+jYiMM/phnye6r693Y/YeTwnow7yMRqIVrlsyRDpM5T4ON7Vuq4iVtEFYXm0n+sUS9K0ByrhB6TSsD9Wr/6coRz1VZsI+AdBQvxhjMbjOMmTJTUKz1J6ZuCoQ+mgKieuJRDuhq4f3jrrsfdBYjyROMHeMjl2Fxwy4Rxd2l+Ls+rtxOFh9l3QCUYBANLY2pY/qArq6KdZICxBygLnlczPMcNCMTBmshwAriWNGtoF3aM/n1QegORGAAJTm3bQygmu5l8SLsjhb36kD/NhBpEv1BjcG7C4z+r1d4g3X4yMxgInMPUg8LitMi8ALl2c0JvITUPxRrLziu0UzYxSJYefhPKOqI07cYc2GgjmAmmrk7sQ6o94TQswfLjptNgeMCA8XRonqqu1gy0U+qK0Y5S2fZvzk4XCzyFqZF5nK6sKCcGhBxJmnKJQi4F3ohPbOtCh9QoNIgCFgdoTcMnMCIdK5tHn1sxmQSgJEFBwfKOlY7N/VBctcp0KenN7MjM6WOzqK98Xjqcs01oRPh891QL7E8GvswzSJbVm0Ex4sXP9mcWbGRCdwczDcE7Tt9o/8GoaiddI38VRoytsJfom/KpIPsBfFntoeGRwHJURqL8znGdsVc2fh8VHymgPVub+Za3ZdOqbdN0mtMgUQuBOuGGW07XWcemJwuMgkPHeSpslAU40RbBKrWEl6K7DU8TyctPhq+co2DSoyhYuBWpY5IeHdNpz5t5zzWFKwdGrjEKbG8fZZb6tOXK9fIRwSwyscGu8qnKLFrq1MLn0OPF8yPlMxiJ+Jfwer+JzxfTONiMnDfWG46ArW6IX2wQuPLm98QKUsqm5W7qibu/ust7A0qipWPCoGVIkjbb0oKdRm7k9LqxoepVCbrTUcRunPNxScOnjmPZ9UAhXNzh+0bgNldSbG9pA+239OFEQyoHieIAwGp2GwmDqHU0lhRVhk1Xq8S+fccRpdsnddRuXsLoQr3eIBF+4SNp9PCX4HdfpwksKrf+jF4iPT+BTIAiRk0Hb7RujG2F3k3NkH8n3JMZCsKtKUSP1L0cMo9y70VOTXYnFIKHm50rBylrZQFqTO6cXH4v5HzkabF65Xq1NCVjgksmDnPikugaq9OKohBJHyN9NPtDI95+DUCS8E17N4vj8akC24HanRTNxz9jUkkNBncuw1f+JxQMu21ylvxgdbG/xZYUPiiF+MyfYGmBjxxOWAebBXViQROgr5t0i9JxEzlCUD18jMAS3A12jdwg3l2A/KhJD2T/SVjkX8nuj+KC1eaQjGm4qqAO6zoYxHPMXCwsbs0mC8uwF78G57ia1QQiIqyEdW1g3ah1KvdrOA+QZK0xukgJoDfycdKHpj0QaX8Nh3+KpDuEkCRR+X5uFfqM6MaOcjkVEHaPvlBgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":312,"cache_read_input_tokens":106324,"cache_creation":{"ephemeral_5m_input_tokens":312,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f1273b1e-17bc-4be9-9060-27afcab1cc51"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLFaFK1kfinJAMiKAof","type":"message","role":"assistant","content":[{"type":"text","text":"TikTok fait. YouTube maintenant — j'ajoute l'estimation de cadence à partir des dates relatives des vidéos."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":312,"cache_read_input_tokens":106324,"cache_creation":{"ephemeral_5m_input_tokens":312,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"936b80d8-f1ab-4c40-9120-03bcf49c4971"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLFaFK1kfinJAMiKAof","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PXR1fBS2P4s38Z2zMNYUv6","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":"def _s(v):\n \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n return v.get(\"content\") if isinstance(v, dict) else v","new_string":"def _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\n_REL_RE = re.compile(\n r\"(\\d+)\\s*(hour|day|week|month|year|heure|jour|semaine|mois|an)\", re.I)\n_REL_DAYS = {\"hour\": 1 / 24, \"heure\": 1 / 24, \"day\": 1, \"jour\": 1,\n \"week\": 7, \"semaine\": 7, \"month\": 30.44, \"mois\": 30.44,\n \"year\": 365.25, \"an\": 365.25}\n\n\ndef _rel_days(published: str | None) -> float | None:\n \"\"\"« 3 weeks ago » / « il y a 2 mois » → ancienneté approx. en jours.\"\"\"\n m = _REL_RE.search(published or \"\")\n if not m:\n return None\n unit = m.group(2).lower()\n unit = next((k for k in _REL_DAYS if unit.startswith(k)), None)\n return float(m.group(1)) * _REL_DAYS[unit] if unit else None\n\n\ndef _cadence(vids: list[dict]) -> float | None:\n \"\"\"Vidéos/mois estimées d'après les dates relatives (approximatif).\"\"\"\n ages = sorted(a for a in (_rel_days(v.get(\"published\")) for v in vids)\n if a is not None)\n if len(ages) < 3 or ages[-1] <= ages[0]:\n return None\n return round((len(ages) - 1) / ((ages[-1] - ages[0]) / 30.44), 2)"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":312,"cache_read_input_tokens":106324,"cache_creation":{"ephemeral_5m_input_tokens":312,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4f1177e4-9686-4421-a44d-2f3e5ce01fb7"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PXR1fBS2P4s38Z2zMNYUv6","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9aa14bac-168d-491e-aa1e-a07fc87c0470","timestamp":"2026-08-21T18:02:36.134Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":"def _s(v):\n \"\"\"ViewModel : champ tantôt chaîne nue, tantôt {content: \"...\"}.\"\"\"\n return v.get(\"content\") if isinstance(v, dict) else v","newString":"def _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\n_REL_RE = re.compile(\n r\"(\\d+)\\s*(hour|day|week|month|year|heure|jour|semaine|mois|an)\", re.I)\n_REL_DAYS = {\"hour\": 1 / 24, \"heure\": 1 / 24, \"day\": 1, \"jour\": 1,\n \"week\": 7, \"semaine\": 7, \"month\": 30.44, \"mois\": 30.44,\n \"year\": 365.25, \"an\": 365.25}\n\n\ndef _rel_days(published: str | None) -> float | None:\n \"\"\"« 3 weeks ago » / « il y a 2 mois » → ancienneté approx. en jours.\"\"\"\n m = _REL_RE.search(published or \"\")\n if not m:\n return None\n unit = m.group(2).lower()\n unit = next((k for k in _REL_DAYS if unit.startswith(k)), None)\n return float(m.group(1)) * _REL_DAYS[unit] if unit else None\n\n\ndef _cadence(vids: list[dict]) -> float | None:\n \"\"\"Vidéos/mois estimées d'après les dates relatives (approximatif).\"\"\"\n ages = sorted(a for a in (_rel_days(v.get(\"published\")) for v in vids)\n if a is not None)\n if len(ages) < 3 or ages[-1] <= ages[0]:\n return None\n return round((len(ages) - 1) / ((ages[-1] - ages[0]) / 30.44), 2)","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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*(\\{.*?\\});\", 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 \"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 [],\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":164,"oldLines":6,"newStart":164,"newLines":32,"lines":[" return v.get(\"content\") if isinstance(v, dict) else v"," "," ","+_REL_RE = re.compile(","+ r\"(\\d+)\\s*(hour|day|week|month|year|heure|jour|semaine|mois|an)\", re.I)","+_REL_DAYS = {\"hour\": 1 / 24, \"heure\": 1 / 24, \"day\": 1, \"jour\": 1,","+ \"week\": 7, \"semaine\": 7, \"month\": 30.44, \"mois\": 30.44,","+ \"year\": 365.25, \"an\": 365.25}","+","+","+def _rel_days(published: str | None) -> float | None:","+ \"\"\"« 3 weeks ago » / « il y a 2 mois » → ancienneté approx. en jours.\"\"\"","+ m = _REL_RE.search(published or \"\")","+ if not m:","+ return None","+ unit = m.group(2).lower()","+ unit = next((k for k in _REL_DAYS if unit.startswith(k)), None)","+ return float(m.group(1)) * _REL_DAYS[unit] if unit else None","+","+","+def _cadence(vids: list[dict]) -> float | None:","+ \"\"\"Vidéos/mois estimées d'après les dates relatives (approximatif).\"\"\"","+ ages = sorted(a for a in (_rel_days(v.get(\"published\")) for v in vids)","+ if a is not None)","+ if len(ages) < 3 or ages[-1] <= ages[0]:","+ return None","+ return round((len(ages) - 1) / ((ages[-1] - ages[0]) / 30.44), 2)","+","+"," def _banner_of(data: dict) -> str | None:"," \"\"\"Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.\"\"\""," b = _walk(data, \"imageBannerViewModel\")"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"332b1ea7-5389-467e-8187-ca689791cdb6","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLGoL9PAmYHGKcrXFre","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011ticRRy8CjCTAGqPKDD8et","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","old_string":" \"avg_views\": (round(sum(views) / len(views))\n if views else None),","new_string":" \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"videos_per_month\": _cadence(vids),\n \"is_live_now\": ('\"style\":\"LIVE\"' in text\n or '\"label\":\"LIVE\"' in text) or None,\n \"has_shorts\": ('\"title\":\"Shorts\"' in text) or None,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1251,"cache_read_input_tokens":106636,"cache_creation":{"ephemeral_5m_input_tokens":1251,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5904776a-a46e-474e-bc7f-59f84dbdb032"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011ticRRy8CjCTAGqPKDD8et","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"dd544d69-7eed-46e7-b574-5a07b37b86bc","timestamp":"2026-08-21T18:02:41.617Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-youtube/src/main.py","oldString":" \"avg_views\": (round(sum(views) / len(views))\n if views else None),","newString":" \"avg_views\": (round(sum(views) / len(views))\n if views else None),\n \"videos_per_month\": _cadence(vids),\n \"is_live_now\": ('\"style\":\"LIVE\"' in text\n or '\"label\":\"LIVE\"' in text) or None,\n \"has_shorts\": ('\"title\":\"Shorts\"' in text) or None,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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*(\\{.*?\\});\", 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\n_REL_RE = re.compile(\n r\"(\\d+)\\s*(hour|day|week|month|year|heure|jour|semaine|mois|an)\", re.I)\n_REL_DAYS = {\"hour\": 1 / 24, \"heure\": 1 / 24, \"day\": 1, \"jour\": 1,\n \"week\": 7, \"semaine\": 7, \"month\": 30.44, \"mois\": 30.44,\n \"year\": 365.25, \"an\": 365.25}\n\n\ndef _rel_days(published: str | None) -> float | None:\n \"\"\"« 3 weeks ago » / « il y a 2 mois » → ancienneté approx. en jours.\"\"\"\n m = _REL_RE.search(published or \"\")\n if not m:\n return None\n unit = m.group(2).lower()\n unit = next((k for k in _REL_DAYS if unit.startswith(k)), None)\n return float(m.group(1)) * _REL_DAYS[unit] if unit else None\n\n\ndef _cadence(vids: list[dict]) -> float | None:\n \"\"\"Vidéos/mois estimées d'après les dates relatives (approximatif).\"\"\"\n ages = sorted(a for a in (_rel_days(v.get(\"published\")) for v in vids)\n if a is not None)\n if len(ages) < 3 or ages[-1] <= ages[0]:\n return None\n return round((len(ages) - 1) / ((ages[-1] - ages[0]) / 30.44), 2)\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 \"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 [],\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":317,"oldLines":6,"newStart":317,"newLines":10,"lines":[" \"external_links\": about.get(\"external_links\") or [],"," \"avg_views\": (round(sum(views) / len(views))"," if views else None),","+ \"videos_per_month\": _cadence(vids),","+ \"is_live_now\": ('\"style\":\"LIVE\"' in text","+ or '\"label\":\"LIVE\"' in text) or None,","+ \"has_shorts\": ('\"title\":\"Shorts\"' in text) or None,"," \"top_video\": (max("," (v for v in vids if isinstance(v[\"views\"], int)),"," key=lambda v: v[\"views\"], default=None)),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"decbf274-1034-4046-b040-894fb4b31cc7","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLHCjobrmYKmjL9a9n1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISrwgKpAEIERgCKkAmS3D9AhiHiJQr9wqaBasvn1iLZ7OSMZ5fGGCa8yD95ew1qpyUPxbM5LPlgBOrdRtkJ5Qw98U0VY0vKLWhlfxbMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHJpaLUBhIMuVPw4yJ14hBZezbTGgy56zNbrUBFXHZZaR8iMLQrdR6y6NBk7ssvjMbJEZxBYWWVUm7bhciajmBge0UMqRqQNwIWB0HbOmhKQJCWGiq3BqHGFm/hUR3pNWp/DkfbFvuW9Mz5KiirexX4Bc8N9EpSMacb2tFVExn579h47qYXmL7E2bpzcuH6cRdzAeVXxRkB/GfoUY7QP/yAzNxGNYagTJR6LW+bQGUkLmQ7Lo0MRkoR4P9lVoNlTeUlGq/ldEYGaOaC1T/8nLaggDVK1DM1sjftYoQbSJ1eirD5TyeBMxiPx/kwmoYtxpvcyDNCE3Zmvi3pyfpnkPzGSDrYAgoWwX5U3YvdRp/NuE51eL4ROsQ598e1AQNskNcwHFeRToiiULmtohoiJSv0Q5BSplqxYlwdRzf/BUUf43MsPgqwby0zvIUtGys279YJjRzKrl3npQpixUzfZn8xOu3waX+J4fKDU+mu4VSPVpDdUhkz+rSpJg9nPb5tf9/ItDBMWgCPvl8loUjkT/wSEV6pVCjzGYWY33f1ga5a1ebd85HxnGFy/g50gXP/otblbAuw9u8CbqpmLkS+3CZk4uDcEXovQFYTW6IEtIGysI+YB+IK+eP9qtXL0GhDYfufNkyIAkiAVBQTF+1j0N7OEKukXxeWuSK844cjkYVxVHWmQiAcrXK1c+BbGI/FFiSC1tQZWu/iPWp0fDCZpeVQayJuImLlN+X/70cP7eJbX/Sn+uhJgn/E4WDZUCSYMOLLj2Vgn395J6SVb46blmAMlffUTlzjtJv7AmqMRgyloaeHSlzXB3tMizzHPAc7WMSpl8+My/J4Dfi7edMZvoAFnCD0JD3IIk/zcpDMn3A5q3vUuPuYDGJFI36QZyDLy4c4tVG05g763B+dtWyjF4rM5rGfYQxYrJ/epMOUU4DO2tj4hgVdznnocKr3lrLFk4jRjoOgMllfDhs705y76mf+3FDZfW8TtWAfkpt5/81k7E0h/qHy9rDzNeGmPOQQB5bW4KSK8kS+QXa0SqEQoehIto5AtFm7A21a0qkSMWek9zNLurB42Sqa0mUpqdHZdX04GrcO7UmepF7YH4Xtd7YooWiD9X7p3B/9+702Y5eU8hD0gLTtjk92E9EYmpxzlR7/sQUemduhE026KBngSkk8PiKfz6iOgl9ekAPHmWQ4IpaWNJulhCoci87fzdMYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":360,"cache_read_input_tokens":107887,"cache_creation":{"ephemeral_5m_input_tokens":360,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"808f555e-b4be-4b48-b5e4-e531d7ce850f"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLHCjobrmYKmjL9a9n1","type":"message","role":"assistant","content":[{"type":"text","text":"YouTube fait (cadence vidéos/mois, live, shorts). Passons à X."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":360,"cache_read_input_tokens":107887,"cache_creation":{"ephemeral_5m_input_tokens":360,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d03878fd-74d3-41e0-8ac4-20d196e51345"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLHCjobrmYKmjL9a9n1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UuUYQ55nEqHrhKY4MPZHND","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" 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","new_string":" hashtags: dict[str, int] = {}\n mentions: 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 for mn in t[\"mentions\"]:\n mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1\n quotes = [t[\"quotes\"] for t in own\n if isinstance(t[\"quotes\"], int)]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":360,"cache_read_input_tokens":107887,"cache_creation":{"ephemeral_5m_input_tokens":360,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c81a5f95-cb63-4a61-a567-5221211a200a"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UuUYQ55nEqHrhKY4MPZHND","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"985f9930-1c33-44ae-a6e6-9e63de6be708","timestamp":"2026-08-21T18:02:53.043Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","oldString":" 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","newString":" hashtags: dict[str, int] = {}\n mentions: 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 for mn in t[\"mentions\"]:\n mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1\n quotes = [t[\"quotes\"] for t in own\n if isinstance(t[\"quotes\"], int)]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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'', 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 \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],\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 # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\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":163,"oldLines":9,"newStart":163,"newLines":14,"lines":[" top = max((t for t in own if isinstance(t[\"likes\"], int)),"," key=lambda t: t[\"likes\"], default=None)"," hashtags: dict[str, int] = {}","+ mentions: dict[str, int] = {}"," for t in own:"," for h in t[\"hashtags\"]:"," hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1","+ for mn in t[\"mentions\"]:","+ mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1","+ quotes = [t[\"quotes\"] for t in own","+ if isinstance(t[\"quotes\"], int)]"," created_ts = _ts(user.get(\"created_at\"))"," await Actor.push_data({"," \"kind\": \"profile\","]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5e84c9ee-a4bd-47c4-88be-e936d4a902d0","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLJ3VU4oh1mv7cyuD1Q","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01V6jKUdGQyeBA9eqNSUJvbF","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" \"is_verified\": bool(user.get(\"verified\")\n or user.get(\"is_blue_verified\")),\n \"is_blue_verified\": user.get(\"is_blue_verified\"),","new_string":" \"is_verified\": bool(user.get(\"verified\")\n or user.get(\"is_blue_verified\")),\n \"is_blue_verified\": user.get(\"is_blue_verified\"),\n \"verified_type\": user.get(\"verified_type\"),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":808,"cache_read_input_tokens":108247,"cache_creation":{"ephemeral_5m_input_tokens":808,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5f7d8b0d-b91a-46fa-99c0-1df9fb7d0385"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01V6jKUdGQyeBA9eqNSUJvbF","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"097721fd-eb98-4b97-9ee2-01e1ebe75530","timestamp":"2026-08-21T18:02:58.518Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","oldString":" \"is_verified\": bool(user.get(\"verified\")\n or user.get(\"is_blue_verified\")),\n \"is_blue_verified\": user.get(\"is_blue_verified\"),","newString":" \"is_verified\": bool(user.get(\"verified\")\n or user.get(\"is_blue_verified\")),\n \"is_blue_verified\": user.get(\"is_blue_verified\"),\n \"verified_type\": user.get(\"verified_type\"),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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'', 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 \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],\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 # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\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 mentions: 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 for mn in t[\"mentions\"]:\n mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1\n quotes = [t[\"quotes\"] for t in own\n if isinstance(t[\"quotes\"], int)]\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":192,"oldLines":6,"newStart":192,"newLines":7,"lines":[" \"is_verified\": bool(user.get(\"verified\")"," or user.get(\"is_blue_verified\")),"," \"is_blue_verified\": user.get(\"is_blue_verified\"),","+ \"verified_type\": user.get(\"verified_type\"),"," \"is_protected\": user.get(\"protected\"),"," \"created_at\": user.get(\"created_at\"),"," \"account_age_days\": (round((time.time() - created_ts)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4ee7f2de-aa96-4026-886e-fd3fad71350e","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLJSqQTkkLXs9Q9Zva2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CHH5YuhYVR4udLzUUW1BDP","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" \"avg_replies\": (round(sum(reps) / len(reps))\n if reps else None),","new_string":" \"avg_replies\": (round(sum(reps) / len(reps))\n if reps else None),\n \"avg_quotes\": (round(sum(quotes) / len(quotes))\n if quotes else None),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":993,"cache_read_input_tokens":109055,"cache_creation":{"ephemeral_5m_input_tokens":993,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a8953364-60af-4881-bb65-4790afe63ec7"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CHH5YuhYVR4udLzUUW1BDP","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d95e122e-8961-405a-aebb-644bf3248fc8","timestamp":"2026-08-21T18:03:03.900Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","oldString":" \"avg_replies\": (round(sum(reps) / len(reps))\n if reps else None),","newString":" \"avg_replies\": (round(sum(reps) / len(reps))\n if reps else None),\n \"avg_quotes\": (round(sum(quotes) / len(quotes))\n if quotes else None),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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'', 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 \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],\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 # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\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 mentions: 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 for mn in t[\"mentions\"]:\n mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1\n quotes = [t[\"quotes\"] for t in own\n if isinstance(t[\"quotes\"], int)]\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 \"verified_type\": user.get(\"verified_type\"),\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":206,"oldLines":6,"newStart":206,"newLines":8,"lines":[" \"avg_retweets\": avg_rts,"," \"avg_replies\": (round(sum(reps) / len(reps))"," if reps else None),","+ \"avg_quotes\": (round(sum(quotes) / len(quotes))","+ if quotes else None),"," \"engagement_rate_pct\": engagement,"," \"tweets_per_week\": tweets_per_week,"," \"last_tweet_at\": own[0][\"created_at\"] if own else None,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"358aeb1c-0ad1-4500-955b-49c4b3bc5619","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLJqszgpcQHJJZDhNkq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_013HfMVXw5M4N8QaXLZ86diK","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","old_string":" \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n reverse=True)[:8],","new_string":" \"link_share_pct\": (round(sum(bool(t[\"links\"])\n for t in own)\n / len(own) * 100)\n if own else None),\n \"retweet_share_pct\": (round(sum(t[\"is_retweet\"]\n for t in tweets)\n / len(tweets) * 100)\n if tweets else None),\n \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n reverse=True)[:8],\n # comptes les plus mentionnés → signal d'identité §12.1\n \"top_mentions\": sorted(mentions, key=mentions.get,\n reverse=True)[:8],"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":310,"cache_read_input_tokens":110048,"cache_creation":{"ephemeral_5m_input_tokens":310,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"49f73574-bc7f-4a97-a81e-8a4490166590"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_013HfMVXw5M4N8QaXLZ86diK","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1f8f8911-e8ad-4444-aa0a-f2fc5285569b","timestamp":"2026-08-21T18:03:10.445Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-x/src/main.py","oldString":" \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n reverse=True)[:8],","newString":" \"link_share_pct\": (round(sum(bool(t[\"links\"])\n for t in own)\n / len(own) * 100)\n if own else None),\n \"retweet_share_pct\": (round(sum(t[\"is_retweet\"]\n for t in tweets)\n / len(tweets) * 100)\n if tweets else None),\n \"top_hashtags\": sorted(hashtags, key=hashtags.get,\n reverse=True)[:8],\n # comptes les plus mentionnés → signal d'identité §12.1\n \"top_mentions\": sorted(mentions, key=mentions.get,\n reverse=True)[:8],","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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'', 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 \"media_urls\": [x.get(\"media_url_https\") for x in media\n if x.get(\"media_url_https\")][:4],\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 # 429 : retries avec backoff + NOUVELLE session proxy\n # (nouvelle IP résidentielle) — une seule relance ne\n # suffisait pas (13/40 handles en 429, constat 2026-08-21)\n for i, imp in enumerate((\"chrome\", None, \"chrome\")):\n if resp.status_code != 429:\n break\n await asyncio.sleep(5 * (i + 1))\n resp = await fetcher.get(SYNDICATION_URL.format(u=u),\n session_id=f\"{u}r{i}\",\n impersonate=imp)\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 mentions: 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 for mn in t[\"mentions\"]:\n mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1\n quotes = [t[\"quotes\"] for t in own\n if isinstance(t[\"quotes\"], int)]\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 \"verified_type\": user.get(\"verified_type\"),\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 \"avg_quotes\": (round(sum(quotes) / len(quotes))\n if quotes 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":219,"oldLines":8,"newStart":219,"newLines":19,"lines":[" for t in own)"," / len(own) * 100)"," if own else None),","+ \"link_share_pct\": (round(sum(bool(t[\"links\"])","+ for t in own)","+ / len(own) * 100)","+ if own else None),","+ \"retweet_share_pct\": (round(sum(t[\"is_retweet\"]","+ for t in tweets)","+ / len(tweets) * 100)","+ if tweets else None),"," \"top_hashtags\": sorted(hashtags, key=hashtags.get,"," reverse=True)[:8],","+ # comptes les plus mentionnés → signal d'identité §12.1","+ \"top_mentions\": sorted(mentions, key=mentions.get,","+ reverse=True)[:8],"," \"top_tweet\": ({\"url\": top[\"url\"], \"likes\": top[\"likes\"],"," \"retweets\": top[\"retweets\"]}"," if top else None),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3da32369-303a-4804-9f7a-4f9af346fb63","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLKLJ2sfZYT7q4yEi12","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISyQkKpAEIERgCKkDgeXAvMVXa7dEcD2UFHQ+yOUvE2Xa1AKyZ6960YoJjL7juxCsSHUKGgwwrtzfJZzSmAjR7lcuC9t+n8z8q+0WPMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHnpaLUBhIM9F88e26dUqG40KHnGgxAx0ZCBI0dOtwSS5siMGge92GkSIFlZc3uhtGsYVIn1ntvSHCsYSCpyc0840HXvNyy+HxuFXLy8VmNwVNYEirRB9bsclNM6DLHl9lzCuVbsGedc7KkxtVsBqkz/FI+2B06f7bDonTSPBkRvhpD0g8kFirhQ6b7Q4H4ODvZDJSKqjxBNr3jAO2CkntDAJd5JB2Ya2EV7/946BA5FViMKCoWERAZfrGVbP3gnaJzcTpd+Wv2OBeiIhnRMdKA8G6UG41B2VxUG0sPg5ldSpOMeevHtk5FBz95I+6Iot8C5zTEd2Dd7r8YXQr4SksSJ40Jc8m44GUAo3uDjhaFiDnAEoBq/vNXUlL3mXCi+uwruWJSU9tvVAzqJX6EtVFqE7F/tYmlkr0FsKLDkIrW5GFqcGNY4gO4PxAC/zIKTMPa/lKCudCYHvPz1R/st0BVm9ufkuB01f0Ae42gWnLWMUR3ern7/ApI4xgRFjAIx8UAs+lrBGDnwyvA04dtUama+45EkUS6jQJ7dfkUS/TZyMb9IO+/DeAh1fmnt1bdZHJ1ppg8gMw7QhI3nBTJ/3Tscu6O6vH8kbENIAG2GdAHKUp6vpyTaHAHxC0jTpcT3kDndrWdgwamTAIHU6luWvJnNH80LIukHorjBm3wFez8ap98nuEAsI13MJMbW4syeysGqSDsraFzFMD9AFx18SyvBLLTf3PpgDrYy3J7/mkXjCE4B0jZJVlvqfMQpaX8ZtyjX3jf1OvPDx41BiljG94MjcgdEwEO5g3XUxq/0/g3vyIA0thvreizAkPxyrCPJGUiRetr+6qOupKOfZVIg5vOIqChXV2d06k3yUeGnFIzl2HRQvjbaWgLJtOCKHNkr7nC1k3AEoFi3tlCoQi4Je9wmURWaZprgB7ll39fZwxz+H4Q6ssJBAX7QAHb0hd+eMBoDsNk07MR/gY4Sma4pFJF2ET9HvnrJxn6xMp76N1qXjKd3+f1Jea8/X7hpyZE2qgEGibk1FBqMnsoOfJijnAuXuWb7Drx6xDkLnWAsJuICZ/5RWCfhYdLy/aGiP8G9+BGw9/xlEhrQvzPYvM3FeJSJpBeyoF1r5TmCCSLsOo123Cy7CspOTcY43zfuOUs2Cn4nQKl5fJJzP30vw2S0wWLlbVA9amVdkwWokZtVfi/iYXVW7SeAWrZYFR+EuxBOvaC3Z8Zpb+HiGjs2C+AyQrR7K5AD1eX5j+mXLFM0JGgdGfo2wD2wyAHxlXkLcoYC9L9/eDFF12ePbpzpor6W1zajbRnNvZC1YT8ceRpES9qjYRNF+BBkZJRikjSt4v7wd5P6ubcBGkdO+2zPBKPmrRd+uNvlrmVNWHO9ZMuOAypsAae0Vmp+PyfljAbAzfpTHMXC24nyPD4GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":476,"cache_read_input_tokens":110358,"cache_creation":{"ephemeral_5m_input_tokens":476,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"499b9b15-c835-428e-b236-460f244cfedc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLKLJ2sfZYT7q4yEi12","type":"message","role":"assistant","content":[{"type":"text","text":"X fait. Facebook et Threads maintenant."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":476,"cache_read_input_tokens":110358,"cache_creation":{"ephemeral_5m_input_tokens":476,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1ba064b7-ce68-4d9d-bf74-4445bd2b6496"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLKLJ2sfZYT7q4yEi12","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014mi9GDJhLALaToZwJLb96F","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py","old_string":"_CATEGORY_RE = re.compile(r'\"category_name\"\\s*:\\s*\"([^\"]+)\"')\n_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')","new_string":"_CATEGORY_RE = re.compile(r'\"category_name\"\\s*:\\s*\"([^\"]+)\"')\n_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n_PAGE_ID_RES = (\n re.compile(r'\"page_id\"\\s*:\\s*\"?(\\d{6,})\"?'),\n re.compile(r'\"pageID\"\\s*:\\s*\"(\\d{6,})\"'),\n re.compile(r'\"delegate_page_id\"\\s*:\\s*\"(\\d{6,})\"'),\n)\n# site web auto-déclaré de la page → cross-link côté crea-ka\n_WEBSITE_RE = re.compile(\n r'\"website(?:s)?\"\\s*:\\s*\\[?\\s*\"((?:https?:)?(?:[^\"\\\\]|\\\\.)+)\"')\n_RATING_RE = re.compile(r'\"overall_star_rating\"\\s*:\\s*\\{[^{}]*?'\n r'\"value\"\\s*:\\s*([\\d.]+)')\n_OG_URL_RE = re.compile(r'\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' 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","structuredPatch":[{"oldStart":37,"oldLines":6,"newStart":37,"newLines":17,"lines":[" re.I)"," _CATEGORY_RE = re.compile(r'\"category_name\"\\s*:\\s*\"([^\"]+)\"')"," _VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')","+_PAGE_ID_RES = (","+ re.compile(r'\"page_id\"\\s*:\\s*\"?(\\d{6,})\"?'),","+ re.compile(r'\"pageID\"\\s*:\\s*\"(\\d{6,})\"'),","+ re.compile(r'\"delegate_page_id\"\\s*:\\s*\"(\\d{6,})\"'),","+)","+# site web auto-déclaré de la page → cross-link côté crea-ka","+_WEBSITE_RE = re.compile(","+ r'\"website(?:s)?\"\\s*:\\s*\\[?\\s*\"((?:https?:)?(?:[^\"\\\\]|\\\\.)+)\"')","+_RATING_RE = re.compile(r'\"overall_star_rating\"\\s*:\\s*\\{[^{}]*?'","+ r'\"value\"\\s*:\\s*([\\d.]+)')","+_OG_URL_RE = re.compile(r' int | None:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5b6a2a77-6f42-4c68-bac3-b58d3a528a9c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLLSBGmnTebvodmfF17","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MLC4u2xmj6w8bxGzeZoQ5f","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py","old_string":" 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 })","new_string":" cat = _CATEGORY_RE.search(text)\n ver = _VERIFIED_RE.search(text)\n page_id = next((m.group(1) for rx in _PAGE_ID_RES\n if (m := rx.search(text))), None)\n site = _WEBSITE_RE.search(text)\n website = None\n if site:\n try:\n website = json.loads(f'\"{site.group(1)}\"').strip()\n except Exception:\n website = site.group(1)\n if website and not website.startswith(\"http\"):\n website = f\"https://{website}\"\n if website and \"facebook.com\" in website:\n website = None # auto-référence sans valeur\n rating = _RATING_RE.search(text)\n canon = _OG_URL_RE.search(text)\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"facebook\",\n \"found\": True,\n \"username\": u,\n \"page_id\": page_id,\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 \"website\": website,\n \"rating\": float(rating.group(1)) if rating else None,\n \"canonical_url\": (htmllib.unescape(canon.group(1))\n if canon else None),\n \"avatar\": og.get(\"image\"),\n \"og_description\": og.get(\"description\"),\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1184,"cache_read_input_tokens":110834,"cache_creation":{"ephemeral_5m_input_tokens":1184,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"48d713d5-4ade-4e69-9d76-51092e70b605"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MLC4u2xmj6w8bxGzeZoQ5f","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"506e9018-152e-44ac-914f-216562d56d23","timestamp":"2026-08-21T18:03:37.526Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py","oldString":" 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 })","newString":" cat = _CATEGORY_RE.search(text)\n ver = _VERIFIED_RE.search(text)\n page_id = next((m.group(1) for rx in _PAGE_ID_RES\n if (m := rx.search(text))), None)\n site = _WEBSITE_RE.search(text)\n website = None\n if site:\n try:\n website = json.loads(f'\"{site.group(1)}\"').strip()\n except Exception:\n website = site.group(1)\n if website and not website.startswith(\"http\"):\n website = f\"https://{website}\"\n if website and \"facebook.com\" in website:\n website = None # auto-référence sans valeur\n rating = _RATING_RE.search(text)\n canon = _OG_URL_RE.search(text)\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"facebook\",\n \"found\": True,\n \"username\": u,\n \"page_id\": page_id,\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 \"website\": website,\n \"rating\": float(rating.group(1)) if rating else None,\n \"canonical_url\": (htmllib.unescape(canon.group(1))\n if canon else None),\n \"avatar\": og.get(\"image\"),\n \"og_description\": og.get(\"description\"),\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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' 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","structuredPatch":[{"oldStart":114,"oldLines":16,"newStart":114,"newLines":36,"lines":[" return"," cat = _CATEGORY_RE.search(text)"," ver = _VERIFIED_RE.search(text)","+ page_id = next((m.group(1) for rx in _PAGE_ID_RES","+ if (m := rx.search(text))), None)","+ site = _WEBSITE_RE.search(text)","+ website = None","+ if site:","+ try:","+ website = json.loads(f'\"{site.group(1)}\"').strip()","+ except Exception:","+ website = site.group(1)","+ if website and not website.startswith(\"http\"):","+ website = f\"https://{website}\"","+ if website and \"facebook.com\" in website:","+ website = None # auto-référence sans valeur","+ rating = _RATING_RE.search(text)","+ canon = _OG_URL_RE.search(text)"," await Actor.push_data({"," \"kind\": \"profile\","," \"platform\": \"facebook\","," \"found\": True,"," \"username\": u,","+ \"page_id\": page_id,"," \"full_name\": og.get(\"title\"),"," \"biography\": og.get(\"description\"),"," \"followers\": followers,"," \"category\": cat.group(1) if cat else None,"," \"is_verified\": (ver.group(1) == \"true\") if ver else None,","+ \"website\": website,","+ \"rating\": float(rating.group(1)) if rating else None,","+ \"canonical_url\": (htmllib.unescape(canon.group(1))","+ if canon else None),"," \"avatar\": og.get(\"image\"),"," \"og_description\": og.get(\"description\"),"," })"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"bfed6368-bb9a-4a59-a856-02cc3aa96e2a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLMKicJM6oCnspTADTc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E1BoNGyxdKB7G8vXmgmkwZ","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py","old_string":"import asyncio\nimport html as htmllib\nimport re","new_string":"import asyncio\nimport html as htmllib\nimport json\nimport re"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":925,"cache_read_input_tokens":112018,"cache_creation":{"ephemeral_5m_input_tokens":925,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e5ac9fd0-0725-42e9-a1ee-b635f7118bfc"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E1BoNGyxdKB7G8vXmgmkwZ","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"19bff5bf-37c5-49bf-8612-c5d9be7cef60","timestamp":"2026-08-21T18:03:42.267Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-facebook/src/main.py","oldString":"import asyncio\nimport html as htmllib\nimport re","newString":"import asyncio\nimport html as htmllib\nimport json\nimport re","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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' 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 page_id = next((m.group(1) for rx in _PAGE_ID_RES\n if (m := rx.search(text))), None)\n site = _WEBSITE_RE.search(text)\n website = None\n if site:\n try:\n website = json.loads(f'\"{site.group(1)}\"').strip()\n except Exception:\n website = site.group(1)\n if website and not website.startswith(\"http\"):\n website = f\"https://{website}\"\n if website and \"facebook.com\" in website:\n website = None # auto-référence sans valeur\n rating = _RATING_RE.search(text)\n canon = _OG_URL_RE.search(text)\n await Actor.push_data({\n \"kind\": \"profile\",\n \"platform\": \"facebook\",\n \"found\": True,\n \"username\": u,\n \"page_id\": page_id,\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 \"website\": website,\n \"rating\": float(rating.group(1)) if rating else None,\n \"canonical_url\": (htmllib.unescape(canon.group(1))\n if canon 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","structuredPatch":[{"oldStart":11,"oldLines":6,"newStart":11,"newLines":7,"lines":[" "," import asyncio"," import html as htmllib","+import json"," import re"," "," from apify import Actor"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLMKicJM6oCnspTADTc","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GvZgTkHKcLZtyuyLqPMeGL","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py","old_string":"_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n_PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')","new_string":"_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n_PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# liens auto-déclarés de la bio → cross-links forts côté crea-ka (§12.1)\n_BIO_LINKS_RE = re.compile(r'\"bio_links\"\\s*:\\s*\\[(.{0,2000}?)\\]', re.S)\n_LINK_URL_RE = re.compile(r'\"url\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"')\n_PK_RE = re.compile(r'\"pk\"\\s*:\\s*\"?(\\d{4,})\"?')"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":925,"cache_read_input_tokens":112018,"cache_creation":{"ephemeral_5m_input_tokens":925,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"26bf965a-b226-4cd6-970d-1a6738078ba6"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GvZgTkHKcLZtyuyLqPMeGL","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b6dcd688-29bd-4102-928f-b0c6f8156750","timestamp":"2026-08-21T18:03:46.402Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py","oldString":"_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n_PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')","newString":"_VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')\n_PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n# liens auto-déclarés de la bio → cross-links forts côté crea-ka (§12.1)\n_BIO_LINKS_RE = re.compile(r'\"bio_links\"\\s*:\\s*\\[(.{0,2000}?)\\]', re.S)\n_LINK_URL_RE = re.compile(r'\"url\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"')\n_PK_RE = re.compile(r'\"pk\"\\s*:\\s*\"?(\\d{4,})\"?')","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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","structuredPatch":[{"oldStart":24,"oldLines":6,"newStart":24,"newLines":10,"lines":[" _NAME_RE = re.compile(r'\"full_name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')"," _VERIFIED_RE = re.compile(r'\"is_verified\"\\s*:\\s*(true|false)')"," _PIC_RE = re.compile(r'\"profile_pic_url\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')","+# liens auto-déclarés de la bio → cross-links forts côté crea-ka (§12.1)","+_BIO_LINKS_RE = re.compile(r'\"bio_links\"\\s*:\\s*\\[(.{0,2000}?)\\]', re.S)","+_LINK_URL_RE = re.compile(r'\"url\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"')","+_PK_RE = re.compile(r'\"pk\"\\s*:\\s*\"?(\\d{4,})\"?')"," # posts : paires texte + like_count dans les payloads thread_items"," _POST_RE = re.compile("," r'\"caption\"\\s*:\\s*\\{\\s*\"text\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"[^{}]*?\\}'"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4591089f-07b2-4b33-a926-f67fa9efecf5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLMyt3Bv5Xt48epZQsD","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Bn1FcQZiKQnRc4ggNPoVJU","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py","old_string":" 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,","new_string":" bio = _BIO_RE.search(text)\n name = _NAME_RE.search(text)\n ver = _VERIFIED_RE.search(text)\n pic = _PIC_RE.search(text)\n pk = _PK_RE.search(text)\n bio_links: list[str] = []\n bl = _BIO_LINKS_RE.search(text)\n if bl:\n for m2 in _LINK_URL_RE.finditer(bl.group(1)):\n url2 = _dec(m2.group(1))\n if url2 not in bio_links:\n bio_links.append(url2)\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 \"id\": pk.group(1) if pk else None,\n \"full_name\": _dec(name.group(1)) if name else None,\n \"biography\": _dec(bio.group(1)) if bio else None,\n \"bio_links\": bio_links[:5] or 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,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":844,"cache_read_input_tokens":112943,"cache_creation":{"ephemeral_5m_input_tokens":844,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d59b8160-0b36-4951-b5dd-59e9860fae57"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Bn1FcQZiKQnRc4ggNPoVJU","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2e8d871b-72e1-48fa-aa15-be3fb597ab35","timestamp":"2026-08-21T18:03:56.207Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-threads/src/main.py","oldString":" 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,","newString":" bio = _BIO_RE.search(text)\n name = _NAME_RE.search(text)\n ver = _VERIFIED_RE.search(text)\n pic = _PIC_RE.search(text)\n pk = _PK_RE.search(text)\n bio_links: list[str] = []\n bl = _BIO_LINKS_RE.search(text)\n if bl:\n for m2 in _LINK_URL_RE.finditer(bl.group(1)):\n url2 = _dec(m2.group(1))\n if url2 not in bio_links:\n bio_links.append(url2)\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 \"id\": pk.group(1) if pk else None,\n \"full_name\": _dec(name.group(1)) if name else None,\n \"biography\": _dec(bio.group(1)) if bio else None,\n \"bio_links\": bio_links[:5] or 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,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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# liens auto-déclarés de la bio → cross-links forts côté crea-ka (§12.1)\n_BIO_LINKS_RE = re.compile(r'\"bio_links\"\\s*:\\s*\\[(.{0,2000}?)\\]', re.S)\n_LINK_URL_RE = re.compile(r'\"url\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"')\n_PK_RE = re.compile(r'\"pk\"\\s*:\\s*\"?(\\d{4,})\"?')\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","structuredPatch":[{"oldStart":73,"oldLines":6,"newStart":73,"newLines":14,"lines":[" name = _NAME_RE.search(text)"," ver = _VERIFIED_RE.search(text)"," pic = _PIC_RE.search(text)","+ pk = _PK_RE.search(text)","+ bio_links: list[str] = []","+ bl = _BIO_LINKS_RE.search(text)","+ if bl:","+ for m2 in _LINK_URL_RE.finditer(bl.group(1)):","+ url2 = _dec(m2.group(1))","+ if url2 not in bio_links:","+ bio_links.append(url2)"," likes = [p[\"likes\"] for p in posts]"," top = max(posts, key=lambda p: p[\"likes\"], default=None)"," await Actor.push_data({"]},{"oldStart":80,"oldLines":8,"newStart":88,"newLines":10,"lines":[" \"platform\": \"threads\","," \"found\": True,"," \"username\": u,","+ \"id\": pk.group(1) if pk else None,"," \"full_name\": _dec(name.group(1)) if name else None,"," \"biography\": _dec(bio.group(1)) if bio else None,","+ \"bio_links\": bio_links[:5] or None,"," \"followers\": int(fol.group(1)),"," \"is_verified\": (ver.group(1) == \"true\") if ver else None,"," \"avatar\": _dec(pic.group(1)) if pic else None,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"37df101f-a33d-49b6-aa90-63c467fe36f7","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLNhiUDY6swHJvocHf1","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiQwKpAEIERgCKkDLBgiJg77Au7RwktLTeC5HRprNqTESXc1wEPj3F7zKkiM+WvD0C9WTM63TrhmXyidh3i0Bs0+UN7DCwFT/3AMCMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGXpqLUBhIMl9QCrW7ySrA/olBtGgymrZwOpmFYOYgdsgciMG+UBXm8PUm0IDdkBfHLSz+l47I+//tAFm5yyXbMimGLl1E4PrWYR1ej3sKE+J77liqRCmMPI9i6yI+rty50h4jSdxyh30epUqXdKEBp7BD04t/wZQeuiAoLwijhEOpjc2S0CDgegmYQ5UziXtk0UXdcljcqMc9QObR+qy964ym3USFA0eka3QXOjVh0fFoQrQjvoX9naRedkb5lSEgyqCUCyz1+wjOY/YGoMRjpnJmcR/pu+l/a8eFTWhQhbwLJcScjzWwKpjiXblTKMGZP2ON6EB2jsCJomBZptfmK0yvzvLkeu1zSkz3U2bQo7yvKgHKgR90A5U6fFh6ppVInns9iT3AGWKY3k4st2EGfVrI0eFZI8M6mc74FAC6xYGidw9zbYPOKZbabAOFhIyRUf1grwp/SNBm5kW/XlMzNJ95UD+/E7dFfS3o2QIuMjom+U+p7A1t8slMMywEmK2K4022xYHNQX6bjJjB0z88TFqgQk1D3usw0gV4jISOKDGzcN6u2JvmvG62nBAyFqS4kSAp2OO03U8zIAoSDEbMpqGiOJWhqEnv02qu6v8g49G422iDlz71VU8X+ImfiNVUYI4M8RBDyBFA39d76n/oC8VIXTea853BHhE7bvLoskvP6msW4eQQSJuFK7MVWymm2eiOSoC2SEdiw1DlOr+rwmG74Mxd0GtyufggAdFURCWuAwCSFjGC/U2UgESSgRTQzXuuaa06Uhmcj/JuCgQrNpeGgij83v94y3tkMT23XSPHjhuppAFCD6C7qndpMah8yRXWtr5K/JCRih3AUxeMxQ/a/fqrkLUWpymkFO0J9Jmf3n41h+wpHKhPhgg8fgGGrF5JEthCMQPQ655DD2XMhc2ibIq1hrFYYSStXfKCb07RTL97/FnfVsnuDWp65JbXra11gz21k+Db1UjAmKS3yPxsH/tlWBsNBr6/JHZE5wNMnMxsL90fmfizOU9+gnn+L9NFExyQZI6mpGWwP8YIiO17coIbNn0bGiwv8/dzz/EMjf+R5Ny1xQyL6TYHWjkjrMmMSomjP8PBJiE4Uo8AXQY/4rj2okf2Ojb/wQGnvsdoKwVIOFfN7cTAhBAI7OJub33IrKGmCqz6cnEtMBCIWXRWfspDrl1bnI36WCSfZppQlwvtuPLMwlhWqmtri/baFbOFAYEsq3BCB7UYdKuw4FLMp2DB2vgYhzuD/WPp1mQBejwqcISEDMtTh3DitYQ+AvYpeW4CJcSNMfcbudwzDigNOQGL2gteP9L/Wf02iXMqg/lCZ8tnqvgcI4qD3i1W49gwW9VSq+WE8Mn/QtRKReiouV6A6YbqRChZ4/1wP+WFaGfs7V7hSX4TzSHzxNLBEtjo29Q1QpLumBXx1Kw5X1diwbFCbJibYgcQGNTg59RwkrMKRmWq+xMUKjix08SGyWVkhKracHy2KQYdetFwHPDt6FT5HNoaDcKYTFrg7xTfw29GZR/JgxHXeGzSm0vPAEgsPfISUmAAobcMDOyyR3xLJdTBvcDImA9OrzrFk3MoEUxaodxluxfXxGLxOmN5eSe75ZZGbOKiDmqPTHIsJ9dpInqdKk7HsJ3DkhSXaijkEL+3IFWloCT1GHr3cLsAd3c/kSyH2xcBR0xnn77IwG2Zje8hQhknCUqt62RqaSb4yonWihQ0hfXw9jPS+kX7pSHqYJDxbSTAhNCQeOOzbgv3mtvVzoe0P+bxv2j2Y1KNBsd/T8k227QKEO8auue3yyiGw7efhyS005f2X0YL/fK+4IhAKmia9EI4SICXyjNWVmASg2DYYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":939,"cache_read_input_tokens":113787,"cache_creation":{"ephemeral_5m_input_tokens":939,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fd56238e-752b-42c4-967d-1172ac2256e9"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLNhiUDY6swHJvocHf1","type":"message","role":"assistant","content":[{"type":"text","text":"Facebook/Threads faits (page_id, website, rating, bio_links, pk). Twitch et Kick maintenant — Twitch passe au `fetcher.post` durci."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":939,"cache_read_input_tokens":113787,"cache_creation":{"ephemeral_5m_input_tokens":939,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e914aa9e-3663-4c74-adbf-4bf4b95f4c60"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLNhiUDY6swHJvocHf1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Rvqqive7QgfwKD32Zt8CLy","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":" 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 }","new_string":" lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title type\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n totalCount\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":939,"cache_read_input_tokens":113787,"cache_creation":{"ephemeral_5m_input_tokens":939,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"11207c50-ebd3-4b14-8563-173c47369d12"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Rvqqive7QgfwKD32Zt8CLy","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d8817418-217e-46ed-9e1a-67d9ce47ff25","timestamp":"2026-08-21T18:04:11.608Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":" 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 }","newString":" lastBroadcast { startedAt title game { displayName } }\n stream { viewersCount createdAt title type\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n totalCount\n edges { node {\n id title viewCount lengthSeconds publishedAt\n previewThumbnailURL(width: 320, height: 180)\n game { displayName }\n } }\n }","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-twitch)\n# 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.\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":35,"oldLines":10,"newStart":35,"newLines":11,"lines":[" primaryTeam { displayName }"," channel { socialMedias { name title url } }"," lastBroadcast { startedAt title game { displayName } }","- stream { viewersCount createdAt title","+ stream { viewersCount createdAt title type"," previewImageURL(width: 640, height: 360)"," game { displayName } }"," videos(first: 10, sort: TIME) {","+ totalCount"," edges { node {"," id title viewCount lengthSeconds publishedAt"," previewThumbnailURL(width: 320, height: 180)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"11b75fb9-08cc-4b77-90d9-5f3c78cdc4e3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLPqNsUz2tW2ATEnxtg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012t63yvuR4QUZSN6jCMMm18","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","old_string":" 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\")","new_string":" async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n # fetcher.post = retries/backoff/rotation d'IP du net.py commun\n resp = await fetcher.post(\n GQL_URL, data=body, session_id=f\"tw{login}\",\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"})\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1713,"cache_read_input_tokens":114726,"cache_creation":{"ephemeral_5m_input_tokens":1713,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a32e98c2-3178-4975-b9a6-85f6ff514507"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012t63yvuR4QUZSN6jCMMm18","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0bb117d1-4554-49e5-ae36-a8a2e0862eac","timestamp":"2026-08-21T18:04:20.448Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-twitch/src/main.py","oldString":" 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\")","newString":" async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n # fetcher.post = retries/backoff/rotation d'IP du net.py commun\n resp = await fetcher.post(\n GQL_URL, data=body, session_id=f\"tw{login}\",\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"})\n if resp.status_code != 200:\n raise RuntimeError(f\"gql http_{resp.status_code}\")\n return (resp.json().get(\"data\") or {}).get(\"user\")","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-twitch)\n# 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.\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 type\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n totalCount\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":61,"oldLines":23,"newStart":61,"newLines":14,"lines":[" fetcher = Fetcher(proxy, delay=float(inp.get(\"requestDelay\") or 1))"," sem = asyncio.Semaphore(int(inp.get(\"concurrency\") or 4))"," ","- import random","- from curl_cffi import requests as cffi","-"," async def gql(login: str) -> dict | None:"," body = json.dumps({\"query\": QUERY,"," \"variables\": {\"login\": login}})","- p = None","- if fetcher.proxy_configuration:","- p = await fetcher.proxy_configuration.new_url(","- session_id=f\"tw{random.randint(1, 999_999)}\")","- await fetcher._throttle()","- resp = await asyncio.to_thread(","- cffi.post, GQL_URL, data=body,","+ # fetcher.post = retries/backoff/rotation d'IP du net.py commun","+ resp = await fetcher.post(","+ GQL_URL, data=body, session_id=f\"tw{login}\","," headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,","- \"Content-Type\": \"application/json\"},","- impersonate=\"chrome\", timeout=45,","- proxies={\"http\": p, \"https\": p} if p else None)","+ \"Content-Type\": \"application/json\"})"," if resp.status_code != 200:"," raise RuntimeError(f\"gql http_{resp.status_code}\")"," return (resp.json().get(\"data\") or {}).get(\"user\")"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9082fc23-ac9b-4f6f-9585-2e41ff8074bd","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLQVJA1PfmvveDQVdrW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01UzmqUCE8NghhQaYFESotX3","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\"),","new_string":" \"is_live_now\": bool(stream),\n \"live_type\": stream.get(\"type\"),\n \"live_started_at\": stream.get(\"createdAt\"),\n \"live_viewers\": stream.get(\"viewersCount\"),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":761,"cache_read_input_tokens":116439,"cache_creation":{"ephemeral_5m_input_tokens":761,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"094a6714-a3a0-4def-ba74-555189abdfef"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01UzmqUCE8NghhQaYFESotX3","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"87283b13-8949-486d-9cf8-8b5c7179210c","timestamp":"2026-08-21T18:04:28.779Z","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\"),","newString":" \"is_live_now\": bool(stream),\n \"live_type\": stream.get(\"type\"),\n \"live_started_at\": stream.get(\"createdAt\"),\n \"live_viewers\": stream.get(\"viewersCount\"),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-twitch)\n# 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.\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 type\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n totalCount\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 async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n # fetcher.post = retries/backoff/rotation d'IP du net.py commun\n resp = await fetcher.post(\n GQL_URL, data=body, session_id=f\"tw{login}\",\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"})\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":123,"oldLines":6,"newStart":123,"newLines":8,"lines":[" \"avatar\": user.get(\"profileImageURL\"),"," \"banner\": user.get(\"bannerImageURL\"),"," \"is_live_now\": bool(stream),","+ \"live_type\": stream.get(\"type\"),","+ \"live_started_at\": stream.get(\"createdAt\"),"," \"live_viewers\": stream.get(\"viewersCount\"),"," \"live_title\": (stream.get(\"title\") or \"\")[:200] or None,"," \"live_thumbnail\": stream.get(\"previewImageURL\"),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"84be25ae-b3d7-4fa9-93d6-a8849bcb7c44","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLR7MaR75VWdGaY5Ejo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_0141ZJnkbQnnSpYkYma38Fmh","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),","new_string":" \"videos_count\": ((user.get(\"videos\") or {})\n .get(\"totalCount\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":318,"cache_read_input_tokens":117200,"cache_creation":{"ephemeral_5m_input_tokens":318,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"834f1b1c-999d-4303-9362-182ac0a707d5"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_0141ZJnkbQnnSpYkYma38Fmh","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c0de6bfa-a2ad-4b20-9003-0c54aa9c2c6c","timestamp":"2026-08-21T18:04:35.853Z","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),","newString":" \"videos_count\": ((user.get(\"videos\") or {})\n .get(\"totalCount\")),\n \"avg_video_views\": (round(sum(views) / len(views))\n if views else None),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-twitch)\n# 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.\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 type\n previewImageURL(width: 640, height: 360)\n game { displayName } }\n videos(first: 10, sort: TIME) {\n totalCount\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 async def gql(login: str) -> dict | None:\n body = json.dumps({\"query\": QUERY,\n \"variables\": {\"login\": login}})\n # fetcher.post = retries/backoff/rotation d'IP du net.py commun\n resp = await fetcher.post(\n GQL_URL, data=body, session_id=f\"tw{login}\",\n headers={\"Client-ID\": CLIENT_ID, \"User-Agent\": UA,\n \"Content-Type\": \"application/json\"})\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_type\": stream.get(\"type\"),\n \"live_started_at\": stream.get(\"createdAt\"),\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":134,"oldLines":6,"newStart":134,"newLines":8,"lines":[" or None,"," \"last_broadcast_game\": ((last.get(\"game\") or {})"," .get(\"displayName\")),","+ \"videos_count\": ((user.get(\"videos\") or {})","+ .get(\"totalCount\")),"," \"avg_video_views\": (round(sum(views) / len(views))"," if views else None),"," \"recent_games\": list(dict.fromkeys(games))[:5],"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4d941969-5d08-4ed5-82d6-9bd00a1506c2","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLRd32KVhyeZMH3awZu","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4QMKpAEIERgCKkB1rrJDBJJ9Of1zLRBvoygfo8MB2Y7X9YUEYlbkuKXBbiRsBuUhrkNqCeRtrGk/W2rWcHsa5NRSiG0CKs7yQ+/RMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAG4pqLUBhIM/z8LaUEOA5CRgh/XGgxuoB04NH3kbcje2s8iMAd/ctCbK10UR/CnmiMTClxrVecf/cHutK9gXZE+Do1WUpfHUooQCA0E51trDLYS7CrpAcHmy2Tslfbre0RgqLdPtc/CQ0tWyyjHJK4Hlf7WH2n8P/sgLGDqnxiJHbXZAx/T/PztMnacgc9X6hWdzFXYKqNGvXCUYsKZZKhv23WpGgjDqPkH9NLV3gpxX7SSpswWVaZl1APznv2MkF7CgZcfb6EQV/168x2gNDq8CkdI1DK/D2WGrpTq+CZ9M8wFk57uqwzYpP6wdnKj8SQA3L8Drd636uRklPfhg8z0ihmPHxCyD0KJAn7p6YMF0f8srr5lldMN701rFs5rKA6cpQ4poJ5aepytg5NTFJCKfL7kms2gaLt2ckT1rvL3GAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":305,"cache_read_input_tokens":117518,"cache_creation":{"ephemeral_5m_input_tokens":305,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6b1d223b-9b35-4d65-aa82-258f2ab405aa"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLRd32KVhyeZMH3awZu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015ibxG5HMoUvTgY2pAvpnsB","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_started_at\": (live.get(\"start_time\")\n or live.get(\"created_at\")),\n \"live_is_mature\": (bool(live.get(\"is_mature\"))\n if live else None),\n \"live_language\": live.get(\"language\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":305,"cache_read_input_tokens":117518,"cache_creation":{"ephemeral_5m_input_tokens":305,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0b8c263f-43af-4c05-a196-b760beedb18f"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015ibxG5HMoUvTgY2pAvpnsB","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5ae404d2-0897-4f0f-bbad-5b95c44ab7e4","timestamp":"2026-08-21T18:04:43.185Z","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_started_at\": (live.get(\"start_time\")\n or live.get(\"created_at\")),\n \"live_is_mature\": (bool(live.get(\"is_mature\"))\n if live else None),\n \"live_language\": live.get(\"language\"),\n \"live_title\": (live.get(\"session_title\") or \"\")[:200]\n or None,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 \"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":82,"oldLines":6,"newStart":82,"newLines":11,"lines":[" \"subscription_enabled\": ch.get(\"subscription_enabled\"),"," \"is_live_now\": bool(live),"," \"live_viewers\": live.get(\"viewer_count\"),","+ \"live_started_at\": (live.get(\"start_time\")","+ or live.get(\"created_at\")),","+ \"live_is_mature\": (bool(live.get(\"is_mature\"))","+ if live else None),","+ \"live_language\": live.get(\"language\"),"," \"live_title\": (live.get(\"session_title\") or \"\")[:200]"," or None,"," \"live_thumbnail\": ((live.get(\"thumbnail\") or {}).get(\"url\")"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a0b44487-d60d-4a16-aa84-e1f21684178c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLSBRvZCsTWn8jUTAuj","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmwUKpAEIERgCKkArufPuZlHangzM96ILWt96kMn8f8aVGJN0c4j2HiwaIh7PDM7D6zZRw/7ZuZcWgLb+9I9sv/FIwsKfRMSQ6H72Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHBpqLUBhIM+TD3uogOm09oENt2GgzKTZ5t/zgT6H64N9EiMMSgcSaWeVcol2MYwpXTD2zMiU9j3kGlrQ1ZpmGhUypHBwVx4WqDSLdkN3ePu8wFOyqjA9JGHd3w2qidRvk4ukCK433cMFr8Zh6lHx5ipvl+Rus6mUEA8uVeoico761kWzPS0Ew6CpSpx/mzAb358C8zthCcyjQuQQKsFR3wVSJneAUh/bq4s70ZUgo/f1jQ1iNS3iFcFcBS6H1aflBurTk4DSE8q4VNUHaoxVC3LN5B8CI8bAm1VRqr2CzCk52lbliFozJLAB65gxdVnXQyN4EOVrwv01DfIEeoUo4W/8pxSeeyGUlWwjNlgNss2A/ht0Hon1XBrBmRIe5nl1hsvpi2JTTq+VbIN9TPKTkqu8dYy3Cd3TJemqi0fkjshCSX6bWfiPdIOoW3sUU/SiE8DPrW9Pk3o80l/QdxgA4ZJt+jNKgOyDMDnALOkD8ejasnmHd9I+lctZP3AGQElWmepR0ys8PfLrPJIdJsYNUViwwXqNSFxvkEk30CUEj9PVte9NIsF4txofjsx0C1Od4SvQFXGkn2VS3bLjAvGfce7RCSSm99o9yfh6wqtC5gbcHCBY/9Act7uWeFYB1+8KyQA1U2iE/7ejpTbHTmpyBQC3aBf1p+EkzpGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":502,"cache_read_input_tokens":117823,"cache_creation":{"ephemeral_5m_input_tokens":502,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"18cd1e4c-59e6-4a4f-820b-71b4961905a8"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLSBRvZCsTWn8jUTAuj","type":"message","role":"assistant","content":[{"type":"text","text":"Twitch/Kick faits. Les 5 derniers acteurs maintenant."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":502,"cache_read_input_tokens":117823,"cache_creation":{"ephemeral_5m_input_tokens":502,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"15b2998f-0b5e-4bfa-9d1f-11b21daf6072"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLSBRvZCsTWn8jUTAuj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Q726ZYMJ2aRw1eBguaXTzH","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"completed","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, flag mineur, avg_saves, épinglés)","status":"completed","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live)","status":"completed","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (top_mentions, avg_quotes, parts liens/RT, verified_type)","status":"completed","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, pk)","status":"completed","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount, live type/start) + ka-kick (start/mature/langue live)","status":"completed","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"in_progress","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":502,"cache_read_input_tokens":117823,"cache_creation":{"ephemeral_5m_input_tokens":502,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d854cbe5-5822-4f06-a915-b5c4fd27b9d3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Q726ZYMJ2aRw1eBguaXTzH","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f4fc2b5d-5132-4eab-ba24-dc3cefe84eae","timestamp":"2026-08-21T18:04:57.730Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"completed","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, flag mineur, avg_saves, épinglés)","status":"in_progress","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live, liens canoniques)","status":"pending","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (tweet épinglé, part médias/liens, cadence fine)","status":"pending","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, cadence)","status":"pending","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount vidéos) + ka-kick (durée live, start)","status":"pending","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"pending","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"completed","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, flag mineur, avg_saves, épinglés)","status":"completed","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live)","status":"completed","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (top_mentions, avg_quotes, parts liens/RT, verified_type)","status":"completed","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, pk)","status":"completed","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount, live type/start) + ka-kick (start/mature/langue live)","status":"completed","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"in_progress","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLSBRvZCsTWn8jUTAuj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01XU4sXc8H7ha9phvJPq5LKP","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","old_string":"_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}","new_string":"_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 \"instagram\": re.compile(r'\"instagram\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"tiktok\": re.compile(r'\"tiktok\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"twitch\": re.compile(r'\"twitch\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"discord\": re.compile(r'\"discord\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n}\n_PUBLISHED_RE = re.compile(r'\"published_at\"\\s*:\\s*\"([^\"]+)\"')\n_PPN_RE = re.compile(r'\"pay_per_name\"\\s*:\\s*\"([^\"]+)\"')"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":502,"cache_read_input_tokens":117823,"cache_creation":{"ephemeral_5m_input_tokens":502,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"82bad42e-885a-4241-b4fa-860ee91fa69f"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01XU4sXc8H7ha9phvJPq5LKP","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8004ff28-1265-4d8d-8d67-e5fa79688451","timestamp":"2026-08-21T18:05:03.474Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","oldString":"_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}","newString":"_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 \"instagram\": re.compile(r'\"instagram\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"tiktok\": re.compile(r'\"tiktok\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"twitch\": re.compile(r'\"twitch\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n \"discord\": re.compile(r'\"discord\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),\n}\n_PUBLISHED_RE = re.compile(r'\"published_at\"\\s*:\\s*\"([^\"]+)\"')\n_PPN_RE = re.compile(r'\"pay_per_name\"\\s*:\\s*\"([^\"]+)\"')","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page / 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' 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 \"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 })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","structuredPatch":[{"oldStart":34,"oldLines":7,"newStart":34,"newLines":13,"lines":[" \"facebook\": re.compile(r'\"facebook\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),"," \"twitter\": re.compile(r'\"twitter\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),"," \"youtube\": re.compile(r'\"youtube\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+ \"instagram\": re.compile(r'\"instagram\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+ \"tiktok\": re.compile(r'\"tiktok\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+ \"twitch\": re.compile(r'\"twitch\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),","+ \"discord\": re.compile(r'\"discord\"\\s*:\\s*\"(https?:(?:[^\"\\\\]|\\\\.)+)\"'),"," }","+_PUBLISHED_RE = re.compile(r'\"published_at\"\\s*:\\s*\"([^\"]+)\"')","+_PPN_RE = re.compile(r'\"pay_per_name\"\\s*:\\s*\"([^\"]+)\"')"," "," "," def _dec(raw: str) -> str:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"68288cab-6857-4583-9987-7c703d901d01","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLTfHWWcAJ1gsmZC3f7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01397oqY1QRHXbMpXwJizayK","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","old_string":" \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,","new_string":" \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"published_at\": (pub.group(1) if\n (pub := _PUBLISHED_RE.search(text))\n else None),\n \"pay_per_name\": (ppn.group(1)[:40] if\n (ppn := _PPN_RE.search(text))\n else None),"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2005,"cache_read_input_tokens":118325,"cache_creation":{"ephemeral_5m_input_tokens":2005,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a98a54be-499b-4217-be7d-caff69511d20"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01397oqY1QRHXbMpXwJizayK","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9ae915df-4658-41bf-a2d0-f4a96138700b","timestamp":"2026-08-21T18:05:10.085Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-patreon/src/main.py","oldString":" \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,","newString":" \"is_monthly\": (monthly.group(1) == \"true\"\n if monthly else None),\n \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,\n \"published_at\": (pub.group(1) if\n (pub := _PUBLISHED_RE.search(text))\n else None),\n \"pay_per_name\": (ppn.group(1)[:40] if\n (ppn := _PPN_RE.search(text))\n else None),","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-patreon)\n# Desc: Pages Patreon publiques — la page / 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' 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 \"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 })\n\n await asyncio.gather(*[one(u) for u in usernames])\n Actor.log.info(f\"terminé : {len(usernames)} pages\")\n","structuredPatch":[{"oldStart":109,"oldLines":6,"newStart":109,"newLines":12,"lines":[" \"is_monthly\": (monthly.group(1) == \"true\""," if monthly else None),"," \"is_nsfw\": nsfw.group(1) == \"true\" if nsfw else None,","+ \"published_at\": (pub.group(1) if","+ (pub := _PUBLISHED_RE.search(text))","+ else None),","+ \"pay_per_name\": (ppn.group(1)[:40] if","+ (ppn := _PPN_RE.search(text))","+ else None),"," \"creation_name\": (_dec(creation.group(1))[:200]"," if creation else None),"," \"avatar\": htmllib.unescape(img.group(1)) if img else None,"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"206bef75-ef40-41b3-92ca-37aca3e44123","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLU9TRNK8qnVuRZEBdy","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISyAoKpAEIERgCKkBLDgbnQHjDi68LVpoz1dbSUKpi47I3cU/UbiYBpGQ45crsEzJKb7KacDZRwKzLr7iDqW5x5zkYf37cjfcwgajHMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHfpqLUBhIM6eKVvtEqgR/vTUNHGgxZLtcZYrcsaDmhwW4iMKmZFh3aWHfs1EXNWp8XA7RsemupoEogUQRYQNb+j79Jp9ERXaPU/m467MJ2FmZTfirQCD/+uktntt3IT+fwVubOG56fqbMQMf7BZePZyA4C/6rFDU00huZUi41LlTVmI/f5Xq1SwGkTTHr37yT4QscmInbkpK+ww3uNWcuDJI0k00rnLKj0SY+hskqwtiiR7H6eNXU/KZ33OQa3/j+F79A4ciplB8kV8kXdH04xhklMD/RR0K7wg/GqNAjqi3gZBrPGcBeuK5QL0YiRJhCCLBMzR9QwzCcRy9nAHNNs5I0ln7BNWP7FxUWUUPAFxT100O6SfwURPe5l2b7HBT4pG1Z8JcSZrFHPPIS9Ifgd/o2ooEmJiMrcaHXRqYvjVqdS6saibM1VOHdlxbXIUXIuyR0o2thumKqRGxqWZ64Avy/qxDPbAvqdJB8RDcF04fRP2KbGvMmV4XHEOurD52oEyWRsbA/UxYVnGdWTXkH+dMimR6hM9b7tngWDIQbMlKPuIN7KMm+i+e19NFa9SKWMxoBvWTPAy4DztlrgO7Y7YiPrM7MfxO7RUyXd6qSL1Bq1h88Vrt+8A5h6mBMRAjbOQIxnNDLpiMMZv5/nIhSC8kru9+ulr8f4RKXn1m6ENjSdd8yzer0oUISI8z5afAzbxqmMyztcUcJhV/44oSDGtsDQNX2YpZ+Rh5DZMWHaKV37qfWS3XPnk6RaZW3sEIfwUgUVUnO7bTDAGCP2h1s+vhMpCm/7dmOg3bta/bb3vU9U6P41v6wWu8lmEkt++kq6QuIn8YkHXmZG57mHu+usRjvI+/JtikGu+LwJBfaKw6Hbnh+oKZN2v8y9pTk0hxqOy7aF03Kv2d282hELIB/pK+DGVmr3LUUUjdsP/SsAqBc6Es3pn3Wv4Hj76E5iBXHQ6bs2zOfARGKVXRrTI73aoKE/472270W/D5PMAhuPH07oXDFET5LCImhq2pAXkQDT6bVBlQn6oe+NHYDnyniRA6oFfjdKRspCDAY2jFqAkwNgAqSfrKlLIyJnpDWvHJoFmWX7wmj6DZ2Sz3bh0bWkyx0xzKTh91eAIoWmJpsFK9LLGjYCZ9Bj6unqNlDvLi2XpancTzDsvfhpQYNvruUvzbc4902jITjIyaKqUaUuAIzDzlQJqXF6VuOScyYTBZsrJNhoAyJLHJMYUu6dexpMDtccuDd2Q4TraqbUX46Zz2r+jxaNoisFq9xJC/owjqCudBmFXooyP589AqFQkp0oKTdX4LqXqn6zCgYlUXOmgMCOiVLhGOAfdnMW0rCuYrCJBzITDAyHcNmOO1YaZcgGOHprDXxt9FeNCs3MGDVlKgEWVkTHKToaEA4ZrusK0r8jEo29TSo/Z21kEhkg2zMPnKJN+ZLWn90KQ1wUMhhjiJ64NRGWuTb2EUC9i4tgPexCPlp7utCzBKl1lbkP+HA+ulfsXeUMrjoIZI20zRz+qEF5Xurfz/dE3tIx2tLzDysxWOYgnDr0WgkZL2aurJY8/9+fP0TkaDP2FfClkya6ZI7s9VIK4BgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":438,"cache_read_input_tokens":120330,"cache_creation":{"ephemeral_5m_input_tokens":438,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3602c62c-d893-4d79-9f16-46ac633bf2dd"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLU9TRNK8qnVuRZEBdy","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01L1LcepsDBcnbEskcGPTmso","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py","old_string":" found.add(u)\n stats = acc.get(\"timelineStats\") or {}\n await Actor.push_data({","new_string":" found.add(u)\n stats = acc.get(\"timelineStats\") or {}\n tiers = [{\"name\": t.get(\"name\"), \"price\": t.get(\"price\")}\n for t in (acc.get(\"subscriptionTiers\") or [])\n if isinstance(t, dict) and t.get(\"name\")]\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":438,"cache_read_input_tokens":120330,"cache_creation":{"ephemeral_5m_input_tokens":438,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ad720389-c1ef-490b-9998-6d77e6f63406"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01L1LcepsDBcnbEskcGPTmso","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ee986d1d-a3fe-4f64-ae6b-a3aeb81b103e","timestamp":"2026-08-21T18:05:21.649Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py","oldString":" found.add(u)\n stats = acc.get(\"timelineStats\") or {}\n await Actor.push_data({","newString":" found.add(u)\n stats = acc.get(\"timelineStats\") or {}\n tiers = [{\"name\": t.get(\"name\"), \"price\": t.get(\"price\")}\n for t in (acc.get(\"subscriptionTiers\") or [])\n if isinstance(t, dict) and t.get(\"name\")]\n await Actor.push_data({","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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","structuredPatch":[{"oldStart":61,"oldLines":6,"newStart":61,"newLines":9,"lines":[" continue"," found.add(u)"," stats = acc.get(\"timelineStats\") or {}","+ tiers = [{\"name\": t.get(\"name\"), \"price\": t.get(\"price\")}","+ for t in (acc.get(\"subscriptionTiers\") or [])","+ if isinstance(t, dict) and t.get(\"name\")]"," await Actor.push_data({"," \"kind\": \"profile\","," \"platform\": \"fansly\","]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"55e0fd31-4263-4fa2-904e-ac3bb0ec9729","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLV37Ss7nxiAdHKcfAq","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_019CZkZwwQimTNoN4PZ5kPNs","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py","old_string":" \"avatar\": _loc(acc.get(\"avatar\")),\n \"banner\": _loc(acc.get(\"banner\")),\n \"location\": acc.get(\"location\"),\n })","new_string":" \"avatar\": _loc(acc.get(\"avatar\")),\n \"banner\": _loc(acc.get(\"banner\")),\n \"location\": acc.get(\"location\"),\n \"account_created_at\": acc.get(\"createdAt\"),\n \"subscription_tiers\": tiers[:5] or None,\n \"subscription_price\": (tiers[0].get(\"price\")\n if tiers else None),\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":806,"cache_read_input_tokens":120768,"cache_creation":{"ephemeral_5m_input_tokens":806,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9b5765d7-8544-4754-bc4b-580b99d7a2ea"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_019CZkZwwQimTNoN4PZ5kPNs","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2c47381f-e16c-4fd5-910d-cfa0ace581e9","timestamp":"2026-08-21T18:05:27.897Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-fansly/src/main.py","oldString":" \"avatar\": _loc(acc.get(\"avatar\")),\n \"banner\": _loc(acc.get(\"banner\")),\n \"location\": acc.get(\"location\"),\n })","newString":" \"avatar\": _loc(acc.get(\"avatar\")),\n \"banner\": _loc(acc.get(\"banner\")),\n \"location\": acc.get(\"location\"),\n \"account_created_at\": acc.get(\"createdAt\"),\n \"subscription_tiers\": tiers[:5] or None,\n \"subscription_price\": (tiers[0].get(\"price\")\n if tiers else None),\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 tiers = [{\"name\": t.get(\"name\"), \"price\": t.get(\"price\")}\n for t in (acc.get(\"subscriptionTiers\") or [])\n if isinstance(t, dict) and t.get(\"name\")]\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","structuredPatch":[{"oldStart":85,"oldLines":6,"newStart":85,"newLines":10,"lines":[" \"avatar\": _loc(acc.get(\"avatar\")),"," \"banner\": _loc(acc.get(\"banner\")),"," \"location\": acc.get(\"location\"),","+ \"account_created_at\": acc.get(\"createdAt\"),","+ \"subscription_tiers\": tiers[:5] or None,","+ \"subscription_price\": (tiers[0].get(\"price\")","+ if tiers else None),"," })"," for u in batch:"," if u not in found:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8e61d069-d68c-4849-aab3-8f49e0830f18","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLVTcba5Up2cqnB1z43","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H8bFZB3WnYhubPpTv1jYKc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","old_string":" \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n })","new_string":" \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n \"features\": sorted(guild.get(\"features\") or [])[:12],\n \"nsfw_level\": guild.get(\"nsfw_level\"),\n \"premium_tier\": guild.get(\"premium_tier\"),\n \"invite_expires_at\": d.get(\"expires_at\"),\n \"inviter\": ((d.get(\"inviter\") or {}).get(\"username\")\n or None),\n })"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":398,"cache_read_input_tokens":121574,"cache_creation":{"ephemeral_5m_input_tokens":398,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ecfec6ce-576c-4afc-bcb1-7ac980be7298"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H8bFZB3WnYhubPpTv1jYKc","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5f026c73-ef4d-47a2-986c-b0aa12ee995f","timestamp":"2026-08-21T18:05:33.163Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-discord/src/main.py","oldString":" \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n })","newString":" \"vanity_url_code\": guild.get(\"vanity_url_code\"),\n \"features\": sorted(guild.get(\"features\") or [])[:12],\n \"nsfw_level\": guild.get(\"nsfw_level\"),\n \"premium_tier\": guild.get(\"premium_tier\"),\n \"invite_expires_at\": d.get(\"expires_at\"),\n \"inviter\": ((d.get(\"inviter\") or {}).get(\"username\")\n or None),\n })","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 \"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 })\n\n await asyncio.gather(*[one(c) for c in codes])\n Actor.log.info(f\"terminé : {len(codes)} invitations\")\n","structuredPatch":[{"oldStart":81,"oldLines":6,"newStart":81,"newLines":12,"lines":[" f\"{splash_hash}.jpg?size=1024\""," if gid and splash_hash else None),"," \"vanity_url_code\": guild.get(\"vanity_url_code\"),","+ \"features\": sorted(guild.get(\"features\") or [])[:12],","+ \"nsfw_level\": guild.get(\"nsfw_level\"),","+ \"premium_tier\": guild.get(\"premium_tier\"),","+ \"invite_expires_at\": d.get(\"expires_at\"),","+ \"inviter\": ((d.get(\"inviter\") or {}).get(\"username\")","+ or None),"," })"," "," await asyncio.gather(*[one(c) for c in codes])"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d3c69747-584d-4ed0-83df-47368273ee94","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLVr8BQdAA2V9Jej8xF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS7wYKpAEIERgCKkArDiMcjvMvU9GMBLD9qOPlhnnGSZWTL4euEE0rlFYh7V4y+dHnYG1slBixhIW+Qlyk2UvS7tcYd6BhunHdktiEMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH0pqLUBhIMl2ewBi4gEZd2mhNtGgz9x/6QF7ea1sZ9JEYiML8QPzQrXUyZ5pVbECk9JETwc7ahdALwPZCdIpg8ErAt2J9NWYdvM96CztxjyC/HFSr3BF44f5LR7FgvJLej2RStZcdA906JRbhNWEF6kLmj7tolU/F4Cr95r1gzfn3BuaZx+u8+A9aZm+M+rfL7OoTI3Bq6lvDmuJnLTs5pZtQajdmEP6t4BH3QziRU8GUCURw7r2UWhRlbXO54oSePdDHOYfRsxkH3mhVDCptB7YLWzgJCTYZmaykzzWcLx1wmLvJG71PV30qd2+jcOljNi8QXyn8pt9qXXh3dir32ycPh6YzwLy9z50wAjxAaQIDjllzb64q7Bo2vkaUoFLYLOal2yXM1nn7R/IaoX3ABwGnh+mhntMIPkby2tkY2Bdnj7U4wkc8iv06JohkqYaUCLdk3adYOzHSdugQdEyEr4lMpigfL97YnQnvoCX3HZOX7oY9zqCWmdu8j+daAHyNbC9uWTWSIW3i+eNl5UBSSDUqrM5i28TihdWCuQAzzQfnpG7cooKIk7/N2LYEG3V740W+Io2S3UI0pZwwFOK3KobETTXfk/sM/ZdI1gYDwgn9XisCmPGErh1U0nefxU9KH3ZVwQ97YxoT0w0Op7U52bt4fIY/bm9ANWKLoqXcGTxc/RsEIxAxiIFDbLL1d3FOSATqsFhxQz/Y0FPtvl6xi/kPtTs5RB3saYvvwFPlpLHqaXAwLcA3S5i0pKHXT7oMFA3mtXc8Do08x1EUGP/Yej5AHGdvkE+4HJZAna9XSvbwW/dmyTaiNR95zcJSPe+mUkEhqtYwbXTdCQP4+dxfNqPYQaoJaGWmT5dUIVXuhsWQcCObIbSpt8UJyY4zJ564t5ezWl7nXbTY4GgUMXgJmCu8Wn2zHTYwXCYX4lajYsADWdmgKmj1L1NvqNXIYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":385,"cache_read_input_tokens":121972,"cache_creation":{"ephemeral_5m_input_tokens":385,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2a912a7e-8abf-4f01-bb25-b89e18cba6fc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLVr8BQdAA2V9Jej8xF","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01DLQ4Gmn9wdKCfgJMLv37E5","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py","old_string":" \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),\n}","new_string":" \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),\n \"audios_count\": re.compile(r'\"audiosCount\"\\s*:\\s*(\\d+)'),\n \"medias_count\": re.compile(r'\"mediasCount\"\\s*:\\s*(\\d+)'),\n}\n_JOIN_RE = re.compile(r'\"joinDate\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_PERFORMER_RE = re.compile(r'\"isPerformer\"\\s*:\\s*(true|false)')"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":385,"cache_read_input_tokens":121972,"cache_creation":{"ephemeral_5m_input_tokens":385,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"426a8f9f-39b7-4fad-8973-1068c6f0ded5"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DLQ4Gmn9wdKCfgJMLv37E5","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"97134329-cbbc-4dc5-b507-f25683dea899","timestamp":"2026-08-21T18:05:43.175Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py","oldString":" \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),\n}","newString":" \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),\n \"audios_count\": re.compile(r'\"audiosCount\"\\s*:\\s*(\\d+)'),\n \"medias_count\": re.compile(r'\"mediasCount\"\\s*:\\s*(\\d+)'),\n}\n_JOIN_RE = re.compile(r'\"joinDate\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_PERFORMER_RE = re.compile(r'\"isPerformer\"\\s*:\\s*(true|false)')","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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' 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","structuredPatch":[{"oldStart":26,"oldLines":7,"newStart":26,"newLines":11,"lines":[" \"videos_count\": re.compile(r'\"videosCount\"\\s*:\\s*(\\d+)'),"," \"likes\": re.compile(r'\"favoritedCount\"\\s*:\\s*(\\d+)'),"," \"streams_count\": re.compile(r'\"finishedStreamsCount\"\\s*:\\s*(\\d+)'),","+ \"audios_count\": re.compile(r'\"audiosCount\"\\s*:\\s*(\\d+)'),","+ \"medias_count\": re.compile(r'\"mediasCount\"\\s*:\\s*(\\d+)'),"," }","+_JOIN_RE = re.compile(r'\"joinDate\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')","+_PERFORMER_RE = re.compile(r'\"isPerformer\"\\s*:\\s*(true|false)')"," _PRICE_RE = re.compile(r'\"subscribePrice\"\\s*:\\s*([\\d.]+)')"," _STR_RES = {"," \"full_name\": re.compile(r'\"name\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"'),"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3a23afb9-a221-406e-8b6a-007762d7e392","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLWbB3VQM2oBDmCfXVK","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01835fSP2wHUZzgEDg8P9XRw","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py","old_string":" m = _VERIFIED_RE.search(text)\n rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None","new_string":" m = _VERIFIED_RE.search(text)\n rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None\n m = _JOIN_RE.search(text)\n rec[\"join_date\"] = _dec(m.group(1)) if m else None\n m = _PERFORMER_RE.search(text)\n rec[\"is_performer\"] = (m.group(1) == \"true\") if m else None"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":762,"cache_read_input_tokens":122357,"cache_creation":{"ephemeral_5m_input_tokens":762,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8ab8b1fa-5e77-42c2-bd3a-26f7acc1bd33"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01835fSP2wHUZzgEDg8P9XRw","type":"tool_result","content":"The file /Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a57cf030-6de5-4994-8e8e-895998a551d9","timestamp":"2026-08-21T18:05:48.575Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-onlyfans/src/main.py","oldString":" m = _VERIFIED_RE.search(text)\n rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None","newString":" m = _VERIFIED_RE.search(text)\n rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None\n m = _JOIN_RE.search(text)\n rec[\"join_date\"] = _dec(m.group(1)) if m else None\n m = _PERFORMER_RE.search(text)\n rec[\"is_performer\"] = (m.group(1) == \"true\") if m else None","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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 \"audios_count\": re.compile(r'\"audiosCount\"\\s*:\\s*(\\d+)'),\n \"medias_count\": re.compile(r'\"mediasCount\"\\s*:\\s*(\\d+)'),\n}\n_JOIN_RE = re.compile(r'\"joinDate\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"')\n_PERFORMER_RE = re.compile(r'\"isPerformer\"\\s*:\\s*(true|false)')\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' 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","structuredPatch":[{"oldStart":103,"oldLines":6,"newStart":103,"newLines":10,"lines":[" else None)"," m = _VERIFIED_RE.search(text)"," rec[\"is_verified\"] = (m.group(1) == \"true\") if m else None","+ m = _JOIN_RE.search(text)","+ rec[\"join_date\"] = _dec(m.group(1)) if m else None","+ m = _PERFORMER_RE.search(text)","+ rec[\"is_performer\"] = (m.group(1) == \"true\") if m else None"," if not rec.get(\"full_name\"):"," m = _OG_TITLE_RE.search(text)"," if m:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"be922322-20c7-4e36-99ab-e000f2ae7c2a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLX2BwnGMDLktPXh1rW","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01KaXMRqoBVibK93ChXFU3tc","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","old_string":" \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),","new_string":" \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),\n \"banner\": (info.get(\"squareHeroImageUrl\")\n or info.get(\"heroImageUrl\") or None),\n \"snapcode\": info.get(\"snapcodeImageUrl\") or None,"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":390,"cache_read_input_tokens":123119,"cache_creation":{"ephemeral_5m_input_tokens":390,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"75f8cf03-7198-4783-b261-54524bf2834b"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KaXMRqoBVibK93ChXFU3tc","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5c462375-aa90-41ba-9d4c-7e55dd2f64a5","timestamp":"2026-08-21T18:05:54.506Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/actors/ka-snapchat/src/main.py","oldString":" \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),","newString":" \"website\": info.get(\"websiteUrl\"),\n \"address\": info.get(\"address\"),\n \"avatar\": info.get(\"profilePictureUrl\"),\n \"banner\": (info.get(\"squareHeroImageUrl\")\n or info.get(\"heroImageUrl\") or None),\n \"snapcode\": info.get(\"snapcodeImageUrl\") or None,","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \n# File: src/main.py (ka-snapchat)\n# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page\n# snapchat.com/add/ : 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'', 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 \"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,\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":103,"oldLines":6,"newStart":103,"newLines":9,"lines":[" \"website\": info.get(\"websiteUrl\"),"," \"address\": info.get(\"address\"),"," \"avatar\": info.get(\"profilePictureUrl\"),","+ \"banner\": (info.get(\"squareHeroImageUrl\")","+ or info.get(\"heroImageUrl\") or None),","+ \"snapcode\": info.get(\"snapcodeImageUrl\") or None,"," \"has_story\": bool(info.get(\"hasStory\")),"," \"has_spotlight\": bool(info.get(\"hasSpotlightHighlights\")),"," \"has_curated_highlights\": bool("]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"8aa55574-47da-4924-bdce-aadd14e74246","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLXRY8tpBkPbPQzZXvs","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISvAUKpAEIERgCKkCquvQLdbPF4bwqElVFYYN30A1iIGmE7a/OO4I0Dhm/hyKtjzFVlsO6opVEcz0JbZc/t8SVQ+BJ4ahy/jby5UiXMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGIp6LUBhIMdQzvlgINBh/JGqRWGgwS2PEekZWzXkk5W0MiMKIn38jj47yRVjXm2LkBTR3ij0DwezDNdUVR9xgaRanTQX+KbHkJLgt8yA8+W6L9syrEA/8AlGGPbLD091wE04FdAnhPPWal8Vv8YlZNgfqT0kNfZDuhPPmpu+ee3TZnNOZW/PIuu8/0FSpDGfo2vVLWZVNs64tDNuEtAFKH+7I/0anPDjLFODFc0bjsbemLkj3XthkEfq2N3iwu0vvslLpqbbcRPrzSfHefAgeZPaxKKgmn3VivMVUsL2UM5xCFHuE1uhs3zZfmaLu9ZmNVfpGznhvBYT8IHI8+6fyU1SHY8xuTAOIz1DXiitjckDf2GPishPoE6njsGBtqqK3Koq6c9BY1Q4cJbEz/ymNyUO+3wqtTadVCuR+tWTuUFj5dkib8waAsxmn5NpdO9/Z7LMUF4KtM9H5t6+PQTCnNUdChkWESBnj9Ngnw5d0eztRofCNPjOGK0XlWE5UcmG0Amaa0doI8efpORNNkJj3LJR2Ac6fm4um3UsiB1NF/XArTWTJ2l41CWx1cYHJaSGcv0tGDa8bOqoWPfYgofOCowGxoabiqQq6XQYojFH187qgmNiIHfCxIYhVp5iixvSzX69EpDoeO5qH1GjUzAfEJE3ZSN4FIbQeXnsdMEBDHaX3GQk3sPO1y9Uh1+U1FvjUt0X3YftDpAgTdGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":123509,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e35a7238-3f98-4f8a-a1a8-3e8186b1cca0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLXRY8tpBkPbPQzZXvs","type":"message","role":"assistant","content":[{"type":"text","text":"Les 13 acteurs sont enrichis. Vérification syntaxique, puis push."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":123509,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"76d15067-1e4d-4833-b61e-2f94ff95852b"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLXRY8tpBkPbPQzZXvs","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01AkmexnJJEFfK3b3dn3K7XG","name":"Bash","input":{"command":"python3 -m py_compile ka-*/src/main.py ka-x/src/net.py && echo OK-SYNTAX","description":"Vérifier la syntaxe Python des 13 acteurs"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":382,"cache_read_input_tokens":123509,"cache_creation":{"ephemeral_5m_input_tokens":382,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"81b3b991-c961-4429-86c1-0ded53bce085"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01AkmexnJJEFfK3b3dn3K7XG","type":"tool_result","content":"OK-SYNTAX","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b4544431-0e7f-4ccb-a299-e9d69fedeb14","timestamp":"2026-08-21T18:06:01.630Z","tool_use_result":{"stdout":"OK-SYNTAX","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b3f4a74e-b331-45cc-93e1-078b6dd5da2e","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLXwwyisf87s8N636kN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BsNnpGhcWQtMjn5ZRrkEY4","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"in_progress","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":985,"cache_read_input_tokens":123891,"cache_creation":{"ephemeral_5m_input_tokens":985,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b780e4ae-059d-4b96-a66a-641807b46291"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BsNnpGhcWQtMjn5ZRrkEY4","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6feb3dd1-67ba-4434-adcb-785f7297895d","timestamp":"2026-08-21T18:06:07.837Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir ka-instagram (bio entities, flag Threads, business_email, felix)","status":"completed","activeForm":"Enrichissement ka-instagram"},{"content":"Enrichir ka-tiktok (diggCount, flag mineur, avg_saves, épinglés)","status":"completed","activeForm":"Enrichissement ka-tiktok"},{"content":"Enrichir ka-youtube (cadence estimée, shorts/live)","status":"completed","activeForm":"Enrichissement ka-youtube"},{"content":"Enrichir ka-x (top_mentions, avg_quotes, parts liens/RT, verified_type)","status":"completed","activeForm":"Enrichissement ka-x"},{"content":"Enrichir ka-facebook (website, page_id, rating) + ka-threads (bio_links, pk)","status":"completed","activeForm":"Enrichissement ka-facebook/ka-threads"},{"content":"Enrichir ka-twitch (POST robuste, totalCount, live type/start) + ka-kick (start/mature/langue live)","status":"completed","activeForm":"Enrichissement ka-twitch/ka-kick"},{"content":"Enrichir ka-patreon (+ socials IG/TikTok/Twitch/Discord), ka-fansly (prix, tiers), ka-discord (features, expiration), ka-onlyfans (joinDate), ka-snapchat","status":"in_progress","activeForm":"Enrichissement des 5 derniers acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"pending","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"in_progress","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLXwwyisf87s8N636kN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CUUMQZxZVfGWse7kAC4RxX","name":"Bash","input":{"command":"for a in ka-instagram ka-tiktok ka-x ka-youtube ka-twitch ka-kick ka-facebook; do (cd $a && apify push --no-prompt >/tmp/push-$a.log 2>&1 && echo \"$a OK\" || echo \"$a FAIL\") & done; wait","description":"apify push des 7 premiers acteurs en parallèle","timeout":600000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":985,"cache_read_input_tokens":123891,"cache_creation":{"ephemeral_5m_input_tokens":985,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"4bad6049-01d2-4a79-9d01-95c7221ed5bb"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CUUMQZxZVfGWse7kAC4RxX","type":"tool_result","content":"ka-youtube FAIL\nka-facebook FAIL\nka-kick FAIL\nka-x FAIL\nka-tiktok FAIL\nka-instagram FAIL\nka-twitch FAIL","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"57e77d79-4089-419c-9bd0-4477005a4982","timestamp":"2026-08-21T18:06:11.272Z","tool_use_result":{"stdout":"ka-youtube FAIL\nka-facebook FAIL\nka-kick FAIL\nka-x FAIL\nka-tiktok FAIL\nka-instagram FAIL\nka-twitch FAIL","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"60630164-d87e-435f-9958-4570885f2db8","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLYf2mUi73qjnpsAChd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01TijWL7YbLDGGduFcbYNBZ3","name":"Bash","input":{"command":"tail -20 /tmp/push-ka-x.log","description":"Voir l'erreur du push ka-x"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":774,"cache_read_input_tokens":124876,"cache_creation":{"ephemeral_5m_input_tokens":774,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d463d138-c177-4eea-899a-817c8d37d08d"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01TijWL7YbLDGGduFcbYNBZ3","type":"tool_result","content":" --dir= 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= 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= 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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"384ca36e-365f-4a05-8b07-2af4b7cdb862","timestamp":"2026-08-21T18:06:14.568Z","tool_use_result":{"stdout":" --dir= 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= 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= 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}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7d301d60-c0c2-41c5-b0e2-453acf024f30","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLYuBZmLEm3dNYHc9ru","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS/wIKpAEIERgCKkApNbr2JztzX9wGMam3I80eyQSDittQ9A4J6iYNUHvvyMzuel8AeGppEyCBwuD5O9DhyrIbcUVjD89LVdXilzDtMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGap6LUBhIMEc2JRoHdAOX6ypfeGgwiAxAU13TpBvCeaEwiMAubk0wcxXxO/1qgHqNElyjwe3yAnc+1mEMDygVNXESJzyph91J5AvTSdlqV6GSYCiqHARfZQvbvULJDd3yrEborGmCVshdESJ/pz0LEfV8X06w+IuSSHvkDe7LBYP3C8ftzmUPNVQvqXQY5ID6WMHOvQokT/I3C8EaFhPYZ/E4eKipVao65sayrq+AadHuou1GOziiUuYo5Uz4XeFbqXt4voP1T48oRO/G1tjB0F5qecxPYu8POQYx7qRgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":418,"cache_read_input_tokens":125650,"cache_creation":{"ephemeral_5m_input_tokens":418,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2ddb5c85-2fff-40e0-8cb5-cc38112f5c6e"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLYuBZmLEm3dNYHc9ru","type":"message","role":"assistant","content":[{"type":"text","text":"Le flag `--no-prompt` n'existe pas — je relance avec `apify push` simple."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":418,"cache_read_input_tokens":125650,"cache_creation":{"ephemeral_5m_input_tokens":418,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"82f27b86-9f6d-475c-9c48-aca82ddf7897"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLYuBZmLEm3dNYHc9ru","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016oH2YCmB4CAqpt6GvMgwzt","name":"Bash","input":{"command":"for a in ka-instagram ka-tiktok ka-x ka-youtube ka-twitch ka-kick ka-facebook; do (cd $a && apify push >/tmp/push-$a.log 2>&1 && echo \"$a OK\" || echo \"$a FAIL\") & done; wait","description":"apify push des 7 premiers acteurs en parallèle","timeout":600000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":418,"cache_read_input_tokens":125650,"cache_creation":{"ephemeral_5m_input_tokens":418,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"bc06fd65-1c1d-4661-b349-f30b2fcb6081"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"be3yq2j7a","tool_use_id":"toolu_016oH2YCmB4CAqpt6GvMgwzt","description":"apify push des 7 premiers acteurs en parallèle","task_type":"local_bash","uuid":"a65de410-a7d8-4d51-8c01-6b5d1a65ee6a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"be3yq2j7a","tool_use_id":"toolu_016oH2YCmB4CAqpt6GvMgwzt","status":"completed","output_file":"","summary":"apify push des 7 premiers acteurs en parallèle","uuid":"2db786b8-a861-41fe-baec-44926ad4a605","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016oH2YCmB4CAqpt6GvMgwzt","type":"tool_result","content":"ka-x OK\nka-kick OK\nka-facebook OK\nka-instagram OK\nka-tiktok OK\nka-twitch OK\nka-youtube OK","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"baf0e7c9-aae9-4718-906c-fdafb2af10f6","timestamp":"2026-08-21T18:06:39.872Z","tool_use_result":{"stdout":"ka-x OK\nka-kick OK\nka-facebook OK\nka-instagram OK\nka-tiktok OK\nka-twitch OK\nka-youtube OK","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"150e9c1d-882b-4315-8094-8dbe957334e3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLamUmdWMq1o8Soxr3g","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RNZpPWSkJFRcoChimrLSeb","name":"Bash","input":{"command":"for a in ka-threads ka-snapchat ka-fansly ka-onlyfans ka-patreon ka-discord; do (cd $a && apify push >/tmp/push-$a.log 2>&1 && echo \"$a OK\" || echo \"$a FAIL\") & done; wait","description":"apify push des 6 derniers acteurs en parallèle","timeout":600000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":351,"cache_read_input_tokens":126068,"cache_creation":{"ephemeral_5m_input_tokens":351,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"bc01233f-444d-47fc-be2b-9e1ad77f4ded"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bh8iitfwe","tool_use_id":"toolu_01RNZpPWSkJFRcoChimrLSeb","description":"apify push des 6 derniers acteurs en parallèle","task_type":"local_bash","uuid":"911e3dac-8b6f-4854-b46c-73cd7234ffd5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bh8iitfwe","tool_use_id":"toolu_01RNZpPWSkJFRcoChimrLSeb","status":"completed","output_file":"","summary":"apify push des 6 derniers acteurs en parallèle","uuid":"bf100045-84eb-4c3d-bddf-a3b55a6bb3f9","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RNZpPWSkJFRcoChimrLSeb","type":"tool_result","content":"ka-patreon OK\nka-fansly OK\nka-snapchat OK\nka-discord OK\nka-onlyfans OK\nka-threads OK","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ad976a82-d646-4cc7-97cf-578d3102a17a","timestamp":"2026-08-21T18:07:04.955Z","tool_use_result":{"stdout":"ka-patreon OK\nka-fansly OK\nka-snapchat OK\nka-discord OK\nka-onlyfans OK\nka-threads OK","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5a899bef-331c-42e0-9562-944491cb0e83","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLcdrBv3cz7Q9bgW5xt","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS8RcKpAEIERgCKkClbphg5FghfGTI7bIpyS+ToLJdjwp0Mg0vhBsXdI4oR37oWtyCMQoDJi+UMFM4593RJiKlyOSpToZLaJRdQacRMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHjp6LUBhIMSxcTR0DRZN9197LgGgyOtEW/pVbnxzWaluQiMMIThumLSmJWAY3ZEP0NmIUuqcjqKgyPKNNAMhogU404DahS5bKy04fn5Ein2L8dGyr5FffR+rIhhuIrfw7SPNGMkd52RCQDcHsHw7oDpN/WQ9OqPx8aD4efB8oWjJH340iSJz1JCkPVFITbfCsGjb6DahEa5bZlOg0EL/2Nh0XpN6QmhtelXNOq84jjm7Vt86tgbmih9u3975lstqwwCT7esGDuSY6yy33qQXIChpKOQC2CMQ1bkBriYYI40MEl5LB9wqXdSvhAWttQPgbnoWxk4Ygq4h2JoQNWUiTaIkGeXYntxXmkMxNH29CtCa1/jWGWFxvAEpyNurPavZWafoZi+Acg6RKyOFg5FVN2X2yW4ddiiKAVWAmkoWS0RvxyWA3HqRkmulM0oHMM4O4wq17ufzWUYZy0Bzdzs1Ez9l2xFfKp5GkNEj01VjCHggQ04xRqvTQsnpTWGnpkczo8euDSbTMXdabyLeYf8WuPMYR9SOMHtdT7LaW9+ae2ZTwM9zVaBlA85hOLpSzHQ77hwVYgye24zPLZGIk22ab2l9ASiAd0/nov9Y7ZQtjNoFF2qoI3TCKT7rNDMl4OpvnF9jpB1znMCd0tqmwXyk6ZLQaP5a7TwkM0+E788XeFy26ZkDaxjWnSktkUFu8zEC89y3gKYsroGgtEmpXyBLgaDiqwM8ZqxCWmC1JnlH6uZ0ik1HTiPbw4xjpYyynoI0QdNSXiYzOPgXdy+gE+OPWoGEw6wlVIjt2NwgncsMcZJug+/whS1aUTEsFn/v4FvwQ0i73WCOCLUkmoCFqccDYqyqreLOeWiXa9cVPy1eRTACOBxTy1H68HZidXI/6ELCxJCxwlTcgm84SQHGsuf73OiKAH5Zrw3M9wx4jIQAOKfSqQdD409vdgnLCcE1dF8vmYrj4buaoYnu3SDfCj87kmZSPaugjEoC1GaEIBGG+BJXHLH+TuVnWZUfOsihYuRi5zNaRFzpEklYm47COf1sePlUy3BxxhXospYaXSky9GKhHJpqdDJowhxgTlscOfY72pX+nJib1gv2fr3gNHGgv2wnoFipjPpbN6BJTgNxqzT+MSr+RnxT04Kki2Zuvu+uzFq9VGbNAtDiafn19JQrFUsS3y8uCn/ZV02+2l8gp9PKwP3sLgoGqDFf/VMfcnIvZgOam/kvMq0y80h565RXuwhRyenFuEUANn5vWjW6PChVtoe+sR0xuS2p6GTq1SBH+xrU1izvH56IFzlolQlYKbBTQ25lEa1CRRTch+6NslfDtP8ic7iC3fTnJJ0yIOo/ywuWbq3teI1izCHj8XexJAabC1UCDGB54tUk4NdPR7IkMCdX/yXCgr0qCuKIcaJuTor03yCNFXt5OeJHWF36MasOPV1wRHrrZdL8RFr1Z11WRH9Kppmx0ZFsfHKC3bFnTcLRPVwDmJ/2Kq3wuVRAzjQb7DVaY9JpXZCMGHJ0ipMd1OCVhBp4/nt2V25C4eRSHCQ0C9cDs8HXp0INcz1BlBUL/UpKs218K5y15WSX5RdC1DnoipIn749D720j5DyCvaSw0xdTcr7pGPwLJQ4ZnR1sQA3P6HHO2lHLKTc9aDMbgtXlKjEs71x1rpcelezQRt0ozAcLUjwfjGYd8vT9pQ7h3QKThpxBHMnyt9Hyi/aGFOWpWshgFXGyi/GCmom0QIHxRBZRRImDhZF0F6HwzWSrWpx3jrP9YUQr7yVieXsLl1IZaeOML889df3LcFeGKcJBOWpZaCYXlnmi/52Q7ye4RIonBuJ0AR0+D5ko6hgqJaNqUYwsn/wsB9grmhJySunPzKRiTXTNZexczqMLQaLTR8fHQ+Pq1zHWg4lq7gRjp566QPJeNd7OSvglalWysrelYGi7E5T3PvvYkTm/DSEhVxZ4BTShyY4Zge0iLkwU2BtOzrnVYbTavuOjY7NP8xR66zMfvcAAzbODdvjoXUoRo9MtfrnPzDt52oOdVhrWbE5uBA40D78Ba9t79fhKqT9eSWeJHZGzpe/GgGRXtuQPO0V5Vt+cdUIWrXGWQD4VtVxMgni5//CxyVSik/Ua55GiUC3lXNc1JUi2sVVwcfK7I4PnsHOBrFcaA8GJNwvVbisl5T/LbWWmOWIY1OqB8geQHrfvoKTqhRVMVvKU9IwHb+9Q7dM98QphEO8mfnuYPd1W112+ctNT7v4aRjZDsb9+QXKQzKaJkKvHkmjP8U0h8jhqWHbBNBICE+ESCF1F73ZpaY5i0g1A/WWtRAKAEWZFWaOgvm/afnKtMh2kXZ0dhToqIcKr05Gkr/7WXCCoQdY7qDVj5yD6ob3yb5H5fmtRbmSdFHOrUzwCfuiIbPa7/Y5vGzswRhya++RtCgkQgTD31ZTuCTe9+XIwqt6E6kOeWYaL340nB+vTPcclr4zW/Pe/+GloCYDv6Tnzkb5hhvn69BhpSXvF2zef8dquk2zNsQ1NYrJoyjK5BpIqSX8xIsO8Qsb0wXVFcp5idkimmruzOVjMf8mbJG9zoXMN3/gGdTm4wwtp4l3702LxiPv/C+WgGCPNnKsOirVMEuEGk8SIonjha1MulJamXaYfuWrsXBubJ2XUgtoZZqikIPMXbKPq04JeMB6jAxA32JMBMQgUbNVUPmfbVI7ZB1MvWYPmNjxJh++U/8fGYTj/4mBUsvTtFY/kWqlvIESMDfL2QrxGZh5hM3dXV30dsdVi3dtOxDwOXgRp9pO49TEI3ap2rONvYfNycJn7mC/p9zlZoDn8Pq2xwUuZ0e+Y0jFqrCjxzm+l/jrDTXA4V9fUkiewYq81iXwRybzgYlYXI0WEdBFMgPp+54yosQD45uABOW2StIovLkz1/pV7TryyR95J3PkjxLtziHzYUhndvWNxh5Q8xz3Ona4EXf9rEV860vXRzJR2WyC6UtKrXqfhdiB1k6nPvlRXsELHTM6dzLkVE/lCnME7pUQcuCAcmIGEzTO9WDqJKLoESynNRYfUEBY7StMHCljT05OA2nWQjwPTveg7VPkHdMVYpVEyI6VUerpBeFb1SKKhcGS6UELEeJsgBcX2kQ37hFEMPmTKvSzBELpUFP+wZunsVZpZU8MTTJHxRbvgLnoZdQxrgkeBU3G8jCa8zrS8JIF68W6+zWaEViLiEEho2KmEFB6/w1OA3yJJBs9ymUMgC/VMUWw7p8Avo9gcMIQO3ZwuTmYGVExRlbF1VG+wuWC5+9mO4z1mIg0IpbOth3o/GQCuyk5NlhVVvoAAMCZU8l3wmJ99ZPdWHtGzOlojnPMZmjABOMs9pdTvoscGXYEOv4+PWkIuLMniSqSWyUKOxqH3YY/yld4cdBzOLDqFVvX/ImuHiRRx2ZInowfMNqNBtIL8t/Omko/sjuLTcrBnM+/cT5vkanyOREPbTWqJ8YBpDOViW6bGG4jXW2rHJjWKdkGSXLsVAzxNgaAB4yaW2ge47L0XYu+Cy4mRx5dEF4+cEXPDhVf0kgWlty+MDrwW/S0QIqq10BFsQNQL5FR/RHo4QgnSsHkRRZfUHnGbbvqcirerrxRGJZnCKoTeLbRCVEqhGL1ggAuQOmS6SzPOiYOVK5PJOmgME4WaeAsWFtjBwjfYh2Y/k5+dytjoAKKsQEElbcQcIb73wRHcDuu9Pc4kd3uXee4RFjhiK1Jwx7rkRedPM+WQqfWcMvB4lBFetDzeFJCVx1urLOsw9D4Q7dEWboCcnFeC7uwEA0FTihkrlKvkfZoPEyu7NBUVZEsmnBnCRRU6HTRzT2egDrwVf8BB982W8dlMZE74bJqgaveIH88jmjoJJbJPaL2Mpr/9CUvwMUEL4YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":262,"cache_read_input_tokens":126419,"cache_creation":{"ephemeral_5m_input_tokens":262,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"cd6507ff-0955-4df3-ba7a-359c70701a59"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLcdrBv3cz7Q9bgW5xt","type":"message","role":"assistant","content":[{"type":"text","text":"Les 13 acteurs sont poussés et buildés. Runs de test de validation — un handle connu par plateforme, via run-sync."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":262,"cache_read_input_tokens":126419,"cache_creation":{"ephemeral_5m_input_tokens":262,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"59f3de27-f977-49b6-9478-479f8e18b8b1"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLcdrBv3cz7Q9bgW5xt","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012bPLjV6dWAdNPzsUMrfmSN","name":"Bash","input":{"command":"run() { curl -s --max-time 300 \"https://api.apify.com/v2/acts/gorgeous_thistle~$1/run-sync-get-dataset-items?token=$APIFY_TOKEN&memory=1024\" -H \"Content-Type: application/json\" -d \"$2\" > /tmp/test-$1.json; echo \"$1 done\"; }\nrun ka-instagram '{\"usernames\":[\"veroniquecloutier\"]}' &\nrun ka-tiktok '{\"usernames\":[\"lysandrenadeau\"]}' &\nrun ka-x '{\"usernames\":[\"pksubban\"]}' &\nrun ka-youtube '{\"usernames\":[\"@denyzee\"]}' &\nrun ka-twitch '{\"usernames\":[\"xqc\"]}' &\nrun ka-kick '{\"usernames\":[\"xqc\"]}' &\nwait","description":"Runs de test des 6 gros acteurs (run-sync)","timeout":330000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":262,"cache_read_input_tokens":126419,"cache_creation":{"ephemeral_5m_input_tokens":262,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8e3d1ead-d240-4bd2-9c18-78b868994756"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"b540fgfdo","tool_use_id":"toolu_012bPLjV6dWAdNPzsUMrfmSN","status":"completed","output_file":"","summary":"Runs de test des 6 gros acteurs (run-sync)","uuid":"cf734114-4f6d-45ad-b08c-5727d88ce027","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"b540fgfdo","tool_use_id":"toolu_012bPLjV6dWAdNPzsUMrfmSN","description":"Runs de test des 6 gros acteurs (run-sync)","task_type":"local_bash","uuid":"805aab2d-2a03-4af7-889c-5322aeb19238","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012bPLjV6dWAdNPzsUMrfmSN","type":"tool_result","content":"ka-twitch done\nka-x done\nka-kick done\nka-tiktok done\nka-instagram done\nka-youtube done","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"10744a7d-3e5c-41c8-b372-a121d718bfa8","timestamp":"2026-08-21T18:07:41.282Z","tool_use_result":{"stdout":"ka-twitch done\nka-x done\nka-kick done\nka-tiktok done\nka-instagram done\nka-youtube done","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b3ac604d-d34c-4649-8a45-84b52e762ac3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLfKavXBwH7KNweFt2e","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016tSn9Cn5bWsV3eEFzqxD3o","name":"Bash","input":{"command":"for a in ka-instagram ka-tiktok ka-x ka-youtube ka-twitch ka-kick; do echo \"=== $a ===\"; python3 -c \"\nimport json\nitems=json.load(open('/tmp/test-$a.json'))\nif not isinstance(items,list) or not items: print('EMPTY/ERR:', str(items)[:200]); raise SystemExit\nit=items[0]\nprint('found:',it.get('found'),'| followers:',it.get('followers'),'| err:',it.get('error'))\nkeys=['bio_mentions','bio_hashtags','has_threads','business_email','igtv_videos_count','likes_given','is_under_18','avg_saves','pinned_videos_count','videos_per_month','has_shorts','is_live_now','top_mentions','avg_quotes','link_share_pct','retweet_share_pct','verified_type','videos_count','live_type','live_started_at','live_is_mature','live_language','page_id','website','rating']\nprint({k:it.get(k) for k in keys if k in it})\n\"; done","description":"Vérifier les nouveaux champs dans les résultats de test"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1712,"cache_read_input_tokens":126681,"cache_creation":{"ephemeral_5m_input_tokens":1712,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"165a6145-730f-4aa1-8456-2f072d31081b"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016tSn9Cn5bWsV3eEFzqxD3o","type":"tool_result","content":"=== ka-instagram ===\nfound: True | followers: 63 | err: None\n{'bio_mentions': [], 'bio_hashtags': [], 'has_threads': False, 'business_email': None, 'igtv_videos_count': 0}\n=== ka-tiktok ===\nfound: True | followers: 348149 | err: None\n{'likes_given': 0, 'is_under_18': None, 'avg_saves': None, 'pinned_videos_count': None, 'is_live_now': False, 'videos_count': 500}\n=== ka-x ===\nfound: False | followers: None | err: user_not_found\n{}\n=== ka-youtube ===\nfound: True | followers: 2590000 | err: None\n{'videos_per_month': 1.17, 'has_shorts': True, 'is_live_now': None, 'videos_count': 266}\n=== ka-twitch ===\nfound: True | followers: 12535614 | err: None\n{'is_live_now': True, 'videos_count': 45, 'live_type': 'live', 'live_started_at': '2026-08-21T16:20:41Z'}\n=== ka-kick ===\nfound: True | followers: 1101723 | err: None\n{'is_live_now': True, 'live_started_at': '2026-08-21 16:20:45', 'live_is_mature': True, 'live_language': 'English'}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0ff9aee6-47f7-4b83-afb8-5248b576826f","timestamp":"2026-08-21T18:07:48.413Z","tool_use_result":{"stdout":"=== ka-instagram ===\nfound: True | followers: 63 | err: None\n{'bio_mentions': [], 'bio_hashtags': [], 'has_threads': False, 'business_email': None, 'igtv_videos_count': 0}\n=== ka-tiktok ===\nfound: True | followers: 348149 | err: None\n{'likes_given': 0, 'is_under_18': None, 'avg_saves': None, 'pinned_videos_count': None, 'is_live_now': False, 'videos_count': 500}\n=== ka-x ===\nfound: False | followers: None | err: user_not_found\n{}\n=== ka-youtube ===\nfound: True | followers: 2590000 | err: None\n{'videos_per_month': 1.17, 'has_shorts': True, 'is_live_now': None, 'videos_count': 266}\n=== ka-twitch ===\nfound: True | followers: 12535614 | err: None\n{'is_live_now': True, 'videos_count': 45, 'live_type': 'live', 'live_started_at': '2026-08-21T16:20:41Z'}\n=== ka-kick ===\nfound: True | followers: 1101723 | err: None\n{'is_live_now': True, 'live_started_at': '2026-08-21 16:20:45', 'live_is_mature': True, 'live_language': 'English'}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a0db8da2-8fab-4e2f-8755-8a96d7af88cc","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLfpMYrbC1zj2YY8RPm","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISmQwKpAEIERgCKkCwNPYx5hD+TnqztNl8c8zf6t6TeGHmBMW9jY/MYL4aFTU689sa4KMqiqz37dpKmd3BBfdOWYKz1Hinazr/HBMwMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGCqKLUBhIM0tQxbF2VlvO4oeKPGgwOJS3ARhTEEWYQ0xciMJdv/OUsdronbUCkpfsnh4s1TSOJQCnEZtOKAMkb16wv/ddJGC3J02gv8xoFgw3IySqhCsAZSCNuKChTm0kOcNaakwxx5R9oEEEzinOGon/JLSD/LZSPpgPlNQpX/al4rlPCMUeG+k2sxk2tQhYtRNoPAFnP5m+y2+cCgw6mGkrakE0HZIeo9CPd6zIQypm9ERYkBVIzAD8BTUoHTSWVMNh72paL+iyX3uUFVdcAOXybJVKtLtxP4ADhiB8PE6YaWERTIf+sIYegTcablpzHrYISn7o5/3IlJ4GMoC82To0Spn10Uyt9MKm7tiIVBIjjA+Wh19MbqQj1tH1cHVxmuhVgoB/sDddzPJMqm0A4G7tdOPnuFbfJbpe860tjepxd2wCcdglNB7kx9+yKm0ZZ1LWhp7dGOHKFW6FVxJD3xVvqUE17dMHXAUNFV9KdJEQ+ARKPs2NO2Ncind51aqiI57cub0IPLua+nkE9iowD5Em5z56BiPW2cxe15ekStOrpZcYUzJaW3yQrJnvynUDPmFvtz0j9M8729ARm1sfNHsf2iKiMlxptIQhlj55o6Ri9O/HX/a2RGDWt0ZfNqdShtXshRgPiYiBC9psLtZ0m+hTfaLsjrB+l7WZosCcB7nf+vgNf9bLNdlChsW0mu3eVcRyxyrEoZhgUks3WCwn5i+/CXOiQqxfhYgMAth3JFCR/hcVcBh6qQFC/KHNBHS0ZTcJgg7+SpM3+GlVN/FqpZQf2cTcT8i+q7gscDQdTJPAvpD5+xBFcmVXeWxv0zV/0YdLtsT5u3bSVHZqKi21nE+Fm3dcSp2kQUXeDPCTug2uJsD8RaXlBcA+qpQyeiaR7ZQPwek/K3G3GVQES+aYTntTxMKVwhYwaXoJe4/F8K7zuKWAIXYdiu5/1n/BCGFTtOzp2Hy/RCxmvshRQ42+lDU1qmxW8ie3Rrxy54gYCUFvgtBM5T91jaOs6Ku1yCqWkEv3rN4TnHPI/OWCQszucbSce1hK2uiI8jm1t/7FBplQ9+iakgIYCP9j4F2SHYTnSHFy7Ge/5fx5gFztSLac//qOvBB9l/V1xoLX0hUFAuo8Hjub4feKKLZUyCGIJKhTmOJU562wIH27QoF2kfQP/yx+GOfKkC4NeQNPVEwj1OOwYpFtC0TCyzxYVnPPp/xtwVHQV2lV9WeypRoOG+9rKKNmzmZlbt55Y/OfyvkNrnyafhvi69lReXCaNsTItP+C0XTip18qDN8sSkrcTQ1b4ArcY7glGJL8zERjivByZu525gCLCfCySmuU6rjyG1jKuw4q0kzGNiYmUcABaJMPhWAg0ZDcvugnvqp3ifpBPU2dPi6fMd8KEsNt2Nk8vgPEzHVnv8YUtKlesoLmJgPzlVwGbGXniyChflCVCpVnULn45o0a/JVWF9GSohHj0/BlMyj35tDeRPunBZwwYjt7dge/+CoOHZ3/k/WkzfvhzAKTp/OZa/bjqBcv8KRqHA3wVh6c0ljIxN2kucAWYkb8enWUJTqFI/waqORzMgh1YRLjUkjZEEqciKMA7te90JmbVaCJFySgf5g/gwgrjWl1s+XLedDM4kVlJMRy4CFG9bIUpk4YQ3vDjFAMSPDzxmGK1Q/so9tEvgXrsNb6J9mG8663vFAxoFAjSEjs0hyvxCRmnODkfFSvvML81oIpaMvWKNP2lSIhc588G0UTAWZwjwfds9ulBuIMebnTMxx5epZDZBg4GKPjL+o2auyzIwNjJEBsCs1EjYUns7yOzI5hSek4+6wbIvgGN9TBzDt9wDTqyLp1UtoAHjDgOVUzuC+0oTW+v8qBhGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":881,"cache_read_input_tokens":128393,"cache_creation":{"ephemeral_5m_input_tokens":881,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2c5451b0-af2a-437a-ac14-1ec252af1805"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLfpMYrbC1zj2YY8RPm","type":"message","role":"assistant","content":[{"type":"text","text":"5/6 gros acteurs validés avec les nouveaux champs (cadence YouTube 1.17/mois, totalCount Twitch, live Kick, flag Threads IG). Je reteste X avec un handle sûr et je valide les 6 petits acteurs."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":881,"cache_read_input_tokens":128393,"cache_creation":{"ephemeral_5m_input_tokens":881,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"263cc954-602a-4fef-8551-9fdede39cde6"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLfpMYrbC1zj2YY8RPm","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01E27kB3RbyrZRUFbPqpixVo","name":"Bash","input":{"command":"run() { curl -s --max-time 300 \"https://api.apify.com/v2/acts/gorgeous_thistle~$1/run-sync-get-dataset-items?token=$APIFY_TOKEN&memory=1024\" -H \"Content-Type: application/json\" -d \"$2\" > /tmp/test-$1.json; echo \"$1 done\"; }\nrun ka-x '{\"usernames\":[\"elonmusk\"]}' &\nrun ka-threads '{\"usernames\":[\"zuck\"]}' &\nrun ka-snapchat '{\"usernames\":[\"kyliejenner\"]}' &\nrun ka-fansly '{\"usernames\":[\"alexapearl\"]}' &\nrun ka-onlyfans '{\"usernames\":[\"bellathorne\"]}' &\nrun ka-patreon '{\"usernames\":[\"cgpgrey\"]}' &\nrun ka-discord '{\"usernames\":[\"fortnite\"]}' &\nwait","description":"Runs de test X + 6 petits acteurs","timeout":330000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":881,"cache_read_input_tokens":128393,"cache_creation":{"ephemeral_5m_input_tokens":881,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6f406639-8235-4817-a1dd-f4007fa1387c"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bdii11dez","tool_use_id":"toolu_01E27kB3RbyrZRUFbPqpixVo","status":"completed","output_file":"","summary":"Runs de test X + 6 petits acteurs","uuid":"30d66845-f125-4b73-b76f-2801afac18cc","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bdii11dez","tool_use_id":"toolu_01E27kB3RbyrZRUFbPqpixVo","description":"Runs de test X + 6 petits acteurs","task_type":"local_bash","uuid":"1561654f-b4e6-4e8e-a4db-6327b51c993c","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01E27kB3RbyrZRUFbPqpixVo","type":"tool_result","content":"ka-discord done\nka-threads done\nka-fansly done\nka-snapchat done\nka-patreon done\nka-x done\nka-onlyfans done","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8a71dfb4-1944-4bf4-8558-2411dd6aa908","timestamp":"2026-08-21T18:08:19.332Z","tool_use_result":{"stdout":"ka-discord done\nka-threads done\nka-fansly done\nka-snapchat done\nka-patreon done\nka-x done\nka-onlyfans done","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4a2163fc-f051-49a5-9d52-8a495e7b97ae","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLi6k6WTpTLYcxSdhYj","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_014DMZEWt7W1JF5VJKeyLH4W","name":"Bash","input":{"command":"for a in ka-x ka-threads ka-snapchat ka-fansly ka-onlyfans ka-patreon ka-discord; do echo \"=== $a ===\"; python3 -c \"\nimport json\nitems=json.load(open('/tmp/test-$a.json'))\nif not isinstance(items,list) or not items: print('EMPTY/ERR:', str(items)[:200]); raise SystemExit\nit=items[0]\nprint('found:',it.get('found'),'| followers:',it.get('followers'),'| err:',it.get('error'))\nkeys=['top_mentions','avg_quotes','link_share_pct','retweet_share_pct','verified_type','bio_links','id','banner','snapcode','subscription_tiers','subscription_price','account_created_at','join_date','is_performer','audios_count','medias_count','published_at','pay_per_name','social_links','features','nsfw_level','premium_tier','invite_expires_at','inviter']\nprint({k:it.get(k) for k in keys if k in it})\n\"; done","description":"Vérifier les nouveaux champs des 7 tests"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1456,"cache_read_input_tokens":129274,"cache_creation":{"ephemeral_5m_input_tokens":1456,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3d80db03-a6be-40cc-9448-ecdfc0d55335"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_014DMZEWt7W1JF5VJKeyLH4W","type":"tool_result","content":"=== ka-x ===\nfound: True | followers: 241454878 | err: None\n{'top_mentions': [], 'avg_quotes': 43583, 'link_share_pct': 0, 'retweet_share_pct': 0, 'verified_type': None, 'id': '44196397', 'banner': 'https://pbs.twimg.com/profile_banners/44196397/1774145451'}\n=== ka-threads ===\nfound: True | followers: 5723312 | err: None\n{'bio_links': None, 'id': '63055343223'}\n=== ka-snapchat ===\nfound: True | followers: 29960000 | err: None\n{'banner': 'https://cf-st.sc-cdn.net/aps/bolt/aHR0cHM6Ly9jZi1zdC5zYy1jZG4ubmV0L2QvakRGcnNVaW1ta2ZsamFPa1ZUTWVwP2JvPUVna3lBUVJJQWxBWllBRSUzRCZ1Yz0yNQ._RS0,1080_FMjpeg', 'snapcode': 'https://app.snapchat.com/web/deeplink/snapcode?username=kyliejenner&type=SVG&bitmoji=enable'}\n=== ka-fansly ===\nfound: True | followers: 3 | err: None\n{'id': '286608408126169088', 'banner': None, 'subscription_tiers': None, 'subscription_price': None, 'account_created_at': None}\n=== ka-onlyfans ===\nfound: False | followers: None | err: wall_200\n{}\n=== ka-patreon ===\nfound: False | followers: None | err: http_200\n{}\n=== ka-discord ===\nfound: True | followers: 1844365 | err: None\n{'banner': 'https://cdn.discordapp.com/banners/322850917248663552/4ae97fb02bc48b283600e8123b033fb3.png?size=1024', 'features': ['AGE_VERIFICATION_LARGE_GUILD', 'ANIMATED_BANNER', 'ANIMATED_ICON', 'AUDIO_BITRATE_128_KBPS', 'AUDIO_BITRATE_256_KBPS', 'AUDIO_BITRATE_384_KBPS', 'AUTO_MODERATION', 'BANNER', 'BYPASS_SLOWMODE_PERMISSION_MIGRATION_COMPLETE', 'CHANNEL_ICON_EMOJIS_GENERATED', 'COMMUNITY', 'COMMUNITY_EXP_LARGE_UNGATED'], 'nsfw_level': 0, 'premium_tier': 3, 'invite_expires_at': None, 'inviter': None}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3e4d43e3-bf1c-402a-9fa1-12a30fa56cc6","timestamp":"2026-08-21T18:08:26.085Z","tool_use_result":{"stdout":"=== ka-x ===\nfound: True | followers: 241454878 | err: None\n{'top_mentions': [], 'avg_quotes': 43583, 'link_share_pct': 0, 'retweet_share_pct': 0, 'verified_type': None, 'id': '44196397', 'banner': 'https://pbs.twimg.com/profile_banners/44196397/1774145451'}\n=== ka-threads ===\nfound: True | followers: 5723312 | err: None\n{'bio_links': None, 'id': '63055343223'}\n=== ka-snapchat ===\nfound: True | followers: 29960000 | err: None\n{'banner': 'https://cf-st.sc-cdn.net/aps/bolt/aHR0cHM6Ly9jZi1zdC5zYy1jZG4ubmV0L2QvakRGcnNVaW1ta2ZsamFPa1ZUTWVwP2JvPUVna3lBUVJJQWxBWllBRSUzRCZ1Yz0yNQ._RS0,1080_FMjpeg', 'snapcode': 'https://app.snapchat.com/web/deeplink/snapcode?username=kyliejenner&type=SVG&bitmoji=enable'}\n=== ka-fansly ===\nfound: True | followers: 3 | err: None\n{'id': '286608408126169088', 'banner': None, 'subscription_tiers': None, 'subscription_price': None, 'account_created_at': None}\n=== ka-onlyfans ===\nfound: False | followers: None | err: wall_200\n{}\n=== ka-patreon ===\nfound: False | followers: None | err: http_200\n{}\n=== ka-discord ===\nfound: True | followers: 1844365 | err: None\n{'banner': 'https://cdn.discordapp.com/banners/322850917248663552/4ae97fb02bc48b283600e8123b033fb3.png?size=1024', 'features': ['AGE_VERIFICATION_LARGE_GUILD', 'ANIMATED_BANNER', 'ANIMATED_ICON', 'AUDIO_BITRATE_128_KBPS', 'AUDIO_BITRATE_256_KBPS', 'AUDIO_BITRATE_384_KBPS', 'AUTO_MODERATION', 'BANNER', 'BYPASS_SLOWMODE_PERMISSION_MIGRATION_COMPLETE', 'CHANNEL_ICON_EMOJIS_GENERATED', 'COMMUNITY', 'COMMUNITY_EXP_LARGE_UNGATED'], 'nsfw_level': 0, 'premium_tier': 3, 'invite_expires_at': None, 'inviter': None}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ea86e242-ced5-453a-9700-13d05efd911a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLibkrG2gsn8MUDFm1r","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIShg0KpAEIERgCKkB7Qu5tiguLi47r58dfJ6WwcYkOV5r+3RY6z4dsCj02MWDpWlTDQVbf8SRCM/jvBlDiZCDVXH99UqPY1o2XZJz3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGpqKLUBhIMkfhkrK/7dzozsuFMGgwha/xekLeGcyGpcY4iMOOfJkv4dDpRL3VqmTkVNCrTxb+q7OJ6CYmK7UeqOdlmn55jvHh8FGZK40tW+DlqMyqOCyANw2SMCLrwp+IGOv5j3RfKpfRcWs4HYfKZl+CS1KhHu1ykuhJkGBX9NIdluq5r4bDI0cL5XgM+OGP4P2GsG0dzm7rTswFDyq9sIxDW2wsRCwuiEsgVWyqTBZBZPeuSdOE6RkagZQENTjAbulQgilg9ZqhXVx4FgwcXnWTqM8gTlDBd7Ul2vGcEsz9i2gg2L5CN6jNaNbbAEaUBVsFNWxcNpDAZ0Yivc/U5culK86Mb6pUSvioxINuOEs5EjKOu5oNaylZZ6Vv8BABC7YEeys7pmyvMCle403X0SgNO4bJQS7R2BduRD/hFckS20BkCT89fhtadxuuLEcE+0llXAR8FE5/MdDHQV3oL1H6zp9bC4aRhTj6kXKGsi9cRxjnP/4I7iNW6yKwPqQ9Ix5c7kBhPDE89l67u4s4NVj3XZG8Ea0oqT4czawHHjnmcBBxLUfZ0VafAb/Rvx0LyWHS5/DPgCOQg0J6yBH7yPw6ANn3gpc3qNzPsC8xENg5bQK71KjyGLMQMkPtieJJbNbKmj2m/d1cShQgIo1XU/9Dt3Qy1Jx+ycO15TvPVajJ1/RtQ9eZmUh6nV2XWKmf97SJfGlZ8/K5adQd0tFzHdnyHe0wAvUdrMEUTIFJ6M1RPoBwMDETzM1v0kmbd3iVNiK6gOgXZ7jy65c4nW3uYvMJIAm3fjxIRoWuDT/MJceU7kP1pyk8mdNzPizznvor+I8cpU/eOsLFwzMyBkR7i2n7HW7otOF1CgwfOh5r08ZSKS1qcECxRs3Wq43+8CbRVaBTc5FaNiUvYGxHLvmGUA2h+aFFqgVdR1shPeYyMrPURAZJ++rPuNwlBa5Ebvzeas6oQKUOkJHfqwAzT6ujoik0PEtrkI2Hx0fLDGKOjo8PmybVEXXJuIkAPfW6EHb7femKYsT/W+O/yC6fQdEWSIaSkA181WGXuCusJCVXuI34lV+Tt3O+hqa5yrqwxg1IhotIeGNggDEuYeEAztGloHkltA+yZfK5dtSRcxu6GJdGz8LNS236qi9plzt8BrCRL/29RqzGV8ppAX8fPnyOzMGA08FYhrTTegModTXf2e458SJt1zFfpG49vLYVd7ekj5rPhcmB+tMa7dw8tjLr2DhGIGcyrX0MeycWFSKCTo4EK0D414yyLgQ0n8vUF+OMIsYYnVdkDCiHHERwMaEUXHGWPQkEJ7/kffcw1CdfloDvBSL5+z4Ws4IjWcmc/HBgOgZrmdX9YAd+kVYjzVta0MMTwoYuMUu3NTDlGZQQX+cTUKqaISs1ZQQN7oKJAFqvTTSmjlFyuP3JM+805ZreoIyoYqEmKRPrCv/BcpVKD2OrcMpNdwGstfXeTh8d0aM3BSJx7HwxjXmPcV47275PVoTICoPY9Kd90y5MBBbDM0H4fHxPJMiXspVeRYZb4sRL2W5K1adAZpNPTDo09mXPOXhNWLwzRfcQIDT4t2vuqtRyGBz/EzHUCjw3/mnFXhNLO5hyU7CgGC+G62VY27Ow/eh8HbnXeJyc0JdKySXH6oi5R2lPqRtQziXNxWQOBgyJDVH0arx7XNDz3hmWw5NUEU+R1CCWP87AfRe92xoE0E/cndx+YJoIXkHeo/fKY+vkd3JxEOeNLfoT8KuAgmi2YpJNyaT2XJvrD7S8H61rvNNG5OBJilD+kua7OAy5t1esUAGYnqhqpQFT9G49KvWR3Az7dpHJLC37ENr3Nj0vNlWYWBLNhujAgL48/n55j1tGY7Ti1coW4QJeWkrKun32ZO0x7+eBFYV7R8T6bGbNl59rlEpbUV2GAvUHW/A45emnGNQP0a9xNlWdpg6kD03oYbtJjnfY4TvwToXkkjQ38EpYgUStz/C2CLh2S2tnCzcFzvl7auzE1H5xOtQU9W152BLNt3BgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1347,"cache_read_input_tokens":130730,"cache_creation":{"ephemeral_5m_input_tokens":1347,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9394f195-d595-4c64-ab57-2513ba8b9a07"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLibkrG2gsn8MUDFm1r","type":"message","role":"assistant","content":[{"type":"text","text":"Presque tout est validé. Trois vérifications complémentaires : Patreon (compte avec patron_count public), Threads avec bio_links, et un autre profil OnlyFans."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1347,"cache_read_input_tokens":130730,"cache_creation":{"ephemeral_5m_input_tokens":1347,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fa1ec53c-de0f-49b1-9b81-6e00033b8639"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLibkrG2gsn8MUDFm1r","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Y5e2SsMTLnPsBcAxkSqA8U","name":"Bash","input":{"command":"run() { curl -s --max-time 300 \"https://api.apify.com/v2/acts/gorgeous_thistle~$1/run-sync-get-dataset-items?token=$APIFY_TOKEN&memory=1024\" -H \"Content-Type: application/json\" -d \"$2\" > /tmp/test2-$1.json; echo \"$1 done\"; }\nrun ka-patreon '{\"usernames\":[\"chapotraphouse\"]}' &\nrun ka-threads '{\"usernames\":[\"mkbhd\"]}' &\nrun ka-onlyfans '{\"usernames\":[\"iamcardib\"]}' &\nwait\nfor a in ka-patreon ka-threads ka-onlyfans; do echo \"=== $a ===\"; python3 -c \"\nimport json\nitems=json.load(open('/tmp/test2-$a.json'))\nit=items[0] if items else {}\nprint('found:',it.get('found'),'| followers:',it.get('followers'),'| err:',it.get('error'))\nkeys=['bio_links','published_at','pay_per_name','social_links','join_date','is_performer','posts_count','subscribe_price_usd']\nprint({k:it.get(k) for k in keys if k in it})\n\"; done","description":"Re-tests Patreon/Threads/OnlyFans avec d'autres handles","timeout":330000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1347,"cache_read_input_tokens":130730,"cache_creation":{"ephemeral_5m_input_tokens":1347,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3e86af6f-19af-4dba-9ddf-7b485ed2dc9e"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bagch174l","tool_use_id":"toolu_01Y5e2SsMTLnPsBcAxkSqA8U","description":"Re-tests Patreon/Threads/OnlyFans avec d'autres handles","task_type":"local_bash","uuid":"1ea1a871-846a-4648-953b-6116f05dbcae","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bagch174l","tool_use_id":"toolu_01Y5e2SsMTLnPsBcAxkSqA8U","status":"completed","output_file":"","summary":"Re-tests Patreon/Threads/OnlyFans avec d'autres handles","uuid":"2c3cfdca-744a-4594-9daf-bd3c300e3e97","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Y5e2SsMTLnPsBcAxkSqA8U","type":"tool_result","content":"ka-onlyfans done\nka-threads done\nka-patreon done\n=== ka-patreon ===\nfound: False | followers: None | err: http_403\n{}\n=== ka-threads ===\nfound: True | followers: 1691253 | err: None\n{'bio_links': ['https://mkbhd.com/']}\n=== ka-onlyfans ===\nfound: False | followers: None | err: wall_200\n{}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"35926778-d3d3-4db7-ad99-e1243f2c4eb2","timestamp":"2026-08-21T18:09:04.649Z","tool_use_result":{"stdout":"ka-onlyfans done\nka-threads done\nka-patreon done\n=== ka-patreon ===\nfound: False | followers: None | err: http_403\n{}\n=== ka-threads ===\nfound: True | followers: 1691253 | err: None\n{'bio_links': ['https://mkbhd.com/']}\n=== ka-onlyfans ===\nfound: False | followers: None | err: wall_200\n{}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"81d77b1d-4f79-47cf-8e62-036e86f53ebf","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLmSSLwA8YyAERYvXGz","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS4wkKpAEIERgCKkBbGdTOr5usOfinVvhDnJFcizoXW65WVqvmqgZnXqP7x9hmTuLDMXyQ/cRcpVqIc9/BRcwNpvtlOWYDk67XLbMCMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHMqKLUBhIMNaMe1nzuXSL+tOeQGgxYtutNliK989px3gQiMBzuenZzxqSYgRj1Gwe4wpgr8dLl7nLHxLOVugSenDQdJZs05W+YCpLBWf3infbx0SrrB568QM4KeeXXqhbVaMmhVxLjtLDT4oFZzbqyNILG0z9EgihD/Zw1jh4whF7zl9W8SaMDXN0gz7nALVj+OiR4PPX2uqt72Vu3W2+IabArQqNakwXGimvauP8cIkXBAOaqpzt3c44LiQBLUxWXtRmGAWbj3F+PIxegcD7dZtoR0RjCH7q7STzfVS5rz5zx1zOhZtKYAtavoD96ziG4AK7jK4l5B3+HnOU7hgKchaCZDrvEvG5PasDtt/uT/j1Yip2fvhzqU9QDlmn8HRRj6SGQY/vqQgtumd2601DK69DHOnuoOXlBFH2NibdmN2EI2m5Y7l4IcvMx8T/PsfudNdEj1KgHD6uX13U3e1EPj6fGq1j+2ulY58C9tdRLsdf1f0vPuXVI6TCWxsE3Z0sF5ivCM1XOEZWfiYcMzM9ZM6cokGWAFsekcZZxYsht8aMOqpMXZrOltDv/4JPsPD/iWlOdj9p0dBlgL2y1GxfY8/Jfo0XU4nft5mT11oDG6L/KjgrjfOmd2F6YPzBg/6GAqgjC1VaiSW5v10LgkBn/KbfVJwB8I/weAr6Wt0B2uL6DUNQJyZmw6FhEJLekk40JwUCrxZEJekkOZryYAwN5rmVehyLxsJVcPOxOnhBqh1F2sefSN/zrBlSOmJDRE3w5c2V+ItEicOAzf9z8syGh/dA7vwtj9ydZ2RfRRkQovUQe2WxOmlXgaC6S6uSpjCjk8Cte1KkEFKg1o17JcSizSStQRnURYuV9DPy+N4GoYstNdV8br1xvN2DejWMZaWcnG2fK5gZQxJZWBefqvvTzJ73UBLC0QLKQkjTK4KZrmXwPCJkoln9bDyiUxOha/j8iejfaw69SCu6yEuU5JCaH8xLr4UdbFuEmyGuWIoF9wwhrLfrVhoBJXLBjtG8AplhuithCy+QyoBCUdi3zhH5tELziiUbXK6/7pIC0e7SJq2yc7+tUvKimHSXq5S6fXyGfOxYnHJiVqTGyU64c5v2Kg6yBhGNYjJThGMRFYJUMooC2Wg2IOmATvwPLTDEkb1Vxut9MkatM5u2zyXSsvK72Mju8V5utJhUZKS917mz3jnSUZZzf1tajNdY+R6+Ev3CaZ37hNWsgsfnVGgocS7tzRQpnmhR+HTwehj60DaU/KJrBgibw+UB+rLpCiWIGXa3RWFvsNtmOOscmXhZ6ku7hbnjTpZJz32TBcyCB68S9vPmGCBm6jCfmf2HEKmlARhT7mVInpj9A+1WIb1SFKhW0BGTC3F3buMHdQL3tTxRPPV87vytpHFveD4JOBfPGpqn0lnubda92rrocfHHQguOWO6dyKBBSOY6ocJWIXhXdN/AYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1251,"cache_read_input_tokens":132077,"cache_creation":{"ephemeral_5m_input_tokens":1251,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"22209b4f-c1b7-4796-b98b-910cae7ac24d"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLmSSLwA8YyAERYvXGz","type":"message","role":"assistant","content":[{"type":"text","text":"Threads bio_links validé. Patreon mérite un dernier essai (les 403 Cloudflare sont capricieux) ; OnlyFans reste best-effort par design (mur)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1251,"cache_read_input_tokens":132077,"cache_creation":{"ephemeral_5m_input_tokens":1251,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"af553955-54d9-46d3-b8a8-bfeea609a7ec"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLmSSLwA8YyAERYvXGz","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015UnhVG2Ji9AvRLTqLg4PoK","name":"Bash","input":{"command":"curl -s --max-time 300 \"https://api.apify.com/v2/acts/gorgeous_thistle~ka-patreon/run-sync-get-dataset-items?token=$APIFY_TOKEN&memory=1024\" -H \"Content-Type: application/json\" -d '{\"usernames\":[\"oversimplified\",\"kurzgesagt\"]}' | python3 -c \"\nimport json,sys\nfor it in json.load(sys.stdin):\n print(it.get('username'),'found:',it.get('found'),'| patrons:',it.get('patrons'),'| err:',it.get('error'))\n print({k:it.get(k) for k in ('published_at','pay_per_name','social_links') if k in it})\n\"","description":"Dernier test ka-patreon (2 handles)","timeout":330000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1251,"cache_read_input_tokens":132077,"cache_creation":{"ephemeral_5m_input_tokens":1251,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"617bb8b6-f97a-44e0-98f4-43a0a2f3befc"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bulei3qo6","tool_use_id":"toolu_015UnhVG2Ji9AvRLTqLg4PoK","status":"completed","output_file":"","summary":"Dernier test ka-patreon (2 handles)","uuid":"a420eef6-a569-4c7b-9579-e9befb950ce3","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bulei3qo6","tool_use_id":"toolu_015UnhVG2Ji9AvRLTqLg4PoK","description":"Dernier test ka-patreon (2 handles)","task_type":"local_bash","uuid":"47b952c8-c750-4cc5-bf00-0771bdbbd256","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015UnhVG2Ji9AvRLTqLg4PoK","type":"tool_result","content":"kurzgesagt found: False | patrons: None | err: http_200\n{}\noversimplified found: True | patrons: 8766 | err: None\n{'published_at': '2017-04-18T15:04:58.000+00:00', 'pay_per_name': 'new video uploaded to YouTube.', 'social_links': None}","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"fdc02ae2-80a5-444e-9e9c-a2589094412e","timestamp":"2026-08-21T18:09:31.986Z","tool_use_result":{"stdout":"kurzgesagt found: False | patrons: None | err: http_200\n{}\noversimplified found: True | patrons: 8766 | err: None\n{'published_at': '2017-04-18T15:04:58.000+00:00', 'pay_per_name': 'new video uploaded to YouTube.', 'social_links': None}","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"35e43487-4c20-4992-98a2-1d0b790c76d2","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLoTEmfJKt1uqg6vqeC","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISlAcKpAEIERgCKkBXYObrqsHkJGNVV619EiiVvuVbF77IfIyMxH+utpy8Nkfru8JQnQ3NWgLtBChFP9BTiLxVBxlSuW50wZMm7THVMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHkqKLUBhIMldOI2QtZXpBZ8OFkGgz0bFFX+Hsg9mmJGGgiMGdX+fMVXOJrRjfGFdW6mv62aug715SmsTA0YuhiJzWLEGmmEBPq3AGRBt78ZpMG6CqcBfXrI2ZM/fhSDi5RIqa1szQL6zxf/7rhacVSxJ+cKkC8SNC48X4Him90rWI98x5ShB5rYSEoGq1Oqm6lNOxA0Atw+9DpWRbidNHnJAGb1wL8JYfE1IY6pQXgUm0nP7BfFaGt6n8voh6zV1qcj7ZYmLQbw5gsqiAa5BsQKK2O0HS36yZOas+WzdnuzPf7+1I6I0K6hjQsRJ6bLCgLB3JQto42nC5EHyoRbYnZuJ7n1C5ptRXouKhO0krfwwGn0TY3XFrGe6C88L7IxLHRIIBFWqTNKX23jzWR+WL12RHq3H3FqGXyfctzrs/mPQeFN3fSXfBac3XVeR72IVW/+D0F7j58gF+GFMD+HETSPkRU2e/IxKhai28l2LyEENsumBFfDQLt3oIn+v2K69DmL7t3/XQ3febAW1zIWUA22Q/rFS9yWvYqtAyW0TY+0uU6tafARbnhpnWnAY9mluWWQ8KSqAe+L0TFZABP7s7HQxXeBwSbwVTzGX8Bd2X09z36iDCPht8e410XyTZh+cbZK+b+XQHcSukBX4c7TkfQZ21KC6uVxW8f3UF9Cyz1X7adlwn6yJ0mFhpzo5B/2xX1bBu9UnGouFjgHI/gWIzONbimCC3MWc16Ae6r3FnSkYUbqD0K1M5s2CNTTipuEcOGkTLUH8HK4BK5oziox8LxwUTwO05Y/5f9BOcxtvno4KiVNsScR3xKs6CNCDC1kB8aoXSfZbz2pmIJ4cPtOj9HFnHe6DfaXijLyYE0pkx6cQ5VeY3Ha9e/sUuJgaIC3vqLJL+7fCwe8LxOzZcG9FEBcXtQls1JhF45zIosTJFBN+qSynzeQtOWyVSITxqyuma0yLJHKCCSmR5ZIWrDZgXMV/B2LF4ztrhpeSlh7U6yZXrzGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":892,"cache_read_input_tokens":133328,"cache_creation":{"ephemeral_5m_input_tokens":892,"ephemeral_1h_input_tokens":0},"output_tokens":10,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e5146a22-f0dd-4d60-bdab-d2b36888dd4e"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLoTEmfJKt1uqg6vqeC","type":"message","role":"assistant","content":[{"type":"text","text":"12/13 acteurs validés avec les nouveaux champs (OnlyFans reste best-effort derrière son mur, comme documenté). Passons au connecteur — je regarde les `metric_map`/`cross_links` actuels."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":892,"cache_read_input_tokens":133328,"cache_creation":{"ephemeral_5m_input_tokens":892,"ephemeral_1h_input_tokens":0},"output_tokens":10,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6c78cdb2-1313-4f9f-a0f8-623eef0ed1e9"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLoTEmfJKt1uqg6vqeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01RWrc29gUssmorWUmiYG6SS","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"in_progress","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":892,"cache_read_input_tokens":133328,"cache_creation":{"ephemeral_5m_input_tokens":892,"ephemeral_1h_input_tokens":0},"output_tokens":10,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e5b5e557-0526-4726-b962-ea409641c73e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01RWrc29gUssmorWUmiYG6SS","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"558b7d50-7ffe-49aa-9ee0-d4a45951c3bf","timestamp":"2026-08-21T18:09:43.793Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"in_progress","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"pending","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"in_progress","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLoTEmfJKt1uqg6vqeC","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JoccxejCkKmDjB7v75N9PB","name":"Grep","input":{"pattern":"metric_map|bio_urls|cross_link|class Apify|_apply_common|def apply","path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","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":892,"cache_read_input_tokens":133328,"cache_creation":{"ephemeral_5m_input_tokens":892,"ephemeral_1h_input_tokens":0},"output_tokens":10,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a20cc10e-8cd4-44e2-8b06-132a76d5fe2e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JoccxejCkKmDjB7v75N9PB","type":"tool_result","content":"22: `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n186: metric_map: dict[str, str] = {}\n266: def apply(self, cr: Creator, acc, it: dict) -> None:\n271: metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n294: self.cross_links(cr, it)\n296: def cross_links(self, cr: Creator, it: dict) -> None:\n297: \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n298: for url in self.bio_urls(it):\n310: account(hit[0], hit[1], \"cross_link\",\n313: def bio_urls(self, it: dict) -> list[str]:\n317:class ApifyInstagram(_ApifySocialEnrich):\n323: metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n336: def bio_urls(self, it: dict) -> list[str]:\n339: def apply(self, cr, acc, it):\n347:class ApifyTikTok(_ApifySocialEnrich):\n353: metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n367: def bio_urls(self, it: dict) -> list[str]:\n371:class ApifyX(_ApifySocialEnrich):\n380: metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n397: def bio_urls(self, it: dict) -> list[str]:\n401:class ApifyFacebook(_ApifySocialEnrich):\n408: metric_map = {\"category\": \"category\"}\n411:class ApifyThreads(_ApifySocialEnrich):\n419: metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n422:class ApifySnapchat(_ApifySocialEnrich):\n428: metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n439: def bio_urls(self, it: dict) -> list[str]:\n443:class ApifyYouTube(_ApifySocialEnrich):\n449: metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n456: def bio_urls(self, it: dict) -> list[str]:\n457: # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n462:class ApifyTwitch(_ApifySocialEnrich):\n468: metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n481: def bio_urls(self, it: dict) -> list[str]:\n487:class ApifyKick(_ApifySocialEnrich):\n492: metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n500: def cross_links(self, cr: Creator, it: dict) -> None:\n501: \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n518: account(hit[0], hit[1], \"cross_link\",\n522:class ApifyOnlyFans(_ApifySocialEnrich):\n528: metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n534: def bio_urls(self, it: dict) -> list[str]:\n538:class ApifyFansly(_ApifySocialEnrich):\n544: metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n549:class ApifyPatreon(_ApifySocialEnrich):\n555: metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n560: def bio_urls(self, it: dict) -> list[str]:\n561: # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n565:class ApifyDiscord(_ApifySocialEnrich):\n571: metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\","}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"91e8d233-eadd-4367-ab6c-8694808217f2","timestamp":"2026-08-21T18:09:45.362Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"22: `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).\n186: metric_map: dict[str, str] = {}\n266: def apply(self, cr: Creator, acc, it: dict) -> None:\n271: metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n294: self.cross_links(cr, it)\n296: def cross_links(self, cr: Creator, it: dict) -> None:\n297: \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n298: for url in self.bio_urls(it):\n310: account(hit[0], hit[1], \"cross_link\",\n313: def bio_urls(self, it: dict) -> list[str]:\n317:class ApifyInstagram(_ApifySocialEnrich):\n323: metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n336: def bio_urls(self, it: dict) -> list[str]:\n339: def apply(self, cr, acc, it):\n347:class ApifyTikTok(_ApifySocialEnrich):\n353: metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n367: def bio_urls(self, it: dict) -> list[str]:\n371:class ApifyX(_ApifySocialEnrich):\n380: metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n397: def bio_urls(self, it: dict) -> list[str]:\n401:class ApifyFacebook(_ApifySocialEnrich):\n408: metric_map = {\"category\": \"category\"}\n411:class ApifyThreads(_ApifySocialEnrich):\n419: metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n422:class ApifySnapchat(_ApifySocialEnrich):\n428: metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n439: def bio_urls(self, it: dict) -> list[str]:\n443:class ApifyYouTube(_ApifySocialEnrich):\n449: metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n456: def bio_urls(self, it: dict) -> list[str]:\n457: # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n462:class ApifyTwitch(_ApifySocialEnrich):\n468: metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n481: def bio_urls(self, it: dict) -> list[str]:\n487:class ApifyKick(_ApifySocialEnrich):\n492: metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n500: def cross_links(self, cr: Creator, it: dict) -> None:\n501: \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n518: account(hit[0], hit[1], \"cross_link\",\n522:class ApifyOnlyFans(_ApifySocialEnrich):\n528: metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n534: def bio_urls(self, it: dict) -> list[str]:\n538:class ApifyFansly(_ApifySocialEnrich):\n544: metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n549:class ApifyPatreon(_ApifySocialEnrich):\n555: metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n560: def bio_urls(self, it: dict) -> list[str]:\n561: # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n565:class ApifyDiscord(_ApifySocialEnrich):\n571: metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",","numLines":50}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"35b21b27-fbde-4b50-aa85-9735dc212631","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLpT3Q5TLPZUkbaUGWE","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01H61PqdYpeVY8euA9taiNKu","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","offset":260,"limit":330},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":2358,"cache_read_input_tokens":134220,"cache_creation":{"ephemeral_5m_input_tokens":2358,"ephemeral_1h_input_tokens":0},"output_tokens":21,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e44f040a-6daf-4fc4-b76c-9d752e83f4f6"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01H61PqdYpeVY8euA9taiNKu","type":"tool_result","content":"260\t for cr, acc in pairs:\n261\t self.apply(cr, acc, it)\n262\t enriched.append(cr)\n263\t return enriched\n264\t\n265\t # -- application aux fiches ------------------------------------------------\n266\t def apply(self, cr: Creator, acc, it: dict) -> None:\n267\t acc.followers = parse_count(it.get(\"followers\")) or acc.followers\n268\t if it.get(\"is_verified\") is not None:\n269\t acc.verified = bool(it[\"is_verified\"])\n270\t acc.last_checked = now_iso()\n271\t metrics = {dst: it.get(src) for src, dst in self.metric_map.items()\n272\t if it.get(src) is not None}\n273\t if self.content_key and it.get(self.content_key):\n274\t metrics[self.content_key] = it[self.content_key][:12]\n275\t # images du profil : avatar/bannière conservés PAR COMPTE (affichage\n276\t # par plateforme + chaîne de repli côté frontend)\n277\t for img_key in (\"avatar\", \"banner\"):\n278\t if it.get(img_key):\n279\t metrics[img_key] = it[img_key]\n280\t metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre\n281\t acc.metrics.pop(f\"{self.source_id}_miss\", None) # ressuscité\n282\t acc.metrics.update(metrics)\n283\t # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la\n284\t # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;\n285\t # sinon on remplit seulement les manquants\n286\t if it.get(\"avatar\") and (not cr.avatar_url\n287\t or acc.platform == cr.primary_platform):\n288\t cr.avatar_url = it[\"avatar\"]\n289\t if it.get(\"banner\") and (not getattr(cr, \"banner_url\", None)\n290\t or acc.platform == cr.primary_platform):\n291\t cr.banner_url = it[\"banner\"]\n292\t if not cr.bio and it.get(\"biography\"):\n293\t cr.bio = it[\"biography\"]\n294\t self.cross_links(cr, it)\n295\t\n296\t def cross_links(self, cr: Creator, it: dict) -> None:\n297\t \"\"\"Liens de bio → link-in-bio ou compte `cross_link` (§12.1).\"\"\"\n298\t for url in self.bio_urls(it):\n299\t url = (url or \"\").strip()\n300\t if url and not url.startswith(\"http\"):\n301\t url = \"https://\" + url\n302\t if not url:\n303\t continue\n304\t if is_linkinbio(url) and not cr.link_in_bio_url:\n305\t cr.link_in_bio_url = url\n306\t continue\n307\t hit = platform_from_url(url)\n308\t if hit and hit[0] != self.platform:\n309\t cr.platforms = merge_accounts(cr.platforms, [\n310\t account(hit[0], hit[1], \"cross_link\",\n311\t url=url).finalize()])\n312\t\n313\t def bio_urls(self, it: dict) -> list[str]:\n314\t return []\n315\t\n316\t\n317\tclass ApifyInstagram(_ApifySocialEnrich):\n318\t source_id = \"instagram-apify\"\n319\t platform = \"instagram\"\n320\t actor = \"ka-instagram\"\n321\t cap = 1800\n322\t content_key = \"recent_posts\"\n323\t metric_map = {\"following\": \"following\", \"posts_count\": \"posts\",\n324\t \"category\": \"category\", \"is_business\": \"is_business\",\n325\t \"business_category\": \"business_category\",\n326\t \"highlight_reels\": \"highlight_reels\",\n327\t \"pronouns\": \"pronouns\", \"bio_links\": \"bio_links\",\n328\t \"avg_likes\": \"avg_likes\", \"avg_comments\": \"avg_comments\",\n329\t \"avg_video_views\": \"avg_video_views\",\n330\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n331\t \"posts_per_week\": \"posts_per_week\",\n332\t \"last_post_at\": \"last_post_at\",\n333\t \"video_share_pct\": \"video_share_pct\",\n334\t \"top_post\": \"top_post\"}\n335\t\n336\t def bio_urls(self, it: dict) -> list[str]:\n337\t return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])\n338\t\n339\t def apply(self, cr, acc, it):\n340\t super().apply(cr, acc, it)\n341\t related = [r.get(\"username\") for r in (it.get(\"related_profiles\")\n342\t or []) if r.get(\"username\")]\n343\t if related: # matière première de découvertes futures (§12.1 mention)\n344\t acc.metrics[\"related_profiles\"] = related[:10]\n345\t\n346\t\n347\tclass ApifyTikTok(_ApifySocialEnrich):\n348\t source_id = \"tiktok-apify\"\n349\t platform = \"tiktok\"\n350\t actor = \"ka-tiktok\"\n351\t cap = 1800\n352\t content_key = \"recent_videos\"\n353\t metric_map = {\"following\": \"following\", \"total_likes\": \"likes\",\n354\t \"videos_count\": \"videos\", \"region\": \"region\",\n355\t \"friends\": \"friends\", \"is_seller\": \"is_seller\",\n356\t \"is_live_now\": \"is_live_now\",\n357\t \"commerce_category\": \"commerce_category\",\n358\t \"account_created_at\": \"account_created_at\",\n359\t \"avg_views\": \"avg_views\", \"avg_likes\": \"avg_likes\",\n360\t \"avg_comments\": \"avg_comments\", \"avg_shares\": \"avg_shares\",\n361\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n362\t \"videos_per_week\": \"videos_per_week\",\n363\t \"last_video_at\": \"last_video_at\",\n364\t \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n365\t \"top_video\": \"top_video\"}\n366\t\n367\t def bio_urls(self, it: dict) -> list[str]:\n368\t return [it.get(\"bio_link\") or \"\"]\n369\t\n370\t\n371\tclass ApifyX(_ApifySocialEnrich):\n372\t source_id = \"x-apify\"\n373\t platform = \"x\"\n374\t actor = \"ka-x\"\n375\t # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n376\t # requêtes simultanées par run (constat 2026-08-21)\n377\t run_concurrency = 4\n378\t cap = 1800\n379\t content_key = \"recent_tweets\"\n380\t metric_map = {\"following\": \"following\", \"tweets_count\": \"tweets\",\n381\t \"location\": \"location\", \"created_at\": \"created_at\",\n382\t \"listed_count\": \"listed\", \"account_age_days\": \"account_age_days\",\n383\t \"avg_likes\": \"avg_likes\", \"avg_retweets\": \"avg_retweets\",\n384\t \"avg_replies\": \"avg_replies\",\n385\t \"engagement_rate_pct\": \"engagement_rate_pct\",\n386\t \"tweets_per_week\": \"tweets_per_week\",\n387\t \"last_tweet_at\": \"last_tweet_at\",\n388\t \"media_share_pct\": \"media_share_pct\",\n389\t \"reply_share_pct\": \"reply_share_pct\",\n390\t \"is_blue_verified\": \"is_blue_verified\",\n391\t \"likes_given\": \"likes_given\", \"website\": \"website\",\n392\t \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}\n393\t\n394\t def extra_input(self) -> dict:\n395\t return {\"maxTweets\": 20}\n396\t\n397\t def bio_urls(self, it: dict) -> list[str]:\n398\t return [it.get(\"website\") or \"\"]\n399\t\n400\t\n401\tclass ApifyFacebook(_ApifySocialEnrich):\n402\t source_id = \"facebook-apify\"\n403\t platform = \"facebook\"\n404\t actor = \"ka-facebook\"\n405\t run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n406\t cap = 400\n407\t revisit_days = 7\n408\t metric_map = {\"category\": \"category\"}\n409\t\n410\t\n411\tclass ApifyThreads(_ApifySocialEnrich):\n412\t source_id = \"threads-apify\"\n413\t platform = \"threads\"\n414\t actor = \"ka-threads\"\n415\t run_concurrency = 6 # maximum du schéma d'input de ka-threads\n416\t cap = 60\n417\t revisit_days = 7\n418\t content_key = \"recent_posts\"\n419\t metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}\n420\t\n421\t\n422\tclass ApifySnapchat(_ApifySocialEnrich):\n423\t source_id = \"snapchat-apify\"\n424\t platform = \"snapchat\"\n425\t actor = \"ka-snapchat\"\n426\t cap = 60\n427\t revisit_days = 7\n428\t metric_map = {\"category\": \"category\", \"has_story\": \"has_story\",\n429\t \"has_spotlight\": \"has_spotlight\", \"address\": \"address\",\n430\t \"subcategory\": \"subcategory\",\n431\t \"publisher_type\": \"publisher_type\",\n432\t \"story_snaps_count\": \"story_snaps\",\n433\t \"lenses_count\": \"lenses\",\n434\t \"story_previews\": \"story_previews\",\n435\t \"spotlight_previews\": \"spotlight_previews\",\n436\t \"website\": \"website\",\n437\t \"spotlight_highlights_count\": \"spotlight_highlights\"}\n438\t\n439\t def bio_urls(self, it: dict) -> list[str]:\n440\t return [it.get(\"website\") or \"\"]\n441\t\n442\t\n443\tclass ApifyYouTube(_ApifySocialEnrich):\n444\t source_id = \"youtube-apify\"\n445\t platform = \"youtube\"\n446\t actor = \"ka-youtube\"\n447\t cap = 1800\n448\t content_key = \"recent_videos\"\n449\t metric_map = {\"videos_count\": \"videos\", \"country\": \"country\",\n450\t \"keywords\": \"keywords\", \"channel_id\": \"channel_id\",\n451\t \"avg_views\": \"avg_views\", \"top_video\": \"top_video\",\n452\t \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n453\t \"external_links\": \"external_links\",\n454\t \"last_video_published\": \"last_video_published\"}\n455\t\n456\t def bio_urls(self, it: dict) -> list[str]:\n457\t # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)\n458\t return [ln.get(\"url\") or \"\" for ln in (it.get(\"external_links\") or [])\n459\t if isinstance(ln, dict)]\n460\t\n461\t\n462\tclass ApifyTwitch(_ApifySocialEnrich):\n463\t source_id = \"twitch-apify\"\n464\t platform = \"twitch\"\n465\t actor = \"ka-twitch\"\n466\t cap = 1200\n467\t content_key = \"recent_videos\"\n468\t metric_map = {\"is_partner\": \"is_partner\", \"is_affiliate\": \"is_affiliate\",\n469\t \"team\": \"team\", \"created_at\": \"created_at\",\n470\t \"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n471\t \"live_game\": \"live_game\",\n472\t \"last_broadcast_at\": \"last_broadcast_at\",\n473\t \"last_broadcast_game\": \"last_broadcast_game\",\n474\t \"last_broadcast_title\": \"last_broadcast_title\",\n475\t \"live_title\": \"live_title\",\n476\t \"live_thumbnail\": \"live_thumbnail\",\n477\t \"avg_video_views\": \"avg_video_views\",\n478\t \"social_links\": \"social_links\",\n479\t \"recent_games\": \"recent_games\"}\n480\t\n481\t def bio_urls(self, it: dict) -> list[str]:\n482\t # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)\n483\t return [ln.get(\"url\") or \"\" for ln in (it.get(\"social_links\") or [])\n484\t if isinstance(ln, dict)]\n485\t\n486\t\n487\tclass ApifyKick(_ApifySocialEnrich):\n488\t source_id = \"kick-apify\"\n489\t platform = \"kick\"\n490\t actor = \"ka-kick\"\n491\t cap = 600\n492\t metric_map = {\"is_live_now\": \"is_live_now\", \"live_viewers\": \"live_viewers\",\n493\t \"subscription_enabled\": \"subscription_enabled\",\n494\t \"live_title\": \"live_title\",\n495\t \"live_thumbnail\": \"live_thumbnail\",\n496\t \"live_category\": \"live_category\",\n497\t \"vod_enabled\": \"vod_enabled\",\n498\t \"recent_categories\": \"recent_categories\"}\n499\t\n500\t def cross_links(self, cr: Creator, it: dict) -> None:\n501\t \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\"\n502\t socials = it.get(\"social_links\") or {}\n503\t tmpl = {\"instagram\": \"https://instagram.com/{}\",\n504\t \"twitter\": \"https://x.com/{}\",\n505\t \"youtube\": \"https://youtube.com/{}\",\n506\t \"tiktok\": \"https://www.tiktok.com/@{}\",\n507\t \"facebook\": \"https://facebook.com/{}\",\n508\t \"discord\": \"https://discord.gg/{}\"}\n509\t for key, val in socials.items():\n510\t val = (val or \"\").strip()\n511\t if not val:\n512\t continue\n513\t url = val if val.startswith(\"http\") else \\\n514\t tmpl.get(key, \"{}\").format(val.lstrip(\"@\"))\n515\t hit = platform_from_url(url)\n516\t if hit and hit[0] != self.platform:\n517\t cr.platforms = merge_accounts(cr.platforms, [\n518\t account(hit[0], hit[1], \"cross_link\",\n519\t url=url).finalize()])\n520\t\n521\t\n522\tclass ApifyOnlyFans(_ApifySocialEnrich):\n523\t source_id = \"onlyfans-apify\"\n524\t platform = \"onlyfans\"\n525\t actor = \"ka-onlyfans\"\n526\t cap = 100\n527\t revisit_days = 7\n528\t metric_map = {\"posts_count\": \"posts\", \"photos_count\": \"photos\",\n529\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n530\t \"streams_count\": \"streams\",\n531\t \"subscribe_price_usd\": \"subscribe_price_usd\",\n532\t \"is_free\": \"is_free\", \"location\": \"location\"}\n533\t\n534\t def bio_urls(self, it: dict) -> list[str]:\n535\t return [it.get(\"website\") or \"\"]\n536\t\n537\t\n538\tclass ApifyFansly(_ApifySocialEnrich):\n539\t source_id = \"fansly-apify\"\n540\t platform = \"fansly\"\n541\t actor = \"ka-fansly\"\n542\t cap = 600\n543\t revisit_days = 7\n544\t metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n545\t \"videos_count\": \"videos\", \"likes\": \"likes\",\n546\t \"following\": \"following\", \"location\": \"location\"}\n547\t\n548\t\n549\tclass ApifyPatreon(_ApifySocialEnrich):\n550\t source_id = \"patreon-apify\"\n551\t platform = \"patreon\"\n552\t actor = \"ka-patreon\"\n553\t cap = 400\n554\t revisit_days = 7\n555\t metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\",\n556\t \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\",\n557\t \"creation_name\": \"creation_name\",\n558\t \"social_links\": \"social_links\"}\n559\t\n560\t def bio_urls(self, it: dict) -> list[str]:\n561\t # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link\n562\t return list((it.get(\"social_links\") or {}).values())\n563\t\n564\t\n565\tclass ApifyDiscord(_ApifySocialEnrich):\n566\t source_id = \"discord-apify\"\n567\t platform = \"discord\"\n568\t actor = \"ka-discord\"\n569\t cap = 400\n570\t revisit_days = 7\n571\t metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\",\n572\t \"partnered\": \"partnered\", \"guild_id\": \"guild_id\",\n573\t \"channel\": \"channel\", \"splash\": \"splash\",\n574\t \"vanity_url_code\": \"vanity_url_code\"}\n575\t\n576\t def target_of(self, acc) -> str:\n577\t \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du\n578\t handle minusculé) : discord.gg/ ou discord.com/invite/.\"\"\"\n579\t url = acc.url or \"\"\n580\t for sep in (\"discord.gg/\", \"discord.com/invite/\", \"/invite/\"):\n581\t if sep in url:\n582\t return url.split(sep, 1)[1].split(\"/\")[0].split(\"?\")[0]\n583\t return acc.handle\n584\t\n585\t\n586\t# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------\n587\t\n588\t# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la\n589\t# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"18172d1d-aa07-4669-a035-f6afdf9d9bf6","timestamp":"2026-08-21T18:09:49.745Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","content":" 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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 run_concurrency = 6 # maximum du schéma d'input de 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 run_concurrency = 6 # maximum du schéma d'input de 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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)","numLines":330,"startLine":260,"totalLines":657}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"7b72f69d-74d0-4b9b-86e9-6a6dd1ddab30","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLpu26T8ET4r8vvRCpB","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzA4KpAEIERgCKkC5axvCuyyl/1bjX3J+HtExQ88uQ9SnHIs5pzlXTk1QJB7uKBnkOhQP3327Vcrs5YU7yE39YI/xHtWgb71v/zAhMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH+qKLUBhIMYpKRvHcXystjz1lEGgxTQ6OX5SSy9BswpEsiMMINX15Lj9og7lo2W982V5is76UQAbxzaV6cfrpwMyRjboGa0xDYMRyIcyfQrplyAirUDLLmpZRx+XjseQqgSjhLBWCVDDpW1YuBz3VvkWPSuZeT2r/yolRFqcHrjOuXyCesArUynxkp1rabcQAeOKLSmJKujFndUUiwqTx4FyjOn2oWwUl+AodCdO9e9G3X/zKTrQO3aIluRQ+7TonXE3nWlR5QFCRsdrl4gmCdyjhUYkXcyITIzKr/zpvT4TFYlxkOoOo8AVOvVFWum0fzxm+1UGO6X8KR0kEwv9Ik/zZIQ7lPHpMTvjMW+TeSZu7a/RIQ4SHEmbBtSCdSDtN/A76y3gSe4jO1a7mIK7OlX297PjXGP/y45MfOnCtkYlBmwyPuj8PjPL9oOyaKYlY693bZnfG/YjuW7h+YwEdx1vlkzZpXYCdEzcBsN67AogpHQ/5pCod5hF5+R4m5UjIVUVLuBt2eqwYEzvttSuDerj9f99qwhz71R7MUaWyhEqAD1lXTdBn93ARp1Pk6FrsD9x6h9Ycv+uxVFIlA3vyHJqI7/q7pSC0lNiHG21tNksrXRityBLMGiNcIX/SezmEgdZDuyNNqHvn+s8SE2O26hjH+6c7LJ88L747OksEJMDwAZFJPeRjEqG6ml0zfbp89VjCSuqKBwLD4U2zSjaXcQn984QaEWI+WXF0iC5/UOlZQJiPXz8gmlYnmeUS2VC1d2o9FiQxiR2NY+jYCqkAA+YH24j2XJdYTOzHJdpIK6nzN4mCKbzZtO8fNhnKrz60Dp4wXLK2NWJXWsHDfpwepSNRLNDJeYKW9ZOIgi3Ob3hWKK069hciF0oNuELzh16PEyH0e143JiYTteVoaKQA/QH+TmFn2pEtt4AkVKNYv3Ip+kqXmgEqAsysdUqWrmOHRWXR9XTbDLiuyzzQgIeIIubiRJZMuyrMJk2oYjerBVSqhrpBLVnsi2xi3T1JMrwHlp3Z+n2yogWHBetElimo5+o7Z/U0NSMJOpXMdKGb+GBGB111G4ndgxksu/XY9l+KtCSCGrDX5ZauC6O85r6cS5GEtnBFTffJcuSGq+tRJx2IoVAyalGmtGhzZkKooAeqvxqXSx5P3CPtr/7NB7a1l8yCN+tWdISSend+aQQIBfgXQlt6c1KGWVJ0sh7n+HgAEH/ia147YeW0vAXWT5RdlUf64PftjmuhSeLUeAGd6UspuB/gEbTO36iDk8rAGJmQUOrSatP0Xk6lCrV9XBDTxkVOl6WXOvF1zEH4uxQrILIwGZmzqWoEwCU39MnYVpfG05c0FFRbKXsV8/trH4k2nJZH2wOpgKSKfgRX0iiphpsPFI5xuPuhinBc/4gmw/1UtwI3f9TVsxF3mo/oscnr0Te5wWZ2k2JQLw/ZzqGBTYS/o8AVnY1lArBCvc9FoooxB8A7vKWFeb6u/qPBBhku80ndn2eqfU4dRbfkX8ga7SjDG9dt5ybKQ3hEoHYMj1/WBs/o3T5F2RQ8gzWUdAiMEVhgm2ouoY/dpFrwP+bYsNAIZvuxXtyrAKMfyhc4dGVMXZwyQpJAfae8JIGGyq/bpclEufZeD7iatfFtiT4jugZLO6hXGZotiPRTj1yipzyQv3uGZp8bD5q1NJrOtwafQOOxUVSNrDidzC4WBao6ofUpvZTs+UkNUMKFqprY3vXlA2n+RyMuTFU9HuYVuANl9kHw2LWDhb8/61SIolCpM8vIa+ZC7kCYEB0g9hvwN6qFhXxY/+xVjgJmLLAw7d87MazMpTudi8mqAdsGq5jDwtoX8KBgt4FSKdT2kNjpXRqdxko1t0+LVI4DpcX0EZoEo3fwIwk7zFHj1juWJeVTARCelFW44gMiSr5KtqqB4yKSuCzqJyWJciibYJvGPxPGUPMpzyNOsKeLggrFkH5wbo5CCYb3ivony1rnXJxNCjGOxWPfTfdryMGlaIZL7NcT0y2RijLjwWcx3hbWTnRU36UynA9mIIRJtMFTY1Udib73+pnv1BWuxkXoLWxtqF/8iCwcKkxWKxCmAwcBoRy3rN3MCnB9Af3PGMhglo99KHqxW7TALPaVKYwDeNmyPYQ3j75iRuoQnkoYX1z92VjjERnW+vC9/33uA8zB+PqUNMDP/o6NTjUjKwNLnRvE800MfvfZY76g2IHRGZ8wf3W/WNEC44rkZEgthCP4982HdacJjJgiDPvtfFTWDZR0m2Xdgrif+0CgkxNkTsxgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6198,"cache_read_input_tokens":136578,"cache_creation":{"ephemeral_5m_input_tokens":6198,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"101ccefd-7ae7-4109-87a1-6a1907092372"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLpu26T8ET4r8vvRCpB","type":"message","role":"assistant","content":[{"type":"text","text":"Je branche les nouveaux champs dans le connecteur. D'abord vérifier si le modèle `Creator` a `is_minor` (pour le flag TikTok)."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6198,"cache_read_input_tokens":136578,"cache_creation":{"ephemeral_5m_input_tokens":6198,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ca85af17-cc18-4120-889e-287f76af807a"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLpu26T8ET4r8vvRCpB","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SaxhaCKnpBjvexM8CfaCbh","name":"Grep","input":{"pattern":"is_minor","path":"/Users/simon-pierreboucher/apps/crea-ka/creaka","output_mode":"content","-n":true,"head_limit":15},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":6198,"cache_read_input_tokens":136578,"cache_creation":{"ephemeral_5m_input_tokens":6198,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e2f17784-d4d7-4365-8963-8ed34179740a"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SaxhaCKnpBjvexM8CfaCbh","type":"tool_result","content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/dedup.py:66: canon.is_minor = canon.is_minor or other.is_minor\n/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py:108: is_minor: bool = False\n/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py:188: if self.is_minor:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/ethics.py:57: if creator.is_minor:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:43: is_minor INTEGER NOT NULL DEFAULT 0,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:160: \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:166: is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:173: (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:360: where = [\"status='active'\", \"is_minor=0\"]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:434: \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:446: base = \"FROM creators WHERE status='active' AND is_minor=0\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:450: \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:455: \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py:170: \"FROM creators WHERE status='active' AND is_minor=0\").fetchall()\n/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py:199: \"WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0 \"\n\n[Showing results with pagination = limit: 15]"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f9001b27-516d-4622-a1e6-9737a86ce52f","timestamp":"2026-08-21T18:10:07.625Z","tool_use_result":{"mode":"content","numFiles":0,"filenames":[],"content":"/Users/simon-pierreboucher/apps/crea-ka/creaka/dedup.py:66: canon.is_minor = canon.is_minor or other.is_minor\n/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py:108: is_minor: bool = False\n/Users/simon-pierreboucher/apps/crea-ka/creaka/schema.py:188: if self.is_minor:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/ethics.py:57: if creator.is_minor:\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:43: is_minor INTEGER NOT NULL DEFAULT 0,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:160: \"\"\"INSERT INTO creators (id, display_name, status, is_minor, region, city,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:166: is_minor=excluded.is_minor, region=excluded.region, city=excluded.city,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:173: (cid, cr.display_name, cr.status, int(cr.is_minor), cr.region, cr.city,\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:360: where = [\"status='active'\", \"is_minor=0\"]\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:434: \"SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0\",\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:446: base = \"FROM creators WHERE status='active' AND is_minor=0\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:450: \" WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0\"\n/Users/simon-pierreboucher/apps/crea-ka/creaka/db.py:455: \"AND c2.is_minor=0 GROUP BY a.platform ORDER BY c DESC\")}\n/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py:170: \"FROM creators WHERE status='active' AND is_minor=0\").fetchall()\n/Users/simon-pierreboucher/apps/crea-ka/creaka/stats.py:199: \"WHERE a.needs_review=0 AND c2.status='active' AND c2.is_minor=0 \"","numLines":15,"appliedLimit":15}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"06c64170-55a7-4327-aba9-e71c67a4ae47","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLr5pFtmdVM5cRRbTfQ","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISsAMKpAEIERgCKkAbEx/KXThR1UCEPbuTzZElFK1DhnKmEYXoHP2FUuiJ6JdhdtoL30aQYRUzsPXBUY0asI0y8VT86Saxp7bZ7AskMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGFqaLUBhIMXVHoqCJXXCnqURM2GgzESB+FDGxl0r/+uwkiMERHS88uqRxeaBZKo6gaNqUPktyXNpzuCNjI05/lXfA/NqiyWagQdUsn3YmNf6RMmyq4AeQuN2Ph8Re7Odd+/VTMgCR1pqKnyf27XnEKVMP3/Bnf4vV7Gh8PNL3KufAzKILt1H6PnU9LbE6t0WLCEiz5QTWZqcHj+J6ckHjod16dXudzrGA2Kl2pxT+eytVIQh7I51rT7ryRQOx917L2cirFLrFbIuvA4Jkd8lhHHs4tPjLQuB1eR0xb3euhG99fyofBhhQpdroHOiU984rW/0Z+8oAC97dM5kqmukSmwtsEXc8dzDKNmxD/REoYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1842,"cache_read_input_tokens":142776,"cache_creation":{"ephemeral_5m_input_tokens":1842,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6403055f-f981-4ec0-9186-429a7b664d18"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLr5pFtmdVM5cRRbTfQ","type":"message","role":"assistant","content":[{"type":"text","text":"`is_minor` existe et masque la fiche partout — le flag TikTok `isUnderAge18` alimentera donc le régime §15 automatiquement. J'étends les connecteurs."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1842,"cache_read_input_tokens":142776,"cache_creation":{"ephemeral_5m_input_tokens":1842,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"73ef7908-d0d4-45f1-9c32-e23536b8fa42"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLr5pFtmdVM5cRRbTfQ","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MVphx6g8EesQ8Gyv3dmbvz","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"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]","new_string":" \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\",\n \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1842,"cache_read_input_tokens":142776,"cache_creation":{"ephemeral_5m_input_tokens":1842,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"bb99a158-3362-405d-9db0-7b6449e55e49"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MVphx6g8EesQ8Gyv3dmbvz","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e5588a39-5b9a-4a64-a093-f8aed6f398cf","timestamp":"2026-08-21T18:10:19.926Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"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]","newString":" \"video_share_pct\": \"video_share_pct\",\n \"top_post\": \"top_post\",\n \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 run_concurrency = 6 # maximum du schéma d'input de 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 run_concurrency = 6 # maximum du schéma d'input de 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":331,"oldLines":7,"newStart":331,"newLines":11,"lines":[" \"posts_per_week\": \"posts_per_week\","," \"last_post_at\": \"last_post_at\","," \"video_share_pct\": \"video_share_pct\",","- \"top_post\": \"top_post\"}","+ \"top_post\": \"top_post\",","+ \"bio_mentions\": \"bio_mentions\",","+ \"bio_hashtags\": \"bio_hashtags\",","+ \"igtv_videos_count\": \"igtv_videos\",","+ \"business_email\": \"business_email\"}"," "," def bio_urls(self, it: dict) -> list[str]:"," return [it.get(\"external_url\") or \"\"] + (it.get(\"bio_links\") or [])"]},{"oldStart":342,"oldLines":6,"newStart":346,"newLines":12,"lines":[" or []) if r.get(\"username\")]"," if related: # matière première de découvertes futures (§12.1 mention)"," acc.metrics[\"related_profiles\"] = related[:10]","+ # flag Meta « compte Threads relié » : même handle → cross_link fort","+ if it.get(\"has_threads\") and it.get(\"username\"):","+ u = it[\"username\"]","+ cr.platforms = merge_accounts(cr.platforms, [","+ account(\"threads\", u, \"cross_link\",","+ url=f\"https://www.threads.com/@{u}\").finalize()])"," "," "," class ApifyTikTok(_ApifySocialEnrich):"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"3e5fce6f-effa-4f88-a7aa-1fdd3ec2b141","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLrzEBKNWoe8brcEG1u","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015T3ojteHcR2Ate798wuDh1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"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 \"\"]","new_string":" \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\",\n \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":943,"cache_read_input_tokens":144618,"cache_creation":{"ephemeral_5m_input_tokens":943,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"640690b2-b94f-4607-944d-180231e88e3a"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015T3ojteHcR2Ate798wuDh1","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"92e8bfcd-a438-4849-8bd5-85a4cfe9a40d","timestamp":"2026-08-21T18:10:30.443Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"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 \"\"]","newString":" \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",\n \"top_video\": \"top_video\",\n \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 run_concurrency = 6 # maximum du schéma d'input de 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 run_concurrency = 6 # maximum du schéma d'input de 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":372,"oldLines":12,"newStart":372,"newLines":20,"lines":[" \"videos_per_week\": \"videos_per_week\","," \"last_video_at\": \"last_video_at\","," \"top_hashtags\": \"top_hashtags\", \"language\": \"language\",","- \"top_video\": \"top_video\"}","+ \"top_video\": \"top_video\",","+ \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",","+ \"pinned_videos_count\": \"pinned_videos\"}"," "," def bio_urls(self, it: dict) -> list[str]:"," return [it.get(\"bio_link\") or \"\"]"," ","+ def apply(self, cr, acc, it):","+ super().apply(cr, acc, it)","+ # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15","+ if it.get(\"is_under_18\") is True:","+ cr.is_minor = True"," ","+"," class ApifyX(_ApifySocialEnrich):"," source_id = \"x-apify\""," platform = \"x\""]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"9d5d0bc0-243c-46cc-839d-4b1eb49d4662","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLsmD88FaRSFZpjrT6w","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_016B4iAztsMXbycXZiSz3QKT","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}","new_string":" \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\",\n \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":501,"cache_read_input_tokens":145561,"cache_creation":{"ephemeral_5m_input_tokens":501,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b9eb1266-5d03-43b6-acbb-112c1ea95e74"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_016B4iAztsMXbycXZiSz3QKT","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2aa51f10-271f-4104-89f8-6ebd4b2487e5","timestamp":"2026-08-21T18:10:36.337Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\"}","newString":" \"likes_given\": \"likes_given\", \"website\": \"website\",\n \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\",\n \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 run_concurrency = 6 # maximum du schéma d'input de 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 run_concurrency = 6 # maximum du schéma d'input de 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":407,"oldLines":7,"newStart":407,"newLines":11,"lines":[" \"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\"}","+ \"top_hashtags\": \"top_hashtags\", \"top_tweet\": \"top_tweet\",","+ \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",","+ \"link_share_pct\": \"link_share_pct\",","+ \"retweet_share_pct\": \"retweet_share_pct\",","+ \"top_mentions\": \"top_mentions\"}"," "," def extra_input(self) -> dict:"," return {\"maxTweets\": 20}"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"ceeaeeb1-0a49-46f6-b3da-0579e0dd5715","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLtCYsRW37gJ28sNqqg","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01WVVRMu4etpJWe4pWwXDod1","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}","new_string":" run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":403,"cache_read_input_tokens":146062,"cache_creation":{"ephemeral_5m_input_tokens":403,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"b83a2b52-1c21-4884-92ee-c5b9239bfcc0"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01WVVRMu4etpJWe4pWwXDod1","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"5292aa8d-4ce2-4c45-ba9c-cc9aedbaa681","timestamp":"2026-08-21T18:10:42.151Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\"}","newString":" run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de 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 run_concurrency = 6 # maximum du schéma d'input de 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":427,"oldLines":9,"newStart":427,"newLines":14,"lines":[" run_concurrency = 6 # maximum du schéma d'input de ka-facebook"," cap = 400"," revisit_days = 7","- metric_map = {\"category\": \"category\"}","+ metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",","+ \"website\": \"website\", \"rating\": \"rating\"}"," ","+ def bio_urls(self, it: dict) -> list[str]:","+ # site web auto-déclaré de la page → cross_link (§12.1)","+ return [it.get(\"website\") or \"\"]"," ","+"," class ApifyThreads(_ApifySocialEnrich):"," source_id = \"threads-apify\""," platform = \"threads\""]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"617e9ae8-8134-42a5-926a-b57f4944fa6f","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLtdK9M7yR44JewJ2Sh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01HSZcAVBzGs1XEi62fFS3He","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" run_concurrency = 6 # maximum du schéma d'input de 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\"}","new_string":" run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":417,"cache_read_input_tokens":146465,"cache_creation":{"ephemeral_5m_input_tokens":417,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ddd78a39-f39e-44a8-82d9-469c3336fa89"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01HSZcAVBzGs1XEi62fFS3He","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"074ecb7c-9b8e-4b33-b075-9be748ecf573","timestamp":"2026-08-21T18:10:48.442Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" run_concurrency = 6 # maximum du schéma d'input de 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\"}","newString":" run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":443,"oldLines":9,"newStart":443,"newLines":14,"lines":[" cap = 60"," revisit_days = 7"," content_key = \"recent_posts\"","- metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\"}","+ metric_map = {\"avg_likes\": \"avg_likes\", \"top_post\": \"top_post\",","+ \"bio_links\": \"bio_links\"}"," ","+ def bio_urls(self, it: dict) -> list[str]:","+ # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)","+ return list(it.get(\"bio_links\") or [])"," ","+"," class ApifySnapchat(_ApifySocialEnrich):"," source_id = \"snapchat-apify\""," platform = \"snapchat\""]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"f705319f-0abe-4a16-beda-de87cdf64bfa","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLu67CiQoBizhoJbScu","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EPMt4Uq5AhYmKLDsQeh8xK","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 \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":829,"cache_read_input_tokens":146882,"cache_creation":{"ephemeral_5m_input_tokens":829,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d85d2e2f-3d0e-4f8c-9c34-f2d2266530d3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EPMt4Uq5AhYmKLDsQeh8xK","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"a8b161d4-1881-4649-9e73-db5b54210834","timestamp":"2026-08-21T18:10:55.140Z","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 \"total_views\": \"total_views\", \"joined_date\": \"joined_date\",\n \"external_links\": \"external_links\",\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":483,"oldLines":7,"newStart":483,"newLines":9,"lines":[" \"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\"}","+ \"last_video_published\": \"last_video_published\",","+ \"videos_per_month\": \"videos_per_month\",","+ \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}"," "," def bio_urls(self, it: dict) -> list[str]:"," # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"4241dfbb-d4cf-40df-9169-04c19c0fb240","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLuawJGkTfKC3uPxsoh","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_015PXCGGtj4UeqYSScgsWoGV","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}","new_string":" \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\",\n \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":513,"cache_read_input_tokens":147711,"cache_creation":{"ephemeral_5m_input_tokens":513,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"7d467324-7907-46fa-a6ac-3ac0e2f1ae80"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_015PXCGGtj4UeqYSScgsWoGV","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"65c022a4-9380-4fe7-abb1-e2f78cf43a2c","timestamp":"2026-08-21T18:11:01.202Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\"}","newString":" \"avg_video_views\": \"avg_video_views\",\n \"social_links\": \"social_links\",\n \"recent_games\": \"recent_games\",\n \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":510,"oldLines":7,"newStart":510,"newLines":10,"lines":[" \"live_thumbnail\": \"live_thumbnail\","," \"avg_video_views\": \"avg_video_views\","," \"social_links\": \"social_links\",","- \"recent_games\": \"recent_games\"}","+ \"recent_games\": \"recent_games\",","+ \"videos_count\": \"videos\",","+ \"live_type\": \"live_type\",","+ \"live_started_at\": \"live_started_at\"}"," "," def bio_urls(self, it: dict) -> list[str]:"," # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b8adb3e8-dff4-4cbe-b4f8-0343c0b015ff","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLv2o3YFqNawhYtnjzd","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_017SKya27dxMLMinMP8qV2b2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}","new_string":" \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\",\n \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":342,"cache_read_input_tokens":148224,"cache_creation":{"ephemeral_5m_input_tokens":342,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6964089e-3d1e-4e34-b395-c70f35efbe75"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_017SKya27dxMLMinMP8qV2b2","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"facdc4dc-ce33-4a98-af4c-8c920354480a","timestamp":"2026-08-21T18:11:06.288Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\"}","newString":" \"live_category\": \"live_category\",\n \"vod_enabled\": \"vod_enabled\",\n \"recent_categories\": \"recent_categories\",\n \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":532,"oldLines":7,"newStart":532,"newLines":9,"lines":[" \"live_thumbnail\": \"live_thumbnail\","," \"live_category\": \"live_category\","," \"vod_enabled\": \"vod_enabled\",","- \"recent_categories\": \"recent_categories\"}","+ \"recent_categories\": \"recent_categories\",","+ \"live_started_at\": \"live_started_at\",","+ \"live_language\": \"live_language\"}"," "," def cross_links(self, cr: Creator, it: dict) -> None:"," \"\"\"Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).\"\"\""]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a93ca2b0-823a-4cc9-8abc-f2538c7fa975","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLvQPKjzbqHgtzahiP8","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Job4D1rTgX3GCSe19iEfRi","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" 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\"}","new_string":" 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 \"join_date\": \"join_date\", \"audios_count\": \"audios\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":333,"cache_read_input_tokens":148566,"cache_creation":{"ephemeral_5m_input_tokens":333,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8e32af36-0c3c-4ac3-8021-cd62323bf961"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Job4D1rTgX3GCSe19iEfRi","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"12eda7c8-b942-4c4b-8082-6fa1a68e5ff3","timestamp":"2026-08-21T18:11:12.139Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" 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\"}","newString":" 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 \"join_date\": \"join_date\", \"audios_count\": \"audios\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}\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 \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":568,"oldLines":7,"newStart":568,"newLines":8,"lines":[" \"videos_count\": \"videos\", \"likes\": \"likes\","," \"streams_count\": \"streams\","," \"subscribe_price_usd\": \"subscribe_price_usd\",","- \"is_free\": \"is_free\", \"location\": \"location\"}","+ \"is_free\": \"is_free\", \"location\": \"location\",","+ \"join_date\": \"join_date\", \"audios_count\": \"audios\"}"," "," def bio_urls(self, it: dict) -> list[str]:"," return [it.get(\"website\") or \"\"]"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"5dd5dddf-56d8-4684-ab80-19c0ca9b8f7f","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLvqThEv8jJvyU1KoBE","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012K11ph7THDRcpMvdAgi5N5","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}","new_string":" metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\",\n \"subscription_price\": \"subscription_price\",\n \"account_created_at\": \"account_created_at\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":440,"cache_read_input_tokens":148899,"cache_creation":{"ephemeral_5m_input_tokens":440,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"26fbeeb1-67a8-4b97-91ff-a39f865cc28c"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012K11ph7THDRcpMvdAgi5N5","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8ad0b72a-44a7-4b7d-a017-f346aa8d3923","timestamp":"2026-08-21T18:11:17.578Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\"}","newString":" metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\",\n \"videos_count\": \"videos\", \"likes\": \"likes\",\n \"following\": \"following\", \"location\": \"location\",\n \"subscription_price\": \"subscription_price\",\n \"account_created_at\": \"account_created_at\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}\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 \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}\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 \"join_date\": \"join_date\", \"audios_count\": \"audios\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":583,"oldLines":7,"newStart":583,"newLines":9,"lines":[" revisit_days = 7"," metric_map = {\"posts_count\": \"posts\", \"images_count\": \"images\","," \"videos_count\": \"videos\", \"likes\": \"likes\",","- \"following\": \"following\", \"location\": \"location\"}","+ \"following\": \"following\", \"location\": \"location\",","+ \"subscription_price\": \"subscription_price\",","+ \"account_created_at\": \"account_created_at\"}"," "," "," class ApifyPatreon(_ApifySocialEnrich):"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"857dfda0-19ba-4eda-9959-666e96f124d4","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLwEeiKnKtxCtTwAMjw","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01B26w74gkVSMTa7raDaBoYQ","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\", \"is_nsfw\": \"is_nsfw\",\n \"creation_name\": \"creation_name\",\n \"social_links\": \"social_links\"}","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 \"published_at\": \"published_at\",\n \"pay_per_name\": \"pay_per_name\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":363,"cache_read_input_tokens":149339,"cache_creation":{"ephemeral_5m_input_tokens":363,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ba0f4829-221c-4e45-88d3-a60eee94862e"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B26w74gkVSMTa7raDaBoYQ","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"64625876-7bda-4447-bf6b-b980f8704354","timestamp":"2026-08-21T18:11:23.718Z","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\", \"is_nsfw\": \"is_nsfw\",\n \"creation_name\": \"creation_name\",\n \"social_links\": \"social_links\"}","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 \"published_at\": \"published_at\",\n \"pay_per_name\": \"pay_per_name\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}\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 \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}\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 \"join_date\": \"join_date\", \"audios_count\": \"audios\"}\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 \"subscription_price\": \"subscription_price\",\n \"account_created_at\": \"account_created_at\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":597,"oldLines":7,"newStart":597,"newLines":9,"lines":[" metric_map = {\"patrons\": \"patrons\", \"posts_count\": \"posts\","," \"is_monthly\": \"is_monthly\", \"is_nsfw\": \"is_nsfw\","," \"creation_name\": \"creation_name\",","- \"social_links\": \"social_links\"}","+ \"social_links\": \"social_links\",","+ \"published_at\": \"published_at\",","+ \"pay_per_name\": \"pay_per_name\"}"," "," def bio_urls(self, it: dict) -> list[str]:"," # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"e7116248-cb97-4696-94e6-413ffbd572eb","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLwgzEYn766igm1hSiR","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01Cd9tAXWpUQPvyozL8v1wRt","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\",\n \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}","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\",\n \"premium_tier\": \"premium_tier\",\n \"nsfw_level\": \"nsfw_level\"}"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":415,"cache_read_input_tokens":149702,"cache_creation":{"ephemeral_5m_input_tokens":415,"ephemeral_1h_input_tokens":0},"output_tokens":15,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"c242d43e-4ed9-4d53-aa8b-5908fa484ff0"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01Cd9tAXWpUQPvyozL8v1wRt","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"ef6ec5c2-fc79-4b6b-b09a-d1823c19e51b","timestamp":"2026-08-21T18:11:31.966Z","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\",\n \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}","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\",\n \"premium_tier\": \"premium_tier\",\n \"nsfw_level\": \"nsfw_level\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}\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 \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}\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 \"join_date\": \"join_date\", \"audios_count\": \"audios\"}\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 \"subscription_price\": \"subscription_price\",\n \"account_created_at\": \"account_created_at\"}\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 \"published_at\": \"published_at\",\n \"pay_per_name\": \"pay_per_name\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\"}\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/ ou discord.com/invite/.\"\"\"\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":615,"oldLines":7,"newStart":615,"newLines":9,"lines":[" metric_map = {\"members\": \"members\", \"online\": \"online\", \"boosts\": \"boosts\","," \"partnered\": \"partnered\", \"guild_id\": \"guild_id\","," \"channel\": \"channel\", \"splash\": \"splash\",","- \"vanity_url_code\": \"vanity_url_code\"}","+ \"vanity_url_code\": \"vanity_url_code\",","+ \"premium_tier\": \"premium_tier\",","+ \"nsfw_level\": \"nsfw_level\"}"," "," def target_of(self, acc) -> str:"," \"\"\"Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"95cf2c5a-de04-4613-9c53-c75bb9c73886","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLxJJFwTBbFtbpaW7ri","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS3gIKpAEIERgCKkBr0p8iwXQXt5ujb1vfGUtbiaOP4Vgo20m18wCS0b2AXOFHjCbEDDq5TvB+BuVrwDXh0GL2zqp1jEELOKhhmncAMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHbqaLUBhIMJBZ9XMHtZ0OJe18OGgyjow99OoLtqFxSuswiMNgZiOLAmNoVdh8VfOdmgBjsEMHpYV1xPcsPCaBfoRXV/sVFKVfJSv9cVMIErdJRmipn7w/rUAPi4Q1mlOYBXDucCw12Jlx3tb58rmTeL0y2jNk+CqJ1izH3or96eOrKkH6g2a/UWGRTCuTqXKfW7PauuUeDcN1VCkzrxwAWeGdUT6TpnxlEA1anzmkncOCsvblMKJMIAm+oAhgB"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":431,"cache_read_input_tokens":150117,"cache_creation":{"ephemeral_5m_input_tokens":431,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"47598e61-c209-4d97-8a1b-ddff96678157"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLxJJFwTBbFtbpaW7ri","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SoxBVRQ1edVzMse1PV1F7A","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","old_string":" \"website\": \"website\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}","new_string":" \"website\": \"website\", \"snapcode\": \"snapcode\",\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":431,"cache_read_input_tokens":150117,"cache_creation":{"ephemeral_5m_input_tokens":431,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"94a91805-5a22-401c-be9b-138019aba496"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SoxBVRQ1edVzMse1PV1F7A","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"590c29c6-87ef-460b-8eb4-68aa11dee67b","timestamp":"2026-08-21T18:11:40.984Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/apps/crea-ka/creaka/connectors/apify_social.py","oldString":" \"website\": \"website\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}","newString":" \"website\": \"website\", \"snapcode\": \"snapcode\",\n \"spotlight_highlights_count\": \"spotlight_highlights\"}","originalFile":"# ==============================================================================\n# Author: Simon-Pierre Boucher \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-` 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# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :\n# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation\n# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs\n# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.\nMISS_PREFIXES = (\"user_not_found\", \"private\", \"not_found\", \"no_profile\",\n \"no_channel\", \"login_wall\", \"invite_invalide\", \"http_404\",\n \"wall_\")\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# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le\n# bassin de chaque plateforme en un seul passage (rattrapage de couverture)\nFORCE_PASS = os.environ.get(\"APIFY_FORCE\", \"\") == \"1\"\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 # concurrence interne du run — DOIT respecter le `maximum` du schéma\n # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des\n # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)\n run_concurrency = RUN_CONCURRENCY\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 if FORCE_PASS:\n return True\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 not FORCE_PASS and 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\": self.run_concurrency,\n **self.extra_input()},\n sorted(targets),\n # passage forcé : tranches plus grosses (bassin entier / 32 runs)\n # → délai par run élargi en proportion\n timeout_s=7200 if FORCE_PASS else 2400)\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 err = str(it.get(\"error\", \"\"))\n if err.startswith((\"http_5\", \"shell\")):\n self.errors += 1\n elif err.startswith(MISS_PREFIXES):\n for cr, acc in pairs: # miss définitif → rotation douce\n acc.metrics[self._stamp_key] = now_iso()\n acc.metrics[f\"{self.source_id}_miss\"] = err\n enriched.append(cr)\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.pop(f\"{self.source_id}_miss\", None) # ressuscité\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 \"bio_mentions\": \"bio_mentions\",\n \"bio_hashtags\": \"bio_hashtags\",\n \"igtv_videos_count\": \"igtv_videos\",\n \"business_email\": \"business_email\"}\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 # flag Meta « compte Threads relié » : même handle → cross_link fort\n if it.get(\"has_threads\") and it.get(\"username\"):\n u = it[\"username\"]\n cr.platforms = merge_accounts(cr.platforms, [\n account(\"threads\", u, \"cross_link\",\n url=f\"https://www.threads.com/@{u}\").finalize()])\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 \"likes_given\": \"likes_given\", \"avg_saves\": \"avg_saves\",\n \"pinned_videos_count\": \"pinned_videos\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n return [it.get(\"bio_link\") or \"\"]\n\n def apply(self, cr, acc, it):\n super().apply(cr, acc, it)\n # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15\n if it.get(\"is_under_18\") is True:\n cr.is_minor = True\n\n\nclass ApifyX(_ApifySocialEnrich):\n source_id = \"x-apify\"\n platform = \"x\"\n actor = \"ka-x\"\n # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4\n # requêtes simultanées par run (constat 2026-08-21)\n run_concurrency = 4\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 \"avg_quotes\": \"avg_quotes\", \"verified_type\": \"verified_type\",\n \"link_share_pct\": \"link_share_pct\",\n \"retweet_share_pct\": \"retweet_share_pct\",\n \"top_mentions\": \"top_mentions\"}\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 run_concurrency = 6 # maximum du schéma d'input de ka-facebook\n cap = 400\n revisit_days = 7\n metric_map = {\"category\": \"category\", \"page_id\": \"page_id\",\n \"website\": \"website\", \"rating\": \"rating\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # site web auto-déclaré de la page → cross_link (§12.1)\n return [it.get(\"website\") or \"\"]\n\n\nclass ApifyThreads(_ApifySocialEnrich):\n source_id = \"threads-apify\"\n platform = \"threads\"\n actor = \"ka-threads\"\n run_concurrency = 6 # maximum du schéma d'input de 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 \"bio_links\": \"bio_links\"}\n\n def bio_urls(self, it: dict) -> list[str]:\n # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)\n return list(it.get(\"bio_links\") or [])\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 \"videos_per_month\": \"videos_per_month\",\n \"has_shorts\": \"has_shorts\", \"is_live_now\": \"is_live_now\"}\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 \"videos_count\": \"videos\",\n \"live_type\": \"live_type\",\n \"live_started_at\": \"live_started_at\"}\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 \"live_started_at\": \"live_started_at\",\n \"live_language\": \"live_language\"}\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 \"join_date\": \"join_date\", \"audios_count\": \"audios\"}\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 \"subscription_price\": \"subscription_price\",\n \"account_created_at\": \"account_created_at\"}\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 \"published_at\": \"published_at\",\n \"pay_per_name\": \"pay_per_name\"}\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 \"channel\": \"channel\", \"splash\": \"splash\",\n \"vanity_url_code\": \"vanity_url_code\",\n \"premium_tier\": \"premium_tier\",\n \"nsfw_level\": \"nsfw_level\"}\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/ ou discord.com/invite/.\"\"\"\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":465,"oldLines":7,"newStart":465,"newLines":7,"lines":[" \"lenses_count\": \"lenses\","," \"story_previews\": \"story_previews\","," \"spotlight_previews\": \"spotlight_previews\",","- \"website\": \"website\",","+ \"website\": \"website\", \"snapcode\": \"snapcode\","," \"spotlight_highlights_count\": \"spotlight_highlights\"}"," "," def bio_urls(self, it: dict) -> list[str]:"]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"28345dbb-d791-4507-b50b-d12bfdf0bbac","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLxxzBmQDdWveY5XVra","type":"message","role":"assistant","content":[{"type":"text","text":"Connecteur étendu. Tests maintenant."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":325,"cache_read_input_tokens":150548,"cache_creation":{"ephemeral_5m_input_tokens":325,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"3b7e1b48-9211-43b4-a859-ce11f0e935dc"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLxxzBmQDdWveY5XVra","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01EhQeUbmXSrkt6P8qKQKW48","name":"Bash","input":{"command":"cd /Users/simon-pierreboucher/apps/crea-ka && .venv/bin/python -m pytest tests/ -q 2>&1 | tail -5","description":"Lancer la suite pytest","timeout":180000},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":325,"cache_read_input_tokens":150548,"cache_creation":{"ephemeral_5m_input_tokens":325,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d03cde53-db39-40ba-bec6-7e78a08f278f"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01EhQeUbmXSrkt6P8qKQKW48","type":"tool_result","content":"................................................... [100%]\n51 passed in 0.10s","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"428e43a7-d30b-456b-a120-03f448ffe873","timestamp":"2026-08-21T18:11:46.525Z","tool_use_result":{"stdout":"................................................... [100%]\n51 passed in 0.10s","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d6e84084-131c-47c9-b7d8-fd4720dddefa","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLyRDWYcp84kCKxZ5sN","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS2gMKpAEIERgCKkBURychgY4MhO1iS85FChIYQERQwmSKVzReXIUe1mJyWrs0IpAOee0Iz2CfodRNdvhFUvfP/yT5Y4lmbM0gjjD8Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAHnqaLUBhIMrLQhApEE1SGu5k+YGgygy9yKgQWbpGJa7nYiMDHZJebA48c2GM7Yxc8qYgaENwybjlGHjkT2MEFQq9tTdhi1p8H+4r7DHjL9Be1A8yriARHU+NIK8dZu4FaJ5ro/K3443JvKTg0UZh8neS2/QCTEqRCl4FGbpyvkq++x44kxUopVWdkq5hLuAt7YbHPXU2h/IZ8g/5krf9wA81+eq7tkc15szD5nqTlswZmZ6iUodMC7T3S1lJ8uOhwUynvJWsieSXWCO7FLavz1vrSoSZ7W60z/hJKQBk3ADmMKA+Fzs8Sv9g5zouShHcFq1yNfP4bNJVn1+5LiUGldIRg98qC7hNKWpGgaydf45njO1hgO3wDN9EAUhxr9ihGXL2QD7MtkryHebRUFtfZmE1/OUU8hoZAYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":548,"cache_read_input_tokens":150873,"cache_creation":{"ephemeral_5m_input_tokens":548,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0346d620-f042-4fa6-b8fa-23121e286fc0"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLyRDWYcp84kCKxZ5sN","type":"message","role":"assistant","content":[{"type":"text","text":"51 tests verts. Vérification d'import du connecteur, puis redéploiement."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":548,"cache_read_input_tokens":150873,"cache_creation":{"ephemeral_5m_input_tokens":548,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6eaaa0f2-4b6f-40c5-b447-d418a83d93af"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLyRDWYcp84kCKxZ5sN","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01GTbYDsTnWbs4EfiQMLisES","name":"Bash","input":{"command":".venv/bin/python -c \"from creaka.connectors.apify_social import ApifyInstagram, ApifyTikTok, ApifyThreads, ApifyFacebook; print('import OK')\"","description":"Vérifier l'import du connecteur modifié"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":548,"cache_read_input_tokens":150873,"cache_creation":{"ephemeral_5m_input_tokens":548,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"761f0070-3704-44e7-b1ff-8f510bea2407"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01GTbYDsTnWbs4EfiQMLisES","type":"tool_result","content":"import OK","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"32eb2e1a-1f88-4246-be96-5dc3c25e6806","timestamp":"2026-08-21T18:11:53.551Z","tool_use_result":{"stdout":"import OK","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"d8310d9e-4479-4213-a9be-6ea785fbaddb","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLytXaLfVYn2SbcUJ6o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01JLiVYYMxn1xwCemHxNYruc","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest (51 OK)","status":"completed","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"in_progress","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":257,"cache_read_input_tokens":151421,"cache_creation":{"ephemeral_5m_input_tokens":257,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"34ca2609-1b88-4ade-9e20-a16e426f82b1"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01JLiVYYMxn1xwCemHxNYruc","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"9f590f1c-8273-47c6-b6be-ab6a53758e7f","timestamp":"2026-08-21T18:12:00.285Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest","status":"in_progress","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"pending","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest (51 OK)","status":"completed","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"in_progress","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLytXaLfVYn2SbcUJ6o","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01SE8kvN82xJ12ttNEhQC9B6","name":"Bash","input":{"command":"pm2 restart crea-ka-web crea-ka-sync --update-env >/dev/null 2>&1; sleep 3; curl -s -o /dev/null -w \"healthz local: %{http_code}\\n\" http://127.0.0.1:8160/healthz; curl -s -o /dev/null -w \"crea-ka.com: %{http_code}\\n\" https://crea-ka.com/healthz; pm2 ls | grep crea-ka","description":"Redémarrer pm2 et vérifier les healthchecks"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":257,"cache_read_input_tokens":151421,"cache_creation":{"ephemeral_5m_input_tokens":257,"ephemeral_1h_input_tokens":0},"output_tokens":23,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"dbf70f3c-cb84-49a5-8286-5fbce4252fd3"}} {"type":"claude","event":{"type":"system","subtype":"task_notification","task_id":"bn4z6zm34","tool_use_id":"toolu_01SE8kvN82xJ12ttNEhQC9B6","status":"completed","output_file":"","summary":"Redémarrer pm2 et vérifier les healthchecks","uuid":"20060865-7954-4955-8737-5edb0276950a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SE8kvN82xJ12ttNEhQC9B6","type":"tool_result","content":"healthz local: 200\ncrea-ka.com: 404\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 42702 │ 3s │ 10 │ online │ 0% │ 41.4mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 42690 │ 3s │ 30 │ online │ 0% │ 84.1mb │ simon-p… │ disabled │","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"71b2acd4-e187-4422-8a13-5b76e39edb5f","timestamp":"2026-08-21T18:12:05.935Z","tool_use_result":{"stdout":"healthz local: 200\ncrea-ka.com: 404\n│ 89 │ crea-ka-ngrok │ default │ N/A │ fork │ 63473 │ 3D │ 0 │ online │ 0% │ 159.1mb │ simon-p… │ disabled │\n│ 94 │ crea-ka-sync │ default │ N/A │ fork │ 42702 │ 3s │ 10 │ online │ 0% │ 41.4mb │ simon-p… │ disabled │\n│ 87 │ crea-ka-web │ default │ N/A │ fork │ 42690 │ 3s │ 30 │ online │ 0% │ 84.1mb │ simon-p… │ disabled │","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"task_started","task_id":"bn4z6zm34","tool_use_id":"toolu_01SE8kvN82xJ12ttNEhQC9B6","description":"Redémarrer pm2 et vérifier les healthchecks","task_type":"local_bash","uuid":"ae9e1b62-a39f-425a-bc03-5967b22de650","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"fe54bcef-a739-4172-9eb4-a03c6318602a","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLzoYDeLkTEhoahuF1r","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiQMKpAEIERgCKkCBsgqyEp/l1sxZQlDcMnBH0S0KWaJeSCHscH3G6bE0Mvn84Za5VJXNc5Cn40b/lqU277+u8o7PVG8aSsE6qj76Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAH7qaLUBhIMhOQfDyjP4X//f89rGgwC/VMpULWgk3Axh6AiMHf/E7Jc0PCfnpxiUzDclNHAVwVktBwqdWfgQjffFyBRrRTYC6bk+rhJro8GNhv3nCqRAcuhotuOeaiuMyKnDGERKfMw7nTLnVouKdT9qQEuwOJZ+j18qd73Non//si0h+WYJ/qxw0hGuQEwVdIY1OAsyYgwZA1+HdxYWSdh9ckD0bdNj4I+l49vQ0QNnHP+j5QvV9jSlH+cm0HmAqzSDwkZLBXyBoetKHaCSugqId76a1+EZF/0qs4kOX2DYhXC+4aHhI4YAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":990,"cache_read_input_tokens":151678,"cache_creation":{"ephemeral_5m_input_tokens":990,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d706e849-8f12-44e4-bba5-2cdc2bb3d0a3"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLzoYDeLkTEhoahuF1r","type":"message","role":"assistant","content":[{"type":"text","text":"Local 200 ; le 404 sur crea-ka.com/healthz est peut-être juste une route non exposée — je vérifie la racine publique."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":990,"cache_read_input_tokens":151678,"cache_creation":{"ephemeral_5m_input_tokens":990,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"f6388baf-ae58-4530-af0c-820744643bcd"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGLzoYDeLkTEhoahuF1r","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PQi9jARi6jMJysrCiELGkr","name":"Bash","input":{"command":"curl -s -o /dev/null -w \"crea-ka.com/: %{http_code}\\n\" https://crea-ka.com/; curl -s -o /dev/null -w \"local /: %{http_code}\\n\" http://127.0.0.1:8160/","description":"Vérifier la racine du site public et locale"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":990,"cache_read_input_tokens":151678,"cache_creation":{"ephemeral_5m_input_tokens":990,"ephemeral_1h_input_tokens":0},"output_tokens":7,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"af3dc710-0274-440b-8714-1b57a009f515"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PQi9jARi6jMJysrCiELGkr","type":"tool_result","content":"crea-ka.com/: 301\nlocal /: 200","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6c4a716e-4a81-46f1-94ec-41270c274868","timestamp":"2026-08-21T18:12:12.030Z","tool_use_result":{"stdout":"crea-ka.com/: 301\nlocal /: 200","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a5d655f5-e277-40ec-aa2f-61b92416da76","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM1H8PvrUCHXuFUcTD2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_012Ew3CcpdkDjnT2JqCLEdqz","name":"Bash","input":{"command":"curl -sL -o /dev/null -w \"crea-ka.com (suivi 301): %{http_code} → %{url_effective}\\n\" https://crea-ka.com/","description":"Suivre la redirection du site public"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":287,"cache_read_input_tokens":152668,"cache_creation":{"ephemeral_5m_input_tokens":287,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1c591251-d240-44b9-beff-37b19f8cf01d"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_012Ew3CcpdkDjnT2JqCLEdqz","type":"tool_result","content":"crea-ka.com (suivi 301): 200 → https://www.crea-ka.com/","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e4ab2602-042a-4d40-9073-121451a51e93","timestamp":"2026-08-21T18:12:17.651Z","tool_use_result":{"stdout":"crea-ka.com (suivi 301): 200 → https://www.crea-ka.com/","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"adc4be11-1c51-4a63-8620-cb05a4c9adf7","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM1gQdAEgM3MF8frrac","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAIS9AIKpAEIERgCKkAlTez7xrgxXDFgbGj+FTSNeOBw9VKhuhpUI9Rp+lvG1ylMay8dqzAEl+oR4AjHuGsJvtgxq99ChDNgEifussQsMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGGqqLUBhIMritl/ctm8XXto0IfGgz3H/M/tma3h9SXdJQiMF5TrdLqioGprexwKVWG7pBtM4ZtQcGfqqDUP5PguGP9uJ1NtFSN4YXrlSbF52UBUCp9jBljtV57uSat89qAuoXajNyJA1hqcrDjeMZeF8luxGZtiweXp6xMJTvsVIjwKwgJVLDNC0RcS1oONGEw8Bu9WWHdHj+8L2INNy8I+Ei5V+kMVe3ERLhEblCIPjEjuidqr6Alzk/JKemwo/D+jGAG8ovDGz8cXDL8a3JLgkAYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":168,"cache_read_input_tokens":152955,"cache_creation":{"ephemeral_5m_input_tokens":168,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"e4f8d45f-81e9-435f-b092-d6f012ffda9b"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM1gQdAEgM3MF8frrac","type":"message","role":"assistant","content":[{"type":"text","text":"Site public OK (200 via www). Commit et push."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":168,"cache_read_input_tokens":152955,"cache_creation":{"ephemeral_5m_input_tokens":168,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"72c34985-c575-4429-a102-0723fbd73881"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM1gQdAEgM3MF8frrac","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01PDSiL5nQJkaMqXfzBAfRgU","name":"Bash","input":{"command":"git status --short && git log --oneline -3","description":"État git avant commit"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":168,"cache_read_input_tokens":152955,"cache_creation":{"ephemeral_5m_input_tokens":168,"ephemeral_1h_input_tokens":0},"output_tokens":4,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"907b653c-3dbc-4a3b-83fd-ed18e1fa6d31"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01PDSiL5nQJkaMqXfzBAfRgU","type":"tool_result","content":" M actors/ka-discord/src/main.py\n M actors/ka-discord/src/net.py\n M actors/ka-facebook/src/main.py\n M actors/ka-facebook/src/net.py\n M actors/ka-fansly/src/main.py\n M actors/ka-fansly/src/net.py\n M actors/ka-instagram/src/main.py\n M actors/ka-instagram/src/net.py\n M actors/ka-kick/src/main.py\n M actors/ka-kick/src/net.py\n M actors/ka-onlyfans/src/main.py\n M actors/ka-onlyfans/src/net.py\n M actors/ka-patreon/src/main.py\n M actors/ka-patreon/src/net.py\n M actors/ka-snapchat/src/main.py\n M actors/ka-snapchat/src/net.py\n M actors/ka-threads/src/main.py\n M actors/ka-threads/src/net.py\n M actors/ka-tiktok/src/main.py\n M actors/ka-tiktok/src/net.py\n M actors/ka-twitch/src/main.py\n M actors/ka-twitch/src/net.py\n M actors/ka-x/src/main.py\n M actors/ka-x/src/net.py\n M actors/ka-youtube/src/main.py\n M actors/ka-youtube/src/net.py\n M creaka/connectors/apify_social.py\n0cad882 Concurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé\ncac232a Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n8fa08f4 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","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"6f5ae87d-bb2d-4601-af0f-2b0a234b4f8f","timestamp":"2026-08-21T18:12:22.582Z","tool_use_result":{"stdout":" M actors/ka-discord/src/main.py\n M actors/ka-discord/src/net.py\n M actors/ka-facebook/src/main.py\n M actors/ka-facebook/src/net.py\n M actors/ka-fansly/src/main.py\n M actors/ka-fansly/src/net.py\n M actors/ka-instagram/src/main.py\n M actors/ka-instagram/src/net.py\n M actors/ka-kick/src/main.py\n M actors/ka-kick/src/net.py\n M actors/ka-onlyfans/src/main.py\n M actors/ka-onlyfans/src/net.py\n M actors/ka-patreon/src/main.py\n M actors/ka-patreon/src/net.py\n M actors/ka-snapchat/src/main.py\n M actors/ka-snapchat/src/net.py\n M actors/ka-threads/src/main.py\n M actors/ka-threads/src/net.py\n M actors/ka-tiktok/src/main.py\n M actors/ka-tiktok/src/net.py\n M actors/ka-twitch/src/main.py\n M actors/ka-twitch/src/net.py\n M actors/ka-x/src/main.py\n M actors/ka-x/src/net.py\n M actors/ka-youtube/src/main.py\n M actors/ka-youtube/src/net.py\n M creaka/connectors/apify_social.py\n0cad882 Concurrence par acteur (run_concurrency) : ka-facebook et ka-threads plafonnent concurrency à 6 dans leur schéma d'input — l'envoi de 10 faisait rejeter TOUS leurs runs en 400 depuis des jours (cause réelle des passages à zéro, pas un mur de login). Facebook débloqué : 1 402 fiches enrichies au passage forcé\ncac232a Couverture Apify forcée : mode APIFY_FORCE=1 (ignore fraîcheur + caps, délai élargi), tampon des miss définitifs (handle mort/mur de login → rotation hebdo au lieu d'un re-scan quotidien en pure perte), concurrence X réduite à 4 + retries 429 avec backoff et rotation d'IP dans ka-x (0.1.7)\n8fa08f4 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","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"09e6caef-ecaa-45da-bec4-727866927bb5","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM22a55qcZhS3DSYyuo","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ET7xhfZB6LipvZzGXgw7JM","name":"Bash","input":{"command":"git add actors creaka/connectors/apify_social.py && git commit -m \"$(cat <<'EOF'\nUpgrade majeur round 2 des 13 acteurs Apify : net.py commun durci (retries 403/407/408/425/429/500/502/503/504, backoff exponentiel + jitter, rotation d'IP par tentative, POST avec retries — Twitch GQL passe par ce moteur) + nouveaux champs par plateforme : IG (bio mentions/hashtags, flag Threads relié → cross-link, business_email public, IGTV), TikTok (likes_given, avg_saves, épinglés, flag mineur isUnderAge18 → is_minor §15), YouTube (videos_per_month, has_shorts, live), X (top_mentions §12.1, avg_quotes, parts liens/RT, verified_type), FB (page_id, website → cross-link, rating), Threads (bio_links → cross-links forts, pk), Twitch (totalCount vidéos, type/début de live), Kick (début/langue/maturité du live), Patreon (+ socials IG/TikTok/Twitch/Discord, published_at), Fansly (tiers/prix, createdAt), Discord (features, premium_tier, expiration), OnlyFans (joinDate, audios), Snapchat (bannière héro, snapcode) — 13 poussés, 12 validés par runs de test ; connecteur : metric_maps étendus, cross-links IG→Threads et bio_links Threads, 51 tests verts\n\nCo-Authored-By: Claude Opus 4.7 \nEOF\n)\" && git push origin main && git status --short","description":"Commit et push vers spbgit"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1167,"cache_read_input_tokens":153123,"cache_creation":{"ephemeral_5m_input_tokens":1167,"ephemeral_1h_input_tokens":0},"output_tokens":20,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d8d87aa7-869a-493e-a180-5e06fbd4ead7"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01ET7xhfZB6LipvZzGXgw7JM","type":"tool_result","content":"[main 88307d2] Upgrade majeur round 2 des 13 acteurs Apify : net.py commun durci (retries 403/407/408/425/429/500/502/503/504, backoff exponentiel + jitter, rotation d'IP par tentative, POST avec retries — Twitch GQL passe par ce moteur) + nouveaux champs par plateforme : IG (bio mentions/hashtags, flag Threads relié → cross-link, business_email public, IGTV), TikTok (likes_given, avg_saves, épinglés, flag mineur isUnderAge18 → is_minor §15), YouTube (videos_per_month, has_shorts, live), X (top_mentions §12.1, avg_quotes, parts liens/RT, verified_type), FB (page_id, website → cross-link, rating), Threads (bio_links → cross-links forts, pk), Twitch (totalCount vidéos, type/début de live), Kick (début/langue/maturité du live), Patreon (+ socials IG/TikTok/Twitch/Discord, published_at), Fansly (tiers/prix, createdAt), Discord (features, premium_tier, expiration), OnlyFans (joinDate, audios), Snapchat (bannière héro, snapcode) — 13 poussés, 12 validés par runs de test ; connecteur : metric_maps étendus, cross-links IG→Threads et bio_links Threads, 51 tests verts\n 27 files changed, 732 insertions(+), 157 deletions(-)\nTo gitsrv:srv/git/crea-ka.git\n 0cad882..88307d2 main -> main","is_error":false}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"34b26409-72cc-4f88-9a00-36a3d90986a4","timestamp":"2026-08-21T18:12:33.490Z","tool_use_result":{"stdout":"[main 88307d2] Upgrade majeur round 2 des 13 acteurs Apify : net.py commun durci (retries 403/407/408/425/429/500/502/503/504, backoff exponentiel + jitter, rotation d'IP par tentative, POST avec retries — Twitch GQL passe par ce moteur) + nouveaux champs par plateforme : IG (bio mentions/hashtags, flag Threads relié → cross-link, business_email public, IGTV), TikTok (likes_given, avg_saves, épinglés, flag mineur isUnderAge18 → is_minor §15), YouTube (videos_per_month, has_shorts, live), X (top_mentions §12.1, avg_quotes, parts liens/RT, verified_type), FB (page_id, website → cross-link, rating), Threads (bio_links → cross-links forts, pk), Twitch (totalCount vidéos, type/début de live), Kick (début/langue/maturité du live), Patreon (+ socials IG/TikTok/Twitch/Discord, published_at), Fansly (tiers/prix, createdAt), Discord (features, premium_tier, expiration), OnlyFans (joinDate, audios), Snapchat (bannière héro, snapcode) — 13 poussés, 12 validés par runs de test ; connecteur : metric_maps étendus, cross-links IG→Threads et bio_links Threads, 51 tests verts\n 27 files changed, 732 insertions(+), 157 deletions(-)\nTo gitsrv:srv/git/crea-ka.git\n 0cad882..88307d2 main -> main","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"a84d0055-463b-40b8-972b-8e37dfb29eec","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM2qMQmCKz93kjUJxB9","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_011eZFW4JfSGDZrBChUrxtpc","name":"TodoWrite","input":{"todos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest (51 OK)","status":"completed","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"completed","activeForm":"Redéploiement et commit"}]},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1184,"cache_read_input_tokens":154290,"cache_creation":{"ephemeral_5m_input_tokens":1184,"ephemeral_1h_input_tokens":0},"output_tokens":25,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1c58e9ff-8413-496e-a058-416d2727449b"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_011eZFW4JfSGDZrBChUrxtpc","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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0b50c0cd-8995-4d38-9db9-a04638368557","timestamp":"2026-08-21T18:12:40.293Z","tool_use_result":{"oldTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest (51 OK)","status":"completed","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"in_progress","activeForm":"Redéploiement et commit"}],"newTodos":[{"content":"Durcir net.py partagé (retries 500/503/504, backoff expo + jitter, POST avec retries) et propager aux 13 acteurs","status":"completed","activeForm":"Durcissement de net.py partagé"},{"content":"Enrichir les 13 acteurs (nouveaux champs par plateforme)","status":"completed","activeForm":"Enrichissement des 13 acteurs"},{"content":"apify push des 13 acteurs + runs de test de validation","status":"completed","activeForm":"Push et validation des 13 acteurs"},{"content":"Étendre metric_maps/cross_links du connecteur apify_social.py + pytest (51 OK)","status":"completed","activeForm":"Extension du connecteur"},{"content":"Redéployer pm2, committer et pousser","status":"completed","activeForm":"Redéploiement et commit"}],"verificationNudgeNeeded":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"85b7361e-6352-4a23-903d-5c7a0e14ac7d","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM3MPw1VPaBm76g3EYP","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISzQUKpAEIERgCKkBooqB71XQqQ7rCzTDefczKQeuImIG4c9qrqkUClX9wXLzWdTZFhbp5t+rwu2i50gfxNmQTf0JtrOsth0MAIHHrMg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okNmZjYzhmODMtMWY5Yy00ZDMzLWJhMDctNGQ0ZGYzN2Q2N2Y1chD0eFf7D17nbHkCPBxNVtIaiAEBqAGfqqLUBhIMYSNfwXf1TTIdVRhqGgzNFblfGMUMGN6U9qsiMPpmD2xHxSBxgpISlKzcH8sw0kfkPXSO7cL24jSoDgcnz7uluPjFW9ScS+Bk6D9qeSrVA3E1spdx8X/a8LsPSXHzq+yVqSKNmT2h0r3JkmEPMcNFzisT0q0N7RWXHzRfmORp3upPBlPfBHAxiUs42sxMze8u5WhUD4yPyhaaEql+zpyniywgQ0s1eH9rwB9MawuXYPlEioBUy9d1y3++FKhZl/KeFi3wZPIu1I5NXn/An+dwP11qggt9W19eibtA3oCLID3xNS7+bTNciohTQQy7zhzFixHvYuLdvh2mCuU6Lxr2LmsENvJMgpWfXy0vKR2VbXwck/6OmxKlll3sBeYpcn87YB26RNj1CRCHyTVuUIfapeYJNg1ntmpfsw7HlXw1g2A4jgGIRfZ3dWfktCpqWbBO4pEDmRAkkC+IjE8PrMgKg+1/PQJ/FyLVde56zHa3e9kTp0iQvtBMjJZA+viNN0nh8r6ZM5XIZxYWCNd+QHO4f5I4dgvdE0Wr4wv74xZmR0ZNZwGIXoN9oE2nPD3eWXE8L9vjxRZcYXf89Fi6UVXSafv+cXYKOVvN6E+dAOQdabKPwe5rhD033aRlfXz6DRzXYd4w66XCFRcsxxaqMc1qpeLNKMM7z/Q2ffpYSmqWIs066C/tLlRXhJb2BKzA6oAKtX6RI9TmXSbdlafioZ/8pYttx2YYAQ=="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":441,"cache_read_input_tokens":155474,"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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"d330f428-adab-43e4-9cd6-1fcd72497b4d"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM3MPw1VPaBm76g3EYP","type":"message","role":"assistant","content":[{"type":"text","text":"Upgrade round 2 terminé et déployé. Je note deux gotchas durement acquis en mémoire avant de conclure."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":441,"cache_read_input_tokens":155474,"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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"34ccbb6e-7674-431d-a7f5-d6f5c6e8d87d"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM3MPw1VPaBm76g3EYP","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01BSbw9epVJ8t8P7uJq8TtF2","name":"Read","input":{"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md"},"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":155474,"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":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2aa86fa7-23a9-480e-887a-307e7bae9ab3"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01BSbw9epVJ8t8P7uJq8TtF2","type":"tool_result","content":"1\t---\n2\tname: Couverture Apify crea-ka — gotchas & mode forcé\n3\tdescription: Plafonds de concurrence des acteurs (400 sinon), mode APIFY_FORCE=1, tampon des miss, état de couverture 2026-08-21\n4\ttype: project\n5\toriginSessionId: eb002d3d-a873-4cca-9361-cb2467df7353\n6\t---\n7\tCampagne « couverture forcée » du 2026-08-21 (commits cac232a, 0cad882) : multi-plateformes 6,8 % → 19,6 % (2 502 créateurs), comptes 14 603 → 18 527.\n8\t\n9\t**Why:** l'utilisateur voyait « 6 % de complétude » = la jauge *créateurs multi-plateformes* du dashboard (/api/stats/dashboard), pas la jauge « complete ». Le sync quotidien (caps + rotation 7 j) ne couvrait qu'une tranche du bassin.\n10\t\n11\t**How to apply:**\n12\t- ⚠️ GOTCHA MAJEUR : le champ `concurrency` envoyé à un acteur DOIT respecter le `maximum` de son input_schema.json, sinon Apify rejette le run en **400 Bad Request** (silencieux : « 0 créateur retourné — source possiblement bloquée »). ka-facebook et ka-threads plafonnent à 6 ; le connecteur a maintenant `run_concurrency` par classe. Si un connecteur Apify tombe à zéro, vérifier D'ABORD ce plafond avant de soupçonner un blocage plateforme.\n13\t- `APIFY_FORCE=1 python run.py sync ` : ignore fraîcheur + caps → passage du bassin ENTIER. Arrêter `pm2 stop crea-ka-sync` pendant (collision quota mémoire Apify → 400 transitoires), le relancer après.\n14\t- Miss définitifs (user_not_found, login_wall, wall_*, http_404…) : tamponnés `_miss` dans account.metrics → rotation hebdo au lieu d'un re-scan quotidien. Un profil ressuscité efface le tampon.\n15\t- X (syndication) : max ~4 requêtes simultanées par run sinon 429 ; ka-x ≥ 0.1.7 retry 429 avec backoff + rotation d'IP. ~60-85 % des vieux handles X sont morts.\n16\t- Facebook : profils personnels = login_wall (miss), mais les PAGES passent très bien (1 402 fiches enrichies).\n17\t- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n18\t"}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"0b14e320-08e4-4788-aa9f-7e08b724abbb","timestamp":"2026-08-21T18:12:47.933Z","tool_use_result":{"type":"text","file":{"filePath":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md","content":"---\nname: Couverture Apify crea-ka — gotchas & mode forcé\ndescription: Plafonds de concurrence des acteurs (400 sinon), mode APIFY_FORCE=1, tampon des miss, état de couverture 2026-08-21\ntype: project\noriginSessionId: eb002d3d-a873-4cca-9361-cb2467df7353\n---\nCampagne « couverture forcée » du 2026-08-21 (commits cac232a, 0cad882) : multi-plateformes 6,8 % → 19,6 % (2 502 créateurs), comptes 14 603 → 18 527.\n\n**Why:** l'utilisateur voyait « 6 % de complétude » = la jauge *créateurs multi-plateformes* du dashboard (/api/stats/dashboard), pas la jauge « complete ». Le sync quotidien (caps + rotation 7 j) ne couvrait qu'une tranche du bassin.\n\n**How to apply:**\n- ⚠️ GOTCHA MAJEUR : le champ `concurrency` envoyé à un acteur DOIT respecter le `maximum` de son input_schema.json, sinon Apify rejette le run en **400 Bad Request** (silencieux : « 0 créateur retourné — source possiblement bloquée »). ka-facebook et ka-threads plafonnent à 6 ; le connecteur a maintenant `run_concurrency` par classe. Si un connecteur Apify tombe à zéro, vérifier D'ABORD ce plafond avant de soupçonner un blocage plateforme.\n- `APIFY_FORCE=1 python run.py sync ` : ignore fraîcheur + caps → passage du bassin ENTIER. Arrêter `pm2 stop crea-ka-sync` pendant (collision quota mémoire Apify → 400 transitoires), le relancer après.\n- Miss définitifs (user_not_found, login_wall, wall_*, http_404…) : tamponnés `_miss` dans account.metrics → rotation hebdo au lieu d'un re-scan quotidien. Un profil ressuscité efface le tampon.\n- X (syndication) : max ~4 requêtes simultanées par run sinon 429 ; ka-x ≥ 0.1.7 retry 429 avec backoff + rotation d'IP. ~60-85 % des vieux handles X sont morts.\n- Facebook : profils personnels = login_wall (miss), mais les PAGES passent très bien (1 402 fiches enrichies).\n- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n","numLines":18,"startLine":1,"totalLines":18}}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"18669b25-53a7-4d53-9319-7c35caa307af","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM3u9PZjSKfyzHqCHaY","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01YJQMpptRwzjdE5kWgDHXS2","name":"Edit","input":{"replace_all":false,"file_path":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md","old_string":"- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.","new_string":"- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n\nRound 2 d'upgrade (2026-08-21, commit 88307d2) — gotchas additionnels :\n- net.py commun (identique dans les 13 acteurs) : retries sur 403/407/408/425/429/500/502/503/504, backoff expo + jitter, `fetcher.post()` avec retries (Twitch GQL l'utilise). Toute modif de net.py se fait dans UN acteur puis `cp` vers les 12 autres.\n- Twitch GQL public : `videos { totalCount }` et `stream { type }` existent et passent — validé xqc.\n- Patreon : beaucoup de campagnes MASQUENT patron_count → erreur `http_200` (page ok, pas de compteur). Ce n'est PAS un acteur cassé (validé avec oversimplified : found + published_at + pay_per_name).\n- OnlyFans : wall_200 quasi systématique désormais (best-effort assumé).\n- Signaux nouveaux consommés par le connecteur : flag IG `has_threads` → cross-link Threads même handle ; `bio_links` Threads → cross-links forts ; TikTok `isUnderAge18` → `cr.is_minor = True` (régime §15, fiche masquée)."},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":1295,"cache_read_input_tokens":155915,"cache_creation":{"ephemeral_5m_input_tokens":1295,"ephemeral_1h_input_tokens":0},"output_tokens":17,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"2bfae184-17b6-4037-969b-b988d021d0ee"}} {"type":"claude","event":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01YJQMpptRwzjdE5kWgDHXS2","type":"tool_result","content":"The file /Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md has been updated successfully."}]},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"1e6a195d-48ab-4024-b59c-6feac4bbd35f","timestamp":"2026-08-21T18:12:59.926Z","tool_use_result":{"filePath":"/Users/simon-pierreboucher/.claude/projects/-Users-simon-pierreboucher-apps-crea-ka/memory/project_apify_couverture.md","oldString":"- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.","newString":"- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n\nRound 2 d'upgrade (2026-08-21, commit 88307d2) — gotchas additionnels :\n- net.py commun (identique dans les 13 acteurs) : retries sur 403/407/408/425/429/500/502/503/504, backoff expo + jitter, `fetcher.post()` avec retries (Twitch GQL l'utilise). Toute modif de net.py se fait dans UN acteur puis `cp` vers les 12 autres.\n- Twitch GQL public : `videos { totalCount }` et `stream { type }` existent et passent — validé xqc.\n- Patreon : beaucoup de campagnes MASQUENT patron_count → erreur `http_200` (page ok, pas de compteur). Ce n'est PAS un acteur cassé (validé avec oversimplified : found + published_at + pay_per_name).\n- OnlyFans : wall_200 quasi systématique désormais (best-effort assumé).\n- Signaux nouveaux consommés par le connecteur : flag IG `has_threads` → cross-link Threads même handle ; `bio_links` Threads → cross-links forts ; TikTok `isUnderAge18` → `cr.is_minor = True` (régime §15, fiche masquée).","originalFile":"---\nname: Couverture Apify crea-ka — gotchas & mode forcé\ndescription: Plafonds de concurrence des acteurs (400 sinon), mode APIFY_FORCE=1, tampon des miss, état de couverture 2026-08-21\ntype: project\noriginSessionId: eb002d3d-a873-4cca-9361-cb2467df7353\n---\nCampagne « couverture forcée » du 2026-08-21 (commits cac232a, 0cad882) : multi-plateformes 6,8 % → 19,6 % (2 502 créateurs), comptes 14 603 → 18 527.\n\n**Why:** l'utilisateur voyait « 6 % de complétude » = la jauge *créateurs multi-plateformes* du dashboard (/api/stats/dashboard), pas la jauge « complete ». Le sync quotidien (caps + rotation 7 j) ne couvrait qu'une tranche du bassin.\n\n**How to apply:**\n- ⚠️ GOTCHA MAJEUR : le champ `concurrency` envoyé à un acteur DOIT respecter le `maximum` de son input_schema.json, sinon Apify rejette le run en **400 Bad Request** (silencieux : « 0 créateur retourné — source possiblement bloquée »). ka-facebook et ka-threads plafonnent à 6 ; le connecteur a maintenant `run_concurrency` par classe. Si un connecteur Apify tombe à zéro, vérifier D'ABORD ce plafond avant de soupçonner un blocage plateforme.\n- `APIFY_FORCE=1 python run.py sync ` : ignore fraîcheur + caps → passage du bassin ENTIER. Arrêter `pm2 stop crea-ka-sync` pendant (collision quota mémoire Apify → 400 transitoires), le relancer après.\n- Miss définitifs (user_not_found, login_wall, wall_*, http_404…) : tamponnés `_miss` dans account.metrics → rotation hebdo au lieu d'un re-scan quotidien. Un profil ressuscité efface le tampon.\n- X (syndication) : max ~4 requêtes simultanées par run sinon 429 ; ka-x ≥ 0.1.7 retry 429 avec backoff + rotation d'IP. ~60-85 % des vieux handles X sont morts.\n- Facebook : profils personnels = login_wall (miss), mais les PAGES passent très bien (1 402 fiches enrichies).\n- Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.\n","structuredPatch":[{"oldStart":15,"oldLines":3,"newStart":15,"newLines":10,"lines":[" - X (syndication) : max ~4 requêtes simultanées par run sinon 429 ; ka-x ≥ 0.1.7 retry 429 avec backoff + rotation d'IP. ~60-85 % des vieux handles X sont morts."," - Facebook : profils personnels = login_wall (miss), mais les PAGES passent très bien (1 402 fiches enrichies)."," - Le levier nº 1 de la jauge multi : cross_links moissonnés par les acteurs (external_links YouTube, social_links Twitch/Kick/Patreon, bio_links IG) + connecteur link-in-bio derrière.","+","+Round 2 d'upgrade (2026-08-21, commit 88307d2) — gotchas additionnels :","+- net.py commun (identique dans les 13 acteurs) : retries sur 403/407/408/425/429/500/502/503/504, backoff expo + jitter, `fetcher.post()` avec retries (Twitch GQL l'utilise). Toute modif de net.py se fait dans UN acteur puis `cp` vers les 12 autres.","+- Twitch GQL public : `videos { totalCount }` et `stream { type }` existent et passent — validé xqc.","+- Patreon : beaucoup de campagnes MASQUENT patron_count → erreur `http_200` (page ok, pas de compteur). Ce n'est PAS un acteur cassé (validé avec oversimplified : found + published_at + pay_per_name).","+- OnlyFans : wall_200 quasi systématique désormais (best-effort assumé).","+- Signaux nouveaux consommés par le connecteur : flag IG `has_threads` → cross-link Threads même handle ; `bio_links` Threads → cross-links forts ; TikTok `isUnderAge18` → `cr.is_minor = True` (régime §15, fiche masquée)."]}],"userModified":false,"replaceAll":false}}} {"type":"claude","event":{"type":"system","subtype":"status","status":"requesting","uuid":"b4bb8d21-24ae-459c-a278-3b190d7e5700","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353"}} {"type":"claude","event":{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011CeGM4oKgQLP9XJ8vmjXhq","type":"message","role":"assistant","content":[{"type":"text","text":"Upgrade round 2 des 13 acteurs Apify terminé, validé et déployé (commit `88307d2` poussé sur spbgit).\n\n**Robustesse** — `net.py` commun durci dans les 13 acteurs : retries sur 9 statuts transitoires (dont 500/503/504), backoff exponentiel plafonné + jitter, nouvelle IP résidentielle à chaque tentative, et un `post()` avec retries que Twitch GQL utilise désormais (avant : un seul essai sans filet).\n\n**Complétude** — nouveaux champs par plateforme, les plus porteurs :\n- **Instagram** : mentions/hashtags de bio, courriel pro public, **flag « compte Threads relié » → cross-link automatique** vers Threads (même handle)\n- **Threads** : **bio_links auto-déclarés → cross-links forts** (§12.1) — validé sur mkbhd\n- **TikTok** : `isUnderAge18` → **`is_minor` automatique (régime §15, fiche masquée)**, likes donnés, avg_saves, épinglés\n- **YouTube** : cadence `videos_per_month` (validé : 1,17/mois), détection Shorts et live\n- **X** : `top_mentions` (signal d'identité §12.1), avg_quotes, parts liens/RT\n- **Facebook** : page_id, **site web → cross-link**, note de la page\n- **Twitch/Kick** : nb total de vidéos, type/début/langue du live\n- **Patreon** (+4 réseaux sociaux détectés, date de création), **Fansly** (prix/tiers), **Discord** (features, boost tier), **OnlyFans** (date d'inscription), **Snapchat** (bannière héro, snapcode)\n\n**Validation** : 13 poussés et buildés, 12 confirmés par runs de test avec les nouveaux champs présents (OnlyFans reste best-effort derrière son mur, comme documenté). Connecteur étendu (metric_maps + 3 nouveaux flux de cross-links), 51 tests verts, pm2 redémarré, healthz 200 local et crea-ka.com en ligne. Les nouveaux champs entreront dans les fiches au fil des passages du sync quotidien."}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":822,"cache_read_input_tokens":157210,"cache_creation":{"ephemeral_5m_input_tokens":822,"ephemeral_1h_input_tokens":0},"output_tokens":2,"service_tier":"standard","inference_geo":"global"},"context_management":null},"parent_tool_use_id":null,"session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","uuid":"8bbf8bd5-bd9e-4302-bb14-53ea6ce38bf2"}} {"type":"claude","event":{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":1015087,"duration_api_ms":914502,"num_turns":97,"result":"Upgrade round 2 des 13 acteurs Apify terminé, validé et déployé (commit `88307d2` poussé sur spbgit).\n\n**Robustesse** — `net.py` commun durci dans les 13 acteurs : retries sur 9 statuts transitoires (dont 500/503/504), backoff exponentiel plafonné + jitter, nouvelle IP résidentielle à chaque tentative, et un `post()` avec retries que Twitch GQL utilise désormais (avant : un seul essai sans filet).\n\n**Complétude** — nouveaux champs par plateforme, les plus porteurs :\n- **Instagram** : mentions/hashtags de bio, courriel pro public, **flag « compte Threads relié » → cross-link automatique** vers Threads (même handle)\n- **Threads** : **bio_links auto-déclarés → cross-links forts** (§12.1) — validé sur mkbhd\n- **TikTok** : `isUnderAge18` → **`is_minor` automatique (régime §15, fiche masquée)**, likes donnés, avg_saves, épinglés\n- **YouTube** : cadence `videos_per_month` (validé : 1,17/mois), détection Shorts et live\n- **X** : `top_mentions` (signal d'identité §12.1), avg_quotes, parts liens/RT\n- **Facebook** : page_id, **site web → cross-link**, note de la page\n- **Twitch/Kick** : nb total de vidéos, type/début/langue du live\n- **Patreon** (+4 réseaux sociaux détectés, date de création), **Fansly** (prix/tiers), **Discord** (features, boost tier), **OnlyFans** (date d'inscription), **Snapchat** (bannière héro, snapcode)\n\n**Validation** : 13 poussés et buildés, 12 confirmés par runs de test avec les nouveaux champs présents (OnlyFans reste best-effort derrière son mur, comme documenté). Connecteur étendu (metric_maps + 3 nouveaux flux de cross-links), 51 tests verts, pm2 redémarré, healthz 200 local et crea-ka.com en ligne. Les nouveaux champs entreront dans les fiches au fil des passages du sync quotidien.","stop_reason":"end_turn","session_id":"eb002d3d-a873-4cca-9361-cb2467df7353","total_cost_usd":5.291538899999997,"usage":{"input_tokens":160,"cache_creation_input_tokens":284260,"cache_read_input_tokens":9855413,"output_tokens":50153,"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":284260},"inference_geo":"","iterations":[{"input_tokens":2,"output_tokens":765,"cache_read_input_tokens":157210,"cache_creation_input_tokens":822,"cache_creation":{"ephemeral_5m_input_tokens":822,"ephemeral_1h_input_tokens":0},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-fable-5":{"inputTokens":129300,"outputTokens":58736,"cacheReadInputTokens":9855413,"cacheCreationInputTokens":284260,"webSearchRequests":0,"costUSD":5.291538899999997,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"ac8ad2b1-fd16-469c-a5a3-8bdde90d749c"}} {"type":"status","state":"idle"}