@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.
- 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 +1336 -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 +513 -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,353 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""project_excludes.py — HTTP-backed exclude list (2026-05-12 migration).
|
|
3
|
+
|
|
4
|
+
Self-improving per-project exclusion list. Claude proposes specific keywords
|
|
5
|
+
during Phase 2b-prep when it rejects an off-topic candidate; those keywords
|
|
6
|
+
get appended as `-term` to all future search queries for that project after
|
|
7
|
+
they clear an activation gate (>=2 distinct batches).
|
|
8
|
+
|
|
9
|
+
All reads and writes now route through /api/v1/project-excludes on the
|
|
10
|
+
social-autoposter-website API. Direct SQL is GONE; the only Python state
|
|
11
|
+
this module owns is the local reserved-keyword check (which reads
|
|
12
|
+
config.json on disk).
|
|
13
|
+
|
|
14
|
+
CLI usage
|
|
15
|
+
---------
|
|
16
|
+
# List active excludes for a project (JSON to stdout):
|
|
17
|
+
python3 scripts/project_excludes.py active --platform twitter --project Vipassana
|
|
18
|
+
|
|
19
|
+
# Active excludes split by kind (reddit):
|
|
20
|
+
python3 scripts/project_excludes.py active-split --platform reddit --project studyly
|
|
21
|
+
|
|
22
|
+
# Propose a new exclude (used by log_twitter_skips.py / post_reddit.py):
|
|
23
|
+
python3 scripts/project_excludes.py propose \
|
|
24
|
+
--platform reddit --project studyly --term subreddit:bestofredditorupdates \
|
|
25
|
+
--candidate-id 10196 --batch-id rdtcycle-20260512-163303 \
|
|
26
|
+
--reason 'off-topic drama subreddit'
|
|
27
|
+
|
|
28
|
+
# Stamp last_used_at when terms get appended to a live query:
|
|
29
|
+
python3 scripts/project_excludes.py mark-used \
|
|
30
|
+
--platform reddit --project studyly --terms subreddit:foo subreddit:bar
|
|
31
|
+
|
|
32
|
+
# Decay: prune terms unused in 60 days with <3 batches.
|
|
33
|
+
python3 scripts/project_excludes.py decay [--days 60]
|
|
34
|
+
|
|
35
|
+
Module API
|
|
36
|
+
----------
|
|
37
|
+
from project_excludes import active_excludes, active_excludes_by_kind, \
|
|
38
|
+
propose, mark_used, decay
|
|
39
|
+
|
|
40
|
+
Activation gate
|
|
41
|
+
---------------
|
|
42
|
+
A term is APPLIED to live queries only when array_length(batch_ids,1) >= 2,
|
|
43
|
+
so one false-rejection can't mute the searches. The proposal IS recorded
|
|
44
|
+
on first emission so we can audit "Claude proposed this once but never again".
|
|
45
|
+
|
|
46
|
+
False-negative guards: structural validation (term shape, allowed kinds per
|
|
47
|
+
platform) is enforced server-side. Reserved-keyword check is enforced LOCALLY
|
|
48
|
+
before we hit the network, because config.json lives on the client.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
import argparse
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import sys
|
|
56
|
+
|
|
57
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
58
|
+
|
|
59
|
+
from http_api import api_get, api_post, api_delete
|
|
60
|
+
from project_topics import topics_for_project
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
ACTIVATION_BATCH_FLOOR = 2 # term must appear in this many distinct batches before applying
|
|
64
|
+
DECAY_DAYS_DEFAULT = 60 # prune unused terms older than this with <3 distinct batches
|
|
65
|
+
TERM_MIN_LEN = 3
|
|
66
|
+
# Bare-keyword form (Twitter): "cricket", "kohli". Kept for back-compat.
|
|
67
|
+
TERM_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{1,30}$")
|
|
68
|
+
# Reddit-only typed form: "subreddit:bestofredditorupdates" (sub bans) or
|
|
69
|
+
# "keyword:foo" (explicit keyword ban). The 2026-05-11 reddit wiring writes
|
|
70
|
+
# subreddit: rows; keyword: is kept as a future-proof typed-keyword path so
|
|
71
|
+
# reddit and twitter never collide on the same row even if they share a name.
|
|
72
|
+
TYPED_TERM_RE = re.compile(r"^(subreddit|keyword):[a-z0-9][a-z0-9_\-]{1,40}$")
|
|
73
|
+
|
|
74
|
+
# Per-platform allowed term kinds. Twitter stays bare-keyword-only (legacy
|
|
75
|
+
# behavior unchanged); reddit accepts subreddit: and keyword: typed forms only,
|
|
76
|
+
# so an accidentally-bare term ("anki") can never silently kill a core seed.
|
|
77
|
+
ALLOWED_KINDS = {
|
|
78
|
+
"twitter": {"bare"},
|
|
79
|
+
"reddit": {"subreddit", "keyword"},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_term(term):
|
|
84
|
+
"""Return (kind, value) for a normalized term.
|
|
85
|
+
|
|
86
|
+
- Bare "cricket" -> ("bare", "cricket") [twitter form]
|
|
87
|
+
- "subreddit:bestofredditorupdates" -> ("subreddit", "bestofredditorupdates")
|
|
88
|
+
- "keyword:powerpoint" -> ("keyword", "powerpoint")
|
|
89
|
+
Returns (None, None) for unrecognized shapes.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(term, str):
|
|
92
|
+
return None, None
|
|
93
|
+
t = term.strip().lower()
|
|
94
|
+
if ":" in t:
|
|
95
|
+
kind, _, val = t.partition(":")
|
|
96
|
+
kind = kind.strip()
|
|
97
|
+
val = val.strip()
|
|
98
|
+
if kind in ("subreddit", "keyword") and val:
|
|
99
|
+
return kind, val
|
|
100
|
+
return None, None
|
|
101
|
+
if TERM_RE.match(t):
|
|
102
|
+
return "bare", t
|
|
103
|
+
return None, None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _load_reserved_terms_for_project(project_name):
|
|
107
|
+
"""Tokens we MUST NEVER let Claude exclude. Source: project_search_topics (DB) for the project.
|
|
108
|
+
|
|
109
|
+
Topic entries can be Twitter-search-style strings with OR/parens/quotes;
|
|
110
|
+
we split them into bare lowercase tokens so a query string like
|
|
111
|
+
`"vipassana" OR "Goenka"` reserves both `vipassana` and `goenka`.
|
|
112
|
+
"""
|
|
113
|
+
reserved = set()
|
|
114
|
+
if not project_name:
|
|
115
|
+
return reserved
|
|
116
|
+
try:
|
|
117
|
+
topics = topics_for_project(project_name)
|
|
118
|
+
except Exception:
|
|
119
|
+
return reserved
|
|
120
|
+
for t in topics:
|
|
121
|
+
if not isinstance(t, str):
|
|
122
|
+
continue
|
|
123
|
+
for tok in re.split(r"[\s\(\)\"\'\|]+|\bOR\b|\bAND\b|\bNOT\b|min_faves:\d+|since:[\d\-]+|-filter:\w+", t):
|
|
124
|
+
tok = tok.strip().lower()
|
|
125
|
+
if tok and TERM_MIN_LEN <= len(tok) <= 32:
|
|
126
|
+
reserved.add(tok)
|
|
127
|
+
# Also reserve the project name itself (case-insensitive single token).
|
|
128
|
+
reserved.add(project_name.lower())
|
|
129
|
+
return reserved
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def normalize_term(term):
|
|
133
|
+
"""Return a normalized term, or None if invalid."""
|
|
134
|
+
if not isinstance(term, str):
|
|
135
|
+
return None
|
|
136
|
+
t = term.strip().lower().strip("\"'")
|
|
137
|
+
if len(t) < TERM_MIN_LEN:
|
|
138
|
+
return None
|
|
139
|
+
if TYPED_TERM_RE.match(t):
|
|
140
|
+
return t
|
|
141
|
+
if TERM_RE.match(t):
|
|
142
|
+
return t
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _kind_allowed_for_platform(kind, platform):
|
|
147
|
+
"""Gate which term kinds a given platform may write/read."""
|
|
148
|
+
if not kind:
|
|
149
|
+
return False
|
|
150
|
+
allowed = ALLOWED_KINDS.get(platform)
|
|
151
|
+
if not allowed:
|
|
152
|
+
return False
|
|
153
|
+
return kind in allowed
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def active_excludes(platform, project, min_batches=ACTIVATION_BATCH_FLOOR):
|
|
157
|
+
"""Return the list of currently-active exclude terms for (platform, project).
|
|
158
|
+
|
|
159
|
+
Only terms that have cleared the activation gate (>=ACTIVATION_BATCH_FLOOR
|
|
160
|
+
distinct proposing batches) are returned. Order: longest-first so when
|
|
161
|
+
the query drafter appends them, more-specific terms win lex-sort tooltips.
|
|
162
|
+
"""
|
|
163
|
+
resp = api_get(
|
|
164
|
+
"/api/v1/project-excludes",
|
|
165
|
+
query={"platform": platform, "project": project, "min_batches": min_batches},
|
|
166
|
+
)
|
|
167
|
+
data = resp.get("data") if isinstance(resp, dict) else None
|
|
168
|
+
if not data:
|
|
169
|
+
return []
|
|
170
|
+
return list(data.get("terms") or [])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def active_excludes_by_kind(platform, project):
|
|
174
|
+
"""Same as active_excludes() but split by kind for reddit callers."""
|
|
175
|
+
terms = active_excludes(platform, project)
|
|
176
|
+
out = {"subreddit": [], "keyword": [], "bare": []}
|
|
177
|
+
for t in terms:
|
|
178
|
+
kind, value = parse_term(t)
|
|
179
|
+
if kind in out and value:
|
|
180
|
+
out[kind].append(value)
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def propose(platform, project, term, candidate_id=None, batch_id=None, reason=None):
|
|
185
|
+
"""UPSERT a single proposed exclude via HTTP.
|
|
186
|
+
|
|
187
|
+
The reserved-keyword check runs LOCALLY (config.json is on the client).
|
|
188
|
+
Structural validation (regex, platform-kind allowed) runs on both sides;
|
|
189
|
+
the server is authoritative.
|
|
190
|
+
|
|
191
|
+
outcome keys:
|
|
192
|
+
ok (bool) success
|
|
193
|
+
term (str | None) normalized term (None if rejected by validation)
|
|
194
|
+
action (str) one of: 'inserted', 'bumped', 'duplicate_batch',
|
|
195
|
+
'rejected_invalid', 'rejected_reserved'
|
|
196
|
+
active (bool) whether the term is now ACTIVE (>=ACTIVATION_BATCH_FLOOR)
|
|
197
|
+
"""
|
|
198
|
+
norm = normalize_term(term)
|
|
199
|
+
if norm is None:
|
|
200
|
+
return {"ok": False, "term": None, "action": "rejected_invalid", "active": False}
|
|
201
|
+
|
|
202
|
+
kind, value = parse_term(norm)
|
|
203
|
+
if not _kind_allowed_for_platform(kind, platform):
|
|
204
|
+
return {"ok": False, "term": norm, "action": "rejected_invalid", "active": False}
|
|
205
|
+
|
|
206
|
+
if kind in ("bare", "keyword"):
|
|
207
|
+
reserved = _load_reserved_terms_for_project(project)
|
|
208
|
+
check_val = value if kind == "keyword" else norm
|
|
209
|
+
if check_val in reserved:
|
|
210
|
+
return {"ok": False, "term": norm, "action": "rejected_reserved", "active": False}
|
|
211
|
+
reserved_for_post = sorted(reserved)
|
|
212
|
+
else:
|
|
213
|
+
reserved_for_post = []
|
|
214
|
+
|
|
215
|
+
body = {
|
|
216
|
+
"platform": platform,
|
|
217
|
+
"project": project,
|
|
218
|
+
"term": norm,
|
|
219
|
+
"reserved_terms": reserved_for_post,
|
|
220
|
+
}
|
|
221
|
+
if candidate_id is not None:
|
|
222
|
+
body["candidate_id"] = int(candidate_id)
|
|
223
|
+
if batch_id:
|
|
224
|
+
body["batch_id"] = batch_id
|
|
225
|
+
if reason:
|
|
226
|
+
body["reason"] = reason[:500]
|
|
227
|
+
resp = api_post("/api/v1/project-excludes", body)
|
|
228
|
+
data = resp.get("data") if isinstance(resp, dict) else None
|
|
229
|
+
if not data:
|
|
230
|
+
return {"ok": False, "term": norm, "action": "rejected_invalid", "active": False}
|
|
231
|
+
return {
|
|
232
|
+
"ok": bool(data.get("ok")),
|
|
233
|
+
"term": data.get("term") or norm,
|
|
234
|
+
"action": data.get("action") or "unknown",
|
|
235
|
+
"active": bool(data.get("active")),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def mark_used(platform, project, terms):
|
|
240
|
+
"""Stamp last_used_at for each term we just appended to a query."""
|
|
241
|
+
if not terms:
|
|
242
|
+
return 0
|
|
243
|
+
resp = api_post(
|
|
244
|
+
"/api/v1/project-excludes/mark-used",
|
|
245
|
+
{"platform": platform, "project": project, "terms": list(terms)},
|
|
246
|
+
)
|
|
247
|
+
data = resp.get("data") if isinstance(resp, dict) else None
|
|
248
|
+
if not data:
|
|
249
|
+
return 0
|
|
250
|
+
return int(data.get("stamped") or 0)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def decay(days=DECAY_DAYS_DEFAULT, dry_run=False):
|
|
254
|
+
"""Prune terms with <3 distinct batches that haven't been used in `days`."""
|
|
255
|
+
resp = api_delete(
|
|
256
|
+
"/api/v1/project-excludes",
|
|
257
|
+
query={"days": days, "dry_run": "true" if dry_run else None},
|
|
258
|
+
)
|
|
259
|
+
data = resp.get("data") if isinstance(resp, dict) else None
|
|
260
|
+
if dry_run:
|
|
261
|
+
rows = (data or {}).get("rows") or []
|
|
262
|
+
return [
|
|
263
|
+
{
|
|
264
|
+
"platform": r.get("platform"),
|
|
265
|
+
"project": r.get("project"),
|
|
266
|
+
"term": r.get("term"),
|
|
267
|
+
"batches": r.get("batches"),
|
|
268
|
+
"last_used_at": r.get("last_used_at"),
|
|
269
|
+
}
|
|
270
|
+
for r in rows
|
|
271
|
+
]
|
|
272
|
+
return int((data or {}).get("pruned_count") or 0)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main():
|
|
276
|
+
parser = argparse.ArgumentParser()
|
|
277
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
278
|
+
|
|
279
|
+
a = sub.add_parser("active", help="List active excludes for a project")
|
|
280
|
+
a.add_argument("--platform", required=True)
|
|
281
|
+
a.add_argument("--project", required=True)
|
|
282
|
+
a.add_argument("--as-flags", action="store_true",
|
|
283
|
+
help="Print as space-joined `-term` flags instead of JSON list.")
|
|
284
|
+
|
|
285
|
+
asplit = sub.add_parser("active-split",
|
|
286
|
+
help="Active excludes split by kind {subreddit, keyword, bare}. Reddit-friendly.")
|
|
287
|
+
asplit.add_argument("--platform", required=True)
|
|
288
|
+
asplit.add_argument("--project", required=True)
|
|
289
|
+
|
|
290
|
+
p = sub.add_parser("propose", help="Propose a new exclude term")
|
|
291
|
+
p.add_argument("--platform", required=True)
|
|
292
|
+
p.add_argument("--project", required=True)
|
|
293
|
+
p.add_argument("--term", required=True)
|
|
294
|
+
p.add_argument("--candidate-id", type=int)
|
|
295
|
+
p.add_argument("--batch-id")
|
|
296
|
+
p.add_argument("--reason")
|
|
297
|
+
|
|
298
|
+
m = sub.add_parser("mark-used", help="Stamp last_used_at on terms appended to a live query")
|
|
299
|
+
m.add_argument("--platform", required=True)
|
|
300
|
+
m.add_argument("--project", required=True)
|
|
301
|
+
m.add_argument("--terms", nargs="+", required=True)
|
|
302
|
+
|
|
303
|
+
d = sub.add_parser("decay", help="Prune unused unverified terms")
|
|
304
|
+
d.add_argument("--days", type=int, default=DECAY_DAYS_DEFAULT)
|
|
305
|
+
d.add_argument("--dry-run", action="store_true")
|
|
306
|
+
|
|
307
|
+
args = parser.parse_args()
|
|
308
|
+
|
|
309
|
+
if args.cmd == "active":
|
|
310
|
+
terms = active_excludes(args.platform, args.project)
|
|
311
|
+
if args.as_flags:
|
|
312
|
+
sys.stdout.write(" ".join(f"-{t}" for t in terms))
|
|
313
|
+
sys.stdout.write("\n")
|
|
314
|
+
else:
|
|
315
|
+
json.dump(terms, sys.stdout)
|
|
316
|
+
sys.stdout.write("\n")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
if args.cmd == "active-split":
|
|
320
|
+
split = active_excludes_by_kind(args.platform, args.project)
|
|
321
|
+
json.dump(split, sys.stdout)
|
|
322
|
+
sys.stdout.write("\n")
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
if args.cmd == "propose":
|
|
326
|
+
out = propose(
|
|
327
|
+
args.platform, args.project, args.term,
|
|
328
|
+
candidate_id=args.candidate_id,
|
|
329
|
+
batch_id=args.batch_id,
|
|
330
|
+
reason=args.reason,
|
|
331
|
+
)
|
|
332
|
+
json.dump(out, sys.stdout)
|
|
333
|
+
sys.stdout.write("\n")
|
|
334
|
+
return 0 if out["ok"] else 2
|
|
335
|
+
|
|
336
|
+
if args.cmd == "mark-used":
|
|
337
|
+
n = mark_used(args.platform, args.project, args.terms)
|
|
338
|
+
print(f"mark_used: {n} rows stamped")
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
if args.cmd == "decay":
|
|
342
|
+
if args.dry_run:
|
|
343
|
+
rows = decay(days=args.days, dry_run=True)
|
|
344
|
+
json.dump(rows, sys.stdout, indent=2)
|
|
345
|
+
sys.stdout.write("\n")
|
|
346
|
+
else:
|
|
347
|
+
n = decay(days=args.days, dry_run=False)
|
|
348
|
+
print(f"decay: {n} rows pruned (older than {args.days}d, <3 batches)")
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if __name__ == "__main__":
|
|
353
|
+
sys.exit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single source of truth for project -> client_slug + booking_table.
|
|
3
|
+
|
|
4
|
+
Derives both from ~/social-autoposter/config.json so no list of projects is
|
|
5
|
+
maintained in parallel across project_stats.py, pick_top_page.py,
|
|
6
|
+
pick_top_pages.py, or the cal webhook routing.
|
|
7
|
+
|
|
8
|
+
client_slug rule:
|
|
9
|
+
project `name` lowercased with dashes/spaces stripped, unless the
|
|
10
|
+
project explicitly defines a `client_slug` field. Matches every entry
|
|
11
|
+
hard-coded historically (Cyrano->cyrano, PieLine->pieline, paperback-expert
|
|
12
|
+
->paperbackexpert, fde10x->fde10x, etc.).
|
|
13
|
+
|
|
14
|
+
booking_table rule:
|
|
15
|
+
cal.com/* -> cal_bookings
|
|
16
|
+
calendly.com/* -> calendly_bookings
|
|
17
|
+
anything else / unset -> None (project does not attribute bookings)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from functools import lru_cache
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _derive_slug(name: str) -> str:
|
|
31
|
+
return name.lower().replace("-", "").replace(" ", "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=1)
|
|
35
|
+
def _projects() -> list[dict]:
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(CONFIG_PATH.read_text()).get("projects", [])
|
|
38
|
+
except (OSError, ValueError):
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _find(project_name: str) -> Optional[dict]:
|
|
43
|
+
for p in _projects():
|
|
44
|
+
if p.get("name") == project_name:
|
|
45
|
+
return p
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_client_slug(project_name: str) -> Optional[str]:
|
|
50
|
+
"""Return the client_slug used in cal_bookings / calendly_bookings for
|
|
51
|
+
this project. Returns None if the project is not in config.json."""
|
|
52
|
+
p = _find(project_name)
|
|
53
|
+
if p is None:
|
|
54
|
+
return None
|
|
55
|
+
return p.get("client_slug") or _derive_slug(project_name)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_booking_table(project_name: str) -> Optional[str]:
|
|
59
|
+
"""Return 'cal_bookings', 'calendly_bookings', or None if the project has
|
|
60
|
+
no booking link configured."""
|
|
61
|
+
p = _find(project_name)
|
|
62
|
+
if p is None:
|
|
63
|
+
return None
|
|
64
|
+
link = (p.get("booking_link") or "").lower()
|
|
65
|
+
if "calendly.com" in link:
|
|
66
|
+
return "calendly_bookings"
|
|
67
|
+
if "cal.com" in link:
|
|
68
|
+
return "cal_bookings"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def bookings_require_utm(project_name: str) -> bool:
|
|
73
|
+
"""Whether to gate `real_bookings` on `utm_source IS NOT NULL`.
|
|
74
|
+
|
|
75
|
+
Default False: each cal.com booking page is product-specific, so any
|
|
76
|
+
non-test booking on it is by definition a prospect for that product, no
|
|
77
|
+
UTM needed. Set True for projects whose booking destination is a shared
|
|
78
|
+
surface (e.g. paperback-expert's calendly account hosts Michael DeLon's
|
|
79
|
+
whole business funnel, not just b00kd.com inbound)."""
|
|
80
|
+
p = _find(project_name)
|
|
81
|
+
if p is None:
|
|
82
|
+
return False
|
|
83
|
+
return bool(p.get("bookings_require_utm"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
# Smoke-test: print the derivation for every project in config.json.
|
|
88
|
+
for p in _projects():
|
|
89
|
+
name = p.get("name", "")
|
|
90
|
+
print(f"{name!r:<28} slug={get_client_slug(name)!r:<22} "
|
|
91
|
+
f"table={get_booking_table(name)!r}")
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified funnel stats per project: social posts -> pageviews -> CTA clicks -> bookings.
|
|
3
|
+
|
|
4
|
+
Reads config.json for project definitions, queries:
|
|
5
|
+
- Posts + bookings stats via s4l.ai HTTP /api/v1/stats/* (no direct DB)
|
|
6
|
+
- PostHog API (POSTHOG_PERSONAL_API_KEY): pageviews + CTA clicks by domain
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/project_stats.py [--project NAME] [--days 30] [--quiet]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
21
|
+
from http_api import api_get # noqa: E402
|
|
22
|
+
from project_slugs import get_client_slug, get_booking_table # noqa: E402
|
|
23
|
+
|
|
24
|
+
ENV_PATH = os.path.expanduser("~/social-autoposter/.env")
|
|
25
|
+
CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_env():
|
|
29
|
+
if os.path.exists(ENV_PATH):
|
|
30
|
+
with open(ENV_PATH) as f:
|
|
31
|
+
for line in f:
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if line and not line.startswith("#") and "=" in line:
|
|
34
|
+
k, v = line.split("=", 1)
|
|
35
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_config():
|
|
39
|
+
with open(CONFIG_PATH) as f:
|
|
40
|
+
return json.load(f)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def posthog_query(api_key, project_id, event, host_filter, after_date):
|
|
44
|
+
"""Query PostHog events API for events matching a host."""
|
|
45
|
+
url = f"https://us.posthog.com/api/projects/{project_id}/events/"
|
|
46
|
+
params = {
|
|
47
|
+
"event": event,
|
|
48
|
+
"limit": 1000,
|
|
49
|
+
"after": after_date,
|
|
50
|
+
}
|
|
51
|
+
if host_filter:
|
|
52
|
+
params["properties"] = json.dumps([
|
|
53
|
+
{"key": "$host", "value": host_filter, "type": "event"}
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
query = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in params.items())
|
|
57
|
+
full_url = f"{url}?{query}"
|
|
58
|
+
|
|
59
|
+
req = urllib.request.Request(full_url, headers={
|
|
60
|
+
"Authorization": f"Bearer {api_key}",
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
65
|
+
data = json.loads(resp.read())
|
|
66
|
+
return data.get("results", [])
|
|
67
|
+
except (urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
68
|
+
print(f" PostHog API error for {event} on {host_filter}: {e}", file=sys.stderr)
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_posthog_stats(api_key, project_id, domains, days):
|
|
73
|
+
"""Get pageviews and CTA clicks from PostHog for given domains."""
|
|
74
|
+
after = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
75
|
+
stats = {"pageviews": 0, "cta_clicks": 0, "pageview_details": {}, "cta_details": []}
|
|
76
|
+
|
|
77
|
+
for domain in domains:
|
|
78
|
+
pvs = posthog_query(api_key, project_id, "$pageview", domain, after)
|
|
79
|
+
stats["pageviews"] += len(pvs)
|
|
80
|
+
paths = {}
|
|
81
|
+
for ev in pvs:
|
|
82
|
+
path = ev.get("properties", {}).get("$pathname", "/")
|
|
83
|
+
paths[path] = paths.get(path, 0) + 1
|
|
84
|
+
stats["pageview_details"][domain] = {
|
|
85
|
+
"total": len(pvs),
|
|
86
|
+
"top_pages": dict(sorted(paths.items(), key=lambda x: -x[1])[:10]),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ctas = posthog_query(api_key, project_id, "cta_click", domain, after)
|
|
90
|
+
if not ctas:
|
|
91
|
+
ctas = posthog_query(api_key, project_id, "$autocapture", domain, after)
|
|
92
|
+
ctas = [e for e in ctas if "book" in (e.get("properties", {}).get("$el_text", "") or "").lower()]
|
|
93
|
+
stats["cta_clicks"] += len(ctas)
|
|
94
|
+
for c in ctas:
|
|
95
|
+
props = c.get("properties", {})
|
|
96
|
+
stats["cta_details"].append({
|
|
97
|
+
"text": props.get("$el_text") or props.get("text", "?"),
|
|
98
|
+
"section": props.get("section", "?"),
|
|
99
|
+
"time": c.get("timestamp", "?")[:16],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
return stats
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_project_domains(project):
|
|
106
|
+
"""Extract all domains associated with a project."""
|
|
107
|
+
domains = []
|
|
108
|
+
website = project.get("website", "")
|
|
109
|
+
if website:
|
|
110
|
+
domain = website.replace("https://", "").replace("http://", "").rstrip("/")
|
|
111
|
+
domains.append(domain)
|
|
112
|
+
|
|
113
|
+
lp = project.get("landing_pages")
|
|
114
|
+
if isinstance(lp, dict):
|
|
115
|
+
base = lp.get("base_url", "")
|
|
116
|
+
if base:
|
|
117
|
+
domain = base.replace("https://", "").replace("http://", "").rstrip("/")
|
|
118
|
+
if domain not in domains:
|
|
119
|
+
domains.append(domain)
|
|
120
|
+
elif isinstance(lp, str) and lp.startswith("http"):
|
|
121
|
+
domain = lp.replace("https://", "").replace("http://", "").rstrip("/")
|
|
122
|
+
if domain not in domains:
|
|
123
|
+
domains.append(domain)
|
|
124
|
+
|
|
125
|
+
return domains
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def print_project_report(name, post_stats, platforms, posthog, bookings, quiet=False):
|
|
129
|
+
"""Print formatted report for one project."""
|
|
130
|
+
print(f"\n{'='*60}")
|
|
131
|
+
print(f" {name}")
|
|
132
|
+
print(f"{'='*60}")
|
|
133
|
+
|
|
134
|
+
print(f"\n Social Posts:")
|
|
135
|
+
print(f" Total: {post_stats.get('total', 0)} | Recent: {post_stats.get('recent', 0)} | Active: {post_stats.get('active', 0)} | Removed: {post_stats.get('removed', 0)}")
|
|
136
|
+
print(f" Engagement: {post_stats.get('total_upvotes', 0)} upvotes, {post_stats.get('total_comments', 0)} comments, {post_stats.get('total_views', 0)} views")
|
|
137
|
+
if platforms:
|
|
138
|
+
parts = [f"{p}: {c}" for p, c in platforms.items()]
|
|
139
|
+
print(f" Platforms: {', '.join(parts)}")
|
|
140
|
+
|
|
141
|
+
if posthog and (posthog["pageviews"] > 0 or posthog["cta_clicks"] > 0):
|
|
142
|
+
print(f"\n Website Analytics (PostHog):")
|
|
143
|
+
print(f" Pageviews: {posthog['pageviews']} | CTA Clicks: {posthog['cta_clicks']}")
|
|
144
|
+
if not quiet:
|
|
145
|
+
for domain, info in posthog.get("pageview_details", {}).items():
|
|
146
|
+
print(f" {domain}: {info['total']} pageviews")
|
|
147
|
+
for path, count in list(info.get("top_pages", {}).items())[:5]:
|
|
148
|
+
print(f" {path}: {count}")
|
|
149
|
+
if posthog["cta_details"]:
|
|
150
|
+
print(f" CTA clicks:")
|
|
151
|
+
for cta in posthog["cta_details"][:5]:
|
|
152
|
+
print(f" [{cta['time']}] \"{cta['text']}\" ({cta['section']})")
|
|
153
|
+
|
|
154
|
+
if bookings:
|
|
155
|
+
print(f"\n Cal.com Bookings:")
|
|
156
|
+
print(f" Total: {bookings.get('total', 0)} | Booked: {bookings.get('booked', 0)} | Cancelled: {bookings.get('cancelled', 0)} | Real: {bookings.get('real_bookings', 0)}")
|
|
157
|
+
if not quiet and bookings.get("recent"):
|
|
158
|
+
for b in bookings["recent"][:3]:
|
|
159
|
+
flag = " [TEST]" if "test" in (b["name"] or "").lower() or "example" in (b["email"] or "").lower() else ""
|
|
160
|
+
print(f" {b['created']} - {b['name']} ({b['email']}) - {b['status']}{flag}")
|
|
161
|
+
|
|
162
|
+
if posthog and bookings:
|
|
163
|
+
pvs = posthog["pageviews"]
|
|
164
|
+
ctas = posthog["cta_clicks"]
|
|
165
|
+
real = bookings.get("real_bookings", 0)
|
|
166
|
+
print(f"\n Funnel:")
|
|
167
|
+
if pvs:
|
|
168
|
+
print(f" Pageviews -> CTA Clicks: {pvs} -> {ctas} ({(ctas/pvs*100):.1f}% CTR)")
|
|
169
|
+
else:
|
|
170
|
+
print(f" Pageviews -> CTA Clicks: 0 -> {ctas}")
|
|
171
|
+
if ctas:
|
|
172
|
+
print(f" CTA Clicks -> Bookings: {ctas} -> {real} ({(real/ctas*100):.1f}% conversion)")
|
|
173
|
+
else:
|
|
174
|
+
print(f" CTA Clicks -> Bookings: 0 -> {real}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main():
|
|
178
|
+
parser = argparse.ArgumentParser(description="Unified project funnel stats")
|
|
179
|
+
parser.add_argument("--project", help="Filter to specific project name")
|
|
180
|
+
parser.add_argument("--days", type=int, default=30, help="Lookback period in days (default: 30)")
|
|
181
|
+
parser.add_argument("--quiet", action="store_true", help="Compact output")
|
|
182
|
+
args = parser.parse_args()
|
|
183
|
+
|
|
184
|
+
load_env()
|
|
185
|
+
config = load_config()
|
|
186
|
+
|
|
187
|
+
api_key = os.environ.get("POSTHOG_PERSONAL_API_KEY")
|
|
188
|
+
project_id = os.environ.get("POSTHOG_PROJECT_ID", "330744")
|
|
189
|
+
|
|
190
|
+
if not api_key:
|
|
191
|
+
print("ERROR: POSTHOG_PERSONAL_API_KEY not set in .env", file=sys.stderr)
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
|
|
194
|
+
projects_with_stats = [
|
|
195
|
+
"fazm", "Cyrano", "PieLine", "Terminator", "S4L",
|
|
196
|
+
"macOS MCP", "Vipassana", "WhatsApp MCP", "AI Browser Profile", "macOS Session Replay",
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
print(f"Project Funnel Stats (last {args.days} days)")
|
|
200
|
+
print(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
|
201
|
+
|
|
202
|
+
for proj in config.get("projects", []):
|
|
203
|
+
name = proj["name"]
|
|
204
|
+
if args.project and args.project.lower() != name.lower():
|
|
205
|
+
continue
|
|
206
|
+
if name not in projects_with_stats and not args.project:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
client_slug = get_client_slug(name)
|
|
210
|
+
booking_table = get_booking_table(name)
|
|
211
|
+
detail = (api_get("/api/v1/stats/project-detail", query={
|
|
212
|
+
"project": name, "days": int(args.days), "platform": "",
|
|
213
|
+
"client_slug": client_slug or "",
|
|
214
|
+
"booking_table": booking_table or "cal_bookings",
|
|
215
|
+
"require_utm": "0",
|
|
216
|
+
}).get("data") or {})
|
|
217
|
+
post_stats = detail.get("post_stats") or {}
|
|
218
|
+
platforms = detail.get("platforms") or {}
|
|
219
|
+
bookings = detail.get("bookings") if client_slug else None
|
|
220
|
+
|
|
221
|
+
domains = get_project_domains(proj)
|
|
222
|
+
ph_override = proj.get("posthog", {})
|
|
223
|
+
ph_key = os.environ.get(ph_override.get("api_key_env", ""), api_key)
|
|
224
|
+
ph_pid = ph_override.get("project_id", project_id)
|
|
225
|
+
posthog = get_posthog_stats(ph_key, ph_pid, domains, args.days) if domains else None
|
|
226
|
+
|
|
227
|
+
print_project_report(name, post_stats, platforms, posthog, bookings, args.quiet)
|
|
228
|
+
|
|
229
|
+
# Overall summary
|
|
230
|
+
overall = (api_get("/api/v1/stats/posts-overall", query={
|
|
231
|
+
"days": int(args.days), "platform": "",
|
|
232
|
+
}).get("data") or {})
|
|
233
|
+
total_all = int(overall.get("total") or 0)
|
|
234
|
+
total_recent = int(overall.get("recent") or 0)
|
|
235
|
+
print(f"\n{'='*60}")
|
|
236
|
+
print(f" Overall: {total_all} total posts, {total_recent} in last {args.days} days")
|
|
237
|
+
print(f"{'='*60}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
main()
|