@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,279 @@
1
+ #!/usr/bin/env python3
2
+ """Pick the next (project, subreddit) pair for an original Reddit thread.
3
+
4
+ Rules:
5
+ - Only consider projects with threads.enabled=true.
6
+ - A project's own_community (if set) is a candidate every run (subject to its
7
+ own floor_days override, default 1 day for own community).
8
+ - External subreddits are subject to the default 3-day floor (configurable via
9
+ threads.external_floor_days).
10
+ - Entry filter: skip any subreddit where this account has posted an original
11
+ thread (thread_url == our_url) within that sub's floor window.
12
+ - Also skip any subreddit listed in subreddit_bans.thread_blocked.
13
+ - Among eligible candidates, prefer own_community if present. Otherwise, weight
14
+ projects by config weight.
15
+
16
+ Usage:
17
+ python3 scripts/pick_thread_target.py # stdout: PROJECT\tSUBREDDIT
18
+ python3 scripts/pick_thread_target.py --json # full context
19
+ python3 scripts/pick_thread_target.py --show-all # debug view
20
+ """
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import random
26
+ import sys
27
+ from datetime import datetime, timezone
28
+
29
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
30
+ from http_api import api_get
31
+
32
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
33
+ DEFAULT_OWN_FLOOR_DAYS = 1
34
+ DEFAULT_EXTERNAL_FLOOR_DAYS = 3
35
+
36
+
37
+ def load_config():
38
+ with open(CONFIG_PATH) as f:
39
+ return json.load(f)
40
+
41
+
42
+ def _parse_dt(s):
43
+ """Parse an ISO posted_at string to an aware datetime, or None."""
44
+ if not s:
45
+ return None
46
+ s = str(s)
47
+ if s.endswith("Z"):
48
+ s = s[:-1] + "+00:00"
49
+ try:
50
+ dt = datetime.fromisoformat(s)
51
+ except ValueError:
52
+ return None
53
+ if dt.tzinfo is None:
54
+ dt = dt.replace(tzinfo=timezone.utc)
55
+ return dt
56
+
57
+
58
+ def _fetch_own_reddit_threads(days):
59
+ """Fetch our original Reddit threads (thread_url == our_url) posted in the
60
+ last `days` days via the HTTP API. Returns a list of (thread_url, days_ago)
61
+ tuples. Replaces the former direct posts SELECT (own_threads_only mirrors
62
+ the thread_url = our_url predicate)."""
63
+ cutoff = datetime.now(timezone.utc).timestamp() - int(days) * 86400
64
+ since = datetime.fromtimestamp(cutoff, tz=timezone.utc).isoformat()
65
+ resp = api_get(
66
+ "/api/v1/posts",
67
+ query={
68
+ "platform": "reddit",
69
+ "own_threads_only": "true",
70
+ "since": since,
71
+ "order_by": "posted_at",
72
+ "order_dir": "desc",
73
+ "limit": 500,
74
+ },
75
+ )
76
+ posts = (resp.get("data") or {}).get("posts") or []
77
+ now_ts = datetime.now(timezone.utc).timestamp()
78
+ out = []
79
+ for p in posts:
80
+ dt = _parse_dt(p.get("posted_at"))
81
+ if dt is None:
82
+ continue
83
+ days_ago = (now_ts - dt.timestamp()) / 86400.0
84
+ out.append((p.get("thread_url"), days_ago, p.get("project_name")))
85
+ return out
86
+
87
+
88
+ def norm_sub(s):
89
+ if not s:
90
+ return ""
91
+ s = s.strip()
92
+ if s.lower().startswith("r/"):
93
+ s = s[2:]
94
+ return s.lower()
95
+
96
+
97
+ def _ban_entry_to_slug(entry):
98
+ """Extract the sub slug from a comment_blocked / thread_blocked entry.
99
+
100
+ Entries are either bare strings (pre-2026-05-11) or audit dicts
101
+ {"sub": ..., "added_at": ..., "reason": ..., "project": ...}.
102
+ Returns lowercased slug (no r/ prefix) or empty string.
103
+ """
104
+ if isinstance(entry, str):
105
+ return norm_sub(entry)
106
+ if isinstance(entry, dict):
107
+ return norm_sub(entry.get("sub") or "")
108
+ return ""
109
+
110
+
111
+ def load_thread_blocked_subs(config):
112
+ """Load subreddits where we cannot create new threads.
113
+
114
+ Reads subreddit_bans.thread_blocked. For the thread-creation pipeline
115
+ only, the comment pipeline uses subreddit_bans.comment_blocked via
116
+ reddit_tools._load_comment_blocked_subs().
117
+
118
+ Handles both ban-list shapes: bare string (pre-2026-05-11) and audit
119
+ dict {"sub": ..., "added_at": ..., "reason": ..., "project": ...}.
120
+ """
121
+ bans = config.get("subreddit_bans") or {}
122
+ out = set()
123
+ if isinstance(bans, dict):
124
+ for entry in bans.get("thread_blocked") or []:
125
+ slug = _ban_entry_to_slug(entry)
126
+ if slug:
127
+ out.add(slug)
128
+ elif isinstance(bans, list):
129
+ # Legacy flat-list form, treat as thread_blocked.
130
+ for entry in bans:
131
+ slug = _ban_entry_to_slug(entry)
132
+ if slug:
133
+ out.add(slug)
134
+ return out
135
+
136
+
137
+ def recent_posts_by_sub(max_days):
138
+ """Return dict: sub_slug (lowercased) -> days_since_last_our_thread."""
139
+ rows = _fetch_own_reddit_threads(max_days)
140
+ latest = {}
141
+ for url, days_ago, _project in rows:
142
+ if not url or "/r/" not in url:
143
+ continue
144
+ sub = url.split("/r/", 1)[1].split("/", 1)[0].lower()
145
+ if sub not in latest or days_ago < latest[sub]:
146
+ latest[sub] = float(days_ago)
147
+ return latest
148
+
149
+
150
+ def recent_posts_by_project(days=7):
151
+ """Return dict: project_name -> count of original threads posted in last N days."""
152
+ rows = _fetch_own_reddit_threads(days)
153
+ counts = {}
154
+ for _url, _days_ago, project in rows:
155
+ if not project:
156
+ continue
157
+ counts[project] = counts.get(project, 0) + 1
158
+ return counts
159
+
160
+
161
+ def build_candidates(config):
162
+ recent = recent_posts_by_sub(max_days=max(
163
+ DEFAULT_OWN_FLOOR_DAYS, DEFAULT_EXTERNAL_FLOOR_DAYS, 14))
164
+ thread_blocked = load_thread_blocked_subs(config)
165
+ candidates = []
166
+ for p in config.get("projects", []):
167
+ t = p.get("threads") or {}
168
+ if not t.get("enabled"):
169
+ continue
170
+ ext_floor = int(t.get("external_floor_days", DEFAULT_EXTERNAL_FLOOR_DAYS))
171
+ # Own community
172
+ own = t.get("own_community")
173
+ if own:
174
+ if isinstance(own, dict):
175
+ sub_display = own.get("subreddit")
176
+ own_floor = int(own.get("floor_days", DEFAULT_OWN_FLOOR_DAYS))
177
+ else:
178
+ sub_display = own
179
+ own_floor = DEFAULT_OWN_FLOOR_DAYS
180
+ slug = norm_sub(sub_display)
181
+ if sub_display and slug not in thread_blocked:
182
+ last = recent.get(slug)
183
+ if last is None or last >= own_floor:
184
+ candidates.append((p, sub_display, True, own_floor, last))
185
+ # External subs
186
+ for sub in t.get("external_subreddits") or []:
187
+ slug = norm_sub(sub)
188
+ if slug in thread_blocked:
189
+ continue
190
+ last = recent.get(slug)
191
+ if last is not None and last < ext_floor:
192
+ continue
193
+ candidates.append((p, sub, False, ext_floor, last))
194
+ return candidates, recent, thread_blocked
195
+
196
+
197
+ def pick(candidates, recent_project_counts=None):
198
+ own_candidates = [c for c in candidates if c[2]]
199
+ if own_candidates:
200
+ return random.choice(own_candidates)
201
+ if not candidates:
202
+ return None
203
+ recent_project_counts = recent_project_counts or {}
204
+ by_project = {}
205
+ for p, sub, is_own, floor, last in candidates:
206
+ by_project.setdefault(p["name"], {"project": p, "entries": []})
207
+ by_project[p["name"]]["entries"].append((sub, is_own, floor, last))
208
+ names = list(by_project.keys())
209
+ # Inverse recent-share weighting: keep config weight as the prior, but
210
+ # penalise projects that already posted a lot in the last 7 days.
211
+ # effective = base_weight / (1 + posts_last_7d). 0 posts => no change,
212
+ # each recent post halves the odds relative to a never-posted peer at 1.
213
+ weights = [
214
+ by_project[n]["project"].get("weight", 1)
215
+ / (1 + recent_project_counts.get(n, 0))
216
+ for n in names
217
+ ]
218
+ chosen_name = random.choices(names, weights=weights, k=1)[0]
219
+ proj = by_project[chosen_name]["project"]
220
+ sub, is_own, floor, last = random.choice(by_project[chosen_name]["entries"])
221
+ return (proj, sub, is_own, floor, last)
222
+
223
+
224
+ def main():
225
+ ap = argparse.ArgumentParser()
226
+ ap.add_argument("--json", action="store_true")
227
+ ap.add_argument("--show-all", action="store_true")
228
+ args = ap.parse_args()
229
+
230
+ config = load_config()
231
+ candidates, recent, thread_blocked = build_candidates(config)
232
+ recent_project_counts = recent_posts_by_project(days=7)
233
+
234
+ if args.show_all:
235
+ print(f"Thread-blocked subs ({len(thread_blocked)}): {sorted(thread_blocked)}")
236
+ print(f"Recent thread subs: {len(recent)}")
237
+ for sub, days in sorted(recent.items(), key=lambda x: x[1]):
238
+ print(f" {sub}: {days:.2f}d ago")
239
+ eligible_projects = {}
240
+ for p, sub, is_own, floor, last in candidates:
241
+ eligible_projects.setdefault(p["name"], p)
242
+ print(f"\nProject weights (base / posts_7d / effective):")
243
+ rows = []
244
+ for name, p in eligible_projects.items():
245
+ base = p.get("weight", 1)
246
+ posts_7d = recent_project_counts.get(name, 0)
247
+ eff = base / (1 + posts_7d)
248
+ rows.append((name, base, posts_7d, eff))
249
+ for name, base, posts_7d, eff in sorted(rows, key=lambda r: -r[3]):
250
+ print(f" {name:25} base={base:>3} posts_7d={posts_7d:>2} effective={eff:.3f}")
251
+ print(f"\nEligible candidates: {len(candidates)}")
252
+ for p, sub, is_own, floor, last in candidates:
253
+ tag = "OWN" if is_own else "ext"
254
+ last_str = f"last={last:.2f}d" if last is not None else "last=never"
255
+ print(f" [{tag}] {p['name']:25} {sub:30} floor={floor}d {last_str}")
256
+ return
257
+
258
+ choice = pick(candidates, recent_project_counts=recent_project_counts)
259
+ if not choice:
260
+ print("NO_ELIGIBLE_TARGET", file=sys.stderr)
261
+ sys.exit(2)
262
+
263
+ proj, sub, is_own, floor, last = choice
264
+ if args.json:
265
+ print(json.dumps({
266
+ "project": proj,
267
+ "subreddit": sub,
268
+ "is_own_community": is_own,
269
+ "floor_days": floor,
270
+ "last_posted_days_ago": last,
271
+ "eligible_count": len(candidates),
272
+ "thread_blocked_count": len(thread_blocked),
273
+ }, indent=2))
274
+ else:
275
+ print(f"{proj['name']}\t{sub}")
276
+
277
+
278
+ if __name__ == "__main__":
279
+ main()
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env python3
2
+ """Pick the next (project, topic_angle) pair for an original Twitter thread.
3
+
4
+ Mirrors scripts/pick_thread_target.py (Reddit), adapted for Twitter:
5
+
6
+ Differences vs the Reddit picker:
7
+ - No subreddit dimension. The natural floor unit is (project, topic_angle).
8
+ - Hard global daily cap. Across all projects, never post more than
9
+ TWITTER_DAILY_CAP original threads in a UTC calendar day. Enforced via a
10
+ COUNT(*) of posts where platform='twitter' AND thread_url=our_url AND
11
+ posted_at::date = CURRENT_DATE. If hit, exit non-zero so the orchestrator
12
+ cleanly skips the launchd fire.
13
+ - Per-project per-angle floor window (twitter_threads.topic_floor_days,
14
+ default 2). Picks an angle that is either never-used or older than the
15
+ floor for the given project.
16
+ - Project weight + inverse recent-share weighting (same as Reddit picker)
17
+ so we don't pile every fire on one project.
18
+
19
+ Usage:
20
+ python3 scripts/pick_twitter_thread_target.py # PROJECT\tANGLE
21
+ python3 scripts/pick_twitter_thread_target.py --json # full context
22
+ python3 scripts/pick_twitter_thread_target.py --show-all # debug view
23
+ """
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import random
29
+ import sys
30
+
31
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
+ from http_api import api_get # noqa: E402
33
+
34
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
35
+ DEFAULT_TOPIC_FLOOR_DAYS = 2
36
+ TWITTER_DAILY_CAP = 3 # hard global cap. user requirement, do not raise without explicit ask.
37
+
38
+
39
+ def load_config():
40
+ with open(CONFIG_PATH) as f:
41
+ return json.load(f)
42
+
43
+
44
+ def _fetch_picker_context(angle_window_days=14, counts_window_days=7):
45
+ """Single call to /api/v1/twitter/picker-context for all three reads.
46
+
47
+ Replaces the previous trio of direct-DB SELECTs (daily_count_today,
48
+ recent_angles_by_project, recent_posts_by_project) with one HTTP roundtrip.
49
+ Returned by the route already trimmed to the same row shape we used to
50
+ derive in Python: { daily_count_today: int, recent_posts_by_project:
51
+ {name: int}, project_angles: {name: [{summary, days_ago}, ...]} }.
52
+ """
53
+ resp = api_get(
54
+ "/api/v1/twitter/picker-context",
55
+ query={
56
+ "angle_window_days": angle_window_days,
57
+ "counts_window_days": counts_window_days,
58
+ },
59
+ )
60
+ return resp.get("data") or {}
61
+
62
+
63
+ def recent_angles_by_project(project_angles_payload):
64
+ """Reshape the route's project_angles dict into the same {project: {_rows: [...]}}
65
+ structure the old direct-DB helper produced, so angle_recency() works
66
+ unchanged.
67
+ """
68
+ out = {}
69
+ for project_name, rows in (project_angles_payload or {}).items():
70
+ bucket = out.setdefault(project_name, {})
71
+ bucket.setdefault("_rows", []).extend(
72
+ (r.get("summary") or "", float(r.get("days_ago") or 0)) for r in rows
73
+ )
74
+ return out
75
+
76
+
77
+ def angle_recency(project_recents, angle_text):
78
+ """Given project_recents[project] (a dict with '_rows' list of
79
+ (summary, days_ago)), return the smallest days_ago for any row whose
80
+ summary contains the first 60 chars of angle_text. None if never used.
81
+ """
82
+ rows = (project_recents or {}).get("_rows") or []
83
+ needle = (angle_text or "").strip()[:60].lower()
84
+ if not needle:
85
+ return None
86
+ best = None
87
+ for summary, days_ago in rows:
88
+ if needle in (summary or "").lower():
89
+ if best is None or days_ago < best:
90
+ best = days_ago
91
+ return best
92
+
93
+
94
+ def build_candidates(config, project_recents):
95
+ candidates = [] # (project_dict, angle_text, floor_days, last_used_days_ago_or_None)
96
+ for p in config.get("projects", []):
97
+ tt = p.get("twitter_threads") or {}
98
+ if not tt.get("enabled"):
99
+ continue
100
+ floor = int(tt.get("topic_floor_days", DEFAULT_TOPIC_FLOOR_DAYS))
101
+ angles = tt.get("topic_angles") or []
102
+ if not angles:
103
+ continue
104
+ recents_for_proj = project_recents.get(p["name"], {})
105
+ for angle in angles:
106
+ last = angle_recency(recents_for_proj, angle)
107
+ if last is not None and last < floor:
108
+ continue # too recent
109
+ candidates.append((p, angle, floor, last))
110
+ return candidates, project_recents
111
+
112
+
113
+ def pick(candidates, recent_project_counts=None):
114
+ if not candidates:
115
+ return None
116
+ recent_project_counts = recent_project_counts or {}
117
+ by_project = {}
118
+ for p, angle, floor, last in candidates:
119
+ by_project.setdefault(p["name"], {"project": p, "entries": []})
120
+ by_project[p["name"]]["entries"].append((angle, floor, last))
121
+ names = list(by_project.keys())
122
+ # Inverse recent-share: keep config weight as the prior, penalise projects
123
+ # that already posted a lot in the last 7d.
124
+ weights = [
125
+ by_project[n]["project"].get("weight", 1)
126
+ / (1 + recent_project_counts.get(n, 0))
127
+ for n in names
128
+ ]
129
+ chosen_name = random.choices(names, weights=weights, k=1)[0]
130
+ proj = by_project[chosen_name]["project"]
131
+ angle, floor, last = random.choice(by_project[chosen_name]["entries"])
132
+ return (proj, angle, floor, last)
133
+
134
+
135
+ def main():
136
+ ap = argparse.ArgumentParser()
137
+ ap.add_argument("--json", action="store_true")
138
+ ap.add_argument("--show-all", action="store_true")
139
+ args = ap.parse_args()
140
+
141
+ config = load_config()
142
+
143
+ # One HTTP roundtrip for all picker context (daily count, recent angles
144
+ # per project, recent post counts per project). Was three separate
145
+ # psycopg2 SELECTs on `posts` before the 2026-05-18 routes migration.
146
+ ctx = _fetch_picker_context(angle_window_days=14, counts_window_days=7)
147
+
148
+ # Hard daily cap. Check FIRST so the picker exits cheap when the day is
149
+ # already saturated.
150
+ today_count = int(ctx.get("daily_count_today") or 0)
151
+ if today_count >= TWITTER_DAILY_CAP and not args.show_all:
152
+ print(f"DAILY_CAP_REACHED: {today_count}/{TWITTER_DAILY_CAP} posts today",
153
+ file=sys.stderr)
154
+ sys.exit(3)
155
+
156
+ project_recents = recent_angles_by_project(ctx.get("project_angles"))
157
+ candidates = build_candidates(config, project_recents)
158
+ recent_project_counts = ctx.get("recent_posts_by_project") or {}
159
+
160
+ if args.show_all:
161
+ print(f"Daily cap: {today_count}/{TWITTER_DAILY_CAP} posts today (UTC)")
162
+ eligible_projects = {}
163
+ for p, angle, floor, last in candidates:
164
+ eligible_projects.setdefault(p["name"], p)
165
+ print(f"\nProject weights (base / posts_7d / effective):")
166
+ rows = []
167
+ for name, p in eligible_projects.items():
168
+ base = p.get("weight", 1)
169
+ posts_7d = recent_project_counts.get(name, 0)
170
+ eff = base / (1 + posts_7d)
171
+ rows.append((name, base, posts_7d, eff))
172
+ for name, base, posts_7d, eff in sorted(rows, key=lambda r: -r[3]):
173
+ print(f" {name:25} base={base:>3} posts_7d={posts_7d:>2} effective={eff:.3f}")
174
+ print(f"\nEligible candidates: {len(candidates)}")
175
+ for p, angle, floor, last in candidates:
176
+ last_str = f"last={last:.2f}d" if last is not None else "last=never"
177
+ angle_short = (angle[:70] + "...") if len(angle) > 73 else angle
178
+ print(f" {p['name']:20} floor={floor}d {last_str:14} {angle_short}")
179
+ return
180
+
181
+ choice = pick(candidates, recent_project_counts=recent_project_counts)
182
+ if not choice:
183
+ print("NO_ELIGIBLE_TARGET", file=sys.stderr)
184
+ sys.exit(2)
185
+
186
+ proj, angle, floor, last = choice
187
+ if args.json:
188
+ print(json.dumps({
189
+ "project": proj,
190
+ "topic_angle": angle,
191
+ "floor_days": floor,
192
+ "last_used_days_ago": last,
193
+ "eligible_count": len(candidates),
194
+ "daily_count_today": today_count,
195
+ "daily_cap": TWITTER_DAILY_CAP,
196
+ }, indent=2))
197
+ else:
198
+ print(f"{proj['name']}\t{angle}")
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
@@ -0,0 +1,32 @@
1
+ #!/bin/bash
2
+ OUT=/Users/matthewdi/social-autoposter/scripts/podlog_threads_out.txt
3
+ > "$OUT"
4
+ URLS=(
5
+ "https://old.reddit.com/r/selfhosted/comments/1tddgg7/i_want_to_automatically_scrape_my_news_podcasts/"
6
+ "https://old.reddit.com/r/podcasting/comments/1sqx0hy/thoughts_on_the_rise_of_ai_generated_podcasts/"
7
+ "https://old.reddit.com/r/webdev/comments/1tcnil0/at_what_point_did_web_development_start_feeling/"
8
+ "https://old.reddit.com/r/selfhosted/comments/1teic8d/anyone_enjoying_using_ai_to_manage_your_homelab/"
9
+ "https://old.reddit.com/r/selfhosted/comments/1tcxb1b/services_with_actually_generous_free_tiers_for/"
10
+ "https://old.reddit.com/r/opensource/comments/1t7fx4d/i_contributed_to_open_source_for_the_first_time/"
11
+ "https://old.reddit.com/r/opensource/comments/1t5h3j6/how_do_i_start_contributing_to_open_source_devops/"
12
+ "https://old.reddit.com/r/opensource/comments/1tfm90j/condenseit_selfhosted_ai_news_digest_mit_licensed/"
13
+ "https://old.reddit.com/r/Entrepreneur/comments/1sthfgz/how_do_you_decide_between_code_and_marketing_in/"
14
+ )
15
+ for URL in "${URLS[@]}"; do
16
+ echo "===URL=== $URL" >> "$OUT"
17
+ TRIES=0
18
+ while [ $TRIES -lt 4 ]; do
19
+ RESP=$(python3 /Users/matthewdi/social-autoposter/scripts/reddit_tools.py fetch "$URL" 2>&1)
20
+ if echo "$RESP" | grep -q '"rate_limited"'; then
21
+ WAIT=$(echo "$RESP" | grep -o '"wait_seconds": *[0-9]*' | grep -o '[0-9]*')
22
+ [ -z "$WAIT" ] && WAIT=300
23
+ sleep $((WAIT + 15))
24
+ TRIES=$((TRIES + 1))
25
+ else
26
+ echo "$RESP" >> "$OUT"
27
+ break
28
+ fi
29
+ done
30
+ sleep 280
31
+ done
32
+ echo "===DONE===" >> "$OUT"