@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.
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 +1314 -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 +497 -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,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()