@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,530 @@
1
+ #!/usr/bin/env python3
2
+ """Find candidate threads to comment on via Reddit JSON API + Moltbook API.
3
+
4
+ Also generates Twitter/LinkedIn search URLs for browser-based discovery.
5
+
6
+ Usage:
7
+ python3 scripts/find_threads.py [--subreddits r/ClaudeAI,r/programming]
8
+ python3 scripts/find_threads.py --topic "macOS automation"
9
+ python3 scripts/find_threads.py --include-twitter --include-linkedin
10
+ python3 scripts/find_threads.py --include-moltbook --include-twitter --include-linkedin
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ import time
19
+ import urllib.request
20
+ from datetime import datetime, timezone
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+ from http_api import api_get
24
+ from moltbook_tools import fetch_moltbook_json, MoltbookRateLimitedError
25
+ from project_topics import topics_for_project
26
+ try:
27
+ from account_resolver import resolve as _resolve_account
28
+ except Exception:
29
+ def _resolve_account(_platform): # type: ignore[unused-arg]
30
+ return None
31
+
32
+ CONFIG_PATH = os.path.expanduser("~/social-autoposter/config.json")
33
+
34
+
35
+ def load_config():
36
+ if os.path.exists(CONFIG_PATH):
37
+ with open(CONFIG_PATH) as f:
38
+ return json.load(f)
39
+ return {}
40
+
41
+
42
+ def fetch_json(url, headers=None, user_agent="social-autoposter/1.0"):
43
+ hdrs = {"User-Agent": user_agent}
44
+ if headers:
45
+ hdrs.update(headers)
46
+ req = urllib.request.Request(url, headers=hdrs)
47
+ try:
48
+ with urllib.request.urlopen(req, timeout=15) as resp:
49
+ return json.loads(resp.read())
50
+ except Exception as e:
51
+ print(f" ERROR fetching {url}: {e}", file=sys.stderr)
52
+ return None
53
+
54
+
55
+ def get_already_posted():
56
+ """Return set of thread URLs we've already posted in.
57
+
58
+ Scoped per Reddit account when one is configured (this helper feeds the
59
+ Reddit branch of find_threads.py). Falls back to all-platform unscoped
60
+ on the legacy path so existing callers that haven't wired an account
61
+ keep the old behavior. Other platforms have their own scoped readers
62
+ (score_twitter_candidates.py, github_tools.py, score_linkedin_candidates.py).
63
+ """
64
+ acct = _resolve_account("reddit")
65
+ if acct:
66
+ resp = api_get(
67
+ "/api/v1/posts/thread-urls",
68
+ query={"platform": "reddit", "our_account": acct},
69
+ )
70
+ else:
71
+ resp = api_get("/api/v1/posts/thread-urls", query={"all_platforms": 1})
72
+ urls = (resp.get("data") or {}).get("thread_urls") or []
73
+ return {u for u in urls if u}
74
+
75
+
76
+ def get_engaged_linkedin_authors():
77
+ """Return set of LinkedIn authors we've already commented on.
78
+
79
+ LinkedIn batch commenting uses search result pages (not unique post URLs),
80
+ so URL-based dedup doesn't work. This provides author-level dedup instead.
81
+ """
82
+ resp = api_get("/api/v1/linkedin-engaged", query={"list_authors": 1})
83
+ authors = (resp.get("data") or {}).get("authors") or []
84
+ return {a for a in authors if a}
85
+
86
+
87
+ def get_engaged_linkedin_post_ids():
88
+ """Return sorted list of every LinkedIn URN ID we've engaged with —
89
+ 16-19 digit numbers found in thread_url or our_url for platform='linkedin'.
90
+ LinkedIn surfaces the same post under /feed/update/urn:li:activity:<X>/,
91
+ /posts/...-share-<Y>-<suffix>, and /posts/...-ugcPost-<Z>-<suffix>;
92
+ the X/Y/Z are different numbers but the SET of IDs across all rows
93
+ that touch the same post overlaps. Used by run-linkedin.sh to brief
94
+ the LLM so it skips a candidate whose URL contains any engaged ID."""
95
+ import linkedin_url as li_url
96
+ return li_url.get_engaged_ids()
97
+
98
+
99
+ def get_recent_posts(limit=5):
100
+ """Return our last N post contents for repetition checking."""
101
+ resp = api_get(
102
+ "/api/v1/posts",
103
+ query={"order_by": "id", "order_dir": "desc", "limit": int(limit)},
104
+ )
105
+ posts = (resp.get("data") or {}).get("posts") or []
106
+ return [p.get("our_content") for p in posts]
107
+
108
+
109
+ def check_rate_limit(max_per_day=4000):
110
+ """Return (posts_today, can_post). Default limit: 4000/day.
111
+
112
+ The count endpoint filters by platform equality (no negation), so we
113
+ take the all-platform 24h count and subtract the github_issues 24h
114
+ count to reproduce the original `platform != 'github_issues'` gate.
115
+ """
116
+ total_resp = api_get("/api/v1/posts/count", query={"within_seconds": 86400})
117
+ gh_resp = api_get(
118
+ "/api/v1/posts/count",
119
+ query={"within_seconds": 86400, "platform": "github_issues"},
120
+ )
121
+ total = int((total_resp.get("data") or {}).get("count") or 0)
122
+ gh = int((gh_resp.get("data") or {}).get("count") or 0)
123
+ count = max(0, total - gh)
124
+ can_post = count < max_per_day if max_per_day else True
125
+ return count, can_post
126
+
127
+
128
+ def fetch_reddit_threads(subreddits, sort="new", limit=10, user_agent="social-autoposter/1.0"):
129
+ """Fetch threads from subreddits via Reddit JSON API.
130
+
131
+ Uses multi-subreddit requests (r/sub1+sub2+sub3) to reduce API calls.
132
+ Randomizes subreddit order so different subs get coverage across runs.
133
+ Backs off on 429 rate limits instead of silently skipping.
134
+ """
135
+ import random
136
+
137
+ clean_subs = [s.lstrip("r/") for s in subreddits]
138
+ random.shuffle(clean_subs)
139
+
140
+ # Batch into groups of 5 (Reddit supports multi-sub via r/a+b+c)
141
+ batches = []
142
+ for i in range(0, len(clean_subs), 5):
143
+ batches.append(clean_subs[i:i + 5])
144
+
145
+ # Cap at 10 batches (50 subs) per run to stay within rate limits
146
+ batches = batches[:10]
147
+
148
+ threads = []
149
+ consecutive_429s = 0
150
+ delay = 4
151
+
152
+ for batch in batches:
153
+ multi_sub = "+".join(batch)
154
+ url = f"https://old.reddit.com/r/{multi_sub}/{sort}.json?limit={limit}"
155
+ data = fetch_json(url, user_agent=user_agent)
156
+
157
+ if data is None:
158
+ consecutive_429s += 1
159
+ if consecutive_429s >= 3:
160
+ print(f" Rate limited after {consecutive_429s} failures, stopping with "
161
+ f"{len(threads)} threads", file=sys.stderr)
162
+ break
163
+ delay = min(delay * 2, 30)
164
+ time.sleep(delay)
165
+ continue
166
+
167
+ consecutive_429s = 0
168
+
169
+ for child in data.get("data", {}).get("children", []):
170
+ post = child.get("data", {})
171
+ created = post.get("created_utc", 0)
172
+ age_hours = (datetime.now(timezone.utc).timestamp() - created) / 3600 if created else 999
173
+ subreddit = post.get("subreddit", "")
174
+
175
+ threads.append({
176
+ "platform": "reddit",
177
+ "subreddit": f"r/{subreddit}",
178
+ "url": f"https://old.reddit.com{post.get('permalink', '')}",
179
+ "title": post.get("title", ""),
180
+ "author": post.get("author", ""),
181
+ "score": post.get("score", 0),
182
+ "num_comments": post.get("num_comments", 0),
183
+ "age_hours": round(age_hours, 1),
184
+ "selftext": post.get("selftext", ""),
185
+ })
186
+
187
+ time.sleep(delay)
188
+
189
+ return threads
190
+
191
+
192
+ def fetch_moltbook_threads(api_key, limit=50):
193
+ """Fetch threads from Moltbook REST API.
194
+
195
+ Fetches multiple pages and filters out spam (mint/token posts).
196
+ """
197
+ if not api_key:
198
+ return []
199
+
200
+ threads = []
201
+ spam_patterns = ['mbc-20', 'mbc20', '"op":"mint"', '"tick"', 'pump.fun']
202
+ spam_title_patterns = ['mint', 'mbc20', 'token launch', 'inscription', 'redx',
203
+ 'wang ', 'bot claim', 'hackai']
204
+
205
+ for offset in [0, 50]:
206
+ try:
207
+ data = fetch_moltbook_json(
208
+ f"https://www.moltbook.com/api/v1/posts?sort=new&limit={limit}&offset={offset}",
209
+ api_key=api_key,
210
+ )
211
+ except MoltbookRateLimitedError as e:
212
+ print(f" Moltbook rate-limited for {int(e.reset_seconds)}s, skipping thread discovery",
213
+ file=sys.stderr)
214
+ break
215
+ if not data or "posts" not in data:
216
+ break
217
+
218
+ for post in data["posts"]:
219
+ content = post.get("content", "")
220
+ title = post.get("title", "")
221
+
222
+ # Filter spam
223
+ if any(p in content.lower() for p in spam_patterns):
224
+ continue
225
+ if any(p in title.lower() for p in spam_title_patterns):
226
+ continue
227
+ if len(content) < 40:
228
+ continue
229
+
230
+ threads.append({
231
+ "platform": "moltbook",
232
+ "url": f"https://www.moltbook.com/post/{post.get('uuid', post.get('id', ''))}",
233
+ "title": title,
234
+ "author": post.get("author", {}).get("name", ""),
235
+ "score": post.get("upvotes", 0),
236
+ "num_comments": post.get("comment_count", 0),
237
+ "content": content,
238
+ })
239
+
240
+ return threads
241
+
242
+
243
+ def generate_twitter_search_urls(topics, exclusions=None):
244
+ """Generate X/Twitter search URLs for browser-based discovery.
245
+
246
+ Twitter has no free public search API, so we generate search URLs
247
+ that the agent browses via Playwright to find threads.
248
+ """
249
+ import urllib.parse
250
+
251
+ excluded_accounts = set()
252
+ if exclusions:
253
+ excluded_accounts = {a.lower() for a in exclusions.get("twitter_accounts", [])}
254
+
255
+ threads = []
256
+ for topic in topics:
257
+ # Build exclusion string for the query
258
+ exclude_str = " ".join(f"-from:{acct}" for acct in excluded_accounts)
259
+ query = f"{topic} {exclude_str}".strip()
260
+ # min_faves:5 filters to tweets with some engagement
261
+ search_url = f"https://x.com/search?q={urllib.parse.quote(query + ' min_faves:5')}&f=live"
262
+
263
+ threads.append({
264
+ "platform": "twitter",
265
+ "url": search_url,
266
+ "title": f"Search: {topic}",
267
+ "author": "",
268
+ "score": 0,
269
+ "num_comments": 0,
270
+ "discovery_method": "search_url",
271
+ "search_topic": topic,
272
+ })
273
+
274
+ return threads
275
+
276
+
277
+ def generate_linkedin_search_urls(topics, exclusions=None):
278
+ """Generate LinkedIn search URLs for browser-based discovery.
279
+
280
+ LinkedIn has no public search API, so we generate content search URLs
281
+ that the agent browses via Playwright to find posts.
282
+ """
283
+ import urllib.parse
284
+
285
+ threads = []
286
+ for topic in topics:
287
+ search_url = f"https://www.linkedin.com/search/results/content/?keywords={urllib.parse.quote(topic)}&sortBy=%22date_posted%22"
288
+
289
+ threads.append({
290
+ "platform": "linkedin",
291
+ "url": search_url,
292
+ "title": f"Search: {topic}",
293
+ "author": "",
294
+ "score": 0,
295
+ "num_comments": 0,
296
+ "discovery_method": "search_url",
297
+ "search_topic": topic,
298
+ })
299
+
300
+ return threads
301
+
302
+
303
+ def fetch_github_issues(search_topics, exclusions=None, limit=10):
304
+ """Search GitHub issues using gh CLI and return candidate threads.
305
+
306
+ Rotates through search_topics, picking a random subset each run.
307
+ """
308
+ import random
309
+ import subprocess
310
+
311
+ excluded_repos = set()
312
+ excluded_authors = set()
313
+ if exclusions:
314
+ excluded_repos = {r.lower() for r in exclusions.get("github_repos", [])}
315
+ excluded_authors = {a.lower() for a in exclusions.get("authors", [])}
316
+
317
+ # Pick 5 random topics to rotate
318
+ topics = random.sample(search_topics, min(5, len(search_topics)))
319
+ threads = []
320
+
321
+ for topic in topics:
322
+ try:
323
+ result = subprocess.run(
324
+ ["gh", "search", "issues", topic, "--limit", "10",
325
+ "--state", "open", "--sort", "updated",
326
+ "--json", "url,title,author,repository"],
327
+ capture_output=True, text=True, timeout=15
328
+ )
329
+ if result.returncode != 0:
330
+ continue
331
+ issues = json.loads(result.stdout) if result.stdout.strip() else []
332
+ except Exception as e:
333
+ print(f" ERROR searching GitHub for '{topic}': {e}", file=sys.stderr)
334
+ continue
335
+
336
+ for issue in issues:
337
+ repo_name = issue.get("repository", {}).get("nameWithOwner", "")
338
+ author = issue.get("author", {}).get("login", "")
339
+
340
+ # Apply exclusions
341
+ if any(excl in repo_name.lower() for excl in excluded_repos):
342
+ continue
343
+ if author.lower() in excluded_authors:
344
+ continue
345
+
346
+ threads.append({
347
+ "platform": "github_issues",
348
+ "url": issue.get("url", ""),
349
+ "title": issue.get("title", ""),
350
+ "author": author,
351
+ "score": 0,
352
+ "num_comments": 0,
353
+ "search_topic": topic,
354
+ "repository": repo_name,
355
+ })
356
+
357
+ if len(threads) >= limit:
358
+ break
359
+
360
+ return threads[:limit]
361
+
362
+
363
+ def load_exclusions(config):
364
+ """Load exclusion lists from config."""
365
+ excl = config.get("exclusions", {})
366
+ return {
367
+ "authors": {a.lower() for a in excl.get("authors", [])},
368
+ "subreddits": {s.lower().lstrip("r/") for s in excl.get("subreddits", [])},
369
+ "urls": excl.get("urls", []),
370
+ "keywords": [k.lower() for k in excl.get("keywords", [])],
371
+ }
372
+
373
+
374
+ def is_excluded(thread, exclusions):
375
+ """Check if a thread matches any exclusion rule."""
376
+ # Author exclusion
377
+ author = thread.get("author", "").lower()
378
+ if author and author in exclusions["authors"]:
379
+ return "excluded_author"
380
+
381
+ # Subreddit exclusion
382
+ sub = thread.get("subreddit", "").lower().lstrip("r/")
383
+ if sub and sub in exclusions["subreddits"]:
384
+ return "excluded_subreddit"
385
+
386
+ # URL pattern exclusion
387
+ url = thread.get("url", "")
388
+ for pattern in exclusions["urls"]:
389
+ if pattern in url:
390
+ return "excluded_url"
391
+
392
+ # Keyword exclusion (skip threads containing these keywords)
393
+ if exclusions["keywords"]:
394
+ text = f"{thread.get('title', '')} {thread.get('selftext', '')} {thread.get('content', '')}".lower()
395
+ for kw in exclusions["keywords"]:
396
+ if kw in text:
397
+ return "excluded_keyword"
398
+
399
+ return None
400
+
401
+
402
+ def filter_threads(threads, already_posted, topic=None, exclusions=None):
403
+ """Filter out already-posted threads and optionally filter by topic."""
404
+ if exclusions is None:
405
+ exclusions = {"authors": set(), "subreddits": set(), "urls": [], "keywords": []}
406
+ filtered = []
407
+ for t in threads:
408
+ if t["url"] in already_posted:
409
+ t["skip_reason"] = "already_posted"
410
+ continue
411
+ excl_reason = is_excluded(t, exclusions)
412
+ if excl_reason:
413
+ t["skip_reason"] = excl_reason
414
+ continue
415
+ if topic and t.get("discovery_method") != "search_url":
416
+ text = f"{t.get('title', '')} {t.get('selftext', '')} {t.get('content', '')}".lower()
417
+ if topic.lower() not in text:
418
+ continue
419
+ filtered.append(t)
420
+ return filtered
421
+
422
+
423
+ def main():
424
+ parser = argparse.ArgumentParser(description="Find candidate threads to comment on")
425
+ parser.add_argument("--subreddits", default=None, help="Comma-separated subreddits (e.g. ClaudeAI,programming)")
426
+ parser.add_argument("--topic", default=None, help="Filter threads by topic keyword")
427
+ parser.add_argument("--sort", default="new", choices=["new", "hot", "top"], help="Reddit sort order")
428
+ parser.add_argument("--limit", type=int, default=10, help="Threads per subreddit")
429
+ parser.add_argument("--include-moltbook", action="store_true", help="Also search Moltbook")
430
+ parser.add_argument("--include-twitter", action="store_true", help="Generate X/Twitter search URLs")
431
+ parser.add_argument("--include-linkedin", action="store_true", help="Generate LinkedIn search URLs")
432
+ parser.add_argument("--include-github", action="store_true", help="Search GitHub issues via gh CLI")
433
+ parser.add_argument("--project", default=None, help="Use topics/subreddits from a specific project in config.json")
434
+ parser.add_argument("--force", action="store_true", help="Skip rate limit check")
435
+ args = parser.parse_args()
436
+
437
+ config = load_config()
438
+
439
+ # If --project is specified, use that project's config for topics/subreddits
440
+ project_config = None
441
+ if args.project:
442
+ for p in config.get("projects", []):
443
+ if p["name"].lower() == args.project.lower():
444
+ project_config = p
445
+ break
446
+ if not project_config:
447
+ print(json.dumps({"error": f"project '{args.project}' not found", "threads": []}))
448
+ sys.exit(1)
449
+
450
+ subreddits = args.subreddits.split(",") if args.subreddits else (
451
+ project_config.get("subreddits", config.get("subreddits", []))
452
+ if project_config else config.get("subreddits", [])
453
+ )
454
+ reddit_username = config.get("accounts", {}).get("reddit", {}).get("username", "")
455
+ user_agent = f"social-autoposter/1.0 (u/{reddit_username})" if reddit_username else "social-autoposter/1.0"
456
+
457
+ # Rate limit check
458
+ posts_today, can_post = check_rate_limit()
459
+ if not can_post and not args.force:
460
+ print(json.dumps({"error": "rate_limit", "posts_today": posts_today, "threads": []}))
461
+ sys.exit(1)
462
+
463
+ already_posted = get_already_posted()
464
+ recent_posts = get_recent_posts()
465
+
466
+ # Pre-filter excluded subreddits before fetching (saves API calls)
467
+ exclusions = load_exclusions(config)
468
+ if exclusions["subreddits"]:
469
+ subreddits = [s for s in subreddits if s.lower().lstrip("r/") not in exclusions["subreddits"]]
470
+
471
+ # Fetch threads
472
+ threads = fetch_reddit_threads(subreddits, sort=args.sort, limit=args.limit, user_agent=user_agent)
473
+
474
+ if args.include_moltbook:
475
+ moltbook_key = os.environ.get("MOLTBOOK_API_KEY", "")
476
+ threads.extend(fetch_moltbook_threads(moltbook_key))
477
+
478
+ # DB-backed search_topics is the single source of truth across platforms
479
+ # (post 2026-05-27 config.json removal; legacy *_topics fields removed 2026-04-30).
480
+ project_search_topics = list(topics_for_project((project_config or {}).get("name") or ""))
481
+
482
+ if args.include_twitter:
483
+ twitter_topics = list(project_search_topics)
484
+ if args.topic:
485
+ twitter_topics = [t for t in twitter_topics if args.topic.lower() in t.lower()]
486
+ raw_excl = config.get("exclusions", {})
487
+ threads.extend(generate_twitter_search_urls(twitter_topics, exclusions=raw_excl))
488
+
489
+ if args.include_linkedin:
490
+ linkedin_topics = list(project_search_topics)
491
+ if args.topic:
492
+ linkedin_topics = [t for t in linkedin_topics if args.topic.lower() in t.lower()]
493
+ raw_excl = config.get("exclusions", {})
494
+ threads.extend(generate_linkedin_search_urls(linkedin_topics, exclusions=raw_excl))
495
+
496
+ if args.include_github:
497
+ github_topics = list(project_search_topics)
498
+ if args.topic:
499
+ github_topics = [t for t in github_topics if args.topic.lower() in t.lower()]
500
+ raw_excl = config.get("exclusions", {})
501
+ threads.extend(fetch_github_issues(github_topics, exclusions=raw_excl))
502
+
503
+ # Filter
504
+ candidates = filter_threads(threads, already_posted, topic=args.topic, exclusions=exclusions)
505
+
506
+ output = {
507
+ "posts_today": posts_today,
508
+ "can_post": can_post,
509
+ "project": project_config["name"] if project_config else None,
510
+ "total_found": len(threads),
511
+ "candidates": len(candidates),
512
+ "recent_post_snippets": [p if p else "" for p in recent_posts],
513
+ "threads": candidates,
514
+ }
515
+
516
+ # Include engaged LinkedIn authors for dedup (author-level, not URL-level)
517
+ if args.include_linkedin:
518
+ output["engaged_linkedin_authors"] = sorted(get_engaged_linkedin_authors())
519
+ output["engaged_linkedin_count"] = len(output["engaged_linkedin_authors"])
520
+ # ID-set dedup: every URN we've engaged with on LinkedIn. LLM must
521
+ # extract the activity/share/ugcPost ID from a candidate post URL
522
+ # and skip if it appears here. Catches URL-shape drift across runs
523
+ # (/feed/update/ vs /posts/...-share-...).
524
+ output["engaged_linkedin_post_ids"] = get_engaged_linkedin_post_ids()
525
+
526
+ print(json.dumps(output, indent=2))
527
+
528
+
529
+ if __name__ == "__main__":
530
+ main()
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Dedicated, isolated logging for the Twitter follow-gate.
3
+
4
+ The follow-gate in score_twitter_candidates.py drops candidate threads whose
5
+ author we already follow. Its `[follow_gate]` stderr markers land in the giant
6
+ mixed twitter-cycle log; this helper ALSO writes a clean, timestamped, greppable
7
+ record to skill/logs/follow-gate.log so you can `tail -f` exactly what the filter
8
+ loads and catches each cycle, without digging through 20MB of cycle output.
9
+
10
+ All functions are best-effort: they NEVER raise, so logging can never break the
11
+ fail-open gate. If the log can't be written, the gate proceeds silently.
12
+
13
+ Line formats (one CYCLE line per scoring run, one SKIP line per dropped author):
14
+ <iso8601> <our_account> CYCLE loaded=<N> source=<ok|404|error|unresolved> checked=<M> skipped=<K> batch=<id>
15
+ <iso8601> <our_account> SKIP @<handle> url=<url> batch=<id>
16
+
17
+ Read it with: tail -f ~/social-autoposter/skill/logs/follow-gate.log
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from datetime import datetime, timezone
23
+
24
+ LOG_PATH = os.path.expanduser("~/social-autoposter/skill/logs/follow-gate.log")
25
+
26
+
27
+ def _now() -> str:
28
+ try:
29
+ return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
30
+ except Exception:
31
+ return "?"
32
+
33
+
34
+ def _append(line: str) -> None:
35
+ try:
36
+ os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
37
+ with open(LOG_PATH, "a") as fh:
38
+ fh.write(line.rstrip("\n") + "\n")
39
+ except Exception:
40
+ # Best-effort: never let logging break the fail-open gate.
41
+ pass
42
+
43
+
44
+ def record_cycle(our_account, loaded, source, checked, skipped, batch_id=None) -> None:
45
+ """One line per scoring run: did the gate load the set (loaded>0, source=ok),
46
+ how many candidates it checked, and how many it skipped this run."""
47
+ _append(
48
+ f"{_now()} {our_account or '(unresolved)'} CYCLE "
49
+ f"loaded={loaded} source={source} checked={checked} "
50
+ f"skipped={skipped} batch={batch_id or '-'}"
51
+ )
52
+
53
+
54
+ def record_skip(our_account, handle, url, batch_id=None) -> None:
55
+ """One line per dropped candidate (author we already follow)."""
56
+ _append(
57
+ f"{_now()} {our_account or '(unresolved)'} SKIP "
58
+ f"@{handle} url={url} batch={batch_id or '-'}"
59
+ )