@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,261 @@
1
+ #!/usr/bin/env python3
2
+ """Refresh engagement stats for Instagram posts via the IG Graph API.
3
+
4
+ For each posts.platform='instagram' row, look up its media_id (matching by
5
+ permalink against /me/media for the account), then call /{media-id}/insights
6
+ to fetch views, reach, likes, comments, saved, shares. Write into the same
7
+ flat columns the rest of the dashboard uses (posts.upvotes/comments_count/
8
+ views) plus engagement_updated_at, and snapshot to post_views_daily for the
9
+ Trends tab.
10
+
11
+ Source-of-truth mapping (per `media_posts.target_account`):
12
+ matt_diak -> IG_USER_ID + IG_LONG_TOKEN OR
13
+ IG_USER_ID_MATTDIAK + IG_LONG_TOKEN_MATTDIAK
14
+ matthewheartful -> IG_USER_ID_MATTHEWHEARTFUL + IG_LONG_TOKEN_MATTHEWHEARTFUL
15
+ omidotme -> IG_USER_ID_OMIDOTME + IG_LONG_TOKEN_OMIDOTME
16
+
17
+ Likes -> posts.upvotes (LinkedIn/Twitter convention).
18
+ Views -> posts.views.
19
+ Comments -> posts.comments_count.
20
+
21
+ Usage:
22
+ python3 scripts/update_instagram_stats.py [--quiet] [--limit N]
23
+ """
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+ import time
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ from pathlib import Path
35
+
36
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
37
+ from http_api import api_get, api_patch, api_post
38
+
39
+
40
+ IG_ENV_PATH = Path.home() / "instagram-graph-api" / ".env"
41
+ GRAPH = "https://graph.instagram.com/v22.0"
42
+ SA_CONFIG = Path(__file__).resolve().parent.parent / "config.json"
43
+
44
+
45
+ # ── env / credentials ─────────────────────────────────────────────────────────
46
+
47
+ def load_ig_env():
48
+ env = {}
49
+ for line in IG_ENV_PATH.read_text().splitlines():
50
+ line = line.strip()
51
+ if not line or line.startswith("#") or "=" not in line:
52
+ continue
53
+ k, v = line.split("=", 1)
54
+ env[k.strip()] = v.strip()
55
+ return env
56
+
57
+
58
+ def resolve_account_creds(account_name, ig_env, accounts_cfg):
59
+ match = next(
60
+ (a for a in accounts_cfg if a.get("username", "").lower() == account_name.lower()),
61
+ None,
62
+ )
63
+ if match:
64
+ uid = ig_env.get(match.get("ig_user_id_env", "IG_USER_ID"))
65
+ tok = ig_env.get(match.get("ig_long_token_env", "IG_LONG_TOKEN"))
66
+ if uid and tok:
67
+ return uid, tok
68
+ # Legacy bare-env fallback (matt_diak historically used IG_USER_ID/IG_LONG_TOKEN).
69
+ uid = ig_env.get("IG_USER_ID")
70
+ tok = ig_env.get("IG_LONG_TOKEN")
71
+ return uid, tok
72
+
73
+
74
+ # ── Graph API helpers ─────────────────────────────────────────────────────────
75
+
76
+ def graph_get(path, token, **params):
77
+ params["access_token"] = token
78
+ url = f"{GRAPH}/{path}?{urllib.parse.urlencode(params)}"
79
+ with urllib.request.urlopen(url, timeout=20) as r:
80
+ return json.loads(r.read())
81
+
82
+
83
+ def shortcode_from_url(url):
84
+ """Pull the IG shortcode out of a permalink.
85
+ https://www.instagram.com/reel/DYkkj8RDo9P/ -> DYkkj8RDo9P
86
+ """
87
+ m = re.search(r"/(?:reel|p|tv)/([A-Za-z0-9_-]+)", url or "")
88
+ return m.group(1) if m else None
89
+
90
+
91
+ def fetch_media_map(ig_user_id, token, max_pages=10):
92
+ """Return {shortcode: {id, media_product_type, like_count, comments_count}}
93
+ for the account's recent media. Pages through /me/media until exhaustion or
94
+ max_pages safety cap.
95
+ """
96
+ out = {}
97
+ fields = "id,media_type,media_product_type,permalink,like_count,comments_count"
98
+ url = f"{GRAPH}/{ig_user_id}/media?fields={fields}&limit=100&access_token={token}"
99
+ pages = 0
100
+ while url and pages < max_pages:
101
+ with urllib.request.urlopen(url, timeout=20) as r:
102
+ data = json.loads(r.read())
103
+ for item in data.get("data", []):
104
+ code = shortcode_from_url(item.get("permalink"))
105
+ if code:
106
+ out[code] = item
107
+ url = (data.get("paging") or {}).get("next")
108
+ pages += 1
109
+ return out
110
+
111
+
112
+ def fetch_insights(media_id, mtype, token):
113
+ """Return {metric_name: value}. Pick metrics based on media type."""
114
+ if mtype == "REELS":
115
+ metrics = "views,reach,likes,comments,saved,shares,total_interactions"
116
+ elif mtype == "VIDEO":
117
+ metrics = "views,reach,likes,comments,saved,shares"
118
+ else:
119
+ metrics = "reach,likes,comments,saved,shares"
120
+ try:
121
+ data = graph_get(f"{media_id}/insights", token, metric=metrics)
122
+ except urllib.error.HTTPError as e:
123
+ return {"__error__": f"HTTP {e.code}: {e.read().decode()[:200]}"}
124
+ result = {}
125
+ for m in data.get("data", []):
126
+ name = m.get("name")
127
+ vals = m.get("values") or []
128
+ result[name] = (vals[0] or {}).get("value") if vals else None
129
+ return result
130
+
131
+
132
+ # ── main ──────────────────────────────────────────────────────────────────────
133
+
134
+ def log(msg, quiet=False):
135
+ if not quiet:
136
+ print(msg)
137
+
138
+
139
+ def main():
140
+ parser = argparse.ArgumentParser()
141
+ parser.add_argument("--quiet", action="store_true")
142
+ parser.add_argument("--limit", type=int, default=None)
143
+ args = parser.parse_args()
144
+
145
+ ig_env = load_ig_env()
146
+ try:
147
+ cfg = json.loads(SA_CONFIG.read_text())
148
+ except FileNotFoundError:
149
+ cfg = {}
150
+ accounts_cfg = ((cfg.get("instagram") or {}).get("accounts") or [])
151
+
152
+ resp = api_get(
153
+ "/api/v1/posts",
154
+ query={
155
+ "platform": "instagram",
156
+ "status": "active",
157
+ "has_our_url": "true",
158
+ "order_by": "id",
159
+ "order_dir": "asc",
160
+ "limit": 500,
161
+ },
162
+ )
163
+ rows = (resp.get("data") or {}).get("posts") or []
164
+ if args.limit:
165
+ rows = rows[: args.limit]
166
+
167
+ log(f"[stats-ig] {len(rows)} active IG rows to check", args.quiet)
168
+
169
+ by_account = {}
170
+ for r in rows:
171
+ by_account.setdefault(r["our_account"], []).append(r)
172
+
173
+ checked = 0
174
+ updated = 0
175
+ failed = 0
176
+ not_found = 0
177
+ views_refreshed = 0
178
+
179
+ for account, account_rows in by_account.items():
180
+ uid, tok = resolve_account_creds(account, ig_env, accounts_cfg)
181
+ if not uid or not tok:
182
+ log(f"[stats-ig] missing creds for account={account}; skipping {len(account_rows)} rows", args.quiet)
183
+ failed += len(account_rows)
184
+ continue
185
+ try:
186
+ media_map = fetch_media_map(uid, tok)
187
+ except Exception as e:
188
+ log(f"[stats-ig] media list failed for {account}: {e}; skipping {len(account_rows)} rows", args.quiet)
189
+ failed += len(account_rows)
190
+ continue
191
+ log(f"[stats-ig] account={account} media-map size={len(media_map)}", args.quiet)
192
+
193
+ for r in account_rows:
194
+ checked += 1
195
+ code = shortcode_from_url(r["our_url"])
196
+ if not code:
197
+ log(f"[stats-ig] id={r['id']} no shortcode in {r['our_url']}", args.quiet)
198
+ not_found += 1
199
+ continue
200
+ item = media_map.get(code)
201
+ if not item:
202
+ log(f"[stats-ig] id={r['id']} shortcode={code} not in /me/media listing", args.quiet)
203
+ not_found += 1
204
+ continue
205
+
206
+ media_id = item["id"]
207
+ mtype = item.get("media_product_type") or item.get("media_type")
208
+ ins = fetch_insights(media_id, mtype, tok)
209
+ if "__error__" in ins:
210
+ log(f"[stats-ig] id={r['id']} insights error: {ins['__error__']}", args.quiet)
211
+ failed += 1
212
+ continue
213
+
214
+ likes = ins.get("likes") or item.get("like_count") or 0
215
+ comments = ins.get("comments") or item.get("comments_count") or 0
216
+ views = ins.get("views") # None for photos; that's fine
217
+
218
+ old = (r["upvotes"] or 0, r["comments_count"] or 0, r["views"] or 0)
219
+ new = (likes, comments, views or 0)
220
+
221
+ api_patch(
222
+ f"/api/v1/posts/{r['id']}",
223
+ {
224
+ "upvotes": likes,
225
+ "comments_count": comments,
226
+ "views": views,
227
+ "stamp_engagement_now": True,
228
+ "stamp_status_checked_now": True,
229
+ "reset_deletion_detect_count": True,
230
+ },
231
+ )
232
+ if views is not None:
233
+ api_post(
234
+ "/api/v1/post-views-daily/snapshot",
235
+ {"post_id": r["id"], "views": views},
236
+ )
237
+ views_refreshed += 1
238
+ if new != old:
239
+ updated += 1
240
+ log(
241
+ f"[stats-ig] id={r['id']} code={code} type={mtype} "
242
+ f"likes={likes} comments={comments} views={views}",
243
+ args.quiet,
244
+ )
245
+ # Be polite to the Graph API.
246
+ time.sleep(0.2)
247
+
248
+ log(
249
+ f"[stats-ig] done: checked={checked} updated={updated} "
250
+ f"not_found={not_found} failed={failed} views_refreshed={views_refreshed}",
251
+ args.quiet,
252
+ )
253
+ # Machine-readable summary for the shell wrapper to consume.
254
+ print(
255
+ f"SUMMARY:CHECKED={checked} UPDATED={updated} NOT_FOUND={not_found} "
256
+ f"FAILED={failed} VIEWS_REFRESHED={views_refreshed}"
257
+ )
258
+
259
+
260
+ if __name__ == "__main__":
261
+ main()
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env python3
2
+ """Update LinkedIn engagement stats for OUR engagement-comments stored in `posts`.
3
+
4
+ This is the posts-table sibling of update_linkedin_comment_stats_from_feed.py.
5
+ Same feed JSON shape (produced by skill/stats-linkedin.sh via
6
+ scrape_linkedin_comment_stats.py); different DB target.
7
+
8
+ Why this exists separately from the replies-table updater:
9
+ LinkedIn engagement-comments are stored in the `posts` table (Twitter
10
+ parity: posts table holds top-level + reply rows alike, identified by
11
+ the URL extracted from `our_url`). The legacy `replies` table holds an
12
+ older sliver (~173 rows) from a previous pipeline shape and is updated
13
+ by update_linkedin_comment_stats_from_feed.py. New rows land in `posts`.
14
+
15
+ Input JSON shape (one record per OUR comment visible on the activity tab,
16
+ virtualized list, partial coverage per fire is expected; identical to the
17
+ shape the replies-table updater consumes):
18
+ [
19
+ {
20
+ "comment_id": "7457492815716032512",
21
+ "parent_kind": "ugcPost" | "activity" | "share",
22
+ "parent_id": "7457485938131161088",
23
+ "impressions": 156,
24
+ "reactions": 7,
25
+ "replies": 1
26
+ },
27
+ ...
28
+ ]
29
+
30
+ Matching strategy:
31
+ - Posts written 2026-05-11 onward have `our_url` containing
32
+ `?commentUrn=urn:li:comment:(...,<comment_id>)` because
33
+ linkedin_api.py:comment_on_post now embeds it (and reply_to_comment
34
+ already did). We parse comment_id out of `our_url` and key on it.
35
+ - Older rows (pre-2026-05-11) where the autoposter set
36
+ `our_url = thread_url` (parent post URL only, no commentUrn) cannot
37
+ be matched here and will silently miss. They are not backfillable
38
+ without per-permalink scraping (the exact pattern that triggered
39
+ the 2026-04-17 + 2026-05-05 LinkedIn lockouts), so we accept the
40
+ loss. Going forward every new engagement-comment is captured.
41
+ - The 97 pre-existing rows that already have `?commentUrn=` in
42
+ `our_url` (replies-to-comments via reply_to_comment) work
43
+ immediately.
44
+
45
+ Behavior:
46
+ - Match each feed record by comment_id against posts.our_url's
47
+ `commentUrn=` second-numeric-id field.
48
+ - If matched: write upvotes (=reactions), comments_count (=replies),
49
+ views (=impressions), engagement_updated_at = NOW(). Only overwrite
50
+ a column when the new value is non-null.
51
+ - Unmatched feed rows are logged but NOT errors (the same feed JSON
52
+ is consumed by the replies-table updater immediately after this
53
+ script, so a row unmatched here might match there).
54
+ - scan_no_change_count IS maintained, matching stats.py's
55
+ Twitter behavior so dashboard sorting / freshness gates work the
56
+ same way across platforms.
57
+
58
+ Output (stdout) one line for stats.sh's extract_field to parse:
59
+ LinkedInPosts: <T> total, <S> skipped, <C> checked,
60
+ <U> updated, <D> deleted, <E> errors
61
+
62
+ Usage:
63
+ python3 scripts/update_linkedin_stats_from_feed.py \\
64
+ --from-json /tmp/li-stats-feed.json \\
65
+ [--summary /tmp/li-stats-summary.json] \\
66
+ [--dry-run] [--quiet]
67
+ """
68
+ from __future__ import annotations
69
+
70
+ import argparse
71
+ import json
72
+ import os
73
+ import re
74
+ import sys
75
+ import urllib.parse
76
+ from typing import Optional
77
+
78
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
79
+ from http_api import api_get, api_post # noqa: E402
80
+
81
+
82
+ # `urn:li:comment:(urn:li:activity:<parent>,<comment_id>)`
83
+ # `urn:li:comment:(urn:li:ugcPost:<parent>,<comment_id>)`
84
+ # `urn:li:comment:(activity:<parent>,<comment_id>)` ← bare-kind form
85
+ # `urn:li:comment:(ugcPost:<parent>,<comment_id>)` ← bare-kind form
86
+ #
87
+ # Same lenient regex as update_linkedin_comment_stats_from_feed.py — the
88
+ # inner `urn:li:` prefix on the parent namespace is optional because both
89
+ # forms appear in our data depending on which posting path wrote the row.
90
+ COMMENT_URN_RE = re.compile(
91
+ r"urn:li:comment:\((?:urn:li:)?(?P<kind>\w+):(?P<parent>\d+),(?P<cid>\d+)\)"
92
+ )
93
+
94
+
95
+ def extract_comment_id(our_url: Optional[str]) -> Optional[tuple[str, str, str]]:
96
+ """Return (parent_kind, parent_id, comment_id) parsed from our_url, or None."""
97
+ if not our_url:
98
+ return None
99
+ decoded = urllib.parse.unquote(our_url)
100
+ m = COMMENT_URN_RE.search(decoded)
101
+ if not m:
102
+ return None
103
+ return (m.group("kind"), m.group("parent"), m.group("cid"))
104
+
105
+
106
+ def load_feed(path: str) -> list[dict]:
107
+ with open(path) as f:
108
+ raw = json.load(f)
109
+ if not isinstance(raw, list):
110
+ raise ValueError(f"feed file must be a JSON array, got {type(raw).__name__}")
111
+ out = []
112
+ for r in raw:
113
+ if not isinstance(r, dict):
114
+ continue
115
+ cid = r.get("comment_id")
116
+ if not cid:
117
+ continue
118
+ out.append({
119
+ "comment_id": str(cid),
120
+ "parent_kind": r.get("parent_kind") or "",
121
+ "parent_id": str(r.get("parent_id") or ""),
122
+ "impressions": r.get("impressions"),
123
+ "reactions": r.get("reactions"),
124
+ "replies": r.get("replies"),
125
+ })
126
+ return out
127
+
128
+
129
+ def load_engagement_comments() -> dict:
130
+ """Return {comment_id: {id, our_url, upvotes, comments_count, views}}.
131
+
132
+ Only includes LinkedIn `posts` rows where `our_url` carries a
133
+ commentUrn (i.e., we can identify OUR comment, not just the parent
134
+ thread). Status='active' OR 'removed' (removed rows still benefit
135
+ from a final-stats read in case they come back).
136
+
137
+ Migrated 2026-06-01 to GET /api/v1/linkedin-engagement-comments. The
138
+ server returns every candidate row; the brittle commentUrn regex
139
+ (extract_comment_id) stays single-sourced here in Python so the API
140
+ surface never has to replicate it.
141
+ """
142
+ resp = api_get("/api/v1/linkedin-engagement-comments")
143
+ rows = (resp.get("data") or {}).get("rows") or []
144
+ out = {}
145
+ for r in rows:
146
+ parsed = extract_comment_id(r.get("our_url"))
147
+ if not parsed:
148
+ continue
149
+ _, _, cid = parsed
150
+ out[cid] = {
151
+ "id": r["id"],
152
+ "our_url": r["our_url"],
153
+ "upvotes": int(r.get("upvotes") or 0),
154
+ "comments_count": int(r.get("comments_count") or 0),
155
+ "views": int(r.get("views") or 0),
156
+ }
157
+ return out
158
+
159
+
160
+ def compute_one(db_row: dict, feed: dict, dry_run: bool, quiet: bool) -> dict:
161
+ """Compute the update for one feed record against one DB row.
162
+
163
+ Returns {post_id, upvotes, comments_count, views, changed}. Only
164
+ overwrites a column when the feed value is non-null (preserves last
165
+ known value for fresh comments that don't yet have impressions
166
+ computed by LinkedIn). The actual write (UPDATE posts +
167
+ scan_no_change_count maintenance + post_views_daily snapshot) happens
168
+ server-side when the batch is POSTed.
169
+ """
170
+ new_rxn = feed["reactions"]
171
+ new_imp = feed["impressions"]
172
+ new_rep = feed["replies"]
173
+
174
+ next_upv = db_row["upvotes"] if new_rxn is None else int(new_rxn)
175
+ next_cmt = db_row["comments_count"] if new_rep is None else int(new_rep)
176
+ next_vws = db_row["views"] if new_imp is None else int(new_imp)
177
+
178
+ changed = (
179
+ next_upv != db_row["upvotes"]
180
+ or next_cmt != db_row["comments_count"]
181
+ or next_vws != db_row["views"]
182
+ )
183
+
184
+ if not quiet:
185
+ tag = "UPDATED" if changed else "same"
186
+ if dry_run:
187
+ tag = f"DRY-{tag}"
188
+ print(
189
+ f" [{db_row['id']:>6}] cid={feed['comment_id']:>20s} "
190
+ f"upv {db_row['upvotes']}->{next_upv} "
191
+ f"cmt {db_row['comments_count']}->{next_cmt} "
192
+ f"views {db_row['views']}->{next_vws} [{tag}]",
193
+ flush=True,
194
+ )
195
+
196
+ return {
197
+ "post_id": db_row["id"],
198
+ "upvotes": next_upv,
199
+ "comments_count": next_cmt,
200
+ "views": next_vws,
201
+ "changed": changed,
202
+ }
203
+
204
+
205
+ def run(from_json: str,
206
+ summary_path: Optional[str],
207
+ dry_run: bool,
208
+ quiet: bool) -> dict:
209
+ feed = load_feed(from_json)
210
+ if not feed:
211
+ return {
212
+ "ok": True,
213
+ "total": 0, "skipped": 0, "checked": 0,
214
+ "updated": 0, "deleted": 0, "errors": 0,
215
+ "note": "empty_feed",
216
+ }
217
+
218
+ posts_by_cid = load_engagement_comments()
219
+ if not quiet:
220
+ print(
221
+ f"[stats] feed_rows={len(feed)} db_posts_w_commentUrn={len(posts_by_cid)}",
222
+ flush=True,
223
+ )
224
+
225
+ updates = []
226
+ unmatched = []
227
+ errors = 0
228
+
229
+ for fr in feed:
230
+ row = posts_by_cid.get(fr["comment_id"])
231
+ if row is None:
232
+ unmatched.append(fr["comment_id"])
233
+ continue
234
+ try:
235
+ updates.append(compute_one(row, fr, dry_run=dry_run, quiet=quiet))
236
+ except Exception as e:
237
+ errors += 1
238
+ if not quiet:
239
+ print(f" ERROR id={row['id']} {e}", flush=True)
240
+ continue
241
+
242
+ # Counts come from the locally-computed `changed` flags so the totals
243
+ # match in both dry-run and live mode. The server applies the writes
244
+ # (UPDATE posts + scan_no_change_count + post_views_daily snapshot) and
245
+ # returns its own {updated, unchanged}, which equal these.
246
+ updated = sum(1 for u in updates if u["changed"])
247
+ unchanged = sum(1 for u in updates if not u["changed"])
248
+
249
+ if not dry_run and updates:
250
+ resp = api_post("/api/v1/linkedin-engagement-comments", {"updates": updates})
251
+ srv = resp.get("data") or {}
252
+ # Trust the server tallies when present; they should match locals.
253
+ updated = int(srv.get("updated", updated))
254
+ unchanged = int(srv.get("unchanged", unchanged))
255
+
256
+ total = len(feed)
257
+ checked = updated + unchanged
258
+ skipped = len(unmatched)
259
+ deleted = 0
260
+
261
+ result = {
262
+ "ok": True,
263
+ "total": total,
264
+ "skipped": skipped,
265
+ "checked": checked,
266
+ "updated": updated,
267
+ "unchanged": unchanged,
268
+ "deleted": deleted,
269
+ "errors": errors,
270
+ "unmatched": unmatched,
271
+ }
272
+
273
+ if summary_path:
274
+ try:
275
+ with open(summary_path, "w") as f:
276
+ json.dump({
277
+ "refreshed": updated,
278
+ "removed": deleted,
279
+ "unavailable": 0,
280
+ "not_found": len(unmatched),
281
+ }, f)
282
+ except Exception as e:
283
+ print(
284
+ f"WARN: failed to write summary {summary_path}: {e}",
285
+ file=sys.stderr,
286
+ )
287
+
288
+ return result
289
+
290
+
291
+ def main() -> None:
292
+ p = argparse.ArgumentParser(
293
+ description=(
294
+ "Apply LinkedIn engagement-comment readings to the posts table."
295
+ )
296
+ )
297
+ p.add_argument("--from-json", required=True,
298
+ help="Path to JSON produced by scrape_linkedin_comment_stats.py.")
299
+ p.add_argument("--summary", default=None,
300
+ help="Path to write {refreshed,removed,unavailable,not_found} sidecar.")
301
+ p.add_argument("--dry-run", action="store_true",
302
+ help="Compute updates but do not write to DB.")
303
+ p.add_argument("--quiet", action="store_true", help="Minimal output.")
304
+ args = p.parse_args()
305
+
306
+ try:
307
+ result = run(args.from_json, args.summary, args.dry_run, args.quiet)
308
+ except Exception as e:
309
+ print(json.dumps({"ok": False, "error": "fatal", "detail": str(e)}),
310
+ file=sys.stderr)
311
+ sys.exit(1)
312
+
313
+ if not result.get("ok"):
314
+ print(json.dumps(result, indent=2), file=sys.stderr)
315
+ sys.exit(1)
316
+
317
+ print(
318
+ f"LinkedInPosts: {result['total']} total, "
319
+ f"{result['skipped']} skipped, "
320
+ f"{result['checked']} checked, "
321
+ f"{result['updated']} updated, "
322
+ f"{result['deleted']} deleted, "
323
+ f"{result['errors']} errors"
324
+ )
325
+
326
+
327
+ if __name__ == "__main__":
328
+ main()
@@ -0,0 +1,72 @@
1
+ """Read the social-autoposter package.json version once and cache it.
2
+
3
+ Every write to posts / replies / dms stamps `autoposter_version` so we can
4
+ attribute engagement back to the release of the autoposter code that
5
+ produced it ("did 1.5.0 outperform 1.4.x on Reddit?").
6
+
7
+ The value comes from:
8
+ 1. AUTOPOSTER_VERSION env var, if set (lets us pin during testing or
9
+ override for a one-off backfill).
10
+ 2. package.json `version` field in the repo root.
11
+
12
+ Returns None when both lookups fail. Callers MUST tolerate None and pass
13
+ it through to the API; the API stores NULL and the column stays empty for
14
+ that row rather than blocking the write.
15
+
16
+ Why not git SHA: the auto-commit agent at ~/git-dashboard/auto_commit.py
17
+ fires every minute, so the SHA changes constantly without release intent
18
+ and would be noise. The version string is manually bumped per meaningful
19
+ release (bin/cli.js + package.json), which is the right granularity for
20
+ "did this prompt change improve engagement?" analyses.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ from typing import Optional
27
+
28
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
29
+ _PKG_PATH = os.path.join(_REPO_ROOT, "package.json")
30
+
31
+ _cached: Optional[str] = None
32
+ _cached_loaded = False
33
+
34
+
35
+ def read_version() -> Optional[str]:
36
+ """Return the autoposter version string, or None if unavailable.
37
+
38
+ Reads env first, then package.json. Result is cached for the process
39
+ lifetime since the version never changes mid-run.
40
+ """
41
+ global _cached, _cached_loaded
42
+ if _cached_loaded:
43
+ return _cached
44
+
45
+ env_val = (os.environ.get("AUTOPOSTER_VERSION") or "").strip()
46
+ if env_val:
47
+ _cached = env_val
48
+ _cached_loaded = True
49
+ return _cached
50
+
51
+ try:
52
+ with open(_PKG_PATH, "r", encoding="utf-8") as f:
53
+ pkg = json.load(f)
54
+ v = pkg.get("version")
55
+ if isinstance(v, str) and v.strip():
56
+ _cached = v.strip()
57
+ _cached_loaded = True
58
+ return _cached
59
+ except (OSError, json.JSONDecodeError):
60
+ pass
61
+
62
+ _cached_loaded = True
63
+ _cached = None
64
+ return None
65
+
66
+
67
+ if __name__ == "__main__":
68
+ # CLI: `python3 scripts/version.py` -> prints the version (or empty
69
+ # line). Used by shell scripts that want to thread the value into env
70
+ # before spawning sub-processes.
71
+ v = read_version()
72
+ print(v or "")