@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,52 @@
1
+ #!/usr/bin/env python3
2
+ """DEPRECATED 2026-05-05.
3
+
4
+ This script implemented the per-permalink scrape loop pattern that LinkedIn's
5
+ anti-bot system flagged on 2026-05-05 (incident #2 after 2026-04-17). Even
6
+ when CDP-attached to the linkedin-agent MCP, looping `page.goto` over 30
7
+ `/feed/update/<urn>/` permalinks per fire is itself the banned pattern,
8
+ regardless of which Chrome process drives it.
9
+
10
+ Replaced by:
11
+ - skill/stats-linkedin.sh (Claude-driven, MCP linkedin-agent only)
12
+ - scripts/update_linkedin_stats_from_feed.py (DB writer with scan_no_change_count)
13
+
14
+ The new pipeline does ONE navigation per fire to /in/me/recent-activity/all/,
15
+ scroll-loads in-page (native LinkedIn UX), and extracts engagement counts
16
+ for every visible post in a single DOM read. No permalink hops.
17
+
18
+ The locked skill/stats.sh (Step 4 LinkedIn leg) still references this file
19
+ path. Until stats.sh is unlocked and updated to call stats-linkedin.sh
20
+ directly, this stub stays in place to fail fast and keep the rest of
21
+ stats.sh's per-platform fan-out unaffected.
22
+
23
+ Do NOT restore the old body. The git history preserves it if archaeology
24
+ is needed.
25
+ """
26
+
27
+ import json
28
+ import sys
29
+
30
+
31
+ def main() -> None:
32
+ print(
33
+ json.dumps({
34
+ "ok": False,
35
+ "error": "deprecated",
36
+ "detail": (
37
+ "scrape_linkedin_stats_browser.py was retired 2026-05-05 "
38
+ "after triggering LinkedIn anti-bot fingerprinting (incident "
39
+ "#2 in 3 weeks). Use skill/stats-linkedin.sh instead, which "
40
+ "runs MCP-only with a single activity-feed navigation. See "
41
+ "the file header for full context."
42
+ ),
43
+ }),
44
+ file=sys.stderr,
45
+ )
46
+ # Exit 2 (not 1) so stats.sh logs it as a hard failure distinct from
47
+ # 'no eligible posts' (exit 0 with note) or runtime error (exit 1).
48
+ sys.exit(2)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env python3
2
+ """Update Reddit view counts in the database.
3
+
4
+ Reddit doesn't expose view counts via API. Views are scraped from the
5
+ profile page by Claude using MCP Playwright, then saved to a JSON file.
6
+ This script reads that JSON and updates the `views` column in the DB.
7
+
8
+ IMPORTANT — Browser scraping notes for Claude:
9
+ Reddit virtualizes the DOM: items scrolled off-screen get removed.
10
+ You MUST collect view data incrementally as you scroll — NOT after
11
+ scrolling to the bottom. Use this pattern:
12
+ 1. Collect visible articles + view counts
13
+ 2. Scroll down ~600px
14
+ 3. Wait 800-1500ms for new content
15
+ 4. Collect again (dedup by URL in a Map/dict)
16
+ 5. Repeat until no new articles load (check article count, not scroll height)
17
+ View counts appear as text nodes matching /^\d[\d,.]*[KkMm]?\s*views?$/
18
+ inside <article> elements. Parse "1.3K views" -> 1300, "2 views" -> 2.
19
+
20
+ Usage:
21
+ python3 scripts/scrape_reddit_views.py --from-json /tmp/reddit_views.json
22
+ python3 scripts/scrape_reddit_views.py --from-json /tmp/reddit_views.json --json
23
+ """
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
+ import http_api
33
+ from http_api import api_get
34
+
35
+
36
+ def extract_ids(url):
37
+ """Extract (post_id, comment_id) from any reddit URL format."""
38
+ url = re.sub(r"https?://(old|www|new)\.reddit\.com", "", url)
39
+ url = re.sub(r"\?.*$", "", url).rstrip("/")
40
+
41
+ # New format: /r/sub/comments/POST_ID/comment/COMMENT_ID
42
+ m = re.search(r"/comments/([a-z0-9]+)/comment/([a-z0-9]+)", url)
43
+ if m:
44
+ return (m.group(1), m.group(2))
45
+
46
+ # Old format: /r/sub/comments/POST_ID/slug/COMMENT_ID
47
+ m = re.search(r"/comments/([a-z0-9]+)/[^/]+/([a-z0-9]+)", url)
48
+ if m:
49
+ return (m.group(1), m.group(2))
50
+
51
+ # Post only: /r/sub/comments/POST_ID/...
52
+ m = re.search(r"/comments/([a-z0-9]+)", url)
53
+ if m:
54
+ return (m.group(1), None)
55
+
56
+ return (None, None)
57
+
58
+
59
+ def _list_active_reddit_posts():
60
+ """Paginated GET against /api/v1/posts?platform=reddit&status=active.
61
+
62
+ Returns a list of {id, our_url} dicts so callers can keep the prior
63
+ contract. The API's `limit` is capped at 500 server-side; we page by
64
+ walking `posted_at` cursors until we get a short page back.
65
+ """
66
+ out = []
67
+ since = None
68
+ seen_ids = set()
69
+ while True:
70
+ query = {
71
+ "platform": "reddit",
72
+ "status": "active",
73
+ "limit": 500,
74
+ }
75
+ if since:
76
+ query["since"] = since
77
+ resp = api_get("/api/v1/posts", query=query)
78
+ rows = ((resp or {}).get("data") or {}).get("posts") or []
79
+ new = 0
80
+ oldest = None
81
+ for r in rows:
82
+ pid = r.get("id")
83
+ if pid is None or pid in seen_ids:
84
+ continue
85
+ seen_ids.add(pid)
86
+ if not r.get("our_url"):
87
+ continue
88
+ out.append({"id": int(pid), "our_url": r.get("our_url")})
89
+ new += 1
90
+ ts = r.get("posted_at")
91
+ if ts and (oldest is None or ts < oldest):
92
+ oldest = ts
93
+ # Stop when the server returned fewer than the page size (no more
94
+ # posts behind the cursor) OR no rows were new this iteration.
95
+ if not rows or new == 0 or len(rows) < 500:
96
+ break
97
+ # The /api/v1/posts GET orders by posted_at DESC and filters
98
+ # since >= ${since}. Walking older requires the inverse (a
99
+ # `posted_at <` cursor), which the route doesn't yet expose; one
100
+ # page of 500 covers most refresh cycles. If we ever outgrow that,
101
+ # add `before` / `cursor` to the GET and resume here.
102
+ break
103
+ return out
104
+
105
+
106
+ def update_views(db, scraped_data, quiet=False):
107
+ """Match scraped view data to DB posts and update.
108
+
109
+ scraped_data accepts:
110
+ - list of dicts {url, views, score?, comments_count?}
111
+ - legacy list of {url, views}
112
+ - legacy dict {url: views}
113
+
114
+ Score sources on the profile page:
115
+ - Thread rows: <shreddit-post score="N" comment-count="N">
116
+ - Comment rows: <shreddit-comment-action-row score="N"> (no reply count)
117
+ Views are visible text on both row types.
118
+ """
119
+ # Normalise to list of dicts
120
+ if isinstance(scraped_data, dict):
121
+ normalised = [{"url": u, "views": v} for u, v in scraped_data.items()]
122
+ else:
123
+ normalised = []
124
+ for item in scraped_data:
125
+ if isinstance(item, dict):
126
+ normalised.append(item)
127
+ elif isinstance(item, (list, tuple)) and len(item) >= 2:
128
+ normalised.append({"url": item[0], "views": item[1]})
129
+
130
+ views_by_comment = {}
131
+ views_by_post = {} # post_id -> max views (threads)
132
+ score_by_comment = {} # comment_id -> score (comment rows)
133
+ score_by_post = {} # post_id -> score (thread rows)
134
+ cc_by_post = {} # post_id -> comment-count attr (thread rows)
135
+
136
+ for item in normalised:
137
+ url = item.get("url")
138
+ if not url:
139
+ continue
140
+ views = item.get("views")
141
+ score = item.get("score")
142
+ cc = item.get("comments_count")
143
+ post_id, comment_id = extract_ids(url)
144
+
145
+ if views is not None:
146
+ if comment_id:
147
+ views_by_comment[comment_id] = views
148
+ if post_id:
149
+ if post_id not in views_by_post or views > views_by_post[post_id]:
150
+ views_by_post[post_id] = views
151
+ if score is not None:
152
+ if comment_id:
153
+ score_by_comment[comment_id] = score
154
+ elif post_id:
155
+ score_by_post[post_id] = score
156
+ if cc is not None and post_id and not comment_id:
157
+ cc_by_post[post_id] = cc
158
+
159
+ posts = _list_active_reddit_posts()
160
+
161
+ matched = 0
162
+ matched_comment_score = 0
163
+ matched_thread_stats = 0
164
+ unmatched = 0
165
+
166
+ for post in posts:
167
+ db_id, our_url = post["id"], post["our_url"]
168
+ post_id, comment_id = extract_ids(our_url)
169
+
170
+ views = None
171
+ if comment_id and comment_id in views_by_comment:
172
+ views = views_by_comment[comment_id]
173
+ elif post_id and post_id in views_by_post:
174
+ views = views_by_post[post_id]
175
+
176
+ score_val = None
177
+ cc_val = None
178
+ if comment_id:
179
+ score_val = score_by_comment.get(comment_id)
180
+ elif post_id:
181
+ score_val = score_by_post.get(post_id)
182
+ cc_val = cc_by_post.get(post_id)
183
+
184
+ has_update = views is not None or score_val is not None or cc_val is not None
185
+ if has_update:
186
+ patch_body = {"stamp_engagement_now": True}
187
+ if views is not None:
188
+ patch_body["views"] = views
189
+ if score_val is not None:
190
+ patch_body["upvotes"] = score_val
191
+ if cc_val is not None:
192
+ patch_body["comments_count"] = cc_val
193
+ http_api.api_patch(f"/api/v1/posts/{db_id}", patch_body)
194
+ if views is not None:
195
+ http_api.api_post(f"/api/v1/posts/{db_id}/views", {"views": views})
196
+ matched += 1
197
+ if comment_id and score_val is not None:
198
+ matched_comment_score += 1
199
+ if comment_id is None and (score_val is not None or cc_val is not None):
200
+ matched_thread_stats += 1
201
+ else:
202
+ unmatched += 1
203
+
204
+ # ---- Second pass: walk the `replies` table (DM-rail follow-ups) ----
205
+ # 2026-05-18: the Reddit profile-page scrape already captures view + score
206
+ # for every comment we've made, including reply-to-replies that live in
207
+ # the `replies` table (not `posts`). Before this pass those rows defaulted
208
+ # to views=0 because update_reddit_replies() uses Reddit's JSON API, which
209
+ # doesn't expose per-comment views. The scrape data is already on disk;
210
+ # all we have to do is also match `replies.our_reply_id` against the
211
+ # scraped (post_id, comment_id) keys and PATCH the row.
212
+ replies_matched = 0
213
+ replies_unmatched = 0
214
+ try:
215
+ resp = api_get(
216
+ "/api/v1/replies",
217
+ query={
218
+ "platform": "reddit",
219
+ "status": "replied",
220
+ "has_our_reply_id": "true",
221
+ "order_by": "id",
222
+ "limit": 500,
223
+ },
224
+ )
225
+ reply_rows = ((resp or {}).get("data") or {}).get("replies") or []
226
+ except Exception:
227
+ reply_rows = []
228
+
229
+ for r in reply_rows:
230
+ rid = r.get("id")
231
+ our_reply_id = r.get("our_reply_id")
232
+ if not rid or not our_reply_id:
233
+ continue
234
+ # our_reply_id is the bare base-36 comment ID (no `t1_` prefix).
235
+ cid = our_reply_id.replace("t1_", "")
236
+ views = views_by_comment.get(cid)
237
+ score = score_by_comment.get(cid)
238
+ if views is None and score is None:
239
+ replies_unmatched += 1
240
+ continue
241
+ patch_body = {"stamp_engagement_now": True}
242
+ if views is not None:
243
+ patch_body["views"] = int(views)
244
+ if score is not None:
245
+ patch_body["upvotes"] = int(score)
246
+ try:
247
+ http_api.api_patch(f"/api/v1/replies/{int(rid)}", patch_body)
248
+ replies_matched += 1
249
+ except Exception:
250
+ replies_unmatched += 1
251
+
252
+ return {
253
+ "matched": matched,
254
+ "matched_comment_score": matched_comment_score,
255
+ "matched_thread_stats": matched_thread_stats,
256
+ "unmatched": unmatched,
257
+ "replies_matched": replies_matched,
258
+ "replies_unmatched": replies_unmatched,
259
+ "scraped_total": len(normalised),
260
+ "with_views": len(views_by_comment) + len(views_by_post),
261
+ "with_score_comment": len(score_by_comment),
262
+ "with_score_thread": len(score_by_post),
263
+ "with_comments_count": len(cc_by_post),
264
+ }
265
+
266
+
267
+ def main():
268
+ parser = argparse.ArgumentParser(description="Update Reddit view counts from scraped JSON")
269
+ parser.add_argument("--from-json", required=True, help="Path to JSON file with scraped views")
270
+ parser.add_argument("--quiet", action="store_true", help="Minimal output")
271
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
272
+ parser.add_argument("--summary", default=None,
273
+ help="Write a small JSON file ({refreshed: N, unmatched: N}) so "
274
+ "stats.sh can aggregate the dashboard refreshed pill.")
275
+ args = parser.parse_args()
276
+
277
+ if not os.path.exists(args.from_json):
278
+ print(f"ERROR: File not found: {args.from_json}", file=sys.stderr)
279
+ sys.exit(1)
280
+
281
+ with open(args.from_json) as f:
282
+ scraped_data = json.load(f)
283
+
284
+ if not args.quiet:
285
+ print(f"Loaded {len(scraped_data)} items from {args.from_json}")
286
+
287
+ result = update_views(None, scraped_data, quiet=args.quiet)
288
+
289
+ # Aggregate totals via /api/v1/posts/totals. Excludes platforms we don't
290
+ # want in the headline (github_issues, moltbook) and only counts active
291
+ # rows. Net upvotes strip the self-upvote +1 server-side.
292
+ from datetime import datetime, timezone as _tz
293
+ totals_resp = api_get(
294
+ "/api/v1/posts/totals",
295
+ query={
296
+ "status": "active",
297
+ "exclude_platforms": "github_issues,moltbook",
298
+ },
299
+ )
300
+ t = (totals_resp or {}).get("data") or {}
301
+ total_views = int(t.get("total_views") or 0)
302
+ total_upvotes = int(t.get("total_upvotes") or 0)
303
+ total_comments = int(t.get("total_comments") or 0)
304
+ total_posts = int(t.get("total_posts") or 0)
305
+ first_post_iso = t.get("first_post_at")
306
+ first_post = None
307
+ if first_post_iso:
308
+ try:
309
+ first_post = datetime.fromisoformat(first_post_iso.replace("Z", "+00:00"))
310
+ except Exception:
311
+ first_post = None
312
+ if first_post:
313
+ now = datetime.now(first_post.tzinfo) if first_post.tzinfo else datetime.now()
314
+ days = max((now - first_post).days, 1)
315
+ else:
316
+ days = 1
317
+
318
+ result["totals"] = {
319
+ "total_views": total_views, "total_upvotes": total_upvotes,
320
+ "total_comments": total_comments, "total_posts": total_posts,
321
+ "days_active": days, "views_per_day": round(total_views / days) if days else 0,
322
+ }
323
+
324
+ if args.summary:
325
+ try:
326
+ # `refreshed` is the count stats.sh consumes for the "views-refreshed"
327
+ # pill. Sum both legs: posts table + replies table (DM-rail follow-ups,
328
+ # added 2026-05-18). Pre-2026-05-18 logs only had the posts leg.
329
+ refreshed_total = int(result.get("matched", 0) or 0) + \
330
+ int(result.get("replies_matched", 0) or 0)
331
+ with open(args.summary, "w") as f:
332
+ json.dump({
333
+ "refreshed": refreshed_total,
334
+ "refreshed_posts": int(result.get("matched", 0) or 0),
335
+ "refreshed_replies": int(result.get("replies_matched", 0) or 0),
336
+ "unmatched": int(result.get("unmatched", 0) or 0),
337
+ }, f)
338
+ except Exception as e:
339
+ print(f"WARN: failed to write summary {args.summary}: {e}", file=sys.stderr)
340
+
341
+ if args.json:
342
+ print(json.dumps(result, indent=2))
343
+ else:
344
+ # Stats.sh greps for "^Reddit Views:" and extracts the "<N> DB posts
345
+ # updated" number for the views-refreshed pill. Include the replies
346
+ # leg in the same number so the pill reflects ALL rows whose view
347
+ # counts got written this run, not just the posts table.
348
+ total_refreshed = result.get("matched", 0) + result.get("replies_matched", 0)
349
+ print(
350
+ f"Reddit Views: {result['with_views']} had views, "
351
+ f"{total_refreshed} DB posts updated "
352
+ f"(posts={result.get('matched', 0)} replies={result.get('replies_matched', 0)}), "
353
+ f"{result['unmatched']} unmatched"
354
+ )
355
+ t = result["totals"]
356
+ print(f"\n--- Totals ({t['days_active']} days) ---")
357
+ print(f"Posts: {t['total_posts']} | "
358
+ f"Views: {t['total_views']:,} | "
359
+ f"Upvotes: {t['total_upvotes']:,} | "
360
+ f"Comments: {t['total_comments']:,} | "
361
+ f"Views/day: {t['views_per_day']:,}")
362
+
363
+
364
+ if __name__ == "__main__":
365
+ main()