@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,658 @@
1
+ #!/usr/bin/env python3
2
+ """Reusable UniPile LinkedIn functions for the post-commenting pipeline.
3
+
4
+ Scope: SEARCH posts + COMMENT on a post + REACT (like) to a post. No stats,
5
+ no DMs. The COMMENT path auto-likes the parent post by default (also_like),
6
+ mirroring the Twitter proactive-comment path. These are the units the LinkedIn
7
+ post-comments pipeline reuses.
8
+
9
+ Credentials resolve env-first, keychain-second:
10
+ UNIPILE_DSN | keychain "unipile-dsn" e.g. api45.unipile.com:17570
11
+ UNIPILE_API_KEY | keychain "unipile-api-key"
12
+ UNIPILE_ACCOUNT_ID | keychain "unipile-account-id-linkedin-m13v" e.g. wHDpysUnRbm7Q0lvyv9pQQ
13
+
14
+ CLI:
15
+ python3 linkedin_unipile.py probe
16
+ python3 linkedin_unipile.py search --keywords "ai agents" --date-posted past_week --limit 5
17
+ python3 linkedin_unipile.py search --url "https://www.linkedin.com/search/results/posts/?keywords=..."
18
+ python3 linkedin_unipile.py comment --social-id "urn:li:activity:7332661864792854528" --text "..."
19
+ python3 linkedin_unipile.py react --social-id "urn:li:activity:7332661864792854528"
20
+ """
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ import urllib.error
28
+ import urllib.parse
29
+ import urllib.request
30
+ from datetime import datetime, timezone
31
+
32
+ COMMENT_CHAR_LIMIT = 1250 # LinkedIn comment limit, enforced by UniPile too.
33
+ DATE_POSTED_VALUES = ("past_24h", "past_week", "past_month")
34
+
35
+
36
+ class UnipileConfigError(RuntimeError):
37
+ """Missing or unresolvable UniPile credentials."""
38
+
39
+
40
+ class UnipileApiError(RuntimeError):
41
+ """UniPile returned a non-2xx response."""
42
+
43
+ def __init__(self, message, status, response):
44
+ super().__init__(message)
45
+ self.status = status
46
+ self.response = response
47
+
48
+
49
+ # The live UniPile account lives under matt@mediar.ai. An older (dead) i@m13v.com
50
+ # trial shares the same keychain service names, so we must look up the scoped
51
+ # entry first; an unscoped `-w` returns whichever the keychain orders first
52
+ # (often the stale one). Override with UNIPILE_KEYCHAIN_ACCOUNT.
53
+ KEYCHAIN_ACCOUNT = os.environ.get("UNIPILE_KEYCHAIN_ACCOUNT", "matt@mediar.ai")
54
+
55
+
56
+ def _keychain(service, account=None):
57
+ cmd = ["security", "find-generic-password", "-s", service]
58
+ if account:
59
+ cmd += ["-a", account]
60
+ cmd += ["-w"]
61
+ try:
62
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
63
+ if out.returncode == 0:
64
+ return (out.stdout.strip() or None)
65
+ except Exception:
66
+ pass
67
+ return None
68
+
69
+
70
+ def _keychain_any(service):
71
+ return _keychain(service, KEYCHAIN_ACCOUNT) or _keychain(service)
72
+
73
+
74
+ def get_config():
75
+ dsn = os.environ.get("UNIPILE_DSN") or _keychain_any("unipile-dsn")
76
+ api_key = os.environ.get("UNIPILE_API_KEY") or _keychain_any("unipile-api-key")
77
+ account_id = (os.environ.get("UNIPILE_ACCOUNT_ID")
78
+ or _keychain_any("unipile-account-id-linkedin-m13v"))
79
+ missing = [name for name, val in (
80
+ ("UNIPILE_DSN / keychain:unipile-dsn", dsn),
81
+ ("UNIPILE_API_KEY / keychain:unipile-api-key", api_key),
82
+ ("UNIPILE_ACCOUNT_ID / keychain:unipile-account-id-linkedin-m13v", account_id),
83
+ ) if not val]
84
+ if missing:
85
+ raise UnipileConfigError("missing UniPile config: " + "; ".join(missing))
86
+ return {"dsn": dsn, "api_key": api_key, "account_id": account_id}
87
+
88
+
89
+ def _request(method, path, query=None, body=None, timeout=30):
90
+ cfg = get_config()
91
+ url = "https://" + cfg["dsn"] + path
92
+ if query:
93
+ url += "?" + urllib.parse.urlencode(query)
94
+ headers = {"X-API-KEY": cfg["api_key"], "accept": "application/json"}
95
+ data = None
96
+ if body is not None:
97
+ data = json.dumps(body).encode("utf-8")
98
+ headers["content-type"] = "application/json"
99
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
100
+ try:
101
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
102
+ raw = resp.read().decode("utf-8", "replace")
103
+ status = resp.status
104
+ except urllib.error.HTTPError as exc:
105
+ raw = exc.read().decode("utf-8", "replace")
106
+ status = exc.code
107
+ except urllib.error.URLError as exc:
108
+ return 0, {"error": "network", "detail": str(exc)}
109
+ try:
110
+ parsed = json.loads(raw) if raw else {}
111
+ except ValueError:
112
+ parsed = {"_raw": raw}
113
+ return status, parsed
114
+
115
+
116
+ def make_our_url(social_id, comment_urn=None):
117
+ """LinkedIn permalink for a post (and our comment within it, when known)."""
118
+ base = "https://www.linkedin.com/feed/update/" + social_id + "/"
119
+ if comment_urn:
120
+ return base + "?commentUrn=" + urllib.parse.quote(comment_urn, safe="")
121
+ return base
122
+
123
+
124
+ def _first(d, *keys):
125
+ for k in keys:
126
+ v = d.get(k)
127
+ if v not in (None, "", []):
128
+ return v
129
+ return None
130
+
131
+
132
+ def _normalize_post(item):
133
+ if not isinstance(item, dict):
134
+ return {"raw": item}
135
+ author = item.get("author")
136
+ if isinstance(author, dict):
137
+ author_name = _first(author, "name", "public_name", "full_name")
138
+ author_headline = _first(author, "headline", "occupation", "subtitle")
139
+ author_id = _first(author, "id", "provider_id", "public_identifier")
140
+ author_public_id = _first(author, "public_identifier")
141
+ else:
142
+ author_name = _first(item, "author_name", "actor_name")
143
+ author_headline = None
144
+ author_id = None
145
+ author_public_id = None
146
+ social_id = _first(item, "social_id", "share_urn", "urn")
147
+ return {
148
+ "social_id": social_id,
149
+ "id": _first(item, "id"),
150
+ "share_url": _first(item, "share_url", "permalink", "url"),
151
+ "text": _first(item, "text", "commentary", "content"),
152
+ "author_name": author_name,
153
+ "author_headline": author_headline,
154
+ "author_id": author_id,
155
+ "author_public_id": author_public_id,
156
+ "author_followers": None, # filled by _enrich_followers when requested
157
+ "reaction_count": _first(item, "reaction_counter", "reaction_count",
158
+ "reactions_count", "likes"),
159
+ "comment_count": _first(item, "comment_counter", "comment_count",
160
+ "comments_count"),
161
+ "repost_count": _first(item, "repost_counter", "repost_count",
162
+ "reposts_count", "shares_count"),
163
+ "is_repost": item.get("is_repost"),
164
+ # parsed_datetime is the authoritative ISO timestamp; `date` is a
165
+ # relative string ("now", "5h", "2d") that's harder to score on.
166
+ "posted_at": _first(item, "parsed_datetime", "date_posted", "created_at"),
167
+ "raw": item,
168
+ }
169
+
170
+
171
+ def search_posts(keywords=None, *, url=None, date_posted=None, sort_by="date",
172
+ content_type=None, author_keywords=None, limit=10, cursor=None,
173
+ account_id=None, with_followers=False):
174
+ """Search LinkedIn posts. Pass keywords= (structured) or url= (paste a
175
+ LinkedIn search-results URL). Returns {items, cursor, count, raw}.
176
+
177
+ with_followers=True makes one extra GET /users/{id} per distinct author to
178
+ fill author_followers (search alone never returns follower count). UniPile
179
+ is flat-rate, so the extra calls are free; they let the LinkedIn scorer use
180
+ its author-reach multiplier exactly as the browser path did."""
181
+ cfg = get_config()
182
+ query = {"account_id": account_id or cfg["account_id"], "limit": limit}
183
+ if cursor:
184
+ query["cursor"] = cursor
185
+ if url:
186
+ body = {"url": url}
187
+ else:
188
+ if not keywords:
189
+ raise ValueError("search_posts requires keywords= or url=")
190
+ body = {"api": "classic", "category": "posts", "keywords": keywords}
191
+ if sort_by:
192
+ body["sort_by"] = sort_by
193
+ if date_posted:
194
+ body["date_posted"] = date_posted
195
+ if content_type:
196
+ body["content_type"] = content_type
197
+ if author_keywords:
198
+ body["author"] = {"keywords": author_keywords}
199
+ status, resp = _request("POST", "/api/v1/linkedin/search", query=query, body=body)
200
+ if status != 200:
201
+ raise UnipileApiError("search failed: HTTP %s" % status, status, resp)
202
+ if isinstance(resp, dict):
203
+ items = resp.get("items")
204
+ next_cursor = resp.get("cursor")
205
+ if not next_cursor and isinstance(resp.get("paging"), dict):
206
+ next_cursor = resp["paging"].get("cursor")
207
+ elif isinstance(resp, list):
208
+ items, next_cursor = resp, None
209
+ else:
210
+ items, next_cursor = None, None
211
+ items = items or []
212
+ norm = [_normalize_post(it) for it in items]
213
+ if with_followers:
214
+ _enrich_followers(norm, account_id=query["account_id"])
215
+ return {"items": norm, "cursor": next_cursor, "count": len(norm), "raw": resp}
216
+
217
+
218
+ def get_profile(identifier, *, account_id=None, timeout=30):
219
+ """GET /api/v1/users/{identifier} — returns the LinkedIn profile dict
220
+ (follower_count, connections_count, is_influencer, headline, ...).
221
+ identifier accepts either the public_identifier (e.g. 'mahendraakula') or
222
+ the internal provider id (e.g. 'ACoAA...'). Raises UnipileApiError on non-200."""
223
+ cfg = get_config()
224
+ path = "/api/v1/users/" + urllib.parse.quote(str(identifier), safe="")
225
+ status, resp = _request("GET", path,
226
+ query={"account_id": account_id or cfg["account_id"]},
227
+ timeout=timeout)
228
+ if status != 200:
229
+ raise UnipileApiError("profile failed: HTTP %s" % status, status, resp)
230
+ return resp
231
+
232
+
233
+ def _enrich_followers(items, *, account_id=None):
234
+ """Fill author_followers on each normalized item via get_profile. One call
235
+ per DISTINCT author (cached), non-fatal: a failed lookup leaves the field
236
+ None so the scorer falls back to its neutral reach multiplier."""
237
+ cache = {}
238
+ for it in items:
239
+ ident = it.get("author_public_id") or it.get("author_id")
240
+ if not ident:
241
+ continue
242
+ if ident not in cache:
243
+ try:
244
+ prof = get_profile(ident, account_id=account_id)
245
+ cache[ident] = prof.get("follower_count") if isinstance(prof, dict) else None
246
+ except Exception:
247
+ cache[ident] = None
248
+ it["author_followers"] = cache[ident]
249
+
250
+
251
+ def _age_hours_from(posted_at):
252
+ """ISO timestamp → hours since, rounded. None if unparseable."""
253
+ if not posted_at:
254
+ return None
255
+ try:
256
+ dt = datetime.fromisoformat(str(posted_at).replace("Z", "+00:00"))
257
+ if dt.tzinfo is None:
258
+ dt = dt.replace(tzinfo=timezone.utc)
259
+ return round((datetime.now(timezone.utc) - dt).total_seconds() / 3600.0, 2)
260
+ except (ValueError, TypeError):
261
+ return None
262
+
263
+
264
+ def to_pipeline_results(items):
265
+ """Map normalized search items to the candidate shape run-linkedin.sh's
266
+ Phase A envelope + score_linkedin_candidates.py expect. UniPile returns the
267
+ post URN directly in social_id, so post_url/activity_id are always present
268
+ (no click-to-resolve, no namespace guessing like the browser path needs)."""
269
+ results = []
270
+ for it in items:
271
+ social_id = it.get("social_id") or ""
272
+ m = re.search(r"(\d{16,19})", social_id)
273
+ activity_id = m.group(1) if m else None
274
+ post_url = make_our_url(social_id) if social_id else None
275
+ pub = it.get("author_public_id")
276
+ author_profile_url = ("https://www.linkedin.com/in/%s/" % pub) if pub else None
277
+ results.append({
278
+ "post_url": post_url,
279
+ "activity_id": activity_id,
280
+ "all_urns": [activity_id] if activity_id else [],
281
+ "social_id": social_id or None,
282
+ "author_name": it.get("author_name"),
283
+ "author_headline": it.get("author_headline"),
284
+ "author_profile_url": author_profile_url,
285
+ "author_followers": it.get("author_followers"),
286
+ "post_text": it.get("text"),
287
+ "age_hours": _age_hours_from(it.get("posted_at")),
288
+ "reactions": int(it.get("reaction_count") or 0),
289
+ "comments": int(it.get("comment_count") or 0),
290
+ "reposts": int(it.get("repost_count") or 0),
291
+ "is_repost": bool(it.get("is_repost")),
292
+ })
293
+ return results
294
+
295
+
296
+ def _extract_comment_urn(resp):
297
+ """Best-effort: pull an explicit comment URN string from the create-comment
298
+ response. UniPile's documented success body is just {object:"CommentSent"}
299
+ (plus a numeric comment_id) with no URN string, so this usually returns
300
+ None; we parse anyway in case the live response is richer."""
301
+ if not isinstance(resp, dict):
302
+ return None
303
+ for key in ("comment_id", "comment_urn", "social_id", "id", "urn"):
304
+ val = resp.get(key)
305
+ if isinstance(val, str) and "urn:li:comment" in val:
306
+ return val
307
+ return None
308
+
309
+
310
+ def _numeric_comment_id(resp):
311
+ """Pull UniPile's numeric comment id (16-19 digits) out of a create-comment
312
+ response. UniPile returns this as response.comment_id on success; it is the
313
+ same canonical LinkedIn comment id the stats scrape reads from the activity
314
+ feed DOM, which is why post-submit read-back (comment_exists) keys on it."""
315
+ if not isinstance(resp, dict):
316
+ return None
317
+ for key in ("comment_id", "id", "comment_urn", "social_id", "urn"):
318
+ val = resp.get(key)
319
+ if val is None:
320
+ continue
321
+ m = re.search(r"(\d{16,19})", str(val))
322
+ if m:
323
+ return m.group(1)
324
+ nested = resp.get("comment")
325
+ if isinstance(nested, dict):
326
+ return _numeric_comment_id(nested)
327
+ return None
328
+
329
+
330
+ def _build_comment_urn(social_id, resp):
331
+ """Return a urn:li:comment:(<kind>:<parent>,<cid>) identifying OUR new
332
+ comment, or None.
333
+
334
+ Prefers an explicit URN string in the response; otherwise constructs one
335
+ from the parent post's namespace/id (parsed from social_id) plus UniPile's
336
+ numeric comment id. This is what lets make_our_url embed a ?commentUrn= that
337
+ update_linkedin_stats_from_feed.py can key on (it matches purely on the
338
+ trailing <cid>, so the parent kind/id portion only needs to be parseable).
339
+ Without this, every UniPile-posted comment stored a bare parent URL with no
340
+ commentUrn and could never be matched back to its engagement stats."""
341
+ explicit = _extract_comment_urn(resp)
342
+ if explicit:
343
+ return explicit
344
+ cid = _numeric_comment_id(resp)
345
+ if not cid:
346
+ return None
347
+ m = re.match(r"urn:li:(activity|share|ugcPost):(\d{16,19})", social_id or "")
348
+ if not m:
349
+ return None
350
+ kind, parent = m.group(1), m.group(2)
351
+ return "urn:li:comment:(%s:%s,%s)" % (kind, parent, cid)
352
+
353
+
354
+ REACTION_TYPES = ("like", "celebrate", "support", "love", "insightful", "funny")
355
+
356
+
357
+ def react_to_post(social_id, *, reaction_type="like", comment_id=None,
358
+ account_id=None):
359
+ """Add a reaction (default 'like') to a post or one of its comments.
360
+
361
+ UniPile endpoint: POST /api/v1/posts/reaction with a flat body carrying
362
+ post_id (the post's social_id), account_id, and reaction_type. Pass
363
+ comment_id to react to a specific comment instead of the post itself.
364
+ Returns {ok, status, response}."""
365
+ if not social_id:
366
+ raise ValueError("social_id required")
367
+ if reaction_type not in REACTION_TYPES:
368
+ raise ValueError("reaction_type must be one of %s (got %r)"
369
+ % (", ".join(REACTION_TYPES), reaction_type))
370
+ cfg = get_config()
371
+ body = {
372
+ "account_id": account_id or cfg["account_id"],
373
+ "post_id": social_id,
374
+ "reaction_type": reaction_type,
375
+ }
376
+ if comment_id:
377
+ body["comment_id"] = comment_id
378
+ status, resp = _request("POST", "/api/v1/posts/reaction", body=body)
379
+ # LinkedIn happily 201s when you re-like something already liked, so any
380
+ # 2xx (and the non-error object) is success.
381
+ ok = status in (200, 201) and not (isinstance(resp, dict)
382
+ and resp.get("object") == "error")
383
+ return {"ok": ok, "status": status, "response": resp}
384
+
385
+
386
+ def comment_on_post(social_id, text, *, comment_id=None, mentions=None,
387
+ account_id=None, also_like=True):
388
+ """Comment on a post identified by its social_id (urn:li:activity:...).
389
+ comment_id replies to an existing comment; mentions uses {{0}} placeholders.
390
+
391
+ also_like=True (default) auto-likes the parent post right after a successful
392
+ comment, mirroring the Twitter proactive-comment path. The like is fail-soft:
393
+ a reaction error can NEVER fail the comment. The outcome is carried back in
394
+ the `liked` / `like_result` keys for the caller to log.
395
+
396
+ Returns {ok, status, response, comment_urn, our_url, liked, like_result}."""
397
+ if not social_id:
398
+ raise ValueError("social_id required")
399
+ if not text or not text.strip():
400
+ raise ValueError("text required")
401
+ if len(text) > COMMENT_CHAR_LIMIT:
402
+ raise ValueError("comment text exceeds LinkedIn %d-char limit (%d)"
403
+ % (COMMENT_CHAR_LIMIT, len(text)))
404
+ cfg = get_config()
405
+ body = {"account_id": account_id or cfg["account_id"], "text": text}
406
+ if comment_id:
407
+ body["comment_id"] = comment_id
408
+ if mentions:
409
+ body["mentions"] = mentions
410
+ # social_id (urn:li:activity:N) carries only colons, valid as a path segment;
411
+ # the UniPile docs use it unencoded, so we leave it as-is.
412
+ status, resp = _request("POST", "/api/v1/posts/" + social_id + "/comments",
413
+ body=body)
414
+ ok = status in (200, 201) and not (isinstance(resp, dict)
415
+ and resp.get("object") == "error")
416
+ comment_urn = _extract_comment_urn(resp)
417
+
418
+ # Auto-like the parent post on every successful comment. Wrapped so a like
419
+ # failure can NEVER fail the comment itself; the outcome rides out in
420
+ # like_result for the caller to log.
421
+ like_result = {"ok": False, "error": "not_attempted"}
422
+ if ok and also_like:
423
+ try:
424
+ like_result = react_to_post(social_id, reaction_type="like",
425
+ account_id=account_id)
426
+ except Exception as exc: # noqa: BLE001 - like must never break commenting
427
+ like_result = {"ok": False, "error": str(exc)}
428
+
429
+ return {
430
+ "ok": ok,
431
+ "status": status,
432
+ "response": resp,
433
+ "comment_urn": comment_urn,
434
+ "our_url": make_our_url(social_id, comment_urn),
435
+ "liked": bool(like_result.get("ok")),
436
+ "like_result": like_result,
437
+ }
438
+
439
+
440
+ def list_comments(social_id, *, account_id=None, limit=50, cursor=None):
441
+ """GET /api/v1/posts/{social_id}/comments — read a post's comments back.
442
+ Used by Phase B to confirm our just-posted comment actually rendered.
443
+ Returns {items, count, raw}."""
444
+ if not social_id:
445
+ raise ValueError("social_id required")
446
+ cfg = get_config()
447
+ query = {"account_id": account_id or cfg["account_id"], "limit": limit}
448
+ if cursor:
449
+ query["cursor"] = cursor
450
+ status, resp = _request("GET", "/api/v1/posts/" + social_id + "/comments",
451
+ query=query)
452
+ if status != 200:
453
+ raise UnipileApiError("list comments failed: HTTP %s" % status, status, resp)
454
+ if isinstance(resp, dict):
455
+ items = resp.get("items")
456
+ elif isinstance(resp, list):
457
+ items = resp
458
+ else:
459
+ items = None
460
+ items = items or []
461
+ return {"items": items, "count": len(items), "raw": resp}
462
+
463
+
464
+ def comment_exists(social_id, comment_id, *, account_id=None):
465
+ """True if a comment with comment_id is present on the post. Best-effort
466
+ read-back proof for Phase B; falls back to False on any lookup error."""
467
+ if not comment_id:
468
+ return False
469
+ try:
470
+ res = list_comments(social_id, account_id=account_id)
471
+ except Exception:
472
+ return False
473
+ target = str(comment_id)
474
+ for c in res["items"]:
475
+ if not isinstance(c, dict):
476
+ continue
477
+ for k in ("comment_id", "id", "urn", "social_id"):
478
+ v = c.get(k)
479
+ if v is not None and target in str(v):
480
+ return True
481
+ return False
482
+
483
+
484
+ def accounts():
485
+ """GET /api/v1/accounts — used by `probe` to validate credentials."""
486
+ return _request("GET", "/api/v1/accounts")
487
+
488
+
489
+ # --------------------------------------------------------------------------
490
+ # CLI
491
+ # --------------------------------------------------------------------------
492
+ def _cmd_probe(_args):
493
+ cfg = get_config()
494
+ redacted = cfg["api_key"][:6] + "..." + cfg["api_key"][-4:]
495
+ print("dsn=%s account_id=%s api_key=%s" % (cfg["dsn"], cfg["account_id"], redacted),
496
+ file=sys.stderr)
497
+ status, resp = accounts()
498
+ if status != 200:
499
+ print(json.dumps({"ok": False, "status": status, "response": resp}, indent=2))
500
+ return 1
501
+ items = resp.get("items", resp) if isinstance(resp, dict) else resp
502
+ summary = []
503
+ for a in (items if isinstance(items, list) else []):
504
+ srcs = a.get("sources") or [{}]
505
+ summary.append({"id": a.get("id"), "type": a.get("type"),
506
+ "name": a.get("name"),
507
+ "status": (srcs[0] or {}).get("status")})
508
+ print(json.dumps({"ok": True, "status": status, "accounts": summary}, indent=2))
509
+ return 0
510
+
511
+
512
+ def _cmd_search(args):
513
+ res = search_posts(
514
+ keywords=args.keywords, url=args.url, date_posted=args.date_posted,
515
+ sort_by=args.sort_by, content_type=args.content_type,
516
+ author_keywords=args.author_keywords, limit=args.limit, cursor=args.cursor,
517
+ with_followers=args.with_followers,
518
+ )
519
+ if args.raw:
520
+ print(json.dumps(res["raw"], indent=2))
521
+ return 0
522
+ if args.pipeline:
523
+ # Candidate shape run-linkedin.sh Phase A consumes directly.
524
+ out = {
525
+ "ok": True,
526
+ "query": args.keywords or args.url or "",
527
+ "result_count": res["count"],
528
+ "cursor": res["cursor"],
529
+ "results": to_pipeline_results(res["items"]),
530
+ }
531
+ print(json.dumps(out, indent=2, ensure_ascii=False))
532
+ return 0
533
+ slim = [{k: it[k] for k in ("social_id", "author_name", "author_headline",
534
+ "author_followers", "reaction_count",
535
+ "comment_count", "repost_count", "posted_at",
536
+ "share_url", "text")}
537
+ for it in res["items"]]
538
+ print(json.dumps({"count": res["count"], "cursor": res["cursor"],
539
+ "items": slim}, indent=2, ensure_ascii=False))
540
+ return 0
541
+
542
+
543
+ def _cmd_comment(args):
544
+ res = comment_on_post(args.social_id, args.text, comment_id=args.reply_to,
545
+ also_like=not args.no_like)
546
+ print(json.dumps(res, indent=2, ensure_ascii=False))
547
+ return 0 if res["ok"] else 1
548
+
549
+
550
+ def _cmd_react(args):
551
+ res = react_to_post(args.social_id, reaction_type=args.reaction_type,
552
+ comment_id=args.comment_id)
553
+ print(json.dumps(res, indent=2, ensure_ascii=False))
554
+ return 0 if res["ok"] else 1
555
+
556
+
557
+ def _cmd_profile(args):
558
+ prof = get_profile(args.identifier)
559
+ if args.raw:
560
+ print(json.dumps(prof, indent=2, ensure_ascii=False))
561
+ return 0
562
+ keys = ("public_identifier", "first_name", "last_name", "headline",
563
+ "follower_count", "connections_count", "is_influencer",
564
+ "is_creator", "is_premium", "network_distance")
565
+ print(json.dumps({k: prof.get(k) for k in keys}, indent=2, ensure_ascii=False))
566
+ return 0
567
+
568
+
569
+ def _cmd_comments(args):
570
+ if args.contains_id:
571
+ found = comment_exists(args.social_id, args.contains_id)
572
+ print(json.dumps({"social_id": args.social_id,
573
+ "contains_id": args.contains_id, "found": found}))
574
+ return 0 if found else 1
575
+ res = list_comments(args.social_id, limit=args.limit)
576
+ print(json.dumps({"count": res["count"], "items": res["items"]},
577
+ indent=2, ensure_ascii=False))
578
+ return 0
579
+
580
+
581
+ def main(argv=None):
582
+ p = argparse.ArgumentParser(description="UniPile LinkedIn search + comment")
583
+ sub = p.add_subparsers(dest="cmd", required=True)
584
+
585
+ sub.add_parser("probe", help="validate credentials via /accounts")
586
+
587
+ s = sub.add_parser("search", help="search LinkedIn posts")
588
+ s.add_argument("--keywords")
589
+ s.add_argument("--url", help="paste a LinkedIn search-results URL instead of keywords")
590
+ s.add_argument("--date-posted", dest="date_posted", choices=DATE_POSTED_VALUES)
591
+ s.add_argument("--sort-by", dest="sort_by", default="date",
592
+ choices=["date", "relevance"])
593
+ s.add_argument("--content-type", dest="content_type")
594
+ s.add_argument("--author-keywords", dest="author_keywords")
595
+ s.add_argument("--limit", type=int, default=10)
596
+ s.add_argument("--cursor")
597
+ s.add_argument("--raw", action="store_true", help="print the raw API response")
598
+ s.add_argument("--with-followers", dest="with_followers", action="store_true",
599
+ help="enrich each hit with author follower_count (extra GET per author)")
600
+ s.add_argument("--pipeline", action="store_true",
601
+ help="emit run-linkedin.sh Phase A candidate shape")
602
+
603
+ c = sub.add_parser("comment", help="comment on a post")
604
+ c.add_argument("--social-id", dest="social_id", required=True,
605
+ help="urn:li:activity:... (use a post's social_id)")
606
+ c.add_argument("--text", required=True)
607
+ c.add_argument("--reply-to", dest="reply_to",
608
+ help="comment_id to reply to an existing comment")
609
+ c.add_argument("--no-like", dest="no_like", action="store_true",
610
+ help="skip the automatic parent-post like (on by default)")
611
+
612
+ rx = sub.add_parser("react", help="like/react to a post (or a comment)")
613
+ rx.add_argument("--social-id", dest="social_id", required=True,
614
+ help="urn:li:activity:... (the post's social_id)")
615
+ rx.add_argument("--reaction-type", dest="reaction_type", default="like",
616
+ choices=REACTION_TYPES)
617
+ rx.add_argument("--comment-id", dest="comment_id",
618
+ help="react to a specific comment instead of the post")
619
+
620
+ pr = sub.add_parser("profile", help="fetch a LinkedIn profile (follower_count, ...)")
621
+ pr.add_argument("identifier", help="public_identifier or provider id")
622
+ pr.add_argument("--raw", action="store_true", help="print the raw API response")
623
+
624
+ cm = sub.add_parser("comments", help="list a post's comments (read-back verify)")
625
+ cm.add_argument("--social-id", dest="social_id", required=True)
626
+ cm.add_argument("--limit", type=int, default=50)
627
+ cm.add_argument("--contains-id", dest="contains_id",
628
+ help="exit 0 iff a comment with this comment_id is present")
629
+
630
+ args = p.parse_args(argv)
631
+ try:
632
+ if args.cmd == "probe":
633
+ return _cmd_probe(args)
634
+ if args.cmd == "search":
635
+ return _cmd_search(args)
636
+ if args.cmd == "comment":
637
+ return _cmd_comment(args)
638
+ if args.cmd == "react":
639
+ return _cmd_react(args)
640
+ if args.cmd == "profile":
641
+ return _cmd_profile(args)
642
+ if args.cmd == "comments":
643
+ return _cmd_comments(args)
644
+ except UnipileConfigError as exc:
645
+ print("CONFIG ERROR: %s" % exc, file=sys.stderr)
646
+ return 2
647
+ except UnipileApiError as exc:
648
+ print(json.dumps({"ok": False, "status": exc.status,
649
+ "response": exc.response}, indent=2), file=sys.stderr)
650
+ return 1
651
+ except (ValueError, KeyError) as exc:
652
+ print("ERROR: %s" % exc, file=sys.stderr)
653
+ return 2
654
+ return 0
655
+
656
+
657
+ if __name__ == "__main__":
658
+ sys.exit(main())