@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.
- package/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- 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()
|