@m13v/s4l 1.6.197-rc.7
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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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()
|