@m13v/s4l 1.6.197-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,468 @@
1
+ #!/usr/bin/env python3
2
+ """Compute true rolling-24h signup counts for every project with an Amplitude
3
+ block in config.json.
4
+
5
+ Why this exists:
6
+ Amplitude's Segmentation API (used by amplitude_signups.py + project_stats_json.py)
7
+ has two problems for the dashboard's "1d / last 24 hours" view:
8
+
9
+ 1. Buckets by calendar day in the project's display timezone. A signup
10
+ that happened "20 hours ago" can fall outside the "today" bucket.
11
+ 2. Has materialization lag of several hours; the Export API also lags
12
+ 1-2 hours behind real time.
13
+
14
+ The truly real-time source is our own server-side PostHog capture of the
15
+ `newsletter_subscribed` event in /api/signup. It fires synchronously when
16
+ the partner-signin call to Jungle succeeds, carrying:
17
+ - $host: which client site fired it (e.g. studyly.io)
18
+ - partner_outcome: 'partner_created' | 'partner_reused' | 'fallback'
19
+
20
+ For "how many users did we actually create in the Jungle backend in the
21
+ last 24h?", `newsletter_subscribed` with partner_outcome IN
22
+ ('partner_created', 'partner_reused') is the authoritative real-time count.
23
+
24
+ We *also* pull Amplitude Export (last ~26h) as an eventually-consistent
25
+ cross-check so the dashboard can show "X signups (Y attributed in
26
+ Amplitude)" once attribution catches up. Export is still expensive
27
+ (~120 MB / call for studyly), so this script runs on a slow cadence.
28
+
29
+ What this script writes:
30
+ ~/social-autoposter/skill/cache/amplitude_24h_signups.json
31
+ {
32
+ "generated_at_utc": ...,
33
+ "window_hours": 24,
34
+ "projects": [
35
+ {
36
+ "name": "studyly",
37
+ "count_24h": <int>, # primary, from PostHog (real-time)
38
+ "count_24h_source": "posthog_newsletter_subscribed",
39
+ "amplitude_count_24h": <int|null>, # secondary, from Amplitude export
40
+ "amplitude_count_source": "export_api",
41
+ "amplitude_lag_min": <int|null>, # how stale the amplitude side is
42
+ "latest_posthog_match_utc": ...,
43
+ "latest_amplitude_match_utc": ...,
44
+ "partner_outcome_breakdown": {"partner_created": N, "partner_reused": N, "fallback": N},
45
+ ...
46
+ }
47
+ ]
48
+ }
49
+
50
+ project_stats_json.py:_amplitude_signups reads `count_24h` for the days==1
51
+ case so the dashboard's "1d" funnel reflects real-time Jungle signups.
52
+
53
+ Run cadence:
54
+ - PostHog half: cheap (~1s, two HTTP calls). Fine to run every 5 min.
55
+ - Amplitude export half: ~30s + 120 MB. Set to skip when last successful
56
+ pull is < 30 min old (or always run with --no-export).
57
+ - launchd: com.m13v.social-amplitude-24h.plist (StartInterval 300).
58
+
59
+ Usage:
60
+ amplitude_24h_signups.py # all amplitude projects, both sources
61
+ amplitude_24h_signups.py --project studyly # one
62
+ amplitude_24h_signups.py --no-export # PostHog only (fast)
63
+ amplitude_24h_signups.py --print # echo result JSON to stdout
64
+ """
65
+
66
+ import argparse
67
+ import base64
68
+ import gzip
69
+ import io
70
+ import json
71
+ import os
72
+ import subprocess
73
+ import sys
74
+ import tempfile
75
+ import time
76
+ import urllib.error
77
+ import urllib.parse
78
+ import urllib.request
79
+ import zipfile
80
+ from datetime import datetime, timedelta, timezone
81
+
82
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
83
+ CONFIG_PATH = os.path.join(REPO_ROOT, "config.json")
84
+ ENV_PATH = os.path.join(REPO_ROOT, ".env")
85
+ CACHE_DIR = os.path.join(REPO_ROOT, "skill", "cache")
86
+ CACHE_PATH = os.path.join(CACHE_DIR, "amplitude_24h_signups.json")
87
+
88
+ EXPORT_API = "https://amplitude.com/api/2/export"
89
+ POSTHOG_HOST = "https://us.posthog.com"
90
+ POSTHOG_PROJECT_ID = 330744 # m13v org / s4l project; same key all client sites share
91
+
92
+ WINDOW_HOURS = 24
93
+ EXPORT_PULL_HOURS = 26 # 2h buffer for clock skew + ingestion lag
94
+ EXPORT_REFRESH_MIN = 25 # skip export pull if cache is fresher than this
95
+ TIMEOUT_SEC = 300
96
+
97
+
98
+ # ---------- env / config ----------
99
+
100
+
101
+ def load_env():
102
+ """Best-effort load .env (so launchd jobs see API keys)."""
103
+ if not os.path.exists(ENV_PATH):
104
+ return
105
+ with open(ENV_PATH) as f:
106
+ for line in f:
107
+ line = line.strip()
108
+ if line and not line.startswith("#") and "=" in line:
109
+ k, v = line.split("=", 1)
110
+ os.environ.setdefault(k.strip(), v.strip())
111
+
112
+
113
+ def load_config():
114
+ with open(CONFIG_PATH) as f:
115
+ return json.load(f)
116
+
117
+
118
+ def keychain_get(name):
119
+ """Read a generic-password keychain entry, stripping trailing newlines."""
120
+ try:
121
+ out = subprocess.run(
122
+ ["security", "find-generic-password", "-s", name, "-w"],
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=5,
126
+ )
127
+ if out.returncode != 0:
128
+ return None
129
+ return (out.stdout or "").strip()
130
+ except Exception:
131
+ return None
132
+
133
+
134
+ # ---------- PostHog primary count ----------
135
+
136
+
137
+ def posthog_24h_count(proj, posthog_key, now_utc):
138
+ """Return the real-time 24h signup count for `proj` from PostHog.
139
+
140
+ Counts `newsletter_subscribed` events whose `$host` equals the project's
141
+ primary domain and whose `partner_outcome` is 'partner_created' or
142
+ 'partner_reused' (i.e. we actually created or reused a Jungle user).
143
+
144
+ Returns dict { count, partner_outcome_breakdown, latest_match_utc, error? }.
145
+ """
146
+ website = (proj.get("website") or "").lower()
147
+ if "://" in website:
148
+ website = website.split("://", 1)[1]
149
+ website = website.rstrip("/")
150
+
151
+ if not website:
152
+ return {"count": None, "error": "no website in config"}
153
+ if not posthog_key:
154
+ return {"count": None, "error": "no PostHog key"}
155
+
156
+ # HogQL: count unique signups per partner_outcome in last 24h for $host = website.
157
+ # DISTINCT on (email, distinct_id) so client + server captures of the
158
+ # same submission collapse to one (consistent with project_stats_json.py),
159
+ # and a user that retries the form 3 times still counts as 1 signup.
160
+ query = (
161
+ "SELECT properties.partner_outcome AS outcome, "
162
+ "count(DISTINCT coalesce(properties.email, distinct_id)) AS n, "
163
+ "max(timestamp) AS latest "
164
+ "FROM events "
165
+ "WHERE event = 'newsletter_subscribed' "
166
+ f"AND properties.$host = '{website}' "
167
+ f"AND timestamp > now() - interval {WINDOW_HOURS} hour "
168
+ "GROUP BY outcome"
169
+ )
170
+ body = json.dumps({"query": {"kind": "HogQLQuery", "query": query}}).encode()
171
+ req = urllib.request.Request(
172
+ f"{POSTHOG_HOST}/api/projects/{POSTHOG_PROJECT_ID}/query/",
173
+ headers={
174
+ "Authorization": f"Bearer {posthog_key}",
175
+ "Content-Type": "application/json",
176
+ },
177
+ data=body,
178
+ method="POST",
179
+ )
180
+ try:
181
+ data = json.loads(urllib.request.urlopen(req, timeout=30).read())
182
+ except Exception as exc:
183
+ return {"count": None, "error": f"posthog: {type(exc).__name__}: {exc}"}
184
+
185
+ breakdown = {}
186
+ latest = None
187
+ real_count = 0
188
+ for row in data.get("results") or []:
189
+ outcome = (row[0] or "unknown")
190
+ n = int(row[1] or 0)
191
+ ts = row[2]
192
+ breakdown[outcome] = n
193
+ if outcome in ("partner_created", "partner_reused"):
194
+ real_count += n
195
+ if ts and (latest is None or ts > latest):
196
+ latest = ts
197
+ return {
198
+ "count": real_count,
199
+ "partner_outcome_breakdown": breakdown,
200
+ "latest_match_utc": latest,
201
+ }
202
+
203
+
204
+ # ---------- Amplitude eventually-consistent confirmation ----------
205
+
206
+
207
+ def fetch_amplitude_export(api_key, secret_key, start_hour, end_hour):
208
+ auth = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode()
209
+ qs = urllib.parse.urlencode({"start": start_hour, "end": end_hour})
210
+ req = urllib.request.Request(
211
+ f"{EXPORT_API}?{qs}",
212
+ headers={"Authorization": f"Basic {auth}"},
213
+ )
214
+ with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as r:
215
+ return r.read()
216
+
217
+
218
+ def iter_amplitude_events(zip_bytes):
219
+ with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z:
220
+ for name in z.namelist():
221
+ if not name.endswith(".json.gz"):
222
+ continue
223
+ with z.open(name) as f:
224
+ raw = gzip.decompress(f.read())
225
+ for line in raw.splitlines():
226
+ line = line.strip()
227
+ if not line:
228
+ continue
229
+ try:
230
+ yield json.loads(line)
231
+ except json.JSONDecodeError:
232
+ continue
233
+
234
+
235
+ def parse_amplitude_event_time(ev):
236
+ ts = ev.get("event_time") or ev.get("client_event_time")
237
+ if not ts:
238
+ return None
239
+ for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S",
240
+ "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
241
+ try:
242
+ return datetime.strptime(ts, fmt).replace(tzinfo=timezone.utc)
243
+ except ValueError:
244
+ continue
245
+ return None
246
+
247
+
248
+ def amplitude_24h_count(proj, env, now_utc):
249
+ """Return eventually-consistent 24h count from Amplitude Export.
250
+
251
+ Lag: typically 1-2 hours behind real time. Used as a cross-check, not
252
+ the primary number. Returns dict with count, lag, latest_match_utc.
253
+ """
254
+ amp = proj.get("amplitude")
255
+ if not amp:
256
+ return None
257
+ api_key = env.get(amp.get("api_key_env", ""))
258
+ secret_key = env.get(amp.get("secret_key_env", ""))
259
+ if not api_key or not secret_key:
260
+ return {"count": None, "error": f"missing env: {amp.get('api_key_env')} / {amp.get('secret_key_env')}"}
261
+
262
+ signup_event = amp.get("signup_event", "New User Sign Up")
263
+ filt = amp.get("attribution_filter") or {}
264
+ utm_filter = filt.get("utm_source") or []
265
+ if isinstance(utm_filter, str):
266
+ utm_filter = [utm_filter]
267
+ utm_set = {a.lower() for a in utm_filter}
268
+
269
+ end_hour_dt = now_utc.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
270
+ start_hour_dt = end_hour_dt - timedelta(hours=EXPORT_PULL_HOURS)
271
+ start_hour = start_hour_dt.strftime("%Y%m%dT%H")
272
+ end_hour = end_hour_dt.strftime("%Y%m%dT%H")
273
+ cutoff = now_utc - timedelta(hours=WINDOW_HOURS)
274
+
275
+ t0 = time.time()
276
+ try:
277
+ blob = fetch_amplitude_export(api_key, secret_key, start_hour, end_hour)
278
+ except urllib.error.HTTPError as exc:
279
+ body = ""
280
+ try:
281
+ body = exc.read().decode()[:200]
282
+ except Exception:
283
+ pass
284
+ return {"count": None, "error": f"HTTP {exc.code}: {body}"}
285
+ except Exception as exc:
286
+ return {"count": None, "error": f"{type(exc).__name__}: {exc}"}
287
+
288
+ download_sec = time.time() - t0
289
+ download_mb = len(blob) / 1e6
290
+
291
+ count = 0
292
+ latest_match = None
293
+ latest_any = None # latest signup_event of any UTM (lets us measure ingestion lag)
294
+ for ev in iter_amplitude_events(blob):
295
+ if ev.get("event_type") != signup_event:
296
+ continue
297
+ ts = parse_amplitude_event_time(ev)
298
+ if ts and (latest_any is None or ts > latest_any):
299
+ latest_any = ts
300
+ if not utm_set or not ((ev.get("event_properties") or {}).get("utm_source", "").lower() in utm_set):
301
+ continue
302
+ if ts is None or ts < cutoff:
303
+ continue
304
+ count += 1
305
+ if latest_match is None or ts > latest_match:
306
+ latest_match = ts
307
+
308
+ lag_min = None
309
+ if latest_any:
310
+ lag_min = int((now_utc - latest_any).total_seconds() / 60)
311
+
312
+ return {
313
+ "count": count,
314
+ "latest_match_utc": latest_match.isoformat() if latest_match else None,
315
+ "latest_any_signup_utc": latest_any.isoformat() if latest_any else None,
316
+ "lag_min": lag_min,
317
+ "pull_window_utc": [start_hour, end_hour],
318
+ "download_mb": round(download_mb, 1),
319
+ "elapsed_sec": round(time.time() - t0, 1),
320
+ "download_sec": round(download_sec, 1),
321
+ }
322
+
323
+
324
+ # ---------- combine + write ----------
325
+
326
+
327
+ def existing_export_age_min(now_utc):
328
+ """How old (minutes) is the cached Amplitude export half?"""
329
+ if not os.path.exists(CACHE_PATH):
330
+ return 999_999
331
+ try:
332
+ with open(CACHE_PATH) as f:
333
+ cur = json.load(f)
334
+ # Use latest amplitude pull's recorded timestamp.
335
+ for p in cur.get("projects") or []:
336
+ ts = p.get("amplitude_pulled_at_utc")
337
+ if ts:
338
+ pulled = datetime.fromisoformat(ts)
339
+ return int((now_utc - pulled).total_seconds() / 60)
340
+ except Exception:
341
+ pass
342
+ return 999_999
343
+
344
+
345
+ def atomic_write_json(path, payload):
346
+ os.makedirs(os.path.dirname(path), exist_ok=True)
347
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-", suffix=".json")
348
+ try:
349
+ with os.fdopen(fd, "w") as f:
350
+ json.dump(payload, f, indent=2)
351
+ os.replace(tmp, path)
352
+ except Exception:
353
+ try:
354
+ os.unlink(tmp)
355
+ except Exception:
356
+ pass
357
+ raise
358
+
359
+
360
+ def main():
361
+ ap = argparse.ArgumentParser()
362
+ ap.add_argument("--project", help="Limit to one project (name from config.json).")
363
+ ap.add_argument("--no-export", action="store_true", help="Skip the Amplitude export pull (PostHog primary only).")
364
+ ap.add_argument("--force-export", action="store_true", help="Always pull export, ignoring the refresh interval.")
365
+ ap.add_argument("--print", action="store_true", help="Print the resulting JSON to stdout.")
366
+ args = ap.parse_args()
367
+
368
+ load_env()
369
+ config = load_config()
370
+ now_utc = datetime.now(timezone.utc)
371
+
372
+ posthog_key = (
373
+ os.environ.get("POSTHOG_PERSONAL_API_KEY")
374
+ or keychain_get("PostHog-Personal-API-Key-m13v")
375
+ )
376
+
377
+ # Decide whether to refresh the export half this run.
378
+ do_export = (not args.no_export) and (
379
+ args.force_export or existing_export_age_min(now_utc) >= EXPORT_REFRESH_MIN
380
+ )
381
+ # Preserve previous export results on this run if we're skipping.
382
+ prev_amplitude = {}
383
+ if not do_export and os.path.exists(CACHE_PATH):
384
+ try:
385
+ with open(CACHE_PATH) as f:
386
+ cur = json.load(f)
387
+ for p in cur.get("projects") or []:
388
+ prev_amplitude[p["name"]] = {
389
+ "amplitude_count_24h": p.get("amplitude_count_24h"),
390
+ "amplitude_lag_min": p.get("amplitude_lag_min"),
391
+ "amplitude_latest_match_utc": p.get("amplitude_latest_match_utc"),
392
+ "amplitude_pulled_at_utc": p.get("amplitude_pulled_at_utc"),
393
+ "amplitude_error": p.get("amplitude_error"),
394
+ }
395
+ except Exception:
396
+ prev_amplitude = {}
397
+
398
+ out_projects = []
399
+ for proj in config.get("projects", []):
400
+ if args.project and args.project.lower() != proj.get("name", "").lower():
401
+ continue
402
+ if "amplitude" not in proj:
403
+ continue
404
+
405
+ name = proj["name"]
406
+ ph = posthog_24h_count(proj, posthog_key, now_utc)
407
+ ph_count = ph.get("count")
408
+ ph_breakdown = ph.get("partner_outcome_breakdown") or {}
409
+ ph_latest = ph.get("latest_match_utc")
410
+ ph_error = ph.get("error")
411
+
412
+ amp_count = None
413
+ amp_latest = None
414
+ amp_lag = None
415
+ amp_pulled_at = None
416
+ amp_error = None
417
+ if do_export:
418
+ res = amplitude_24h_count(proj, os.environ, now_utc)
419
+ if res:
420
+ amp_count = res.get("count")
421
+ amp_latest = res.get("latest_match_utc")
422
+ amp_lag = res.get("lag_min")
423
+ amp_pulled_at = now_utc.isoformat()
424
+ amp_error = res.get("error")
425
+ else:
426
+ prev = prev_amplitude.get(name) or {}
427
+ amp_count = prev.get("amplitude_count_24h")
428
+ amp_latest = prev.get("amplitude_latest_match_utc")
429
+ amp_lag = prev.get("amplitude_lag_min")
430
+ amp_pulled_at = prev.get("amplitude_pulled_at_utc")
431
+ amp_error = prev.get("amplitude_error")
432
+
433
+ out_projects.append({
434
+ "name": name,
435
+ "count_24h": ph_count,
436
+ "count_24h_source": "posthog_newsletter_subscribed",
437
+ "partner_outcome_breakdown": ph_breakdown,
438
+ "latest_posthog_match_utc": ph_latest,
439
+ "posthog_error": ph_error,
440
+ "amplitude_count_24h": amp_count,
441
+ "amplitude_count_source": "export_api",
442
+ "amplitude_lag_min": amp_lag,
443
+ "amplitude_latest_match_utc": amp_latest,
444
+ "amplitude_pulled_at_utc": amp_pulled_at,
445
+ "amplitude_error": amp_error,
446
+ "attribution_filter": (proj.get("amplitude") or {}).get("attribution_filter"),
447
+ })
448
+ print(
449
+ f" {name}: posthog_count={ph_count} ({ph_breakdown}) "
450
+ f"amplitude_count={amp_count} (lag={amp_lag} min, pulled={amp_pulled_at})",
451
+ file=sys.stderr,
452
+ )
453
+
454
+ payload = {
455
+ "generated_at_utc": now_utc.isoformat(),
456
+ "window_hours": WINDOW_HOURS,
457
+ "amplitude_export_refreshed": do_export,
458
+ "projects": out_projects,
459
+ }
460
+ atomic_write_json(CACHE_PATH, payload)
461
+ print(f"wrote {CACHE_PATH}", file=sys.stderr)
462
+
463
+ if args.print:
464
+ print(json.dumps(payload, indent=2))
465
+
466
+
467
+ if __name__ == "__main__":
468
+ main()
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """Pull signup counts from a client Amplitude project, filtered by our UTM source.
3
+
4
+ Reads `projects[].amplitude` blocks from config.json. For each project that has one,
5
+ queries Amplitude's Dashboard REST API:
6
+ - daily series of `signup_event` filtered by event property `utm_source = <our value>`
7
+ - daily series of the same event with no filter (denominator)
8
+
9
+ Usage:
10
+ amplitude_signups.py # all projects with amplitude block, last 30d, JSON
11
+ amplitude_signups.py --project studyly # one project
12
+ amplitude_signups.py --days 7 # custom window
13
+ amplitude_signups.py --pretty # human-readable table
14
+
15
+ Env vars per project (resolved from `api_key_env` / `secret_key_env` on the block):
16
+ AMPLITUDE_STUDYLY_API_KEY, AMPLITUDE_STUDYLY_SECRET_KEY, ...
17
+
18
+ Auth: HTTP Basic (API_KEY:SECRET_KEY) against amplitude.com/api/2/events/segmentation.
19
+ """
20
+
21
+ import argparse
22
+ import base64
23
+ import json
24
+ import os
25
+ import sys
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from datetime import datetime, timedelta, timezone
30
+
31
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ CONFIG_PATH = os.path.join(REPO_ROOT, "config.json")
33
+ ENV_PATH = os.path.join(REPO_ROOT, ".env")
34
+ API_BASE = "https://amplitude.com/api/2/events/segmentation"
35
+
36
+
37
+ def load_env():
38
+ if not os.path.exists(ENV_PATH):
39
+ return
40
+ with open(ENV_PATH) as f:
41
+ for line in f:
42
+ line = line.strip()
43
+ if line and not line.startswith("#") and "=" in line:
44
+ k, v = line.split("=", 1)
45
+ os.environ.setdefault(k.strip(), v.strip())
46
+
47
+
48
+ def load_config():
49
+ with open(CONFIG_PATH) as f:
50
+ return json.load(f)
51
+
52
+
53
+ def fetch_signup_series(api_key, secret_key, signup_event, attribution_filter, start, end):
54
+ """Return (filtered_series, total_series, x_values) for signup_event over [start, end]."""
55
+ auth = base64.b64encode(f"{api_key}:{secret_key}".encode()).decode()
56
+ headers = {"Authorization": f"Basic {auth}"}
57
+
58
+ def call(filters):
59
+ e = json.dumps({"event_type": signup_event, "filters": filters})
60
+ qs = urllib.parse.urlencode({
61
+ "e": e,
62
+ "start": start,
63
+ "end": end,
64
+ "i": "1",
65
+ "m": "totals",
66
+ })
67
+ req = urllib.request.Request(f"{API_BASE}?{qs}", headers=headers)
68
+ with urllib.request.urlopen(req, timeout=30) as r:
69
+ return json.loads(r.read())
70
+
71
+ filters = [
72
+ {
73
+ "subprop_type": "event",
74
+ "subprop_key": k,
75
+ "subprop_op": "is",
76
+ "subprop_value": v if isinstance(v, list) else [v],
77
+ }
78
+ for k, v in (attribution_filter or {}).items()
79
+ ]
80
+ filtered = call(filters)
81
+ total = call([])
82
+
83
+ x = filtered.get("data", {}).get("xValues", [])
84
+ f_series = (filtered.get("data", {}).get("series") or [[0] * len(x)])[0]
85
+ t_series = (total.get("data", {}).get("series") or [[0] * len(x)])[0]
86
+ return f_series, t_series, x
87
+
88
+
89
+ def project_amplitude_stats(project, days):
90
+ """Pull signup stats for a single project. Returns dict or None if no amplitude block."""
91
+ amp = project.get("amplitude")
92
+ if not amp:
93
+ return None
94
+
95
+ api_key = os.environ.get(amp.get("api_key_env", ""))
96
+ secret_key = os.environ.get(amp.get("secret_key_env", ""))
97
+ if not api_key or not secret_key:
98
+ return {
99
+ "project": project["name"],
100
+ "error": f"missing env: {amp.get('api_key_env')} or {amp.get('secret_key_env')}",
101
+ }
102
+
103
+ end_dt = datetime.now(timezone.utc)
104
+ start_dt = end_dt - timedelta(days=days - 1)
105
+ start = start_dt.strftime("%Y%m%d")
106
+ end = end_dt.strftime("%Y%m%d")
107
+
108
+ try:
109
+ f_series, t_series, x = fetch_signup_series(
110
+ api_key, secret_key,
111
+ amp.get("signup_event", "New User Sign Up"),
112
+ amp.get("attribution_filter") or {},
113
+ start, end,
114
+ )
115
+ except urllib.error.HTTPError as e:
116
+ return {"project": project["name"], "error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
117
+ except Exception as e:
118
+ return {"project": project["name"], "error": f"{type(e).__name__}: {e}"}
119
+
120
+ return {
121
+ "project": project["name"],
122
+ "amplitude_project_id": amp.get("project_id"),
123
+ "signup_event": amp.get("signup_event", "New User Sign Up"),
124
+ "attribution_filter": amp.get("attribution_filter") or {},
125
+ "days": days,
126
+ "start": start,
127
+ "end": end,
128
+ "x_values": x,
129
+ "attributed_series": f_series,
130
+ "total_series": t_series,
131
+ "attributed_total": sum(f_series),
132
+ "total_total": sum(t_series),
133
+ }
134
+
135
+
136
+ def main():
137
+ parser = argparse.ArgumentParser()
138
+ parser.add_argument("--project", help="Filter to specific project name")
139
+ parser.add_argument("--days", type=int, default=30, help="Lookback window in days (default 30)")
140
+ parser.add_argument("--pretty", action="store_true", help="Human-readable output")
141
+ args = parser.parse_args()
142
+
143
+ load_env()
144
+ config = load_config()
145
+
146
+ rows = []
147
+ for proj in config.get("projects", []):
148
+ if args.project and args.project.lower() != proj["name"].lower():
149
+ continue
150
+ if "amplitude" not in proj:
151
+ continue
152
+ stats = project_amplitude_stats(proj, args.days)
153
+ if stats:
154
+ rows.append(stats)
155
+
156
+ if args.pretty:
157
+ for r in rows:
158
+ print(f"\n{r['project']} (Amplitude project {r.get('amplitude_project_id', '?')})")
159
+ if "error" in r:
160
+ print(f" ERROR: {r['error']}")
161
+ continue
162
+ filt = r["attribution_filter"]
163
+ filt_str = ", ".join(f"{k}={v}" for k, v in filt.items()) or "(none)"
164
+ print(f" event: {r['signup_event']} filter: {filt_str} window: {r['start']}-{r['end']}")
165
+ print(f" attributed signups: {r['attributed_total']} / {r['total_total']} total")
166
+ for d, a, t in zip(r["x_values"], r["attributed_series"], r["total_series"]):
167
+ print(f" {d} attributed={a:>4} total={t:>6}")
168
+ else:
169
+ print(json.dumps({
170
+ "generated_at": datetime.now(timezone.utc).isoformat(),
171
+ "days": args.days,
172
+ "projects": rows,
173
+ }, indent=2))
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()