@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,128 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn cooldown state management.
3
+
4
+ Shared cooldown file prevents cron runs from hammering LinkedIn after
5
+ rate limits, checkpoint challenges, or account restrictions.
6
+
7
+ Cooldown file: /tmp/linkedin_cooldown.json
8
+ Format: {"reason": "...", "resume_after": "ISO8601", "created_at": "ISO8601"}
9
+
10
+ Usage:
11
+ # Check if we're in cooldown (exit 0 = clear, exit 1 = in cooldown)
12
+ python3 linkedin_cooldown.py check
13
+
14
+ # Set cooldown (duration in minutes)
15
+ python3 linkedin_cooldown.py set --reason "429 rate limit" --minutes 120
16
+
17
+ # Set cooldown until a specific time
18
+ python3 linkedin_cooldown.py set --reason "account restricted" --until "2026-04-15T21:43:00"
19
+
20
+ # Clear cooldown
21
+ python3 linkedin_cooldown.py clear
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import sys
29
+ from datetime import datetime, timezone
30
+
31
+ COOLDOWN_FILE = "/tmp/linkedin_cooldown.json"
32
+
33
+
34
+ def log(msg: str) -> None:
35
+ print(f"[linkedin-cooldown] {msg}", file=sys.stderr)
36
+
37
+
38
+ def read_cooldown() -> dict | None:
39
+ """Read cooldown state. Returns None if no active cooldown."""
40
+ if not os.path.exists(COOLDOWN_FILE):
41
+ return None
42
+ try:
43
+ with open(COOLDOWN_FILE) as f:
44
+ data = json.load(f)
45
+ resume_after = datetime.fromisoformat(data["resume_after"])
46
+ if resume_after.tzinfo is None:
47
+ resume_after = resume_after.replace(tzinfo=timezone.utc)
48
+ now = datetime.now(timezone.utc)
49
+ if now >= resume_after:
50
+ os.remove(COOLDOWN_FILE)
51
+ return None
52
+ return data
53
+ except (json.JSONDecodeError, KeyError, ValueError):
54
+ os.remove(COOLDOWN_FILE)
55
+ return None
56
+
57
+
58
+ def set_cooldown(reason: str, resume_after: datetime) -> None:
59
+ """Write cooldown state."""
60
+ if resume_after.tzinfo is None:
61
+ resume_after = resume_after.replace(tzinfo=timezone.utc)
62
+ data = {
63
+ "reason": reason,
64
+ "resume_after": resume_after.isoformat(),
65
+ "created_at": datetime.now(timezone.utc).isoformat(),
66
+ }
67
+ with open(COOLDOWN_FILE, "w") as f:
68
+ json.dump(data, f, indent=2)
69
+ log(f"Cooldown set: {reason} (until {resume_after.isoformat()})")
70
+
71
+
72
+ def clear_cooldown() -> None:
73
+ """Remove cooldown file."""
74
+ if os.path.exists(COOLDOWN_FILE):
75
+ os.remove(COOLDOWN_FILE)
76
+ log("Cooldown cleared")
77
+
78
+
79
+ def main():
80
+ if len(sys.argv) < 2:
81
+ print(__doc__)
82
+ sys.exit(1)
83
+
84
+ cmd = sys.argv[1]
85
+
86
+ if cmd == "check":
87
+ state = read_cooldown()
88
+ if state:
89
+ resume = state["resume_after"]
90
+ log(f"In cooldown: {state['reason']} (until {resume})")
91
+ print(json.dumps(state))
92
+ sys.exit(1)
93
+ else:
94
+ log("No active cooldown")
95
+ sys.exit(0)
96
+
97
+ elif cmd == "set":
98
+ import argparse
99
+ parser = argparse.ArgumentParser()
100
+ parser.add_argument("cmd_")
101
+ parser.add_argument("--reason", required=True)
102
+ parser.add_argument("--minutes", type=int)
103
+ parser.add_argument("--until")
104
+ args = parser.parse_args(sys.argv[1:])
105
+
106
+ if args.until:
107
+ resume = datetime.fromisoformat(args.until)
108
+ if resume.tzinfo is None:
109
+ resume = resume.replace(tzinfo=timezone.utc)
110
+ elif args.minutes:
111
+ from datetime import timedelta
112
+ resume = datetime.now(timezone.utc) + timedelta(minutes=args.minutes)
113
+ else:
114
+ print("ERROR: --minutes or --until required", file=sys.stderr)
115
+ sys.exit(1)
116
+
117
+ set_cooldown(args.reason, resume)
118
+
119
+ elif cmd == "clear":
120
+ clear_cooldown()
121
+
122
+ else:
123
+ print(f"Unknown command: {cmd}", file=sys.stderr)
124
+ sys.exit(1)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """Single source of truth for "is this LinkedIn author excluded?".
3
+
4
+ Every LinkedIn rail can call this instead of re-implementing its own matcher:
5
+ - POST/comment rail (discover_linkedin_candidates.py -> drop before the picker)
6
+ - scoring (score_linkedin_candidates.py -> drop before upsert)
7
+ - engage / mentions (engage-linkedin.sh prompt -> inject `slugs`)
8
+ - DM candidate scan (scan_dm_candidates.py)
9
+
10
+ WHY SLUG, NOT NAME (learned 2026-06-03 in the harness):
11
+ A LinkedIn vanity slug (the `/in/<slug>/` segment, e.g. `louis030195`) is a
12
+ unique, stable key. A display name is NOT: a people-search for "Louis
13
+ Beaumont" returns a dozen unrelated real people. So:
14
+ * slug match -> HARD (drop deterministically; this is the reliable path)
15
+ * name match -> SOFT (flag for review only; never an automatic drop,
16
+ because it would hit innocent namesakes)
17
+ In practice discover always extracts author_profile_url, so the slug path
18
+ covers the normal case; the name path is a backstop for reshares/quotes that
19
+ somehow carry only a name.
20
+
21
+ SOURCES (unioned, both optional, fail-open):
22
+ 1. config.json `exclusions.linkedin_profiles` + `exclusions.authors`
23
+ - entries WITHOUT a space -> hard slug
24
+ - entries WITH a space -> soft name
25
+ 2. author_blocklist via GET /api/v1/blocklist?platform=linkedin
26
+ - severity=hard handle -> hard slug
27
+ - severity=soft handle -> soft slug
28
+
29
+ This module does NO direct SQL: the blocklist is read over the website HTTP API
30
+ (per the project DB-access rule), and the read fails open (ok_on_404) so a
31
+ website hiccup can never wedge a posting cycle.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+
40
+ _HERE = os.path.dirname(os.path.abspath(__file__))
41
+ if _HERE not in sys.path:
42
+ sys.path.insert(0, _HERE)
43
+
44
+ REPO = os.path.dirname(_HERE)
45
+ CONFIG_PATH = os.path.join(REPO, "config.json")
46
+
47
+
48
+ # ---------------------------------------------------------------- normalizers
49
+ def slug_from_url(url):
50
+ """Extract the lowercased /in/<slug> segment from a LinkedIn profile URL."""
51
+ m = re.search(r"/in/([^/?#]+)", url or "")
52
+ return m.group(1).lower() if m else None
53
+
54
+
55
+ def norm_name(name):
56
+ return re.sub(r"\s+", " ", (name or "").strip()).lower()
57
+
58
+
59
+ # ------------------------------------------------------------------- sources
60
+ def _config_terms():
61
+ """(hard_slugs, soft_names) from config.json exclusions."""
62
+ hard_slugs, soft_names = set(), set()
63
+ try:
64
+ with open(CONFIG_PATH) as f:
65
+ c = json.load(f)
66
+ except Exception:
67
+ return hard_slugs, soft_names
68
+ ex = c.get("exclusions", {}) or {}
69
+ for term in (ex.get("linkedin_profiles") or []) + (ex.get("authors") or []):
70
+ t = (term or "").strip().lower()
71
+ if not t:
72
+ continue
73
+ if " " in t:
74
+ soft_names.add(re.sub(r"\s+", " ", t))
75
+ else:
76
+ hard_slugs.add(t.lstrip("@"))
77
+ return hard_slugs, soft_names
78
+
79
+
80
+ def _blocklist_terms(platform="linkedin"):
81
+ """(hard_handles, soft_handles) from the author_blocklist HTTP API.
82
+
83
+ Fails open: any error -> empty sets, never raises into a posting cycle.
84
+ """
85
+ hard, soft = set(), set()
86
+ try:
87
+ from http_api import api_get
88
+ resp = api_get("/api/v1/blocklist", query={"platform": platform},
89
+ ok_on_404=True)
90
+ rows = ((resp or {}).get("data") or {}).get("rows") or []
91
+ for r in rows:
92
+ h = (r.get("handle") or "").strip().lstrip("@").lower()
93
+ if not h:
94
+ continue
95
+ if r.get("severity") == "hard":
96
+ hard.add(h)
97
+ elif r.get("severity") == "soft":
98
+ soft.add(h)
99
+ except Exception:
100
+ pass
101
+ return hard, soft
102
+
103
+
104
+ def load_exclusions(platform="linkedin"):
105
+ """Build the unioned exclusion sets once; reuse across many candidates."""
106
+ cfg_hard, cfg_soft_names = _config_terms()
107
+ bl_hard, bl_soft = _blocklist_terms(platform)
108
+ return {
109
+ "hard_slugs": cfg_hard | bl_hard, # slug match -> drop
110
+ "soft_slugs": bl_soft, # slug match -> flag
111
+ "soft_names": cfg_soft_names, # name match -> flag
112
+ }
113
+
114
+
115
+ # ------------------------------------------------------------------- matcher
116
+ def classify_author(author_name, author_profile_url, excl=None):
117
+ """Return (severity, reason).
118
+
119
+ severity: "hard" -> caller should DROP the candidate
120
+ "soft" -> caller should KEEP but flag for review
121
+ None -> not excluded
122
+ """
123
+ if excl is None:
124
+ excl = load_exclusions()
125
+ slug = slug_from_url(author_profile_url)
126
+ if slug:
127
+ if slug in excl["hard_slugs"]:
128
+ return "hard", f"slug:{slug}"
129
+ if slug in excl["soft_slugs"]:
130
+ return "soft", f"blocklist_soft_slug:{slug}"
131
+ name = norm_name(author_name)
132
+ if name and name in excl["soft_names"]:
133
+ return "soft", f"name:{name}"
134
+ return None, ""
135
+
136
+
137
+ def filter_candidates(candidates, excl=None):
138
+ """Split a discover candidate list into (kept, dropped).
139
+
140
+ `kept` keeps soft matches but tags them with `_exclusion_flag`. `dropped`
141
+ are the hard matches. Each item is whatever shape discover emitted; we only
142
+ read author_name + author_profile_url.
143
+ """
144
+ if excl is None:
145
+ excl = load_exclusions()
146
+ kept, dropped = [], []
147
+ for cand in candidates or []:
148
+ sev, reason = classify_author(
149
+ cand.get("author_name"), cand.get("author_profile_url"), excl)
150
+ if sev == "hard":
151
+ cand = dict(cand)
152
+ cand["_exclusion_reason"] = reason
153
+ dropped.append(cand)
154
+ else:
155
+ if sev == "soft":
156
+ cand = dict(cand)
157
+ cand["_exclusion_flag"] = reason
158
+ kept.append(cand)
159
+ return kept, dropped
160
+
161
+
162
+ # ----------------------------------------------------------------------- CLI
163
+ def _extract_list(blob):
164
+ """Find the candidate list inside a discover JSON payload."""
165
+ if isinstance(blob, list):
166
+ return blob, None
167
+ for key in ("candidates", "results", "items"):
168
+ if isinstance(blob.get(key), list):
169
+ return blob[key], key
170
+ return [], None
171
+
172
+
173
+ def main(argv):
174
+ cmd = argv[1] if len(argv) > 1 else ""
175
+ excl = load_exclusions()
176
+
177
+ if cmd == "slugs":
178
+ # Hard slugs only, comma-separated. For injecting into engage prompts.
179
+ print(", ".join(sorted(excl["hard_slugs"])))
180
+ return 0
181
+
182
+ if cmd == "show":
183
+ print(json.dumps({k: sorted(v) for k, v in excl.items()}, indent=2))
184
+ return 0
185
+
186
+ if cmd == "classify":
187
+ name = argv[2] if len(argv) > 2 else ""
188
+ url = argv[3] if len(argv) > 3 else ""
189
+ sev, reason = classify_author(name, url, excl)
190
+ print(json.dumps({"severity": sev, "reason": reason,
191
+ "excluded": sev == "hard"}))
192
+ return 0 if sev == "hard" else 1
193
+
194
+ if cmd == "filter":
195
+ # Read discover JSON on stdin, drop hard matches, flag soft, re-emit.
196
+ raw = sys.stdin.read()
197
+ try:
198
+ blob = json.loads(raw)
199
+ except Exception:
200
+ sys.stdout.write(raw) # pass through unparseable input untouched
201
+ return 0
202
+ items, key = _extract_list(blob)
203
+ kept, dropped = filter_candidates(items, excl)
204
+ if key:
205
+ blob[key] = kept
206
+ out = blob
207
+ else:
208
+ out = kept
209
+ sys.stderr.write(
210
+ f"[li_exclusions] dropped_hard={len(dropped)} "
211
+ f"flagged_soft={sum(1 for k in kept if k.get('_exclusion_flag'))} "
212
+ f"kept={len(kept)} "
213
+ f"slugs={sorted(excl['hard_slugs'])}\n"
214
+ )
215
+ for d in dropped:
216
+ sys.stderr.write(
217
+ f"[li_exclusions] DROP {d.get('author_name')!r} "
218
+ f"({d.get('author_profile_url')}) -> {d.get('_exclusion_reason')}\n"
219
+ )
220
+ print(json.dumps(out))
221
+ return 0
222
+
223
+ sys.stderr.write(
224
+ "usage: linkedin_exclusions.py {slugs|show|classify <name> <url>|filter}\n"
225
+ " slugs - comma-separated hard slug list (for prompt injection)\n"
226
+ " show - dump the unioned exclusion sets as JSON\n"
227
+ " classify - exit 0 (+json) if <name>/<url> is a HARD exclusion\n"
228
+ " filter - stdin discover JSON -> stdout with hard matches dropped\n"
229
+ )
230
+ return 2
231
+
232
+
233
+ if __name__ == "__main__":
234
+ sys.exit(main(sys.argv))