@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,561 @@
1
+ #!/usr/bin/env python3
2
+ """LinkedIn API wrapper for Social Autoposter.
3
+
4
+ Replaces browser automation for posting, commenting, replying, and reacting.
5
+ Browser is still needed for discovery (notifications, search) since LinkedIn
6
+ has no content discovery API.
7
+
8
+ Usage:
9
+ # Post a comment on a LinkedIn post
10
+ python3 linkedin_api.py comment <activity_id> "comment text"
11
+
12
+ # Reply to a comment on a LinkedIn post
13
+ python3 linkedin_api.py reply <activity_id> <parent_comment_urn> "reply text"
14
+
15
+ # Create a new post
16
+ python3 linkedin_api.py post "post text"
17
+
18
+ # Like a post
19
+ python3 linkedin_api.py like <activity_id>
20
+
21
+ # Get user profile info
22
+ python3 linkedin_api.py whoami
23
+
24
+ Environment:
25
+ LINKEDIN_ACCESS_TOKEN - OAuth 2.0 access token (w_member_social scope)
26
+ LINKEDIN_PERSON_URN - Optional. Auto-detected from token if not set.
27
+ """
28
+
29
+ import json
30
+ import os
31
+ import re
32
+ import sys
33
+ import time
34
+ import urllib.parse
35
+
36
+ import requests
37
+
38
+
39
+ # ---- URL-wrap + post_links backfill ----------------------------------------
40
+ #
41
+ # Optional --project flag turns on the wrap; without it linkedin_api.py runs
42
+ # in legacy unwrapped mode (backward-compat for existing call sites that
43
+ # haven't been updated yet). When --project is set, every URL in the text
44
+ # gets minted into post_links and rewritten to https://<project.website>/r/<code>
45
+ # before the API call. After a 201 response, if --reply-id or --post-id was
46
+ # also passed, the minted codes get backfilled with that id so click
47
+ # attribution lands on the right replies/posts row.
48
+ #
49
+ # The minted_session UUID is ALWAYS surfaced in the success JSON envelope so
50
+ # Claude-driven prompts (engage-linkedin.sh, run-linkedin.sh) can pass it to
51
+ # `python3 scripts/dm_short_links.py backfill-{reply,post}` themselves if they
52
+ # don't have the row id at the time of the linkedin_api.py call.
53
+
54
+ def _parse_optional_flags(argv):
55
+ """Scan argv for --project, --reply-id, --post-id, --post-urn flags.
56
+
57
+ Returns dict with optional keys: project, reply_id, post_id. Removes
58
+ consumed flags from argv in-place so the caller's positional indexing
59
+ isn't disturbed (the main() routing uses sys.argv[2], [3], [4] directly).
60
+ """
61
+ flags = {}
62
+ i = 0
63
+ while i < len(argv):
64
+ if argv[i] == "--project" and i + 1 < len(argv):
65
+ flags["project"] = argv[i + 1]
66
+ del argv[i:i + 2]
67
+ continue
68
+ if argv[i] == "--reply-id" and i + 1 < len(argv):
69
+ try:
70
+ flags["reply_id"] = int(argv[i + 1])
71
+ except ValueError:
72
+ pass
73
+ del argv[i:i + 2]
74
+ continue
75
+ if argv[i] == "--post-id" and i + 1 < len(argv):
76
+ try:
77
+ flags["post_id"] = int(argv[i + 1])
78
+ except ValueError:
79
+ pass
80
+ del argv[i:i + 2]
81
+ continue
82
+ i += 1
83
+ return flags
84
+
85
+
86
+ def _wrap_if_project(text, project):
87
+ """If project is set, mint short links for every URL in text and return
88
+ (wrapped_text, minted_session). Otherwise pass through (text, None).
89
+
90
+ Wrap failures are logged to stderr and fall back to the unwrapped text;
91
+ losing attribution on a single LinkedIn comment is preferable to dropping
92
+ a reply we already drafted."""
93
+ if not project:
94
+ return text, None
95
+ try:
96
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
97
+ from dm_short_links import wrap_text_for_post, utm_only_text
98
+ res = wrap_text_for_post(text=text, platform="linkedin", project_name=project)
99
+ if res.get("ok"):
100
+ if res.get("codes"):
101
+ print(f"[linkedin_api] wrapped {len(res['codes'])} URL(s): "
102
+ f"{res['codes']}", file=sys.stderr)
103
+ return res.get("text", text), res.get("minted_session")
104
+ print(f"[linkedin_api] WARNING: URL wrap failed "
105
+ f"({res.get('error')}); falling back to UTM-only", file=sys.stderr)
106
+ return utm_only_text(text=text, platform="linkedin", project_name=project), None
107
+ except Exception as e:
108
+ print(f"[linkedin_api] WARNING: URL wrap raised ({e}); falling back to UTM-only",
109
+ file=sys.stderr)
110
+ try:
111
+ from dm_short_links import utm_only_text
112
+ return utm_only_text(text=text, platform="linkedin", project_name=project), None
113
+ except Exception as ee:
114
+ print(f"[linkedin_api] WARNING: UTM-only fallback also failed ({ee}); "
115
+ f"posting unwrapped", file=sys.stderr)
116
+ return text, None
117
+
118
+
119
+ def _backfill_after_success(minted_session, reply_id=None, post_id=None):
120
+ """Stamp post_links.{reply_id,post_id} for codes minted under
121
+ minted_session. Caller passes exactly one of reply_id / post_id (or
122
+ neither, in which case this is a no-op and Claude-side scripting is
123
+ responsible for backfill via the dm_short_links.py CLI)."""
124
+ if not minted_session:
125
+ return
126
+ try:
127
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
128
+ from dm_short_links import backfill_post_id, backfill_reply_id
129
+ if reply_id is not None:
130
+ backfill_reply_id(minted_session=minted_session, reply_id=reply_id)
131
+ elif post_id is not None:
132
+ backfill_post_id(minted_session=minted_session, post_id=post_id)
133
+ except Exception as e:
134
+ print(f"[linkedin_api] WARNING: backfill failed ({e})", file=sys.stderr)
135
+
136
+
137
+ def get_env():
138
+ """Load .env if needed and return token + person URN."""
139
+ env_path = os.path.expanduser("~/social-autoposter/.env")
140
+ if os.path.exists(env_path):
141
+ with open(env_path) as f:
142
+ for line in f:
143
+ line = line.strip()
144
+ if line and not line.startswith("#") and "=" in line:
145
+ k, v = line.split("=", 1)
146
+ os.environ.setdefault(k, v)
147
+
148
+ token = os.environ.get("LINKEDIN_ACCESS_TOKEN")
149
+ if not token:
150
+ print("ERROR: LINKEDIN_ACCESS_TOKEN not set", file=sys.stderr)
151
+ sys.exit(1)
152
+ return token
153
+
154
+
155
+ def get_person_urn(token):
156
+ """Get the authenticated user's person URN."""
157
+ cached = os.environ.get("LINKEDIN_PERSON_URN")
158
+ if cached:
159
+ return cached
160
+ r = requests.get(
161
+ "https://api.linkedin.com/v2/userinfo",
162
+ headers={"Authorization": f"Bearer {token}"},
163
+ )
164
+ r.raise_for_status()
165
+ sub = r.json()["sub"]
166
+ return f"urn:li:person:{sub}"
167
+
168
+
169
+ def rest_headers(token):
170
+ """Headers for /rest/ endpoints (versioned)."""
171
+ return {
172
+ "Authorization": f"Bearer {token}",
173
+ "Content-Type": "application/json",
174
+ "X-Restli-Protocol-Version": "2.0.0",
175
+ "LinkedIn-Version": "202503",
176
+ }
177
+
178
+
179
+ def v2_headers(token):
180
+ """Headers for /v2/ endpoints (unversioned)."""
181
+ return {
182
+ "Authorization": f"Bearer {token}",
183
+ "Content-Type": "application/json",
184
+ "X-Restli-Protocol-Version": "2.0.0",
185
+ }
186
+
187
+
188
+ def handle_rate_limit(response):
189
+ """Check for 429 rate limit. If detected, write cooldown and exit."""
190
+ if response.status_code == 429:
191
+ error_text = response.text
192
+ print(json.dumps({
193
+ "ok": False,
194
+ "status": 429,
195
+ "error": error_text,
196
+ "rate_limited": True,
197
+ }))
198
+ # Write 2-hour cooldown
199
+ try:
200
+ sys.path.insert(0, os.path.dirname(__file__))
201
+ from linkedin_cooldown import set_cooldown
202
+ from datetime import datetime, timedelta, timezone
203
+ reason = "429 API rate limit"
204
+ if "fuse limit" in error_text.lower():
205
+ reason = "429 CommentCreatePermission fuse limit"
206
+ set_cooldown(reason, datetime.now(timezone.utc) + timedelta(hours=2))
207
+ except Exception:
208
+ pass
209
+ sys.exit(2)
210
+
211
+
212
+ def post_with_retry(method, url, headers, json_data=None, max_retries=2):
213
+ """Make an API request with retry on 5xx and rate limit detection on 429."""
214
+ for attempt in range(max_retries + 1):
215
+ if method == "POST":
216
+ r = requests.post(url, headers=headers, json=json_data)
217
+ elif method == "DELETE":
218
+ r = requests.delete(url, headers=headers)
219
+ else:
220
+ r = requests.get(url, headers=headers)
221
+
222
+ handle_rate_limit(r)
223
+
224
+ if r.status_code >= 500 and attempt < max_retries:
225
+ wait = 5 * (attempt + 1)
226
+ print(f"Server error {r.status_code}, retrying in {wait}s...", file=sys.stderr)
227
+ time.sleep(wait)
228
+ continue
229
+
230
+ return r
231
+ return r
232
+
233
+
234
+ def create_post(token, person_urn, text, project=None, post_id=None):
235
+ """Create a new LinkedIn post. Returns the post URN.
236
+
237
+ If project is set, URLs in text are wrapped via post_links before send.
238
+ If post_id is also passed, post_links.post_id is backfilled after a
239
+ successful 201; otherwise minted_session is returned for the caller to
240
+ backfill out-of-band."""
241
+ text, minted_session = _wrap_if_project(text, project)
242
+ data = {
243
+ "author": person_urn,
244
+ "lifecycleState": "PUBLISHED",
245
+ "visibility": "PUBLIC",
246
+ "commentary": text,
247
+ "distribution": {
248
+ "feedDistribution": "MAIN_FEED",
249
+ "targetEntities": [],
250
+ "thirdPartyDistributionChannels": [],
251
+ },
252
+ }
253
+ r = requests.post(
254
+ "https://api.linkedin.com/rest/posts",
255
+ headers=rest_headers(token),
256
+ json=data,
257
+ )
258
+ if r.status_code == 201:
259
+ post_urn = r.headers.get("x-restli-id", "")
260
+ _backfill_after_success(minted_session, post_id=post_id)
261
+ print(json.dumps({"ok": True, "post_urn": post_urn,
262
+ "minted_session": minted_session}))
263
+ return post_urn
264
+ else:
265
+ print(json.dumps({"ok": False, "status": r.status_code, "error": r.text}))
266
+ sys.exit(1)
267
+
268
+
269
+ def resolve_post_urn(identifier):
270
+ """Convert an activity ID or share ID to the appropriate URN for API calls.
271
+
272
+ The pipeline extracts activity IDs from browser data-urn attributes.
273
+ LinkedIn's socialActions API accepts both urn:li:activity: and urn:li:share: URNs.
274
+ """
275
+ if identifier.startswith("urn:li:"):
276
+ return identifier
277
+ return f"urn:li:activity:{identifier}"
278
+
279
+
280
+ def extract_post_urn_parts(resp, fallback_id):
281
+ """Extract (namespace, id) from a comment response's $URN field.
282
+
283
+ Comment URN shape: urn:li:comment:(urn:li:<ns>:<id>,<comment_id>)
284
+ where <ns> is 'activity', 'ugcPost', or 'share'. The activity, share,
285
+ and ugcPost numeric IDs for the same post are DIFFERENT (LinkedIn
286
+ namespaces, not prefixes), so we must echo back the namespace
287
+ LinkedIn used. If we hard-code 'activity' but the response carried
288
+ 'share', the constructed permalink stores a share-namespace ID
289
+ masquerading as an activity URN, which then 404s on every namespace-
290
+ sensitive read (Unipile, /v2/socialActions, etc.) and gets misflagged
291
+ as deleted. Falls back to ('activity', fallback_id) only when the
292
+ response has no parseable URN at all.
293
+ """
294
+ urn = resp.get("$URN", "")
295
+ m = re.search(r"urn:li:(activity|ugcPost|share):(\d+)", urn)
296
+ if m:
297
+ return m.group(1), m.group(2)
298
+ return "activity", fallback_id
299
+
300
+
301
+ def extract_activity_id_from_response(resp, fallback_id):
302
+ """Deprecated. Use extract_post_urn_parts; kept for any external caller."""
303
+ return extract_post_urn_parts(resp, fallback_id)[1]
304
+
305
+
306
+ def comment_on_post(token, person_urn, activity_id, text, project=None, reply_id=None, post_id=None):
307
+ """Comment on a LinkedIn post. Returns the comment URN.
308
+
309
+ Accepts activity IDs (from browser data-urn), share IDs, or full URNs.
310
+ If urn:li:activity fails with 404, retries with urn:li:share.
311
+
312
+ Optional URL-wrap + post_links attribution: see module-level
313
+ _wrap_if_project / _backfill_after_success docstrings. reply_id wins
314
+ over post_id when both are passed (a comment is naturally a reply).
315
+ """
316
+ text, minted_session = _wrap_if_project(text, project)
317
+ post_urn = resolve_post_urn(activity_id)
318
+ encoded_urn = urllib.parse.quote(post_urn, safe="")
319
+ data = {
320
+ "actor": person_urn,
321
+ "message": {"text": text},
322
+ }
323
+ r = post_with_retry(
324
+ "POST",
325
+ f"https://api.linkedin.com/v2/socialActions/{encoded_urn}/comments",
326
+ headers=v2_headers(token),
327
+ json_data=data,
328
+ )
329
+ # If activity URN fails, try alternative URN formats
330
+ if r.status_code == 400 and "actual threadUrn" in r.text:
331
+ # Extract the real URN from error: "actual threadUrn: urn:li:ugcPost:NNNN"
332
+ m = re.search(r"actual threadUrn:\s*(urn:li:\w+:\d+)", r.text)
333
+ if m:
334
+ real_urn = m.group(1)
335
+ encoded_real = urllib.parse.quote(real_urn, safe="")
336
+ r = post_with_retry(
337
+ "POST",
338
+ f"https://api.linkedin.com/v2/socialActions/{encoded_real}/comments",
339
+ headers=v2_headers(token),
340
+ json_data=data,
341
+ )
342
+ if r.status_code == 404 and not activity_id.startswith("urn:li:"):
343
+ share_urn = f"urn:li:share:{activity_id}"
344
+ encoded_share = urllib.parse.quote(share_urn, safe="")
345
+ r = post_with_retry(
346
+ "POST",
347
+ f"https://api.linkedin.com/v2/socialActions/{encoded_share}/comments",
348
+ headers=v2_headers(token),
349
+ json_data=data,
350
+ )
351
+ if r.status_code == 201:
352
+ resp = r.json()
353
+ comment_id = resp.get("id", "")
354
+ ns, real_id = extract_post_urn_parts(resp, activity_id)
355
+ comment_urn = resp.get("$URN", f"urn:li:comment:(urn:li:{ns}:{real_id},{comment_id})")
356
+ # 2026-05-11: embed ?commentUrn=... so log_post.py stores a URL that
357
+ # uniquely identifies OUR comment, not just the parent post. Before
358
+ # this fix, `our_url` was the bare parent post URL, which made the
359
+ # post-stats pipeline scrape the parent post's reactions (e.g., 188)
360
+ # instead of OUR comment's (e.g., 1). Mirrors the URL shape that
361
+ # reply_to_comment already produces (line ~414).
362
+ our_url = (
363
+ f"https://www.linkedin.com/feed/update/urn:li:{ns}:{real_id}/"
364
+ f"?commentUrn={urllib.parse.quote(comment_urn, safe='')}"
365
+ )
366
+ _backfill_after_success(minted_session, reply_id=reply_id, post_id=post_id)
367
+ print(json.dumps({"ok": True, "comment_urn": comment_urn, "our_url": our_url,
368
+ "post_namespace": ns, "post_id": real_id,
369
+ "activity_id": real_id,
370
+ "minted_session": minted_session}))
371
+ return comment_urn
372
+ else:
373
+ print(json.dumps({"ok": False, "status": r.status_code, "error": r.text}))
374
+ sys.exit(1)
375
+
376
+
377
+ def normalize_comment_urn(urn):
378
+ """Normalize comment URN to the format LinkedIn API expects.
379
+
380
+ Pipeline stores: urn:li:comment:(activity:ID,COMMENT_ID)
381
+ API expects: urn:li:comment:(urn:li:activity:ID,COMMENT_ID)
382
+ """
383
+ import re
384
+ # If it has (activity:ID without urn:li: prefix, add it
385
+ urn = re.sub(
386
+ r"\(activity:(\d+)",
387
+ r"(urn:li:activity:\1",
388
+ urn,
389
+ )
390
+ # If it has (ugcPost:ID without urn:li: prefix, add it
391
+ urn = re.sub(
392
+ r"\(ugcPost:(\d+)",
393
+ r"(urn:li:ugcPost:\1",
394
+ urn,
395
+ )
396
+ return urn
397
+
398
+
399
+ def reply_to_comment(token, person_urn, activity_id, parent_comment_urn, text,
400
+ project=None, reply_id=None, post_id=None):
401
+ """Reply to a specific comment on a LinkedIn post.
402
+
403
+ Optional URL-wrap + post_links attribution: see module-level
404
+ _wrap_if_project / _backfill_after_success docstrings."""
405
+ text, minted_session = _wrap_if_project(text, project)
406
+ post_urn = resolve_post_urn(activity_id)
407
+ encoded_urn = urllib.parse.quote(post_urn, safe="")
408
+ data = {
409
+ "actor": person_urn,
410
+ "message": {"text": text},
411
+ "parentComment": normalize_comment_urn(parent_comment_urn),
412
+ }
413
+ r = post_with_retry(
414
+ "POST",
415
+ f"https://api.linkedin.com/v2/socialActions/{encoded_urn}/comments",
416
+ headers=v2_headers(token),
417
+ json_data=data,
418
+ )
419
+ if r.status_code == 201:
420
+ resp = r.json()
421
+ # NOTE: shadowed `reply_id` here is LinkedIn's API-returned comment id
422
+ # (string), distinct from the function param of the same name above
423
+ # which is our internal replies.id (int). The post_id/reply_id
424
+ # backfill block below uses the function-scope param (the int).
425
+ api_reply_id = resp.get("id", "")
426
+ ns, real_id = extract_post_urn_parts(resp, activity_id)
427
+ reply_urn = resp.get("$URN", f"urn:li:comment:(urn:li:{ns}:{real_id},{api_reply_id})")
428
+ permalink = (
429
+ f"https://www.linkedin.com/feed/update/urn:li:{ns}:{real_id}"
430
+ f"?commentUrn={urllib.parse.quote(reply_urn, safe='')}"
431
+ )
432
+ _backfill_after_success(minted_session, reply_id=reply_id, post_id=post_id)
433
+ print(json.dumps({"ok": True, "reply_urn": reply_urn, "permalink": permalink,
434
+ "minted_session": minted_session}))
435
+ return reply_urn
436
+ else:
437
+ print(json.dumps({"ok": False, "status": r.status_code, "error": r.text}))
438
+ sys.exit(1)
439
+
440
+
441
+ def like_post(token, person_urn, activity_id):
442
+ """Like/react to a LinkedIn post."""
443
+ post_urn = resolve_post_urn(activity_id)
444
+ encoded_urn = urllib.parse.quote(post_urn, safe="")
445
+ data = {"actor": person_urn}
446
+ r = post_with_retry(
447
+ "POST",
448
+ f"https://api.linkedin.com/v2/socialActions/{encoded_urn}/likes",
449
+ headers=v2_headers(token),
450
+ json_data=data,
451
+ )
452
+ if r.status_code == 404 and not activity_id.startswith("urn:li:"):
453
+ share_urn = f"urn:li:share:{activity_id}"
454
+ encoded_share = urllib.parse.quote(share_urn, safe="")
455
+ r = post_with_retry(
456
+ "POST",
457
+ f"https://api.linkedin.com/v2/socialActions/{encoded_share}/likes",
458
+ headers=v2_headers(token),
459
+ json_data=data,
460
+ )
461
+ if r.status_code == 201:
462
+ print(json.dumps({"ok": True, "activity_id": activity_id}))
463
+ else:
464
+ print(json.dumps({"ok": False, "status": r.status_code, "error": r.text}))
465
+ sys.exit(1)
466
+
467
+
468
+ def delete_post(token, post_urn):
469
+ """Delete a LinkedIn post."""
470
+ encoded = urllib.parse.quote(post_urn, safe="")
471
+ r = requests.delete(
472
+ f"https://api.linkedin.com/rest/posts/{encoded}",
473
+ headers=rest_headers(token),
474
+ )
475
+ if r.status_code == 204:
476
+ print(json.dumps({"ok": True, "deleted": post_urn}))
477
+ else:
478
+ print(json.dumps({"ok": False, "status": r.status_code, "error": r.text}))
479
+ sys.exit(1)
480
+
481
+
482
+ def whoami(token):
483
+ """Print authenticated user info."""
484
+ r = requests.get(
485
+ "https://api.linkedin.com/v2/userinfo",
486
+ headers={"Authorization": f"Bearer {token}"},
487
+ )
488
+ r.raise_for_status()
489
+ info = r.json()
490
+ print(json.dumps({"ok": True, "name": info.get("name"), "email": info.get("email"), "sub": info.get("sub")}))
491
+
492
+
493
+ def main():
494
+ if len(sys.argv) < 2:
495
+ print(__doc__)
496
+ sys.exit(1)
497
+
498
+ # Strip optional flags (--project, --reply-id, --post-id) out of argv
499
+ # FIRST so the positional indexing below (sys.argv[2], [3], [4]) keeps
500
+ # working for legacy callers that don't pass any flags. New callers
501
+ # (engage-linkedin.sh / run-linkedin.sh prompts) put --project NAME
502
+ # anywhere after the subcommand and get URL wrapping + post_links
503
+ # attribution for free.
504
+ flags = _parse_optional_flags(sys.argv)
505
+
506
+ cmd = sys.argv[1]
507
+ token = get_env()
508
+ person_urn = get_person_urn(token)
509
+
510
+ if cmd == "comment":
511
+ if len(sys.argv) < 4:
512
+ print("Usage: linkedin_api.py comment <activity_id> <text> "
513
+ "[--project NAME] [--reply-id N] [--post-id N]", file=sys.stderr)
514
+ sys.exit(1)
515
+ comment_on_post(token, person_urn, sys.argv[2], sys.argv[3],
516
+ project=flags.get("project"),
517
+ reply_id=flags.get("reply_id"),
518
+ post_id=flags.get("post_id"))
519
+
520
+ elif cmd == "reply":
521
+ if len(sys.argv) < 5:
522
+ print("Usage: linkedin_api.py reply <activity_id> <parent_comment_urn> <text> "
523
+ "[--project NAME] [--reply-id N] [--post-id N]", file=sys.stderr)
524
+ sys.exit(1)
525
+ reply_to_comment(token, person_urn, sys.argv[2], sys.argv[3], sys.argv[4],
526
+ project=flags.get("project"),
527
+ reply_id=flags.get("reply_id"),
528
+ post_id=flags.get("post_id"))
529
+
530
+ elif cmd == "post":
531
+ if len(sys.argv) < 3:
532
+ print("Usage: linkedin_api.py post <text> "
533
+ "[--project NAME] [--post-id N]", file=sys.stderr)
534
+ sys.exit(1)
535
+ create_post(token, person_urn, sys.argv[2],
536
+ project=flags.get("project"),
537
+ post_id=flags.get("post_id"))
538
+
539
+ elif cmd == "like":
540
+ if len(sys.argv) < 3:
541
+ print("Usage: linkedin_api.py like <activity_id>", file=sys.stderr)
542
+ sys.exit(1)
543
+ like_post(token, person_urn, sys.argv[2])
544
+
545
+ elif cmd == "delete":
546
+ if len(sys.argv) < 3:
547
+ print("Usage: linkedin_api.py delete <post_urn>", file=sys.stderr)
548
+ sys.exit(1)
549
+ delete_post(token, sys.argv[2])
550
+
551
+ elif cmd == "whoami":
552
+ whoami(token)
553
+
554
+ else:
555
+ print(f"Unknown command: {cmd}", file=sys.stderr)
556
+ print(__doc__)
557
+ sys.exit(1)
558
+
559
+
560
+ if __name__ == "__main__":
561
+ main()