@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,312 @@
1
+ #!/usr/bin/env python3
2
+ """Persist prospect profile data to the prospects table.
3
+
4
+ Subcommands:
5
+ upsert - Insert or update a prospect row and return the prospect_id.
6
+ get - Print a prospect row as JSON (for use from shell/Claude prompts).
7
+ link - Link an existing dms row to a prospect by platform+author.
8
+
9
+ The scraping itself is driven by Claude via the per-platform MCP browser
10
+ agents (reddit-agent, twitter-harness, linkedin-agent). This script only
11
+ handles DB persistence: Claude collects the fields and passes them in.
12
+
13
+ Usage:
14
+ python3 fetch_prospect_profile.py upsert \\
15
+ --platform linkedin --author "Karl Treen" \\
16
+ --profile-url https://linkedin.com/in/karltreen \\
17
+ --headline "CEO at Foo" --bio "..." --company Foo --role CEO
18
+
19
+ python3 fetch_prospect_profile.py get --platform linkedin --author "Karl Treen"
20
+
21
+ python3 fetch_prospect_profile.py link --dm-id 510
22
+ """
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+
28
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
+ import db as dbmod
30
+
31
+ # Columns on the prospects table that callers may set (besides platform/author).
32
+ UPDATABLE_COLS = [
33
+ "profile_url",
34
+ "display_name",
35
+ "headline",
36
+ "bio",
37
+ "follower_count",
38
+ "recent_activity",
39
+ "company",
40
+ "role",
41
+ "notes",
42
+ ]
43
+
44
+
45
+ def upsert_prospect(conn, platform, author, fields):
46
+ """Insert (platform, author) if missing, then update any provided fields.
47
+
48
+ Always stamps profile_fetched_at=NOW() when any field is provided.
49
+ Returns the prospect_id.
50
+ """
51
+ # Ensure row exists.
52
+ conn.execute(
53
+ """
54
+ INSERT INTO prospects (platform, author)
55
+ VALUES (%s, %s)
56
+ ON CONFLICT ON CONSTRAINT prospects_platform_author_unique DO NOTHING
57
+ """,
58
+ (platform, author),
59
+ )
60
+
61
+ # Fetch id.
62
+ cur = conn.execute(
63
+ "SELECT id FROM prospects WHERE platform=%s AND author=%s",
64
+ (platform, author),
65
+ )
66
+ row = cur.fetchone()
67
+ if not row:
68
+ conn.commit()
69
+ cur = conn.execute(
70
+ "SELECT id FROM prospects WHERE platform=%s AND author=%s",
71
+ (platform, author),
72
+ )
73
+ row = cur.fetchone()
74
+ prospect_id = row["id"]
75
+
76
+ # Apply any non-null, non-empty field updates.
77
+ sets = []
78
+ params = []
79
+ for col in UPDATABLE_COLS:
80
+ val = fields.get(col)
81
+ if val is None:
82
+ continue
83
+ if isinstance(val, str) and val.strip() == "":
84
+ continue
85
+ sets.append(f"{col} = %s")
86
+ params.append(val)
87
+
88
+ if sets:
89
+ sets.append("profile_fetched_at = NOW()")
90
+ sql = f"UPDATE prospects SET {', '.join(sets)} WHERE id = %s"
91
+ params.append(prospect_id)
92
+ conn.execute(sql, params)
93
+
94
+ conn.commit()
95
+ return prospect_id
96
+
97
+
98
+ def get_prospect(conn, platform, author):
99
+ cur = conn.execute(
100
+ """
101
+ SELECT id, platform, author, profile_url, display_name, headline, bio,
102
+ follower_count, recent_activity, company, role,
103
+ profile_fetched_at, notes, created_at
104
+ FROM prospects WHERE platform=%s AND author=%s
105
+ """,
106
+ (platform, author),
107
+ )
108
+ row = cur.fetchone()
109
+ if not row:
110
+ return None
111
+ d = dict(row)
112
+ for k, v in d.items():
113
+ if hasattr(v, "isoformat"):
114
+ d[k] = v.isoformat()
115
+ return d
116
+
117
+
118
+ def link_dm(conn, dm_id):
119
+ """Link dms.prospect_id to the matching prospect row by (platform, their_author)."""
120
+ cur = conn.execute(
121
+ "SELECT platform, their_author FROM dms WHERE id=%s",
122
+ (dm_id,),
123
+ )
124
+ row = cur.fetchone()
125
+ if not row:
126
+ print(f"ERROR: DM #{dm_id} not found", file=sys.stderr)
127
+ return None
128
+ platform = row["platform"]
129
+ author = row["their_author"]
130
+
131
+ cur = conn.execute(
132
+ "SELECT id FROM prospects WHERE platform=%s AND author=%s",
133
+ (platform, author),
134
+ )
135
+ prow = cur.fetchone()
136
+ if not prow:
137
+ print(
138
+ f"ERROR: no prospect row for {platform}:{author}; run `upsert` first",
139
+ file=sys.stderr,
140
+ )
141
+ return None
142
+
143
+ prospect_id = prow["id"]
144
+ conn.execute(
145
+ "UPDATE dms SET prospect_id=%s WHERE id=%s", (prospect_id, dm_id)
146
+ )
147
+ conn.commit()
148
+ return prospect_id
149
+
150
+
151
+ def _http_upsert(args):
152
+ """DB-free upsert via POST /api/v1/prospects. Returns prospect_id (int).
153
+
154
+ Mirrors upsert_prospect(): we always create-or-update the (platform,author)
155
+ row and stamp profile_fetched_at only when at least one field was supplied.
156
+ """
157
+ from http_api import api_post
158
+
159
+ field_map = {
160
+ "profile_url": args.profile_url,
161
+ "display_name": args.display_name,
162
+ "headline": args.headline,
163
+ "bio": args.bio,
164
+ "follower_count": args.follower_count,
165
+ "recent_activity": args.recent_activity,
166
+ "company": args.company,
167
+ "role": args.role,
168
+ "notes": args.notes,
169
+ }
170
+ body = {"platform": args.platform, "author": args.author}
171
+ has_field = False
172
+ for k, v in field_map.items():
173
+ if v is None:
174
+ continue
175
+ if isinstance(v, str) and v.strip() == "":
176
+ continue
177
+ body[k] = v
178
+ has_field = True
179
+ # Match the DB path: profile_fetched_at is stamped only when a field was set.
180
+ if has_field:
181
+ body["profile_fetched_at_now"] = True
182
+
183
+ resp = api_post("/api/v1/prospects", body)
184
+ prospect = (resp.get("data") or {}).get("prospect") or {}
185
+ return prospect.get("id")
186
+
187
+
188
+ def _http_get(args):
189
+ """DB-free get via GET /api/v1/prospects?platform&author. Returns dict|None."""
190
+ from http_api import api_get
191
+
192
+ resp = api_get(
193
+ "/api/v1/prospects",
194
+ query={"platform": args.platform, "author": args.author},
195
+ ok_on_404=True,
196
+ )
197
+ if not resp.get("ok"):
198
+ return None
199
+ return (resp.get("data") or {}).get("prospect")
200
+
201
+
202
+ def _http_link_dm(dm_id):
203
+ """DB-free link via GET dms -> GET prospect -> PATCH dms.prospect_id.
204
+
205
+ Returns prospect_id (int) or None, printing the same stderr the DB path does.
206
+ """
207
+ from http_api import api_get, api_patch
208
+
209
+ dm_resp = api_get(f"/api/v1/dms/{dm_id}", ok_on_404=True)
210
+ if not dm_resp.get("ok"):
211
+ print(f"ERROR: DM #{dm_id} not found", file=sys.stderr)
212
+ return None
213
+ dm = (dm_resp.get("data") or {}).get("dm") or {}
214
+ platform = dm.get("platform")
215
+ author = dm.get("their_author")
216
+
217
+ p_resp = api_get(
218
+ "/api/v1/prospects",
219
+ query={"platform": platform, "author": author},
220
+ ok_on_404=True,
221
+ )
222
+ prospect = (p_resp.get("data") or {}).get("prospect") if p_resp.get("ok") else None
223
+ if not prospect:
224
+ print(
225
+ f"ERROR: no prospect row for {platform}:{author}; run `upsert` first",
226
+ file=sys.stderr,
227
+ )
228
+ return None
229
+
230
+ prospect_id = prospect.get("id")
231
+ api_patch(f"/api/v1/dms/{dm_id}", {"prospect_id": prospect_id})
232
+ return prospect_id
233
+
234
+
235
+ def _http_dispatch(args):
236
+ """Handle a subcommand over HTTP when DATABASE_URL is absent.
237
+
238
+ Prints identical stdout to the DB path. Exits the process on the same
239
+ conditions (get-miss -> exit 1; link-failure -> exit 1).
240
+ """
241
+ from http_api import api_patch
242
+
243
+ if args.cmd == "upsert":
244
+ pid = _http_upsert(args)
245
+ if args.link_dm is not None:
246
+ api_patch(f"/api/v1/dms/{args.link_dm}", {"prospect_id": pid})
247
+ if args.json:
248
+ out = _http_get(args) or {"id": pid}
249
+ print(json.dumps(out))
250
+ else:
251
+ print(f"prospect_id={pid}")
252
+ return
253
+ if args.cmd == "get":
254
+ row = _http_get(args)
255
+ if row is None:
256
+ print("null")
257
+ sys.exit(1)
258
+ print(json.dumps(row, indent=2))
259
+ return
260
+ if args.cmd == "link":
261
+ pid = _http_link_dm(args.dm_id)
262
+ if pid is None:
263
+ sys.exit(1)
264
+ print(f"prospect_id={pid} linked to DM #{args.dm_id}")
265
+ return
266
+
267
+
268
+ def main():
269
+ ap = argparse.ArgumentParser()
270
+ sub = ap.add_subparsers(dest="cmd", required=True)
271
+
272
+ up = sub.add_parser("upsert", help="Insert or update a prospect row")
273
+ up.add_argument("--platform", required=True, choices=["reddit", "twitter", "linkedin"])
274
+ up.add_argument("--author", required=True)
275
+ up.add_argument("--profile-url")
276
+ up.add_argument("--display-name")
277
+ up.add_argument("--headline")
278
+ up.add_argument("--bio")
279
+ up.add_argument("--follower-count", type=int)
280
+ up.add_argument("--recent-activity")
281
+ up.add_argument("--company")
282
+ up.add_argument("--role")
283
+ up.add_argument("--notes")
284
+ up.add_argument(
285
+ "--link-dm",
286
+ type=int,
287
+ help="Also set dms.prospect_id on this dm_id after upsert",
288
+ )
289
+ up.add_argument(
290
+ "--json", action="store_true", help="Emit {id,platform,author,...} as JSON"
291
+ )
292
+
293
+ gp = sub.add_parser("get", help="Print a prospect row as JSON")
294
+ gp.add_argument("--platform", required=True)
295
+ gp.add_argument("--author", required=True)
296
+
297
+ lk = sub.add_parser("link", help="Link a dms row to its prospect by platform+author")
298
+ lk.add_argument("--dm-id", type=int, required=True)
299
+
300
+ args = ap.parse_args()
301
+
302
+ # HTTP-only lane: every subcommand routes through the s4l.ai HTTP API. The
303
+ # direct-Postgres lane was removed 2026-06-01 — there is NO database-driven
304
+ # path any more, not as primary, not as fallback. DATABASE_URL, if present
305
+ # in the environment, is deliberately ignored; all reads/writes go through
306
+ # _http_dispatch against /api/v1/prospects and /api/v1/dms.
307
+ dbmod.load_env()
308
+ _http_dispatch(args)
309
+
310
+
311
+ if __name__ == "__main__":
312
+ main()
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ fetch_twitter_t1.py
4
+
5
+ Phase 2 of the twitter-cycle. Re-polls fxtwitter for every candidate in a
6
+ given batch_id, writes T1 engagement columns and computes delta_score.
7
+
8
+ python3 scripts/fetch_twitter_t1.py --batch-id <id>
9
+
10
+ delta_score formula:
11
+ Δlikes + 3*Δretweets + 2*Δreplies + Δviews/1000 + Δbookmarks
12
+ Weights picked so retweets/replies (stronger virality signals) beat raw likes,
13
+ views are divided down so they don't dominate.
14
+
15
+ Migrated 2026-05-18: pending-batch read and per-row T1 writes now go through
16
+ the s4l.ai HTTP API (/api/v1/twitter-candidates/pending-batch +
17
+ /api/v1/twitter-candidates/by-id action=set_t1) instead of psycopg2.
18
+ """
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import urllib.request
25
+ from concurrent.futures import ThreadPoolExecutor
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
28
+ from http_api import api_get, api_patch # noqa: E402
29
+
30
+
31
+ def fetch_fxtwitter(handle, tweet_id):
32
+ url = f"https://api.fxtwitter.com/{handle}/status/{tweet_id}"
33
+ req = urllib.request.Request(url, headers={"User-Agent": "social-autoposter/1.0"})
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=10) as resp:
36
+ return json.loads(resp.read())
37
+ except Exception as e:
38
+ print(f" fxtwitter error for {handle}/{tweet_id}: {e}", file=sys.stderr)
39
+ return None
40
+
41
+
42
+ def parse(url):
43
+ m = re.search(r"x\.com/([^/]+)/status/(\d+)", url or "")
44
+ if not m:
45
+ m = re.search(r"twitter\.com/([^/]+)/status/(\d+)", url or "")
46
+ return (m.group(1), m.group(2)) if m else (None, None)
47
+
48
+
49
+ def compute_delta(t0, t1):
50
+ dl = (t1.get("likes", 0) or 0) - (t0.get("likes", 0) or 0)
51
+ dr = (t1.get("retweets", 0) or 0) - (t0.get("retweets", 0) or 0)
52
+ dp = (t1.get("replies", 0) or 0) - (t0.get("replies", 0) or 0)
53
+ dv = (t1.get("views", 0) or 0) - (t0.get("views", 0) or 0)
54
+ db = (t1.get("bookmarks", 0) or 0) - (t0.get("bookmarks", 0) or 0)
55
+ return dl + 3 * dr + 2 * dp + dv / 1000.0 + db
56
+
57
+
58
+ def main():
59
+ p = argparse.ArgumentParser()
60
+ p.add_argument("--batch-id", required=True)
61
+ args = p.parse_args()
62
+
63
+ resp = api_get(
64
+ "/api/v1/twitter-candidates/pending-batch",
65
+ query={"batch_id": args.batch_id},
66
+ )
67
+ rows = (resp.get("data") or {}).get("candidates") or []
68
+
69
+ if not rows:
70
+ print(f"No pending rows for batch {args.batch_id}", file=sys.stderr)
71
+ return
72
+
73
+ print(f"Re-polling {len(rows)} candidates for batch {args.batch_id}", file=sys.stderr)
74
+
75
+ def fetch_row(row):
76
+ cid = row["id"]
77
+ url = row["tweet_url"]
78
+ l0 = row.get("likes_t0")
79
+ r0 = row.get("retweets_t0")
80
+ p0 = row.get("replies_t0")
81
+ v0 = row.get("views_t0")
82
+ b0 = row.get("bookmarks_t0")
83
+ handle, tweet_id = parse(url)
84
+ if not handle:
85
+ return None
86
+ data = fetch_fxtwitter(handle, tweet_id)
87
+ if not data or not data.get("tweet"):
88
+ return None
89
+ t = data["tweet"]
90
+ t1 = {
91
+ "likes": t.get("likes", 0),
92
+ "retweets": t.get("retweets", 0),
93
+ "replies": t.get("replies", 0),
94
+ "views": t.get("views", 0),
95
+ "bookmarks": t.get("bookmarks", 0),
96
+ }
97
+ t0 = {"likes": l0 or 0, "retweets": r0 or 0, "replies": p0 or 0, "views": v0 or 0, "bookmarks": b0 or 0}
98
+ return (cid, url, t1, compute_delta(t0, t1))
99
+
100
+ with ThreadPoolExecutor(max_workers=8) as ex:
101
+ results = list(ex.map(fetch_row, rows))
102
+
103
+ for result in results:
104
+ if result is None:
105
+ continue
106
+ cid, url, t1, delta = result
107
+ try:
108
+ api_patch(
109
+ "/api/v1/twitter-candidates/by-id",
110
+ {
111
+ "id": cid,
112
+ "action": "set_t1",
113
+ "likes_t1": t1["likes"],
114
+ "retweets_t1": t1["retweets"],
115
+ "replies_t1": t1["replies"],
116
+ "views_t1": t1["views"],
117
+ "bookmarks_t1": t1["bookmarks"],
118
+ "delta_score": delta,
119
+ "likes": t1["likes"],
120
+ "retweets": t1["retweets"],
121
+ "replies": t1["replies"],
122
+ "views": t1["views"],
123
+ "bookmarks": t1["bookmarks"],
124
+ },
125
+ ok_on_404=True,
126
+ )
127
+ print(f" #{cid} {url} Δ={delta:.1f}", file=sys.stderr)
128
+ except SystemExit as e:
129
+ print(f" #{cid} {url} set_t1 failed: {e}", file=sys.stderr)
130
+ continue
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()