@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,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())
|