@m13v/s4l 1.6.197-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ twitter_gen_links.py — Phase 2b-gen helper for run-twitter-cycle.sh.
4
+
5
+ Reads a candidate plan JSON file produced by Phase 2b-prep, generates the
6
+ matching landing-page (or falls back to the plain project URL) for each
7
+ candidate, and writes the file back with a `link_url` field per candidate.
8
+
9
+ The browser lock is NOT held while this runs. generate_page.py is pure HTTP +
10
+ git + Cloud-Run-deploy work, no twitter-harness browser use, so other twitter
11
+ pipelines can use the browser during the 10-40 minute landing-page build.
12
+
13
+ Plan file shape (in/out):
14
+ {
15
+ "candidates": [
16
+ {
17
+ "candidate_id": int,
18
+ "candidate_url": str,
19
+ "thread_author": str,
20
+ "thread_text": str,
21
+ "matched_project": str,
22
+ "reply_text": str,
23
+ "engagement_style": str,
24
+ "language": str,
25
+ "has_landing_pages": bool,
26
+ "link_keyword": str, # only when has_landing_pages=true
27
+ "link_slug": str, # only when has_landing_pages=true
28
+ ...
29
+ # Written by THIS script:
30
+ "link_url": str, # final URL to embed in the reply (may be "")
31
+ "link_source": str, # seo_page | plain_url_fallback | plain_url_no_lp |
32
+ # plain_url_timeout_fallback | empty
33
+ },
34
+ ...
35
+ ]
36
+ }
37
+
38
+ Usage:
39
+ python3 twitter_gen_links.py --plan /tmp/twitter_cycle_plan_<batch>.json
40
+
41
+ Exits 0 on best-effort completion (each candidate gets a link_url, even if
42
+ generation failed; the fallback chain protects the cycle from blocking on
43
+ SEO infra issues). Exits non-zero only when the plan file itself is unreadable
44
+ or empty.
45
+ """
46
+
47
+ import argparse
48
+ import json
49
+ import os
50
+ import random
51
+ import subprocess
52
+ import sys
53
+ from pathlib import Path
54
+
55
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
56
+ import audience_pages as audience_pages_mod # noqa: E402
57
+
58
+ REPO_DIR = os.path.expanduser("~/social-autoposter")
59
+ GENERATE_PAGE = os.path.join(REPO_DIR, "seo", "generate_page.py")
60
+ CONFIG_PATH = os.path.join(REPO_DIR, "config.json")
61
+ GEN_TIMEOUT_SEC = 3600 # 60 min per page; observed legit runs take 45-50 min
62
+ # (pre-Claude inventory + decision + improve/new pipeline +
63
+ # deploy verify). Don't lower without re-measuring.
64
+ MAX_AB_HITS_PER_CYCLE = 2 # cap cumulative gen budget at ~2 x 60 min worst case
65
+ # so cycle has room under the 180-min watchdog cap.
66
+
67
+ # A/B gate: per-candidate coin flip for the page-gen lane. 0.25 means 25% of
68
+ # eligible candidates (project has landing_pages config + LLM provided
69
+ # keyword/slug) actually trigger generate_page.py; the rest fall through to
70
+ # the plain project URL with link_source='plain_url_ab_skip'. Tunable via
71
+ # env var so cadence can be swept without a code change. 0.0 disables
72
+ # page-gen entirely; 1.0 restores the pre-A/B behaviour.
73
+ def _page_gen_rate() -> float:
74
+ # Bumped from 0.25 -> 0.30 on 2026-05-08 after CTA pipeline review:
75
+ # /t/ pages convert better than /r/ short-link-only fallbacks (Reddit data
76
+ # showed 17-71% click->signup vs 0% on plain_url_ab_skip). Bumping the
77
+ # default rate gives Twitter a higher share of full landing pages while
78
+ # still leaving 70% on the cheap path for budget reasons. See chat note
79
+ # 2026-05-07 "link suffix pipeline rewrite".
80
+ raw = os.environ.get("TWITTER_PAGE_GEN_RATE", "0.0")
81
+ try:
82
+ v = float(raw)
83
+ except ValueError:
84
+ return 0.30
85
+ if v < 0.0:
86
+ return 0.0
87
+ if v > 1.0:
88
+ return 1.0
89
+ return v
90
+
91
+
92
+ def load_projects() -> dict:
93
+ """Map name -> project dict."""
94
+ with open(CONFIG_PATH) as f:
95
+ cfg = json.load(f)
96
+ return {p["name"]: p for p in cfg.get("projects", [])}
97
+
98
+
99
+ def run_generate(product: str, keyword: str, slug: str) -> tuple[str, str]:
100
+ """Run generate_page.py for a single candidate.
101
+
102
+ Returns (page_url, source_tag). On success: (real_url, "seo_page"). On
103
+ any failure: ("", "<reason>") so the caller can fall back to the plain URL.
104
+ """
105
+ cmd = [
106
+ "python3",
107
+ GENERATE_PAGE,
108
+ "--product", product,
109
+ "--keyword", keyword,
110
+ "--slug", slug,
111
+ "--trigger", "twitter",
112
+ ]
113
+ print(f"[gen] product={product} keyword={keyword!r} slug={slug!r}", flush=True)
114
+ try:
115
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=GEN_TIMEOUT_SEC)
116
+ except subprocess.TimeoutExpired:
117
+ print(f"[gen] TIMEOUT after {GEN_TIMEOUT_SEC}s", flush=True)
118
+ return ("", "timeout")
119
+ print(f"[gen] exit={r.returncode}", flush=True)
120
+ if r.stderr:
121
+ # Trail-truncate so we don't blow out the cycle log on a verbose failure.
122
+ print("[gen][stderr-tail]", r.stderr[-2000:], flush=True)
123
+ # generate_page.py prints its final result via json.dumps(result, indent=2),
124
+ # so the success object is a pretty-printed multi-line block. Scan stdout
125
+ # for every top-level JSON object via JSONDecoder.raw_decode and keep the
126
+ # last dict we can parse: that's the final result line regardless of
127
+ # whether it was emitted as one line or many.
128
+ page_url = ""
129
+ last_obj = None
130
+ decoder = json.JSONDecoder()
131
+ text = r.stdout
132
+ i = text.find("{")
133
+ while i != -1:
134
+ try:
135
+ obj, end = decoder.raw_decode(text, i)
136
+ except json.JSONDecodeError:
137
+ i = text.find("{", i + 1)
138
+ continue
139
+ if isinstance(obj, dict):
140
+ last_obj = obj
141
+ i = text.find("{", end)
142
+ if last_obj and last_obj.get("success") and last_obj.get("page_url"):
143
+ page_url = last_obj["page_url"]
144
+ if not page_url:
145
+ print("[gen] no page_url in stdout; tail=", flush=True)
146
+ print(r.stdout[-2000:], flush=True)
147
+ return ("", "no_page_url")
148
+ return (page_url, "seo_page")
149
+
150
+
151
+ def resolve_link(candidate: dict, projects: dict, page_gen_rate: float) -> tuple[str, str]:
152
+ """Decide the link URL for a single candidate.
153
+
154
+ Order of preference:
155
+ 1. CURATED AUDIENCE PAGE (landing_pages.audience_pages) — wins outright
156
+ when the candidate's link_keyword / search_topic / reply_text matches
157
+ any entry's match_keywords. Skips the A/B gate entirely; curated pages
158
+ are higher-quality than auto-generated /t/<slug> pages.
159
+ 2. SEO page (when has_landing_pages AND dice lands in gen lane)
160
+ 3. plain project URL
161
+ 4. ""
162
+
163
+ The per-candidate dice roll (random.random() < page_gen_rate) only fires
164
+ for projects that actually support landing pages and where the LLM
165
+ supplied a keyword + slug. Eligible-but-lost candidates surface as
166
+ link_source='plain_url_ab_skip' so post-hoc engagement analysis can
167
+ compare the two lanes apples-to-apples.
168
+
169
+ Audience-page hits surface as link_source='audience_page:<angle>' so the
170
+ dashboard and stats can break out curated-page traffic separately.
171
+ """
172
+ proj_name = candidate.get("matched_project") or ""
173
+ proj = projects.get(proj_name) or {}
174
+ # Personal-brand (persona) lane is link-free by definition. Self-promotion
175
+ # mode is pure organic engagement: no company, no signup, no profile URL. Any
176
+ # `website`/`url` a persona project happens to carry (some installs got the
177
+ # user's own X profile written there) must NEVER become a tail link. Enforce
178
+ # it here at the single source so no downstream surface (review card, manual
179
+ # post_drafts) has to strip a link that should never have been generated.
180
+ if proj.get("persona"):
181
+ return ("", "persona_no_link")
182
+ plain_url = proj.get("website") or proj.get("url") or ""
183
+ has_lp = bool(candidate.get("has_landing_pages"))
184
+ keyword = (candidate.get("link_keyword") or "").strip()
185
+ slug = (candidate.get("link_slug") or "").strip()
186
+
187
+ # (1) Curated audience-page short-circuit. Runs BEFORE the A/B gate so a
188
+ # well-targeted curated page always beats a freshly-spun SEO /t/<slug>.
189
+ # Signals checked: link_keyword (LLM nomination), search_topic (the topic
190
+ # bucket the candidate was discovered under), reply_text (the actual draft),
191
+ # and thread_title (raw thread title from Twitter). First match wins per
192
+ # the audience_pages list order in config.json.
193
+ audience_hit = audience_pages_mod.match_by_keyword(
194
+ proj_name,
195
+ keyword=keyword,
196
+ topic=candidate.get("search_topic"),
197
+ reply_text=candidate.get("reply_text"),
198
+ thread_title=candidate.get("thread_title") or candidate.get("thread_text"),
199
+ )
200
+ if audience_hit:
201
+ angle = audience_hit.get("angle") or "unknown"
202
+ url = audience_hit.get("url") or ""
203
+ if url:
204
+ print(f"[gen] audience_page hit: angle={angle} url={url} "
205
+ f"(skipping A/B page-gen)", flush=True)
206
+ return (url, f"audience_page:{angle}")
207
+
208
+ if proj.get("page_gen_disabled"):
209
+ print(f"[gen] page_gen_disabled=true for {proj_name}; using plain URL", flush=True)
210
+ return (plain_url, "plain_url_no_lp")
211
+
212
+ if has_lp and keyword and slug and proj.get("landing_pages"):
213
+ roll = random.random()
214
+ if roll >= page_gen_rate:
215
+ print(f"[gen] AB skip: roll={roll:.3f} >= rate={page_gen_rate:.3f}; "
216
+ f"using plain URL", flush=True)
217
+ if plain_url:
218
+ return (plain_url, "plain_url_ab_skip")
219
+ return ("", "empty_ab_skip")
220
+ print(f"[gen] AB hit: roll={roll:.3f} < rate={page_gen_rate:.3f}; "
221
+ f"running generate_page.py", flush=True)
222
+ page_url, source = run_generate(proj_name, keyword, slug)
223
+ if page_url:
224
+ return (page_url, "seo_page")
225
+ # Fell through; fall back to plain project URL.
226
+ if plain_url:
227
+ return (plain_url, f"plain_url_fallback:{source}")
228
+ return ("", f"empty:{source}")
229
+ # No landing-pages config or LLM didn't supply keyword/slug.
230
+ if plain_url:
231
+ return (plain_url, "plain_url_no_lp")
232
+ return ("", "empty")
233
+
234
+
235
+ def main() -> int:
236
+ ap = argparse.ArgumentParser()
237
+ ap.add_argument("--plan", required=True,
238
+ help="Path to the plan JSON file (read+rewrite in place)")
239
+ args = ap.parse_args()
240
+
241
+ plan_path = Path(args.plan)
242
+ if not plan_path.exists():
243
+ print(f"[gen] plan file not found: {plan_path}", file=sys.stderr)
244
+ return 2
245
+ try:
246
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
247
+ except Exception as e:
248
+ print(f"[gen] plan file unreadable: {e}", file=sys.stderr)
249
+ return 2
250
+
251
+ candidates = plan.get("candidates") or []
252
+ if not candidates:
253
+ print("[gen] plan has 0 candidates; nothing to do", flush=True)
254
+ return 0
255
+
256
+ projects = load_projects()
257
+ page_gen_rate = _page_gen_rate()
258
+ print(f"[gen] page_gen_rate={page_gen_rate:.3f} "
259
+ f"(env TWITTER_PAGE_GEN_RATE)", flush=True)
260
+ print(f"[gen] max_ab_hits_per_cycle={MAX_AB_HITS_PER_CYCLE} "
261
+ f"timeout_per_call_sec={GEN_TIMEOUT_SEC}", flush=True)
262
+
263
+ ab_hits = 0
264
+ for c in candidates:
265
+ cap_reached = ab_hits >= MAX_AB_HITS_PER_CYCLE
266
+ if cap_reached:
267
+ print(f"[gen] AB cap reached ({ab_hits}/"
268
+ f"{MAX_AB_HITS_PER_CYCLE}); forcing plain URL", flush=True)
269
+ link_url, source = resolve_link(c, projects,
270
+ 0.0 if cap_reached else page_gen_rate)
271
+ if source == "seo_page":
272
+ ab_hits += 1
273
+ elif cap_reached and source == "plain_url_ab_skip":
274
+ source = "plain_url_ab_cap"
275
+ c["link_url"] = link_url
276
+ c["link_source"] = source
277
+ print(f"[gen] candidate_id={c.get('candidate_id')} "
278
+ f"link_url={link_url!r} source={source}", flush=True)
279
+
280
+ plan_path.write_text(json.dumps(plan, indent=2), encoding="utf-8")
281
+ print(f"[gen] plan rewritten with link_url for {len(candidates)} candidates",
282
+ flush=True)
283
+ return 0
284
+
285
+
286
+ if __name__ == "__main__":
287
+ sys.exit(main())