@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,1593 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Social Autoposter - LinkedIn posting (Phase A discover+score, Phase B post)
|
|
3
|
+
#
|
|
4
|
+
# Phase A (discovery + scoring, ~$10-15 target): pick a project, consult
|
|
5
|
+
# top/dud query history, draft 8 dynamic search queries, browse the
|
|
6
|
+
# LinkedIn SERPs, extract engagement metrics (reactions/comments/reposts/
|
|
7
|
+
# age/author) for every visible candidate, score serp quality, write a
|
|
8
|
+
# structured JSON envelope to a tmp file, STOP. Bash then pipes the
|
|
9
|
+
# envelope into:
|
|
10
|
+
# - log_linkedin_search_attempts.py (records every query, including
|
|
11
|
+
# zero-result and low-quality, so duds get blocked next cycle)
|
|
12
|
+
# - score_linkedin_candidates.py (computes velocity + virality, upserts
|
|
13
|
+
# into linkedin_candidates, dedupes against engaged URN history)
|
|
14
|
+
# Bash then SELECTs the top pending candidate by velocity_score.
|
|
15
|
+
#
|
|
16
|
+
# Phase B (compose + post + verify + log, ~$10-15 target): given Phase A's
|
|
17
|
+
# chosen candidate (already in linkedin_candidates), navigate straight to
|
|
18
|
+
# the URL, defensively re-check engaged-ids, draft using the project's
|
|
19
|
+
# voice block + engagement styles + top performers report, post via
|
|
20
|
+
# the linkedin-harness MCP (bh_run), verify (DOM + screenshot), log via
|
|
21
|
+
# log_post.py, mark the candidate row 'posted' (or 'skipped'), STOP.
|
|
22
|
+
#
|
|
23
|
+
# Differences vs the pre-2026-04-29 shape:
|
|
24
|
+
# - Phase A extracts ENGAGEMENT (not just URN); we don't fly blind anymore
|
|
25
|
+
# - Phase A logs every search query (positive, zero, low-quality SERP) so
|
|
26
|
+
# the LLM learns which phrasings work and which to retire
|
|
27
|
+
# - Phase B reads its candidate from linkedin_candidates (DB-backed),
|
|
28
|
+
# not from a file, so the same candidate isn't picked twice across runs
|
|
29
|
+
|
|
30
|
+
set -euo pipefail
|
|
31
|
+
|
|
32
|
+
# ===== Whole-run singleton guard (2026-05-30) =====
|
|
33
|
+
# launchd (com.m13v.social-linkedin) fires this script every 900s (15 min),
|
|
34
|
+
# but a full Phase A + Phase B run takes 20+ min. Without a run-level mutex,
|
|
35
|
+
# two fires overlap and BOTH drive the single linkedin-harness Chrome
|
|
36
|
+
# (port 9556) at once: one run searches SERPs (Phase A) while the other
|
|
37
|
+
# posts a comment (Phase B), yanking the same window back and forth. That is
|
|
38
|
+
# the "two LinkedIns running in parallel" symptom (proven via the browser
|
|
39
|
+
# activity log on 2026-05-30: pids 35789 Phase A + 59215 Phase B alive
|
|
40
|
+
# together, both on 9556). The per-phase locks do NOT prevent this because
|
|
41
|
+
# they release between phases. This guard makes the ENTIRE run a singleton:
|
|
42
|
+
# if a prior run-linkedin.sh is still alive, this fire exits immediately.
|
|
43
|
+
S4L_LI_RUN_LOCK="/tmp/saps-run-linkedin.lock"
|
|
44
|
+
if mkdir "$S4L_LI_RUN_LOCK" 2>/dev/null; then
|
|
45
|
+
echo $$ > "$S4L_LI_RUN_LOCK/pid"
|
|
46
|
+
else
|
|
47
|
+
_li_holder="$(cat "$S4L_LI_RUN_LOCK/pid" 2>/dev/null || echo "")"
|
|
48
|
+
if [ -n "$_li_holder" ] && kill -0 "$_li_holder" 2>/dev/null; then
|
|
49
|
+
echo "[run-linkedin] singleton guard: prior full run (pid $_li_holder) still alive; exiting this fire to avoid two drivers on the 9556 Chrome"
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
# holder is dead -> stale lock; reclaim it
|
|
53
|
+
echo "[run-linkedin] singleton guard: reclaiming stale run lock (dead pid ${_li_holder:-unknown})"
|
|
54
|
+
rm -rf "$S4L_LI_RUN_LOCK"
|
|
55
|
+
mkdir "$S4L_LI_RUN_LOCK" && echo $$ > "$S4L_LI_RUN_LOCK/pid"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Transport backend selector (2026-05-28). Two interchangeable paths for the
|
|
59
|
+
# only two browser touchpoints (Phase A SERP search, Phase B comment-post):
|
|
60
|
+
# browser (DEFAULT, ACTIVE) — headed-Chrome path via the linkedin-harness
|
|
61
|
+
# MCP (bh_run). This is what every real run uses.
|
|
62
|
+
# unipile (DISABLED / OFF) — UniPile REST API via scripts/linkedin_unipile.py.
|
|
63
|
+
# *** DO NOT ASSUME THIS PATH IS RUNNING. ***
|
|
64
|
+
# The UniPile-hosted LinkedIn session is dead (it logs
|
|
65
|
+
# itself out and returns 503 no_client_session), which
|
|
66
|
+
# silently zeroed out every discovery cycle. It is now
|
|
67
|
+
# gated OFF behind the default flip below and is only
|
|
68
|
+
# reachable by an explicit LINKEDIN_BACKEND=unipile
|
|
69
|
+
# override (which will still 503 until someone manually
|
|
70
|
+
# reconnects the UniPile account). All the unipile-branch
|
|
71
|
+
# code below (the `if [ "$LINKEDIN_BACKEND" = "unipile" ]`
|
|
72
|
+
# blocks, linkedin_unipile.py calls) is DORMANT, kept only
|
|
73
|
+
# so the path can be revived later. Seeing it in the file
|
|
74
|
+
# does NOT mean it is in use.
|
|
75
|
+
# Everything ELSE (project pick, query drafting, SERP-quality rating, dedup,
|
|
76
|
+
# velocity/virality scoring, voice composition, URL wrapping, log_post.py
|
|
77
|
+
# logging, candidate marking) is byte-for-byte identical across both paths.
|
|
78
|
+
# Override per-run (revives the dormant, currently-broken path):
|
|
79
|
+
# LINKEDIN_BACKEND=unipile ~/social-autoposter/skill/run-linkedin.sh
|
|
80
|
+
LINKEDIN_BACKEND="${LINKEDIN_BACKEND:-browser}"
|
|
81
|
+
|
|
82
|
+
# LinkedIn killswitch (2026-05-27): refuse to run if a prior fire detected
|
|
83
|
+
# session compromise (http_999, authwall, throttle, li_at cleared).
|
|
84
|
+
# State: ~/.claude/social-autoposter/linkedin.killswitch
|
|
85
|
+
# Clear: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear
|
|
86
|
+
# Only gates the browser backend — UniPile has no headed session to compromise.
|
|
87
|
+
if [ "$LINKEDIN_BACKEND" = "browser" ] && [ -f "$HOME/.claude/social-autoposter/linkedin.killswitch" ]; then
|
|
88
|
+
echo "[$(date +%H:%M:%S)] LINKEDIN_KILLSWITCH active. Aborting LinkedIn pipeline."
|
|
89
|
+
echo " Re-auth LinkedIn in harness Chrome, then: python3 ~/social-autoposter/scripts/linkedin_killswitch.py clear"
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
[ -f "$HOME/social-autoposter/.env" ] && source "$HOME/social-autoposter/.env"
|
|
94
|
+
|
|
95
|
+
REPO_DIR="$HOME/social-autoposter"
|
|
96
|
+
SKILL_FILE="$REPO_DIR/SKILL.md"
|
|
97
|
+
LOG_DIR="$REPO_DIR/skill/logs"
|
|
98
|
+
mkdir -p "$LOG_DIR"
|
|
99
|
+
LOG_FILE="$LOG_DIR/run-linkedin-$(date +%Y-%m-%d_%H%M%S).log"
|
|
100
|
+
RUN_START_EPOCH=$(date +%s)
|
|
101
|
+
BATCH_ID="li-$(date +%Y%m%d_%H%M%S)-$$"
|
|
102
|
+
# Export as SA_CYCLE_ID so log_claude_session.py stamps cycle_id on every
|
|
103
|
+
# claude_sessions row spawned by this cycle. Enables per-cycle cost queries
|
|
104
|
+
# via get_run_cost.py --cycle-id. 2026-05-10 cycle_id rollout.
|
|
105
|
+
export SA_CYCLE_ID="$BATCH_ID"
|
|
106
|
+
|
|
107
|
+
echo "=== LinkedIn Post Run: $(date) (batch=$BATCH_ID) ===" | tee "$LOG_FILE"
|
|
108
|
+
|
|
109
|
+
# 2026-05-01: lock policy was changed from "hold for the entire run" to
|
|
110
|
+
# "hold only while a Claude phase is actively driving the browser". The old
|
|
111
|
+
# policy meant a single 25-45min cycle held linkedin-browser exclusively for
|
|
112
|
+
# its full duration, which (a) starved peer pipelines (dm-replies-linkedin,
|
|
113
|
+
# audit-linkedin, link-edit-linkedin) of any browser window and (b) defeated
|
|
114
|
+
# the launchd 15-min cadence: every fire of this job had to wait for the
|
|
115
|
+
# prior fire's full pipeline to finish. The browser is only actually used
|
|
116
|
+
# inside the two run_claude.sh invocations (Phase A discovery, Phase B
|
|
117
|
+
# post). All the work between them (envelope validate, DB ingest, candidate
|
|
118
|
+
# pick, project config, top performers, styles, etc.) is pure DB/CPU and
|
|
119
|
+
# does not need the lock. So we acquire just before each Claude phase and
|
|
120
|
+
# release immediately after.
|
|
121
|
+
source "$REPO_DIR/skill/lock.sh"
|
|
122
|
+
# Browser backend bootstrap (linkedin-harness). Sets MCP_CONFIG_FILE,
|
|
123
|
+
# BROWSER_INSTRUCTIONS, exports LINKEDIN_CDP_URL (so discover_linkedin_candidates.py
|
|
124
|
+
# CDP-attaches to the harness Chrome on 9556), and provides
|
|
125
|
+
# ensure_linkedin_browser_for_backend. Only the LINKEDIN_BACKEND=browser path
|
|
126
|
+
# uses these; the unipile (default) path has no browser. Migrated off the
|
|
127
|
+
# deprecated linkedin-agent MCP on 2026-05-29 (mirrors the Twitter migration).
|
|
128
|
+
source "$REPO_DIR/skill/lib/linkedin-backend.sh"
|
|
129
|
+
|
|
130
|
+
# Idempotent run_monitor.log emitter wired into a chained EXIT/INT/TERM/HUP
|
|
131
|
+
# trap. Without this, SIGTERM landing between Phase B post (where Claude has
|
|
132
|
+
# already submitted the comment via the LinkedIn API and the row is in the
|
|
133
|
+
# `posts` table) and the inline summary write at the bottom of the script
|
|
134
|
+
# silently drops the run from run_monitor.log. Mirrors the same fix shipped
|
|
135
|
+
# to run-reddit-search.sh and run-twitter-cycle.sh.
|
|
136
|
+
#
|
|
137
|
+
# Reads counters from globals the cycle accumulates (RUN_START_EPOCH,
|
|
138
|
+
# PB_RC, LOG_FILE) and re-derives POSTED/SKIPPED/FAILED the same way the
|
|
139
|
+
# inline block does. All shell-outs are wrapped in `timeout 10` so a Postgres
|
|
140
|
+
# hang during shutdown can't wedge the trap.
|
|
141
|
+
#
|
|
142
|
+
# Early-exit failure paths (Phase A no-candidates, etc.) write their own
|
|
143
|
+
# tailored log_run.py line and then set _SA_RUN_SUMMARY_EMITTED=1 to
|
|
144
|
+
# short-circuit this function — the trap fires, no-ops, and the dedicated
|
|
145
|
+
# error reason stays.
|
|
146
|
+
_SA_RUN_SUMMARY_EMITTED=0
|
|
147
|
+
_sa_emit_run_summary_oneshot() {
|
|
148
|
+
[ "${_SA_RUN_SUMMARY_EMITTED:-0}" = "1" ] && return 0
|
|
149
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
150
|
+
|
|
151
|
+
local elapsed window_sec posted skipped failed cost
|
|
152
|
+
elapsed=$(( $(date +%s) - ${RUN_START_EPOCH:-$(date +%s)} ))
|
|
153
|
+
window_sec=$(( elapsed + 60 ))
|
|
154
|
+
posted=0
|
|
155
|
+
posted=$(WINDOW_SEC="$window_sec" timeout 15 python3 - <<'PY' 2>/dev/null || true
|
|
156
|
+
import os, sys
|
|
157
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)) if "__file__" in dir() else os.getcwd(), "scripts"))
|
|
158
|
+
sys.path.insert(0, os.path.expanduser("~/social-autoposter/scripts"))
|
|
159
|
+
try:
|
|
160
|
+
from http_api import api_get
|
|
161
|
+
win = int(os.environ.get("WINDOW_SEC") or "0")
|
|
162
|
+
resp = api_get("/api/v1/posts/count",
|
|
163
|
+
{"platform": "linkedin", "within_seconds": win})
|
|
164
|
+
print(int((resp.get("data") or {}).get("count") or 0))
|
|
165
|
+
except Exception:
|
|
166
|
+
print(0)
|
|
167
|
+
PY
|
|
168
|
+
)
|
|
169
|
+
[ -z "$posted" ] && posted=0
|
|
170
|
+
skipped=0
|
|
171
|
+
if [ "$posted" = "0" ] && [ -n "${LOG_FILE:-}" ] && [ -f "${LOG_FILE:-}" ] \
|
|
172
|
+
&& grep -qE "PHASE_B_SKIP_POST_UNAVAILABLE|## Already engaged|## Comment soft-blocked" "$LOG_FILE" 2>/dev/null; then
|
|
173
|
+
skipped=1
|
|
174
|
+
fi
|
|
175
|
+
failed=0
|
|
176
|
+
if [ "${PB_RC:-1}" -ne 0 ] && [ "$posted" = "0" ] && [ "$skipped" = "0" ]; then
|
|
177
|
+
failed=1
|
|
178
|
+
fi
|
|
179
|
+
cost=$(timeout 10 python3 "$REPO_DIR/scripts/get_run_cost.py" \
|
|
180
|
+
--since "${RUN_START_EPOCH:-0}" \
|
|
181
|
+
--scripts "run-linkedin-phaseA" "run-linkedin-phaseB" \
|
|
182
|
+
2>/dev/null || echo "0.0000")
|
|
183
|
+
# Surface Anthropic-side cause (stream_idle_timeout, monthly_limit,
|
|
184
|
+
# api_overloaded, context_overflow, credit_balance) when failed>0 so
|
|
185
|
+
# the dashboard pill carries the actual error class instead of just
|
|
186
|
+
# showing a silent failed=1 row. Uses ${var:+...} conditional expansion
|
|
187
|
+
# rather than an empty bash array to avoid the `set -u` empty-array
|
|
188
|
+
# pitfall documented in CLAUDE.md (bash 3.2 trips on `"${empty[@]}"`).
|
|
189
|
+
local lk_reason=""
|
|
190
|
+
if [ "$failed" -gt 0 ] && [ -n "${LOG_FILE:-}" ] && [ -f "${LOG_FILE:-}" ]; then
|
|
191
|
+
lk_reason=$(python3 "$REPO_DIR/scripts/classify_run_error.py" "$LOG_FILE" 2>/dev/null)
|
|
192
|
+
fi
|
|
193
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin \
|
|
194
|
+
--posted "$posted" --skipped "$skipped" --failed "$failed" \
|
|
195
|
+
--cost "$cost" --elapsed "$elapsed" \
|
|
196
|
+
${lk_reason:+--failure-reasons "${lk_reason}:1"} 2>/dev/null || true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Trap chain: lock.sh has already installed _sa_release_locks on
|
|
200
|
+
# EXIT INT TERM HUP. Replace with a chained handler so summary fires first,
|
|
201
|
+
# then locks release. _sa_release_locks remains in scope after sourcing.
|
|
202
|
+
trap '_sa_emit_run_summary_oneshot; _sa_release_locks; rm -rf "$S4L_LI_RUN_LOCK" 2>/dev/null || true' EXIT INT TERM HUP
|
|
203
|
+
|
|
204
|
+
# ===== Phase A: discovery + scoring =====
|
|
205
|
+
python3 "$REPO_DIR/scripts/linkedin_search_topic_schema.py" 2>>"$LOG_FILE" || true
|
|
206
|
+
|
|
207
|
+
PROJECT_DIST=$(python3 "$REPO_DIR/scripts/pick_project.py" --platform linkedin --distribution 2>/dev/null || echo "(distribution unavailable)")
|
|
208
|
+
|
|
209
|
+
# Mirror Twitter's ownership boundary: Python picks exactly one project and
|
|
210
|
+
# one project_search_topics row before Claude drafts literal LinkedIn queries.
|
|
211
|
+
set +e
|
|
212
|
+
PROJECT_PICK_JSON=$(REPO_DIR="$REPO_DIR" python3 - <<'PY' 2>>"$LOG_FILE"
|
|
213
|
+
import json
|
|
214
|
+
import os
|
|
215
|
+
import sys
|
|
216
|
+
|
|
217
|
+
repo = os.environ["REPO_DIR"]
|
|
218
|
+
sys.path.insert(0, os.path.join(repo, "scripts"))
|
|
219
|
+
|
|
220
|
+
from pick_project import load_config, pick_project
|
|
221
|
+
from pick_search_topic import pick_topic_for_project
|
|
222
|
+
|
|
223
|
+
project = pick_project(load_config(), platform="linkedin")
|
|
224
|
+
if not project:
|
|
225
|
+
raise SystemExit("no LinkedIn-eligible project with active search_topics")
|
|
226
|
+
|
|
227
|
+
name = project.get("name") or ""
|
|
228
|
+
assignment = pick_topic_for_project(name, platform="linkedin")
|
|
229
|
+
topic = (assignment.get("search_topic") or "").strip()
|
|
230
|
+
if not topic:
|
|
231
|
+
raise SystemExit(f"no search_topic picked for project={name!r}")
|
|
232
|
+
|
|
233
|
+
out = {
|
|
234
|
+
"name": name,
|
|
235
|
+
"description": project.get("description", ""),
|
|
236
|
+
"qualification": project.get("qualification", ""),
|
|
237
|
+
"search_topic": topic,
|
|
238
|
+
"picked_weight_pct": assignment.get("picked_weight_pct"),
|
|
239
|
+
"topic_assignment": assignment,
|
|
240
|
+
"reference_topics": assignment.get("reference_topics") or [],
|
|
241
|
+
}
|
|
242
|
+
print(json.dumps(out, indent=2))
|
|
243
|
+
PY
|
|
244
|
+
)
|
|
245
|
+
PICK_RC=$?
|
|
246
|
+
set -e
|
|
247
|
+
|
|
248
|
+
if [ "$PICK_RC" -ne 0 ] || [ -z "${PROJECT_PICK_JSON:-}" ]; then
|
|
249
|
+
echo "Phase A: project/search_topic picker failed. Skipping LinkedIn run." | tee -a "$LOG_FILE"
|
|
250
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
251
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
252
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
253
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
254
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
255
|
+
exit 0
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
LI_PROJECT_NAME=$(printf '%s' "$PROJECT_PICK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('name',''))")
|
|
259
|
+
LI_SEARCH_TOPIC=$(printf '%s' "$PROJECT_PICK_JSON" | python3 -c "import json,sys; print(json.load(sys.stdin).get('search_topic',''))")
|
|
260
|
+
|
|
261
|
+
if [ -z "$LI_PROJECT_NAME" ] || [ -z "$LI_SEARCH_TOPIC" ]; then
|
|
262
|
+
echo "Phase A: project/search_topic picker returned an incomplete assignment. Skipping LinkedIn run." | tee -a "$LOG_FILE"
|
|
263
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
264
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
265
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
266
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
267
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
268
|
+
exit 0
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
echo "Phase A: picked project=$LI_PROJECT_NAME search_topic='$LI_SEARCH_TOPIC'" | tee -a "$LOG_FILE"
|
|
272
|
+
|
|
273
|
+
# Top-performing historical queries for this exact project/topic
|
|
274
|
+
# (positive signal, last 30d).
|
|
275
|
+
TOP_QUERIES=$(python3 "$REPO_DIR/scripts/top_linkedin_queries.py" --project "$LI_PROJECT_NAME" --search-topic "$LI_SEARCH_TOPIC" --limit 15 --window-days 30 2>/dev/null || echo "[]")
|
|
276
|
+
|
|
277
|
+
# Dud queries to AVOID redrafting for this exact project/topic
|
|
278
|
+
# (zero-result OR low-SERP, last 7d).
|
|
279
|
+
DUD_QUERIES=$(python3 "$REPO_DIR/scripts/top_dud_linkedin_queries.py" --project "$LI_PROJECT_NAME" --search-topic "$LI_SEARCH_TOPIC" --limit 30 --window-days 7 2>/dev/null || echo "[]")
|
|
280
|
+
|
|
281
|
+
# BSD mktemp on macOS only substitutes XXXXXX at the end of the template.
|
|
282
|
+
PHASE_A_OUT=$(mktemp /tmp/sa-run-linkedin-phaseA-XXXXXX)
|
|
283
|
+
PHASE_A_PROMPT=$(mktemp /tmp/sa-run-linkedin-phaseA-prompt-XXXXXX)
|
|
284
|
+
|
|
285
|
+
# --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
|
|
286
|
+
# --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
|
|
287
|
+
# --- UniPile account is manually reconnected. Presence here != in use.
|
|
288
|
+
if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
|
|
289
|
+
# ----- Phase A prompt: UniPile REST backend (no browser) -----
|
|
290
|
+
cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
|
|
291
|
+
You are the Social Autoposter LinkedIn discovery + scoring scout (Phase A),
|
|
292
|
+
running on the UniPile REST backend (no browser, no headed Chrome).
|
|
293
|
+
|
|
294
|
+
Your job: use the pre-selected project and assigned search_topic, draft 8
|
|
295
|
+
DYNAMIC LinkedIn search queries from that one topic, run each through the
|
|
296
|
+
UniPile search CLI, extract engagement metrics, rate SERP quality, pick the
|
|
297
|
+
single best candidate, write a structured JSON envelope to $PHASE_A_OUT, and
|
|
298
|
+
STOP. Do NOT draft a comment. Do NOT post anything. Phase B handles drafting
|
|
299
|
+
+ posting using whatever you write to the candidates list.
|
|
300
|
+
|
|
301
|
+
## Pre-selected project and assigned topic
|
|
302
|
+
$PROJECT_PICK_JSON
|
|
303
|
+
|
|
304
|
+
Assigned project: $LI_PROJECT_NAME
|
|
305
|
+
Assigned search_topic: $LI_SEARCH_TOPIC
|
|
306
|
+
|
|
307
|
+
## Today's distribution (context only; the project is already picked)
|
|
308
|
+
$PROJECT_DIST
|
|
309
|
+
|
|
310
|
+
## Top-performing historical queries for this project/topic
|
|
311
|
+
STYLE inspiration only - do NOT reuse them verbatim. LinkedIn SERPs shift
|
|
312
|
+
daily, so reusing exact phrasing is wasteful. Mine them for the angle/keyword
|
|
313
|
+
combo that worked, then craft something new.
|
|
314
|
+
$TOP_QUERIES
|
|
315
|
+
|
|
316
|
+
## DUD queries to AVOID for this project/topic
|
|
317
|
+
Do NOT redraft any of these phrasings. They have been flat or audience-wrong
|
|
318
|
+
recently. 'zero_results' means LinkedIn rejected the keywords;
|
|
319
|
+
'low_serp_quality' means results came back but were influencer slop /
|
|
320
|
+
off-target audience.
|
|
321
|
+
$DUD_QUERIES
|
|
322
|
+
|
|
323
|
+
## Workflow
|
|
324
|
+
|
|
325
|
+
1. Use ONLY this assigned project and search_topic. Do NOT pick another
|
|
326
|
+
project, do NOT switch topics, and do NOT iterate through the project list.
|
|
327
|
+
|
|
328
|
+
2. Draft 8 search queries for the assigned topic. Each query should:
|
|
329
|
+
- Be 2-4 words (LinkedIn search hates long phrases)
|
|
330
|
+
- Target practitioners, not influencers (no "expert tips", "thought
|
|
331
|
+
leadership", or buzzwordy phrasing)
|
|
332
|
+
- Be FRESH - different from the dud list, different angle from the
|
|
333
|
+
top-performers list (steal the recipe, change the dish)
|
|
334
|
+
- Map directly to the assigned search_topic
|
|
335
|
+
- Cover DIFFERENT facets / pains / personas of the ICP - not 4 reskins
|
|
336
|
+
of the same query. Wider net = higher chance of one ICP-fit hit.
|
|
337
|
+
|
|
338
|
+
Run exactly 8 queries this run. More surface area beats narrow targeting:
|
|
339
|
+
most queries return slop, so the 2-3 that survive should reach you with
|
|
340
|
+
real candidates.
|
|
341
|
+
|
|
342
|
+
3. For EACH query, shell out via the Bash tool (ONE line, no browser):
|
|
343
|
+
|
|
344
|
+
python3 $REPO_DIR/scripts/linkedin_unipile.py search --keywords "<query>" --date-posted past_week --sort-by date --with-followers --pipeline --limit 8
|
|
345
|
+
|
|
346
|
+
This calls the UniPile REST API (a server-hosted LinkedIn session on the
|
|
347
|
+
same account; there is NO local browser to prime or navigate) and prints a
|
|
348
|
+
JSON envelope to stdout:
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
"ok": true,
|
|
352
|
+
"query": "<query>",
|
|
353
|
+
"result_count": N,
|
|
354
|
+
"cursor": "...|null",
|
|
355
|
+
"results": [
|
|
356
|
+
{
|
|
357
|
+
"post_url": "https://www.linkedin.com/feed/update/urn:li:<ns>:<num>/",
|
|
358
|
+
"activity_id": "<num>",
|
|
359
|
+
"all_urns": ["<num>"],
|
|
360
|
+
"social_id": "urn:li:<ns>:<num>",
|
|
361
|
+
"author_name": "...",
|
|
362
|
+
"author_headline": "...|null",
|
|
363
|
+
"author_profile_url": "https://www.linkedin.com/in/<slug>/|null",
|
|
364
|
+
"author_followers": <int|null>,
|
|
365
|
+
"post_text": "...",
|
|
366
|
+
"age_hours": <float|null>,
|
|
367
|
+
"reactions": <int>,
|
|
368
|
+
"comments": <int>,
|
|
369
|
+
"reposts": <int>,
|
|
370
|
+
"is_repost": <bool>
|
|
371
|
+
}, ...
|
|
372
|
+
]
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
UniPile returns the post URN directly in social_id / post_url /
|
|
376
|
+
activity_id, with the CORRECT namespace (activity / share / ugcPost). There
|
|
377
|
+
is NO click-to-resolve step and NO URN-namespace guessing — copy these
|
|
378
|
+
fields through verbatim.
|
|
379
|
+
|
|
380
|
+
Failure handling: if a query prints "ok": false, or an object with an
|
|
381
|
+
"error" / error "response" field (HTTP 401 / 429 / 5xx), treat it like a
|
|
382
|
+
zero-result query — record it in queries_used with candidates_found=0 and
|
|
383
|
+
serp_quality_score=null, then continue to the next query. If the VERY FIRST
|
|
384
|
+
query returns an auth error (HTTP 401 missing_credentials), the UniPile
|
|
385
|
+
session is dead: write the envelope with whatever queries_used you have and
|
|
386
|
+
candidates: [], then STOP.
|
|
387
|
+
|
|
388
|
+
3a. RATE THE SERP QUALITY 0-10 for THIS query, based on:
|
|
389
|
+
- Practitioner ratio: judge from author_headline AND author_followers
|
|
390
|
+
(low-follower / hands-on builders > influencer-tier accounts).
|
|
391
|
+
- Topic fit: do the post_text excerpts actually match the project domain?
|
|
392
|
+
- Freshness: median age_hours of results (lower = better).
|
|
393
|
+
- 0-3 = useless slop, 4-5 = mixed, 6-8 = mostly relevant, 9-10 = goldmine.
|
|
394
|
+
|
|
395
|
+
3b. SKIP candidates authored by Matthew Diakonov / linkedin.com/in/m13v/.
|
|
396
|
+
|
|
397
|
+
3c. Dedup against engaged history. Gather the activity_id of every
|
|
398
|
+
candidate across all queries into one comma-separated list, then run
|
|
399
|
+
ONCE via Bash:
|
|
400
|
+
python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'id1,id2,id3'
|
|
401
|
+
Exit code 0 means at least one is already engaged; use the script's
|
|
402
|
+
output to drop any candidate whose activity_id is already engaged.
|
|
403
|
+
|
|
404
|
+
4. PICK THE SINGLE BEST CANDIDATE across all queries.
|
|
405
|
+
- The UniPile results are NOT pre-scored. Weigh engagement
|
|
406
|
+
(reactions + 2*comments + 3*reposts) against age_hours yourself: a post
|
|
407
|
+
with 40 reactions in 3 hours beats 60 reactions in 5 days. Favor recent
|
|
408
|
+
posts with real, non-trivial engagement.
|
|
409
|
+
|
|
410
|
+
- LEAN TOWARD POSTING. The bar is: "would commenting here be embarrassing
|
|
411
|
+
or off-message for the project?" NOT "is this a perfect ICP fit?"
|
|
412
|
+
A mediocre but on-topic comment costs around twenty cents; a missed real
|
|
413
|
+
fit costs the entire cycle (roughly fifteen dollars). Favor the post.
|
|
414
|
+
|
|
415
|
+
- HARD-REJECT (these are the only auto-disqualifiers):
|
|
416
|
+
1. Direct competitor: the author or their company sells a product that
|
|
417
|
+
competes with the project. Name the competing product in your
|
|
418
|
+
rationale. Vague competitor vibes are NOT enough.
|
|
419
|
+
2. Recruiter / job-ad post: body is "we're hiring", "open role", a job
|
|
420
|
+
description, or a careers-page link.
|
|
421
|
+
3. Off-topic content: politics, personal milestones, unrelated
|
|
422
|
+
industry, news commentary not tied to the project's domain.
|
|
423
|
+
4. Author is m13v / Matthew Diakonov. (Already filtered earlier.)
|
|
424
|
+
|
|
425
|
+
- SOFT SIGNALS (do NOT auto-reject on these alone):
|
|
426
|
+
* Author on a brand/company page (author_profile_url null but
|
|
427
|
+
author_name present): engageable IF the post topic is on-message.
|
|
428
|
+
* Adjacent persona / not the perfect ICP buyer: fine if the topic
|
|
429
|
+
resonates with the project's wedge.
|
|
430
|
+
* Lower follower count / "no-name" author: irrelevant to whether we
|
|
431
|
+
should comment; practitioners with smaller audiences are often
|
|
432
|
+
higher-quality targets than influencers.
|
|
433
|
+
* Some buzzwords / hype framing: tolerable if the underlying post-topic
|
|
434
|
+
is a real practitioner pain.
|
|
435
|
+
|
|
436
|
+
- NAME THE VERDICT EXPLICITLY in your rationale: which hard-reject category
|
|
437
|
+
fired (1/2/3/4), or "soft fit, posting." Do not write "ICP mismatch"
|
|
438
|
+
without naming which category.
|
|
439
|
+
|
|
440
|
+
- One winner. Not a ranked list. Not a top-3.
|
|
441
|
+
|
|
442
|
+
5. Write the envelope to $PHASE_A_OUT with the winner (and ONLY the winner —
|
|
443
|
+
discard runners-up, they are noise that will not be reused) and STOP:
|
|
444
|
+
|
|
445
|
+
\`\`\`bash
|
|
446
|
+
cat > $PHASE_A_OUT <<JSON_EOF
|
|
447
|
+
{
|
|
448
|
+
"project": "$LI_PROJECT_NAME",
|
|
449
|
+
"search_topic": "$LI_SEARCH_TOPIC",
|
|
450
|
+
"language": "en",
|
|
451
|
+
"queries_used": [
|
|
452
|
+
{"query": "ai agents production", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 4, "serp_quality_score": 7.5, "dropped_below_floor": 0},
|
|
453
|
+
{"query": "macos automation tools", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 0, "serp_quality_score": null, "dropped_below_floor": 0},
|
|
454
|
+
{"query": "claude code workflow", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 6, "serp_quality_score": 5.0, "dropped_below_floor": 0}
|
|
455
|
+
],
|
|
456
|
+
"candidates": [
|
|
457
|
+
{
|
|
458
|
+
"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:NUMERIC/",
|
|
459
|
+
"activity_id": "NUMERIC",
|
|
460
|
+
"all_urns": ["NUMERIC"],
|
|
461
|
+
"author_name": "First Last",
|
|
462
|
+
"author_headline": "Headline | role | company (may be null)",
|
|
463
|
+
"author_profile_url": "https://www.linkedin.com/in/SLUG/",
|
|
464
|
+
"author_followers": 2124,
|
|
465
|
+
"post_text": "post body, no newlines, no double quotes, no backticks",
|
|
466
|
+
"age_hours": 6.5,
|
|
467
|
+
"reactions": 42,
|
|
468
|
+
"comments": 7,
|
|
469
|
+
"reposts": 3,
|
|
470
|
+
"search_topic": "$LI_SEARCH_TOPIC",
|
|
471
|
+
"search_query": "ai agents production",
|
|
472
|
+
"language": "en",
|
|
473
|
+
"serp_quality_score": 7.5
|
|
474
|
+
}
|
|
475
|
+
]
|
|
476
|
+
}
|
|
477
|
+
JSON_EOF
|
|
478
|
+
\`\`\`
|
|
479
|
+
|
|
480
|
+
- queries_used MUST contain ONE row per query you ran (including
|
|
481
|
+
zero-result ones — that is the whole point of the dud-learning).
|
|
482
|
+
- project MUST equal "$LI_PROJECT_NAME" and every search_topic MUST equal
|
|
483
|
+
"$LI_SEARCH_TOPIC". The search_query is the literal phrase you ran.
|
|
484
|
+
- candidates_found is the count of usable candidates that query surfaced
|
|
485
|
+
(after dropping self-authored / already-engaged). dropped_below_floor
|
|
486
|
+
is always 0: neither path applies a virality floor (Twitter model).
|
|
487
|
+
- candidates contains AT MOST one row (the winner from step 4). It can be
|
|
488
|
+
empty if step 4 found nothing engageable. bash skips Phase B cleanly
|
|
489
|
+
when empty.
|
|
490
|
+
- The winner row MUST copy post_url, activity_id, and author_followers
|
|
491
|
+
VERBATIM from the chosen search result. Do NOT rebuild or rewrite the URN
|
|
492
|
+
namespace — UniPile already returned the correct one. Do NOT null out
|
|
493
|
+
author_followers; it is a real number on this path and the scorer uses it.
|
|
494
|
+
- candidates must NOT include posts you already engaged on or self-authored.
|
|
495
|
+
- author_headline is optional on output; pass through whatever the search
|
|
496
|
+
returned (may be null).
|
|
497
|
+
- post_text must be safe to embed in a bash double-quoted string. Strip
|
|
498
|
+
backticks, double quotes, and newlines before writing. Truncate to ~500
|
|
499
|
+
chars before writing into the envelope.
|
|
500
|
+
|
|
501
|
+
Then say '## Phase A: envelope written' and STOP.
|
|
502
|
+
|
|
503
|
+
CRITICAL: Use ONLY the Bash tool plus the linkedin_unipile.py / linkedin_url.py
|
|
504
|
+
scripts. There is NO browser in this path — NEVER attempt any browser MCP
|
|
505
|
+
tools (none are loaded) and never try to navigate a webpage.
|
|
506
|
+
CRITICAL: Run exactly 8 search queries this run. Not 2, not 4, not 6. Eight.
|
|
507
|
+
CRITICAL: NEVER use em dashes anywhere.
|
|
508
|
+
PROMPT_EOF
|
|
509
|
+
else
|
|
510
|
+
# ----- Phase A prompt: headed-Chrome browser backend (linkedin-harness) -----
|
|
511
|
+
cat > "$PHASE_A_PROMPT" <<PROMPT_EOF
|
|
512
|
+
You are the Social Autoposter LinkedIn discovery + scoring scout (Phase A).
|
|
513
|
+
|
|
514
|
+
$BROWSER_INSTRUCTIONS
|
|
515
|
+
|
|
516
|
+
Your job: use the pre-selected project and assigned search_topic, draft 8
|
|
517
|
+
DYNAMIC LinkedIn search queries from that one topic, browse each query's
|
|
518
|
+
LinkedIn SERP, extract engagement metrics for every visible candidate post,
|
|
519
|
+
write a structured JSON envelope to $PHASE_A_OUT, and STOP. Do NOT draft a
|
|
520
|
+
comment. Do NOT post anything. Phase B handles drafting + posting using
|
|
521
|
+
whatever you write to the candidates list.
|
|
522
|
+
|
|
523
|
+
## Pre-selected project and assigned topic
|
|
524
|
+
$PROJECT_PICK_JSON
|
|
525
|
+
|
|
526
|
+
Assigned project: $LI_PROJECT_NAME
|
|
527
|
+
Assigned search_topic: $LI_SEARCH_TOPIC
|
|
528
|
+
|
|
529
|
+
## Today's distribution (context only; the project is already picked)
|
|
530
|
+
$PROJECT_DIST
|
|
531
|
+
|
|
532
|
+
## Top-performing historical queries for this project/topic
|
|
533
|
+
These are STYLE inspiration only - do NOT reuse them verbatim. LinkedIn
|
|
534
|
+
SERPs shift daily, so reusing the exact same phrasing is wasteful. Mine
|
|
535
|
+
them for the angle/keyword combo that worked, then craft something new.
|
|
536
|
+
$TOP_QUERIES
|
|
537
|
+
|
|
538
|
+
## DUD queries to AVOID for this project/topic
|
|
539
|
+
Do NOT redraft any of these phrasings. They have been flat or
|
|
540
|
+
audience-wrong recently. Note the 'reason' field - 'zero_results' means
|
|
541
|
+
LinkedIn rejected the keywords; 'low_serp_quality' means results came
|
|
542
|
+
back but were influencer slop / off-target audience.
|
|
543
|
+
$DUD_QUERIES
|
|
544
|
+
|
|
545
|
+
## Workflow
|
|
546
|
+
|
|
547
|
+
1. Use ONLY this assigned project and search_topic. Do NOT pick another
|
|
548
|
+
project, do NOT switch topics, and do NOT iterate through the project list.
|
|
549
|
+
|
|
550
|
+
2. Draft 8 search queries for the assigned topic. Each query should:
|
|
551
|
+
- Be 2-4 words (LinkedIn search hates long phrases)
|
|
552
|
+
- Target practitioners, not influencers (no "expert tips", "thought
|
|
553
|
+
leadership", or buzzwordy phrasing)
|
|
554
|
+
- Be FRESH - different from the dud list, different angle from the
|
|
555
|
+
top-performers list (steal the recipe, change the dish)
|
|
556
|
+
- Map directly to the assigned search_topic
|
|
557
|
+
- Cover DIFFERENT facets / pains / personas of the ICP - not 4 reskins
|
|
558
|
+
of the same query. Wider net = higher chance of one ICP-fit hit.
|
|
559
|
+
|
|
560
|
+
Run 8 queries this run. More surface area beats narrow targeting:
|
|
561
|
+
most queries will return slop and get retired into the dud list, so the
|
|
562
|
+
2-3 that survive should reach the LLM with real candidates. The
|
|
563
|
+
LinkedIn rate budget (40/24h, 150/30d) accommodates this fine; rate
|
|
564
|
+
caps are not the bottleneck, candidate quality is.
|
|
565
|
+
|
|
566
|
+
3. PRIME the harness browser ONCE before the per-query loop. This confirms
|
|
567
|
+
the harness Chrome is up and the session is alive before the discover
|
|
568
|
+
script CDP-attaches to it.
|
|
569
|
+
3pre. Navigate (per the BROWSER BACKEND block) to https://www.linkedin.com/
|
|
570
|
+
(one navigation), then take a screenshot and Read it.
|
|
571
|
+
3pre-check. If the resulting URL contains /uas/login or /checkpoint/, or the
|
|
572
|
+
screenshot shows a login / captcha / verify-you-are-human page, the
|
|
573
|
+
persistent session is dead. Print SESSION_INVALID, write an empty
|
|
574
|
+
envelope (no queries_used, no candidates) and STOP. The user must
|
|
575
|
+
re-auth the harness LinkedIn Chrome interactively before the next run.
|
|
576
|
+
|
|
577
|
+
4. For EACH query, shell out via the Bash tool:
|
|
578
|
+
|
|
579
|
+
SOCIAL_AUTOPOSTER_LINKEDIN_SEARCH=1 $LINKEDIN_DISCOVER_PYTHON \\
|
|
580
|
+
$REPO_DIR/scripts/discover_linkedin_candidates.py content "<query>"
|
|
581
|
+
|
|
582
|
+
The script CDP-attaches to the SAME harness Chrome (LINKEDIN_CDP_URL is
|
|
583
|
+
already exported to the harness port; same cookies/session/fingerprint, no
|
|
584
|
+
second browser), navigates the SERP, extracts every visible card, and prints
|
|
585
|
+
a JSON envelope to stdout. Do NOT drive the browser yourself for discovery —
|
|
586
|
+
the script handles navigation and extraction.
|
|
587
|
+
|
|
588
|
+
Result shape on success:
|
|
589
|
+
|
|
590
|
+
{
|
|
591
|
+
"ok": true,
|
|
592
|
+
"url": "https://www.linkedin.com/search/results/content/?keywords=...",
|
|
593
|
+
"vertical": "content",
|
|
594
|
+
"query": "<query>",
|
|
595
|
+
"result_count": N,
|
|
596
|
+
"dropped_below_virality_floor": 0,
|
|
597
|
+
"virality_floor": null,
|
|
598
|
+
"results": [ // SORTED by velocity_score DESC, top of list = highest score
|
|
599
|
+
{
|
|
600
|
+
"post_url": "...|null",
|
|
601
|
+
"activity_id": "...|null",
|
|
602
|
+
"all_urns": [],
|
|
603
|
+
"author_name": "...",
|
|
604
|
+
"author_headline": "...|null",
|
|
605
|
+
"author_profile_url": "...",
|
|
606
|
+
"author_followers": null,
|
|
607
|
+
"post_text": "...",
|
|
608
|
+
"age_hours": <float>,
|
|
609
|
+
"age_text": "5m",
|
|
610
|
+
"reactions": <int>,
|
|
611
|
+
"comments": <int>,
|
|
612
|
+
"reposts": <int>
|
|
613
|
+
}, ...
|
|
614
|
+
],
|
|
615
|
+
"rate_budget": {
|
|
616
|
+
"daily_used": N, "daily_cap": 40,
|
|
617
|
+
"monthly_used": N, "monthly_cap": 150
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
result_count is ALL cards the SERP returned (Twitter model: no virality
|
|
622
|
+
floor, nothing is dropped on score). The cards are scored and sorted by
|
|
623
|
+
velocity_score DESC so the strongest engagement signal sits at the top,
|
|
624
|
+
but weak cards stay in the list as fallback. dropped_below_virality_floor
|
|
625
|
+
is always 0 now; copy it into queries_used as dropped_below_floor=0. The
|
|
626
|
+
dashboard reads raw SERP volume straight off candidates_found, so a query
|
|
627
|
+
that returns 0 cards still reads as "SERP returned nothing".
|
|
628
|
+
|
|
629
|
+
New SDUI caveat: post_url and activity_id are null for posts that don't
|
|
630
|
+
embed a quoted/reposted share. That's expected — KEEP these in your
|
|
631
|
+
working set, judge them on author/headline/post_text/age/engagement,
|
|
632
|
+
and let step 5 below resolve the URN by clicking into the chosen winner.
|
|
633
|
+
|
|
634
|
+
Failure handling (the JSON's "error" field):
|
|
635
|
+
- "rate_limited" → sleep retry_after_seconds, retry once. If still
|
|
636
|
+
rate-limited after retry, skip this query and
|
|
637
|
+
continue to the next.
|
|
638
|
+
- "serp_redirected" → log this query in queries_used with
|
|
639
|
+
candidates_found=0, serp_quality_score=0;
|
|
640
|
+
skip and move to next query.
|
|
641
|
+
- "session_invalid" → write empty envelope and STOP. Phase B will skip.
|
|
642
|
+
- "mcp_not_running" → same as session_invalid.
|
|
643
|
+
- "navigation_failed" → skip this query, continue.
|
|
644
|
+
- "db_unavailable" → script already fails closed; treat like
|
|
645
|
+
"rate_limited" with no retry budget visible.
|
|
646
|
+
On any non-ok, still append to queries_used so the run is auditable.
|
|
647
|
+
|
|
648
|
+
4a. RATE THE SERP QUALITY 0-10 for THIS query, based on:
|
|
649
|
+
- Practitioner ratio: judge from author_headline and post_text
|
|
650
|
+
(low-follower / hands-on builders > influencer-tier accounts).
|
|
651
|
+
author_followers is null on the new SDUI layout, so headline tone
|
|
652
|
+
is your primary signal.
|
|
653
|
+
- Topic fit: do the post excerpts actually match the project's domain?
|
|
654
|
+
- Freshness: median age_hours of results (lower = better)
|
|
655
|
+
- 0-3 = useless slop, 4-5 = mixed, 6-8 = mostly relevant, 9-10 = goldmine
|
|
656
|
+
Write the score into the queries_used record (see envelope below).
|
|
657
|
+
|
|
658
|
+
4b. SKIP candidates authored by Matthew Diakonov / linkedin.com/in/m13v/.
|
|
659
|
+
|
|
660
|
+
4c. SKIP candidates that already have a known URN AND are already
|
|
661
|
+
engaged. Run:
|
|
662
|
+
python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'comma,sep,urns'
|
|
663
|
+
For each candidate that HAS a non-null activity_id (the embedded-
|
|
664
|
+
quoted-share case), check its all_urns set; if ANY URN already
|
|
665
|
+
engaged, drop the candidate. Candidates with activity_id == null
|
|
666
|
+
skip this check (their URN isn't known yet) — step 5 will resolve
|
|
667
|
+
the URN before the engaged-id check runs again at Phase B.
|
|
668
|
+
|
|
669
|
+
5. PICK THE SINGLE BEST CANDIDATE across all queries.
|
|
670
|
+
- Within each query's "results" array, candidates are PRE-SORTED by
|
|
671
|
+
velocity_score DESCENDING (top of list = strongest engagement signal).
|
|
672
|
+
Default to candidates near the top — the score already encodes
|
|
673
|
+
reactions/comments/reposts/age, so the top of each list is a real
|
|
674
|
+
prior. Walking past the top-3 of any query should require a clear
|
|
675
|
+
ICP-fit reason. Do not skip a #1 just because #4 looks "interesting".
|
|
676
|
+
|
|
677
|
+
- LEAN TOWARD POSTING. The bar is: "would commenting here be embarrassing
|
|
678
|
+
or off-message for the project?" NOT "is this a perfect ICP fit?"
|
|
679
|
+
A mediocre but on-topic comment costs around twenty cents; a missed
|
|
680
|
+
real fit costs the entire cycle (roughly fifteen dollars). The cost is
|
|
681
|
+
asymmetric, so favor the post.
|
|
682
|
+
|
|
683
|
+
- HARD-REJECT (these are the only auto-disqualifiers):
|
|
684
|
+
1. Direct competitor: the author or their company sells a product
|
|
685
|
+
that competes with the project. Name the competing product in
|
|
686
|
+
your rationale ("logistify.ai builds the same RPA-replacement
|
|
687
|
+
agent Mediar does"). Vague competitor vibes are NOT enough.
|
|
688
|
+
2. Recruiter / job-ad post: post body is "we're hiring", "open
|
|
689
|
+
role", a job description, or a careers-page link. Engaging
|
|
690
|
+
drops us into a recruiting funnel, off-message.
|
|
691
|
+
3. Off-topic content: politics, personal milestones (weddings,
|
|
692
|
+
baby announcements), unrelated industry, news commentary not
|
|
693
|
+
tied to the project's domain.
|
|
694
|
+
4. Author is m13v / Matthew Diakonov. (Already filtered earlier.)
|
|
695
|
+
|
|
696
|
+
- SOFT SIGNALS (do NOT auto-reject on these alone):
|
|
697
|
+
* Author is on a brand/company page (author_profile_url null but
|
|
698
|
+
author_name present): engageable IF the post topic is on-message
|
|
699
|
+
for the project. Brand-page comments still get seen.
|
|
700
|
+
* Adjacent persona / not the perfect ICP buyer: a freelance dev
|
|
701
|
+
posting about ops automation is adjacent to Mediar's enterprise-
|
|
702
|
+
ops ICP, not on it. Adjacent is fine if the topic resonates with
|
|
703
|
+
the project's wedge — adjacent personas often spread the message
|
|
704
|
+
to actual buyers.
|
|
705
|
+
* Lower follower count / "no-name" author: irrelevant to whether
|
|
706
|
+
we should comment. Practitioners with smaller audiences are
|
|
707
|
+
often higher-quality engagement targets than influencers.
|
|
708
|
+
* Some buzzwords / hype framing: tolerable if the underlying
|
|
709
|
+
post-topic is a real practitioner pain.
|
|
710
|
+
|
|
711
|
+
- NAME THE VERDICT EXPLICITLY in your rationale: which hard-reject
|
|
712
|
+
category fired (1/2/3/4), or "soft fit, posting." Do not write
|
|
713
|
+
"ICP mismatch" without naming which category.
|
|
714
|
+
|
|
715
|
+
- One winner. Not a ranked list. Not a top-3.
|
|
716
|
+
- If the winner already has a non-null activity_id (rare: only the
|
|
717
|
+
embedded-share case), skip step 5a/5b/5c — go straight to step 6.
|
|
718
|
+
|
|
719
|
+
5a. The winner's SERP card has a clickable timestamp / "Feed post"
|
|
720
|
+
title link that opens the canonical post detail. Click it ONCE
|
|
721
|
+
(per the BROWSER BACKEND block: locate the matching card via
|
|
722
|
+
getBoundingClientRect, then click_at_xy on its timestamp/title link).
|
|
723
|
+
(Use the post_text first ~60 chars to disambiguate which card
|
|
724
|
+
on the SERP is the winner.) Click on exactly one card per run.
|
|
725
|
+
|
|
726
|
+
5b. After the navigation settles, read the resulting page URL via
|
|
727
|
+
the BROWSER BACKEND block's run-code equivalent (bh_run js("""return location.href""")).
|
|
728
|
+
Match /urn:li:(activity|share|ugcPost):(\\d{16,19})/ — capture
|
|
729
|
+
BOTH the URN type (activity / share / ugcPost) and the numeric.
|
|
730
|
+
|
|
731
|
+
CRITICAL: activity / share / ugcPost URNs are DIFFERENT namespaces.
|
|
732
|
+
The same numeric ID resolves to different posts (or to nothing) in
|
|
733
|
+
different namespaces. You MUST preserve the type when building the
|
|
734
|
+
canonical URL — never collapse share/ugcPost to activity.
|
|
735
|
+
|
|
736
|
+
post_url = https://www.linkedin.com/feed/update/urn:li:<TYPE>:<NUM>/
|
|
737
|
+
activity_id = <NUM> (bare numeric, for engaged-id check)
|
|
738
|
+
|
|
739
|
+
If your click in 5a did NOT navigate (page still shows the SERP
|
|
740
|
+
URL), fall back to the 3-dot menu → "Copy link to post" route
|
|
741
|
+
(all clicks via click_at_xy per the BROWSER BACKEND block):
|
|
742
|
+
- click the 3-dot control menu of the winner card
|
|
743
|
+
- click the "Copy link to post" menu item
|
|
744
|
+
- read the URL from clipboard via the run-code equivalent
|
|
745
|
+
(bh_run js("""return await navigator.clipboard.readText()""")) (may fail
|
|
746
|
+
with permission denied in headed Chrome — try Bash 'pbpaste' as a backup)
|
|
747
|
+
- the slug encodes the URN type: parse /-(activity|share|ugcPost)-(\\d{16,19})/
|
|
748
|
+
from the URL. Build canonical exactly as above using the captured TYPE.
|
|
749
|
+
- Example: https://www.linkedin.com/posts/SLUG-share-7455...-pkG-...
|
|
750
|
+
→ urn_type = "share", activity_id = "7455...",
|
|
751
|
+
post_url = https://www.linkedin.com/feed/update/urn:li:share:7455.../
|
|
752
|
+
|
|
753
|
+
5c. If neither 5a nor the copy-link fallback yields a URN, drop this
|
|
754
|
+
winner from your candidates list and pick the NEXT best one. Retry
|
|
755
|
+
5a once on the second-best. If that also fails, write candidates: []
|
|
756
|
+
and STOP — Phase B will skip cleanly. Do NOT loop through every
|
|
757
|
+
candidate trying to resolve URNs.
|
|
758
|
+
|
|
759
|
+
5d. Re-run the engaged-id check on the now-known numeric:
|
|
760
|
+
python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'NUM'
|
|
761
|
+
Exit 0 = already engaged, candidates: [], STOP.
|
|
762
|
+
|
|
763
|
+
6. Write the envelope to $PHASE_A_OUT with the winner (and ONLY the
|
|
764
|
+
winner — discard runners-up, they're noise that won't be reused) and
|
|
765
|
+
STOP:
|
|
766
|
+
|
|
767
|
+
\`\`\`bash
|
|
768
|
+
cat > $PHASE_A_OUT <<JSON_EOF
|
|
769
|
+
{
|
|
770
|
+
"project": "$LI_PROJECT_NAME",
|
|
771
|
+
"search_topic": "$LI_SEARCH_TOPIC",
|
|
772
|
+
"language": "en",
|
|
773
|
+
"queries_used": [
|
|
774
|
+
{"query": "ai agents production", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 4, "serp_quality_score": 7.5, "dropped_below_floor": 2},
|
|
775
|
+
{"query": "macos automation tools", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 0, "serp_quality_score": null, "dropped_below_floor": 0},
|
|
776
|
+
{"query": "claude code workflow", "search_topic": "$LI_SEARCH_TOPIC", "candidates_found": 6, "serp_quality_score": 5.0, "dropped_below_floor": 9}
|
|
777
|
+
],
|
|
778
|
+
"candidates": [
|
|
779
|
+
{
|
|
780
|
+
"post_url": "https://www.linkedin.com/feed/update/urn:li:activity:NUMERIC/",
|
|
781
|
+
"activity_id": "NUMERIC",
|
|
782
|
+
"all_urns": ["NUMERIC", "..."],
|
|
783
|
+
"author_name": "First Last",
|
|
784
|
+
"author_headline": "Headline | role | company (may be null)",
|
|
785
|
+
"author_profile_url": "https://www.linkedin.com/in/SLUG/",
|
|
786
|
+
"author_followers": null,
|
|
787
|
+
"post_text": "post body, no newlines, no double quotes, no backticks",
|
|
788
|
+
"age_hours": 6.5,
|
|
789
|
+
"reactions": 42,
|
|
790
|
+
"comments": 7,
|
|
791
|
+
"reposts": 3,
|
|
792
|
+
"search_topic": "$LI_SEARCH_TOPIC",
|
|
793
|
+
"search_query": "ai agents production",
|
|
794
|
+
"language": "en",
|
|
795
|
+
"serp_quality_score": 7.5
|
|
796
|
+
}
|
|
797
|
+
]
|
|
798
|
+
}
|
|
799
|
+
JSON_EOF
|
|
800
|
+
\`\`\`
|
|
801
|
+
|
|
802
|
+
- queries_used MUST contain ONE row per query you ran (including
|
|
803
|
+
zero-result ones — that is the whole point of the dud-learning).
|
|
804
|
+
- project MUST equal "$LI_PROJECT_NAME" and every search_topic MUST
|
|
805
|
+
equal "$LI_SEARCH_TOPIC". The search_topic is the assigned seed; the
|
|
806
|
+
search_query is the literal phrase you ran on LinkedIn.
|
|
807
|
+
- candidates_found is ALL cards the SERP returned, same as the discover
|
|
808
|
+
script's result_count (Twitter model: no virality floor, nothing is
|
|
809
|
+
dropped on score; cards are sorted by velocity_score DESC). Set
|
|
810
|
+
dropped_below_floor to 0 for every query: the discover script no longer
|
|
811
|
+
rejects cards, so its dropped_below_virality_floor is always 0. The
|
|
812
|
+
dashboard reads raw SERP volume straight off candidates_found, so a
|
|
813
|
+
query with candidates_found=0 still reads as "SERP returned nothing".
|
|
814
|
+
- candidates contains AT MOST one row (the winner from step 5). It can
|
|
815
|
+
be empty if step 5 found nothing engageable. bash will skip Phase B
|
|
816
|
+
cleanly when empty.
|
|
817
|
+
- The winner row MUST have non-null activity_id and post_url (resolved
|
|
818
|
+
at step 5b). Do NOT write null URNs to candidates[] — Phase B no
|
|
819
|
+
longer recovers them.
|
|
820
|
+
- post_url MUST embed the correct URN namespace
|
|
821
|
+
(urn:li:activity:NUM, urn:li:share:NUM, or urn:li:ugcPost:NUM) — NOT
|
|
822
|
+
forcibly rewritten to activity. The shell trusts this URL verbatim.
|
|
823
|
+
- candidates must NOT include posts you already engaged on or self-authored.
|
|
824
|
+
- author_headline is optional on output; pass through whatever the
|
|
825
|
+
discover script returned (may be null).
|
|
826
|
+
- author_followers is null on the current LinkedIn layout; do not invent
|
|
827
|
+
a value.
|
|
828
|
+
- post_text must be safe to embed in a bash double-quoted string. Strip
|
|
829
|
+
backticks, double quotes, and newlines before writing. Truncate to
|
|
830
|
+
~500 chars before writing into the envelope to keep Phase B's prompt
|
|
831
|
+
compact (the full text is still available via the discover script log).
|
|
832
|
+
|
|
833
|
+
Then say '## Phase A: envelope written' and STOP.
|
|
834
|
+
|
|
835
|
+
CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block
|
|
836
|
+
(mcp__linkedin-harness__bh_run). NEVER click the comment textbox. NEVER call
|
|
837
|
+
createComment. NEVER navigate to a post-compose flow. Phase B does all of that.
|
|
838
|
+
CRITICAL: Run exactly 8 search queries this run. Not 2, not 4, not 6. Eight.
|
|
839
|
+
Wider net = better odds of one ICP-fit hit. The rate budget can absorb it.
|
|
840
|
+
CRITICAL: NEVER use em dashes anywhere.
|
|
841
|
+
PROMPT_EOF
|
|
842
|
+
fi
|
|
843
|
+
|
|
844
|
+
# --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
|
|
845
|
+
# --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
|
|
846
|
+
# --- UniPile account is manually reconnected. Presence here != in use.
|
|
847
|
+
if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
|
|
848
|
+
# UniPile path: no headed browser, so no linkedin-browser lock, no
|
|
849
|
+
# ensure_browser_healthy, no harness MCP, no PreToolUse hook lockfile.
|
|
850
|
+
# --strict-mcp-config with NO --mcp-config loads zero MCP servers, leaving
|
|
851
|
+
# the default Bash tool the agent uses to shell out to linkedin_unipile.py.
|
|
852
|
+
set +e
|
|
853
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseA" --strict-mcp-config --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
|
|
854
|
+
PA_RC=${PIPESTATUS[0]}
|
|
855
|
+
set -e
|
|
856
|
+
rm -f "$PHASE_A_PROMPT"
|
|
857
|
+
else
|
|
858
|
+
# Acquire linkedin-browser ONLY for the Phase A Claude run. The shell lock
|
|
859
|
+
# (skill/lock.sh) is FIFO-queued, so if a peer pipeline (dm-replies-linkedin,
|
|
860
|
+
# audit-linkedin, link-edit-linkedin, or our own prior cycle's Phase B) is
|
|
861
|
+
# mid-run, this BLOCKS and polls until release rather than skipping. That
|
|
862
|
+
# matches the run-twitter-cycle.sh + run-reddit-search.sh behaviour.
|
|
863
|
+
#
|
|
864
|
+
# run_claude.sh auto-exports SA_PIPELINE_LOCKED=1 + SA_PIPELINE_PLATFORM,
|
|
865
|
+
# which the PreToolUse hook (~/.claude/hooks/linkedin-agent-lock.sh) honors
|
|
866
|
+
# to skip the cross-session block check. Without that bypass, the hook
|
|
867
|
+
# previously rejected our Claude session if the prior cycle's JSONL was
|
|
868
|
+
# <60s stale (tail-flush window), producing $8.91 empty-envelope runs.
|
|
869
|
+
# 2026-05-01: false-positive hardened by env-var bypass + pgrep alive check.
|
|
870
|
+
acquire_lock "linkedin-browser" 3600
|
|
871
|
+
ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
872
|
+
|
|
873
|
+
set +e
|
|
874
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseA" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PHASE_A_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
|
|
875
|
+
PA_RC=${PIPESTATUS[0]}
|
|
876
|
+
set -e
|
|
877
|
+
|
|
878
|
+
release_lock "linkedin-browser"
|
|
879
|
+
# Defense-in-depth: explicitly clear the hook-layer lockfile so the next
|
|
880
|
+
# pipeline cycle's PreToolUse never sees a stale entry from us. The
|
|
881
|
+
# run_claude.sh exit trap already does this in the happy path; this
|
|
882
|
+
# repeat is harmless and covers SIGKILL of run_claude.sh.
|
|
883
|
+
rm -f "$HOME/.claude/linkedin-agent-lock.json"
|
|
884
|
+
rm -f "$PHASE_A_PROMPT"
|
|
885
|
+
fi
|
|
886
|
+
|
|
887
|
+
# ===== Validate Phase A envelope + run Python ingest steps =====
|
|
888
|
+
if [ "$PA_RC" -ne 0 ] || [ ! -s "$PHASE_A_OUT" ]; then
|
|
889
|
+
echo "Phase A: no envelope (rc=$PA_RC, $([ -s "$PHASE_A_OUT" ] && echo 'file non-empty' || echo 'file empty')). Skipping Phase B." | tee -a "$LOG_FILE"
|
|
890
|
+
rm -f "$PHASE_A_OUT"
|
|
891
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
892
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
893
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 1 --failed 0 --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
894
|
+
_SA_RUN_SUMMARY_EMITTED=1 # short-circuit EXIT-trap emitter; this branch already wrote a tailored line
|
|
895
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
896
|
+
find "$LOG_DIR" -name "run-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true
|
|
897
|
+
exit 0
|
|
898
|
+
fi
|
|
899
|
+
|
|
900
|
+
# Validate the envelope is well-formed JSON; if it isn't, ledger the run
|
|
901
|
+
# as failed and skip Phase B rather than crashing the ingest scripts.
|
|
902
|
+
if ! python3 -c "import json,sys; json.load(open('$PHASE_A_OUT'))" 2>/dev/null; then
|
|
903
|
+
echo "Phase A: envelope is malformed JSON; skipping Phase B." | tee -a "$LOG_FILE"
|
|
904
|
+
rm -f "$PHASE_A_OUT"
|
|
905
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
906
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
907
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
908
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
909
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
910
|
+
exit 0
|
|
911
|
+
fi
|
|
912
|
+
|
|
913
|
+
PA_PROJECT=$(python3 -c "import json; print(json.load(open('$PHASE_A_OUT')).get('project',''))" 2>/dev/null || echo "")
|
|
914
|
+
PA_PROJECT="$LI_PROJECT_NAME"
|
|
915
|
+
PA_SEARCH_TOPIC=$(python3 -c "import json; print(json.load(open('$PHASE_A_OUT')).get('search_topic',''))" 2>/dev/null || echo "")
|
|
916
|
+
PA_SEARCH_TOPIC="$LI_SEARCH_TOPIC"
|
|
917
|
+
|
|
918
|
+
# Ingest queries_used into linkedin_search_attempts (one row per query, dud-aware).
|
|
919
|
+
LI_PROJECT_NAME="$LI_PROJECT_NAME" LI_SEARCH_TOPIC="$LI_SEARCH_TOPIC" python3 -c "
|
|
920
|
+
import os
|
|
921
|
+
import json
|
|
922
|
+
env = json.load(open('$PHASE_A_OUT'))
|
|
923
|
+
project = os.environ.get('LI_PROJECT_NAME') or env.get('project','')
|
|
924
|
+
search_topic = os.environ.get('LI_SEARCH_TOPIC') or env.get('search_topic','')
|
|
925
|
+
out = []
|
|
926
|
+
for q in env.get('queries_used') or []:
|
|
927
|
+
out.append({
|
|
928
|
+
'query': q.get('query',''),
|
|
929
|
+
'project': project,
|
|
930
|
+
'search_topic': search_topic,
|
|
931
|
+
'candidates_found': q.get('candidates_found') or 0,
|
|
932
|
+
'serp_quality_score': q.get('serp_quality_score'),
|
|
933
|
+
'dropped_below_floor': q.get('dropped_below_floor') or 0,
|
|
934
|
+
})
|
|
935
|
+
import sys; json.dump(out, sys.stdout)
|
|
936
|
+
" | python3 "$REPO_DIR/scripts/log_linkedin_search_attempts.py" --batch-id "$BATCH_ID" 2>&1 | tee -a "$LOG_FILE" || true
|
|
937
|
+
|
|
938
|
+
# Ingest candidates into linkedin_candidates (scored + deduped).
|
|
939
|
+
# Stamp serp_quality_score onto each candidate from its parent query so the
|
|
940
|
+
# scoring upsert has the per-row signal even though SERP quality is judged
|
|
941
|
+
# per-query.
|
|
942
|
+
LI_PROJECT_NAME="$LI_PROJECT_NAME" LI_SEARCH_TOPIC="$LI_SEARCH_TOPIC" python3 -c "
|
|
943
|
+
import os
|
|
944
|
+
import json
|
|
945
|
+
env = json.load(open('$PHASE_A_OUT'))
|
|
946
|
+
quality_by_query = {q.get('query',''): q.get('serp_quality_score') for q in env.get('queries_used') or []}
|
|
947
|
+
project = os.environ.get('LI_PROJECT_NAME') or env.get('project','')
|
|
948
|
+
search_topic = os.environ.get('LI_SEARCH_TOPIC') or env.get('search_topic','')
|
|
949
|
+
lang = env.get('language','en')
|
|
950
|
+
cands = []
|
|
951
|
+
for c in env.get('candidates') or []:
|
|
952
|
+
if not isinstance(c, dict):
|
|
953
|
+
continue
|
|
954
|
+
c['matched_project'] = project
|
|
955
|
+
c['search_topic'] = search_topic
|
|
956
|
+
c.setdefault('language', lang)
|
|
957
|
+
if c.get('serp_quality_score') is None:
|
|
958
|
+
c['serp_quality_score'] = quality_by_query.get(c.get('search_query',''))
|
|
959
|
+
cands.append(c)
|
|
960
|
+
import sys; json.dump(cands, sys.stdout)
|
|
961
|
+
" | python3 "$REPO_DIR/scripts/score_linkedin_candidates.py" --batch-id "$BATCH_ID" 2>&1 | tee -a "$LOG_FILE" || true
|
|
962
|
+
|
|
963
|
+
# ===== Pick top pending candidate from this batch (or fallback to global pending) =====
|
|
964
|
+
# We try the freshest batch first so a high-velocity post we just discovered
|
|
965
|
+
# wins over an older pending row that didn't get posted last cycle. If the
|
|
966
|
+
# fresh batch has zero usable rows (everything we saw was already engaged),
|
|
967
|
+
# fall back only inside the same pre-picked project/topic. A cycle should
|
|
968
|
+
# never post an older candidate from some other project just because the
|
|
969
|
+
# fresh search returned nothing.
|
|
970
|
+
PA_PICK=$(REPO_DIR="$REPO_DIR" BATCH_ID="$BATCH_ID" LI_PROJECT_NAME="$LI_PROJECT_NAME" LI_SEARCH_TOPIC="$LI_SEARCH_TOPIC" python3 - <<'PY' 2>/dev/null || echo "{}"
|
|
971
|
+
import json
|
|
972
|
+
import os
|
|
973
|
+
import sys
|
|
974
|
+
|
|
975
|
+
repo = os.environ["REPO_DIR"]
|
|
976
|
+
batch_id = os.environ["BATCH_ID"]
|
|
977
|
+
project = os.environ.get("LI_PROJECT_NAME", "")
|
|
978
|
+
search_topic = os.environ.get("LI_SEARCH_TOPIC", "")
|
|
979
|
+
|
|
980
|
+
sys.path.insert(0, os.path.join(repo, "scripts"))
|
|
981
|
+
from http_api import api_get
|
|
982
|
+
|
|
983
|
+
# Two-stage pending pick (freshest batch first, then same-project/topic
|
|
984
|
+
# fallback within a 96h window) runs server-side; see route.ts. The returned
|
|
985
|
+
# candidate shape matches the keys the PA_* extractors below expect exactly.
|
|
986
|
+
resp = api_get(
|
|
987
|
+
"/api/v1/linkedin-candidates/next-pending",
|
|
988
|
+
{
|
|
989
|
+
"batch_id": batch_id,
|
|
990
|
+
"project": project,
|
|
991
|
+
"search_topic": search_topic,
|
|
992
|
+
"max_age_hours": 96,
|
|
993
|
+
},
|
|
994
|
+
)
|
|
995
|
+
cand = (resp.get("data") or {}).get("candidate")
|
|
996
|
+
if not cand:
|
|
997
|
+
print(json.dumps({}))
|
|
998
|
+
else:
|
|
999
|
+
out = {
|
|
1000
|
+
"post_url": cand.get("post_url") or "",
|
|
1001
|
+
"activity_id": cand.get("activity_id") or "",
|
|
1002
|
+
"all_urns": cand.get("all_urns") or "",
|
|
1003
|
+
"author_name": cand.get("author_name") or "",
|
|
1004
|
+
"author_profile_url": cand.get("author_profile_url") or "",
|
|
1005
|
+
"post_text": cand.get("post_text") or "",
|
|
1006
|
+
"language": cand.get("language") or "en",
|
|
1007
|
+
"project": cand.get("project") or project,
|
|
1008
|
+
"velocity_score": float(cand.get("velocity_score") or 0),
|
|
1009
|
+
"search_query": cand.get("search_query") or "",
|
|
1010
|
+
"search_topic": cand.get("search_topic") or search_topic,
|
|
1011
|
+
}
|
|
1012
|
+
print(json.dumps(out))
|
|
1013
|
+
PY
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
PA_URL=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('post_url',''))")
|
|
1017
|
+
PA_ACTIVITY_ID=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('activity_id',''))")
|
|
1018
|
+
PA_ALL_URNS=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('all_urns',''))")
|
|
1019
|
+
PA_AUTHOR_NAME=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('author_name',''))")
|
|
1020
|
+
PA_AUTHOR_URL=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('author_profile_url',''))")
|
|
1021
|
+
PA_EXCERPT=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('post_text',''))")
|
|
1022
|
+
PA_LANG=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('language','en'))")
|
|
1023
|
+
PA_TITLE_HINT=$(echo "$PA_PICK" | python3 -c "import json,sys; v=json.load(sys.stdin).get('post_text',''); print((v or '').split('\\n')[0])")
|
|
1024
|
+
PA_VELOCITY=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('velocity_score',0))")
|
|
1025
|
+
PA_QUERY=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('search_query',''))")
|
|
1026
|
+
PA_SEARCH_TOPIC=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('search_topic',''))")
|
|
1027
|
+
[ -z "$PA_SEARCH_TOPIC" ] && PA_SEARCH_TOPIC="$LI_SEARCH_TOPIC"
|
|
1028
|
+
[ -z "${PA_PROJECT:-}" ] && PA_PROJECT=$(echo "$PA_PICK" | python3 -c "import json,sys; print(json.load(sys.stdin).get('project',''))")
|
|
1029
|
+
|
|
1030
|
+
# ===== If no candidate, exit cleanly =====
|
|
1031
|
+
# Path D: Phase A's LLM is responsible for clicking-into-best to capture the
|
|
1032
|
+
# URN, so every row reaching this gate must already have a numeric URN.
|
|
1033
|
+
if [ -z "$PA_ACTIVITY_ID" ] || [ -z "$PA_URL" ]; then
|
|
1034
|
+
echo "Phase A: no postable candidate after scoring (project='$PA_PROJECT' topic='$PA_SEARCH_TOPIC'). Skipping Phase B." | tee -a "$LOG_FILE"
|
|
1035
|
+
rm -f "$PHASE_A_OUT"
|
|
1036
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
1037
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
1038
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 1 --failed 0 --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
1039
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
1040
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
1041
|
+
exit 0
|
|
1042
|
+
fi
|
|
1043
|
+
|
|
1044
|
+
# activity_id must be 16-19 digit numeric.
|
|
1045
|
+
case "$PA_ACTIVITY_ID" in
|
|
1046
|
+
''|*[!0-9]*)
|
|
1047
|
+
echo "Phase A picked non-numeric activity_id '$PA_ACTIVITY_ID'. Skipping Phase B." | tee -a "$LOG_FILE"
|
|
1048
|
+
rm -f "$PHASE_A_OUT"
|
|
1049
|
+
ELAPSED=$(( $(date +%s) - RUN_START_EPOCH ))
|
|
1050
|
+
_COST=$(python3 "$REPO_DIR/scripts/get_run_cost.py" --since "$RUN_START_EPOCH" --scripts "run-linkedin-phaseA" "run-linkedin-phaseB" 2>/dev/null || echo "0.0000")
|
|
1051
|
+
python3 "$REPO_DIR/scripts/log_run.py" --script post_linkedin --posted 0 --skipped 0 --failed 1 --cost "$_COST" --elapsed "$ELAPSED" || true
|
|
1052
|
+
_SA_RUN_SUMMARY_EMITTED=1
|
|
1053
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
1054
|
+
exit 0
|
|
1055
|
+
;;
|
|
1056
|
+
esac
|
|
1057
|
+
|
|
1058
|
+
# Build canonical URL. Trust the row's post_url if it's already a
|
|
1059
|
+
# well-formed feed/update/urn:li:(activity|share|ugcPost):NUMERIC/ URL,
|
|
1060
|
+
# because activity / share / ugcPost are DIFFERENT namespaces. Falling
|
|
1061
|
+
# back to "always urn:li:activity:" caused "Post not found" 404s on
|
|
1062
|
+
# share-namespace posts (Andreas Mautsch / Apple Container, 2026-05-01).
|
|
1063
|
+
if [[ "$PA_URL" =~ ^https://www\.linkedin\.com/feed/update/urn:li:(activity|share|ugcPost):[0-9]{16,19}/?$ ]]; then
|
|
1064
|
+
# Already canonical with correct namespace — use it verbatim, just
|
|
1065
|
+
# ensure trailing slash.
|
|
1066
|
+
case "$PA_URL" in */) ;; *) PA_URL="$PA_URL/" ;; esac
|
|
1067
|
+
else
|
|
1068
|
+
# No usable post_url on the row (legacy / malformed). Fall back to
|
|
1069
|
+
# building from activity_id; default namespace is 'activity' which is
|
|
1070
|
+
# correct for the historical majority. If the post is actually a
|
|
1071
|
+
# share/ugcPost, Phase B's URN-type fallback (below) will recover.
|
|
1072
|
+
PA_URL="https://www.linkedin.com/feed/update/urn:li:activity:${PA_ACTIVITY_ID}/"
|
|
1073
|
+
fi
|
|
1074
|
+
|
|
1075
|
+
# The UniPile comment endpoint addresses a post by its social_id (the
|
|
1076
|
+
# urn:li:<ns>:<num> embedded in the canonical URL), not the bare numeric.
|
|
1077
|
+
# Extract it from PA_URL so Phase B's UniPile branch can POST to
|
|
1078
|
+
# /posts/{social_id}/comments. Harmless/unused for the browser path.
|
|
1079
|
+
_pa_url_tail="${PA_URL#*/feed/update/}"
|
|
1080
|
+
PA_SOCIAL_ID="${_pa_url_tail%/}"
|
|
1081
|
+
|
|
1082
|
+
echo "Phase A: chose project=$PA_PROJECT topic='$PA_SEARCH_TOPIC' activity=$PA_ACTIVITY_ID velocity=$PA_VELOCITY query='$PA_QUERY'" | tee -a "$LOG_FILE"
|
|
1083
|
+
|
|
1084
|
+
# Look up the chosen project's full config (only this one).
|
|
1085
|
+
PROJECT_FULL=$(python3 -c "
|
|
1086
|
+
import json, os
|
|
1087
|
+
c = json.load(open(os.path.expanduser('~/social-autoposter/config.json')))
|
|
1088
|
+
p = next((p for p in c.get('projects',[]) if p['name']=='$PA_PROJECT'), {})
|
|
1089
|
+
print(json.dumps(p, indent=2))
|
|
1090
|
+
")
|
|
1091
|
+
|
|
1092
|
+
# Phase B inputs (only Phase B needs styles + top performers).
|
|
1093
|
+
# Engagement-style picker (2026-05-31 LinkedIn alignment to Twitter): pick ONE
|
|
1094
|
+
# assigned style for this cycle PROGRAMMATICALLY, then hand it to the Claude
|
|
1095
|
+
# session instead of letting the post pipeline invent freely (the legacy
|
|
1096
|
+
# generate_styles_block path). The picked style flows three places, identical
|
|
1097
|
+
# to run-twitter-cycle.sh: (1) --style filter for top_performers.py so the
|
|
1098
|
+
# exemplars section shows only posts matching the assigned style, (2)
|
|
1099
|
+
# saps_render_style_block so the prompt block embeds the same assignment, (3)
|
|
1100
|
+
# --assigned-style/--assigned-mode flags on log_post.py so the post pipeline
|
|
1101
|
+
# coerces USE-mode drift back to the assigned name and registers INVENT-mode
|
|
1102
|
+
# inventions. On invent mode PICKED_STYLE is empty and top_performers stays
|
|
1103
|
+
# unfiltered (model sees the full landscape to invent against).
|
|
1104
|
+
source "$REPO_DIR/skill/styles.sh"
|
|
1105
|
+
STYLE_ASSIGN_FILE=$(mktemp -t saps_linkedin_assign_XXXXXX.json)
|
|
1106
|
+
saps_pick_style linkedin posting "$STYLE_ASSIGN_FILE" >/dev/null 2>&1 || true
|
|
1107
|
+
PICKED_STYLE=$(python3 -c "
|
|
1108
|
+
import json
|
|
1109
|
+
try:
|
|
1110
|
+
with open('$STYLE_ASSIGN_FILE') as f:
|
|
1111
|
+
d = json.load(f)
|
|
1112
|
+
print(d.get('style') or '')
|
|
1113
|
+
except Exception:
|
|
1114
|
+
print('')
|
|
1115
|
+
" 2>/dev/null)
|
|
1116
|
+
PICKED_MODE=$(python3 -c "
|
|
1117
|
+
import json
|
|
1118
|
+
try:
|
|
1119
|
+
with open('$STYLE_ASSIGN_FILE') as f:
|
|
1120
|
+
d = json.load(f)
|
|
1121
|
+
print(d.get('mode') or 'use')
|
|
1122
|
+
except Exception:
|
|
1123
|
+
print('use')
|
|
1124
|
+
" 2>/dev/null)
|
|
1125
|
+
echo "Engagement style assigned: mode=$PICKED_MODE style=${PICKED_STYLE:-(invent)}" | tee -a "$LOG_FILE"
|
|
1126
|
+
|
|
1127
|
+
if [ -n "$PICKED_STYLE" ]; then
|
|
1128
|
+
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin --style "$PICKED_STYLE" 2>/dev/null || echo "(top performers report unavailable)")
|
|
1129
|
+
else
|
|
1130
|
+
TOP_REPORT=$(python3 "$REPO_DIR/scripts/top_performers.py" --platform linkedin 2>/dev/null || echo "(top performers report unavailable)")
|
|
1131
|
+
fi
|
|
1132
|
+
STYLES_BLOCK=$(saps_render_style_block "$STYLE_ASSIGN_FILE" linkedin posting)
|
|
1133
|
+
# Best-effort cleanup of the assignment tempfile at wrapper exit.
|
|
1134
|
+
trap 'rm -f "$STYLE_ASSIGN_FILE" 2>/dev/null || true' EXIT
|
|
1135
|
+
|
|
1136
|
+
# Prior-interactions context: surface our last 5 comments on threads by the
|
|
1137
|
+
# same author in the past 30 days (soft context — vary angle, don't repeat).
|
|
1138
|
+
# Empty when we have no history with this person. Failure is silent.
|
|
1139
|
+
AUTHOR_HISTORY_BLOCK=""
|
|
1140
|
+
if [ -n "${PA_AUTHOR_NAME:-}" ]; then
|
|
1141
|
+
AUTHOR_HISTORY_BLOCK=$(python3 "$REPO_DIR/scripts/author_history_block.py" --platform linkedin --author "$PA_AUTHOR_NAME" --days 30 --limit 5 2>>"$LOG_FILE" || true)
|
|
1142
|
+
fi
|
|
1143
|
+
|
|
1144
|
+
PA_SEARCH_TOPIC_ARG=$(python3 -c "import shlex,sys; print(shlex.quote(sys.argv[1]))" "$PA_SEARCH_TOPIC")
|
|
1145
|
+
|
|
1146
|
+
# ===== Link-tail decision (Twitter-style) =====
|
|
1147
|
+
# LinkedIn comments are engagement-only by default; the drafting prompt never
|
|
1148
|
+
# emits a URL, so wrap-post-text (which only short-links URLs already present)
|
|
1149
|
+
# is a no-op and our comments carry no link. Mirror the Twitter "link tail":
|
|
1150
|
+
# resolve the project's clean landing URL, A/B-gate it, and when the arm is
|
|
1151
|
+
# 'link' have Phase B append ONE CTA bridge sentence ending in that URL via
|
|
1152
|
+
# link_tail.py (then wrap-post-text short-links it). Control arm posts no link.
|
|
1153
|
+
LINK_URL=$(python3 -c "import json,sys; p=json.loads(sys.argv[1]); print((p.get('website') or p.get('url') or '').strip())" "$PROJECT_FULL")
|
|
1154
|
+
LINKEDIN_TAIL_LINK_RATE="${LINKEDIN_TAIL_LINK_RATE:-0.5}"
|
|
1155
|
+
TAIL_DECISION=$(python3 -c "import random,sys; url=sys.argv[1].strip(); rate=float(sys.argv[2]); print('link' if (url and random.random()<rate) else 'no_link')" "$LINK_URL" "$LINKEDIN_TAIL_LINK_RATE")
|
|
1156
|
+
echo "[link-tail] project=$PA_PROJECT url=$LINK_URL rate=$LINKEDIN_TAIL_LINK_RATE decision=$TAIL_DECISION" | tee -a "$LOG_FILE"
|
|
1157
|
+
|
|
1158
|
+
# Allow Chrome's profile lockfile to release between phases.
|
|
1159
|
+
sleep 3
|
|
1160
|
+
|
|
1161
|
+
# ===== Phase B: compose + post + verify + log =====
|
|
1162
|
+
PHASE_B_PROMPT=$(mktemp /tmp/sa-run-linkedin-phaseB-prompt-XXXXXX)
|
|
1163
|
+
# --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
|
|
1164
|
+
# --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
|
|
1165
|
+
# --- UniPile account is manually reconnected. Presence here != in use.
|
|
1166
|
+
if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
|
|
1167
|
+
# ----- Phase B prompt: UniPile REST backend (no browser) -----
|
|
1168
|
+
cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
|
|
1169
|
+
You are the Social Autoposter (Phase B), running on the UniPile REST backend
|
|
1170
|
+
(no browser). Your job: post ONE comment on a pre-selected LinkedIn post
|
|
1171
|
+
(already chosen + scored by Phase A) via the UniPile API, verify it landed by
|
|
1172
|
+
reading the post's comments back, log it. STOP. Do NOT search for other
|
|
1173
|
+
candidates.
|
|
1174
|
+
|
|
1175
|
+
Read $SKILL_FILE for tone and content rules.
|
|
1176
|
+
|
|
1177
|
+
## Pre-selected candidate (from Phase A — DO NOT rediscover)
|
|
1178
|
+
- Project: **$PA_PROJECT**
|
|
1179
|
+
- Thread URL: $PA_URL
|
|
1180
|
+
- Post social_id (UniPile comment target): $PA_SOCIAL_ID
|
|
1181
|
+
- Activity URN (numeric): $PA_ACTIVITY_ID
|
|
1182
|
+
- All URNs already seen: $PA_ALL_URNS
|
|
1183
|
+
- Author: $PA_AUTHOR_NAME ($PA_AUTHOR_URL)
|
|
1184
|
+
- Post excerpt: $PA_EXCERPT
|
|
1185
|
+
- Post title hint: $PA_TITLE_HINT
|
|
1186
|
+
- Language: $PA_LANG
|
|
1187
|
+
- Velocity score: $PA_VELOCITY (Phase A picked this as the top candidate)
|
|
1188
|
+
- Search topic that guided discovery: '$PA_SEARCH_TOPIC'
|
|
1189
|
+
- Search query that surfaced it: '$PA_QUERY'
|
|
1190
|
+
|
|
1191
|
+
$AUTHOR_HISTORY_BLOCK
|
|
1192
|
+
|
|
1193
|
+
## Project config
|
|
1194
|
+
$PROJECT_FULL
|
|
1195
|
+
|
|
1196
|
+
## Top performers feedback (use to pick a comment angle)
|
|
1197
|
+
$TOP_REPORT
|
|
1198
|
+
|
|
1199
|
+
$STYLES_BLOCK
|
|
1200
|
+
|
|
1201
|
+
## Workflow
|
|
1202
|
+
|
|
1203
|
+
1. Defensive engaged-id re-check. Run via Bash:
|
|
1204
|
+
python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids '$PA_ACTIVITY_ID'
|
|
1205
|
+
If exit code 0 (already engaged), mark the candidate skipped:
|
|
1206
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
|
|
1207
|
+
then STOP with '## Already engaged (defensive catch in Phase B)'.
|
|
1208
|
+
|
|
1209
|
+
2. Draft the comment using the ASSIGNED engagement style (the style block above
|
|
1210
|
+
already assigns exactly one). This cycle: mode=$PICKED_MODE
|
|
1211
|
+
style='${PICKED_STYLE:-(invent)}'.
|
|
1212
|
+
- In USE mode ($PICKED_MODE=use) you MUST apply the assigned style
|
|
1213
|
+
'${PICKED_STYLE}' verbatim; do NOT pick a different style, do NOT invent a new
|
|
1214
|
+
name. (If your draft drifts, the orchestrator silently coerces it back to
|
|
1215
|
+
the assigned name at log time, so just use the assigned one.)
|
|
1216
|
+
- In INVENT mode ($PICKED_MODE=invent) you craft a NEW snake_case style name
|
|
1217
|
+
not in the curated block above, fitting the post + project. When you log
|
|
1218
|
+
(step 5 rejected / step 6 success), ALSO append this flag to the log_post.py
|
|
1219
|
+
command so the invention registers in engagement_styles_registry:
|
|
1220
|
+
--new-style '{\"description\":\"...\",\"example\":\"...\",\"why_existing_didnt_fit\":\"...\"}'
|
|
1221
|
+
(OMIT --new-style entirely in USE mode.)
|
|
1222
|
+
Apply the project's voice block (voice.tone, never violate voice.never,
|
|
1223
|
+
mirror voice.examples if present). Reply in $PA_LANG.
|
|
1224
|
+
NEVER use em dashes.
|
|
1225
|
+
|
|
1226
|
+
2a. LINK TAIL (A/B-gated, decided by the wrapper). The decision for THIS run is:
|
|
1227
|
+
TAIL_LINK_DECISION = '$TAIL_DECISION'
|
|
1228
|
+
LINK_URL = '$LINK_URL'
|
|
1229
|
+
If TAIL_LINK_DECISION is 'link' AND LINK_URL is non-empty, append ONE short
|
|
1230
|
+
CTA bridge sentence ending in LINK_URL to your draft. Run via Bash:
|
|
1231
|
+
TAIL_RESULT=\$(python3 $REPO_DIR/scripts/link_tail.py \\
|
|
1232
|
+
--reply-text "YOUR_COMMENT_TEXT" \\
|
|
1233
|
+
--link-url '$LINK_URL' \\
|
|
1234
|
+
--thread-text "$PA_EXCERPT" \\
|
|
1235
|
+
--project '$PA_PROJECT' \\
|
|
1236
|
+
--platform linkedin)
|
|
1237
|
+
echo "\$TAIL_RESULT"
|
|
1238
|
+
Parse {ok, text}. If ok is true, REPLACE your draft with tail_result.text
|
|
1239
|
+
(it now ends in the URL); that becomes YOUR_COMMENT_TEXT for every step
|
|
1240
|
+
below. If ok is false, keep your original draft (no link this run).
|
|
1241
|
+
If TAIL_LINK_DECISION is 'no_link' OR LINK_URL is empty, SKIP this step and
|
|
1242
|
+
do NOT add any URL yourself (this is the control arm).
|
|
1243
|
+
|
|
1244
|
+
2b. Wrap any URLs in your draft before posting. Run:
|
|
1245
|
+
WRAP_RESULT=\$(python3 $REPO_DIR/scripts/dm_short_links.py wrap-post-text \\
|
|
1246
|
+
--text "YOUR_COMMENT_TEXT" --platform linkedin --project '$PA_PROJECT')
|
|
1247
|
+
If wrap_result.ok is true: use wrap_result.text as the final comment text
|
|
1248
|
+
and save wrap_result.minted_session as MINTED_SESSION. Otherwise use the
|
|
1249
|
+
original draft and set MINTED_SESSION to empty.
|
|
1250
|
+
|
|
1251
|
+
3. Post the comment via the UniPile API (use the possibly-wrapped text from 2b):
|
|
1252
|
+
COMMENT_RESULT=\$(python3 $REPO_DIR/scripts/linkedin_unipile.py comment --social-id '$PA_SOCIAL_ID' --text "YOUR_COMMENT_TEXT")
|
|
1253
|
+
echo "\$COMMENT_RESULT"
|
|
1254
|
+
The command prints JSON {ok, status, response, comment_urn, our_url} and
|
|
1255
|
+
exits 0 iff ok. A successful post is status 200 or 201 with
|
|
1256
|
+
response.object == "CommentSent" and (usually) a numeric response.comment_id.
|
|
1257
|
+
|
|
1258
|
+
4. POST-SUBMIT VERIFICATION (mandatory). Extract ok + comment_id:
|
|
1259
|
+
COMMENT_OK=\$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('ok'))" "\$COMMENT_RESULT" 2>/dev/null || echo "False")
|
|
1260
|
+
COMMENT_ID=\$(python3 -c "import json,sys; d=json.loads(sys.argv[1]); r=d.get('response') or {}; print(r.get('comment_id') or '')" "\$COMMENT_RESULT" 2>/dev/null || echo "")
|
|
1261
|
+
Then read the comment back from the post to PROVE it rendered:
|
|
1262
|
+
python3 $REPO_DIR/scripts/linkedin_unipile.py comments --social-id '$PA_SOCIAL_ID' --contains-id "\$COMMENT_ID"
|
|
1263
|
+
This command exits 0 iff our comment_id is present in the post's comment list.
|
|
1264
|
+
SUCCESS = COMMENT_OK is "True" AND (the read-back exited 0, OR COMMENT_ID
|
|
1265
|
+
was empty but COMMENT_RESULT showed status 201 / object CommentSent).
|
|
1266
|
+
REJECTED = ok false, non-2xx status, or an error response object.
|
|
1267
|
+
|
|
1268
|
+
5. If REJECTED, do NOT call the success log path. Mark candidate skipped:
|
|
1269
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
|
|
1270
|
+
Then ledger the soft-block:
|
|
1271
|
+
python3 $REPO_DIR/scripts/log_post.py --rejected \\
|
|
1272
|
+
--platform linkedin \\
|
|
1273
|
+
--thread-url '$PA_URL' \\
|
|
1274
|
+
--our-content 'YOUR_COMMENT_TEXT' \\
|
|
1275
|
+
--project '$PA_PROJECT' \\
|
|
1276
|
+
--thread-author '$PA_AUTHOR_NAME' \\
|
|
1277
|
+
--thread-title '$PA_TITLE_HINT' \\
|
|
1278
|
+
--engagement-style STYLE_YOU_CHOSE \\
|
|
1279
|
+
--assigned-style '$PICKED_STYLE' \\
|
|
1280
|
+
--assigned-mode '$PICKED_MODE' \\
|
|
1281
|
+
--search-topic $PA_SEARCH_TOPIC_ARG \\
|
|
1282
|
+
--language '$PA_LANG' \\
|
|
1283
|
+
--rejection-reason 'UNIPILE: <verbatim status + response.object/error from COMMENT_RESULT>' \\
|
|
1284
|
+
--network-response "\$COMMENT_RESULT"
|
|
1285
|
+
Then STOP with '## Comment soft-blocked, ledgered'.
|
|
1286
|
+
|
|
1287
|
+
6. If SUCCESS, log the post and mark candidate posted:
|
|
1288
|
+
LOG_RESULT=\$(python3 $REPO_DIR/scripts/log_post.py \\
|
|
1289
|
+
--platform linkedin \\
|
|
1290
|
+
--thread-url '$PA_URL' \\
|
|
1291
|
+
--our-url '$PA_URL' \\
|
|
1292
|
+
--our-content 'YOUR_COMMENT_TEXT' \\
|
|
1293
|
+
--project '$PA_PROJECT' \\
|
|
1294
|
+
--thread-author '$PA_AUTHOR_NAME' \\
|
|
1295
|
+
--thread-title '$PA_TITLE_HINT' \\
|
|
1296
|
+
--engagement-style STYLE_YOU_CHOSE \\
|
|
1297
|
+
--assigned-style '$PICKED_STYLE' \\
|
|
1298
|
+
--assigned-mode '$PICKED_MODE' \\
|
|
1299
|
+
--search-topic $PA_SEARCH_TOPIC_ARG \\
|
|
1300
|
+
--language '$PA_LANG' \\
|
|
1301
|
+
--urns '$PA_ACTIVITY_ID')
|
|
1302
|
+
echo "\$LOG_RESULT"
|
|
1303
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'posted'}, ok_on_404=True)"
|
|
1304
|
+
If MINTED_SESSION is non-empty: extract post_id from LOG_RESULT and backfill:
|
|
1305
|
+
LOG_POST_ID=\$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('post_id',''))" "\$LOG_RESULT" 2>/dev/null || echo "")
|
|
1306
|
+
[ -n "\$LOG_POST_ID" ] && python3 $REPO_DIR/scripts/dm_short_links.py backfill-post \\
|
|
1307
|
+
--minted-session "\$MINTED_SESSION" --post-id "\$LOG_POST_ID"
|
|
1308
|
+
|
|
1309
|
+
CRITICAL: ONE post only. If anything fails, STOP — do NOT pick another candidate.
|
|
1310
|
+
CRITICAL: Use ONLY the Bash tool plus linkedin_unipile.py / log_post.py /
|
|
1311
|
+
dm_short_links.py / linkedin_url.py. There is NO browser; NEVER attempt
|
|
1312
|
+
any browser MCP tools (none are loaded).
|
|
1313
|
+
CRITICAL: NEVER use em dashes.
|
|
1314
|
+
PROMPT_EOF
|
|
1315
|
+
else
|
|
1316
|
+
# ----- Phase B prompt: headed-Chrome browser backend (linkedin-harness) -----
|
|
1317
|
+
cat > "$PHASE_B_PROMPT" <<PROMPT_EOF
|
|
1318
|
+
You are the Social Autoposter (Phase B). Your job: post ONE comment on a
|
|
1319
|
+
pre-selected LinkedIn post (already chosen + scored by Phase A), verify it
|
|
1320
|
+
landed, log it. STOP. Do NOT search for other candidates.
|
|
1321
|
+
|
|
1322
|
+
Read $SKILL_FILE for tone and content rules.
|
|
1323
|
+
|
|
1324
|
+
$BROWSER_INSTRUCTIONS
|
|
1325
|
+
|
|
1326
|
+
## Pre-selected candidate (from Phase A — DO NOT rediscover)
|
|
1327
|
+
- Project: **$PA_PROJECT**
|
|
1328
|
+
- Thread URL: $PA_URL
|
|
1329
|
+
- Activity URN: $PA_ACTIVITY_ID
|
|
1330
|
+
- All URNs already seen: $PA_ALL_URNS
|
|
1331
|
+
- Author: $PA_AUTHOR_NAME ($PA_AUTHOR_URL)
|
|
1332
|
+
- Post excerpt: $PA_EXCERPT
|
|
1333
|
+
- Post title hint: $PA_TITLE_HINT
|
|
1334
|
+
- Language: $PA_LANG
|
|
1335
|
+
- Velocity score: $PA_VELOCITY (Phase A picked this as the top candidate)
|
|
1336
|
+
- Search topic that guided discovery: '$PA_SEARCH_TOPIC'
|
|
1337
|
+
- Search query that surfaced it: '$PA_QUERY'
|
|
1338
|
+
|
|
1339
|
+
$AUTHOR_HISTORY_BLOCK
|
|
1340
|
+
|
|
1341
|
+
## Project config
|
|
1342
|
+
$PROJECT_FULL
|
|
1343
|
+
|
|
1344
|
+
## Top performers feedback (use to pick a comment angle)
|
|
1345
|
+
$TOP_REPORT
|
|
1346
|
+
|
|
1347
|
+
$STYLES_BLOCK
|
|
1348
|
+
|
|
1349
|
+
## Workflow
|
|
1350
|
+
|
|
1351
|
+
1. Navigate to $PA_URL (per the BROWSER BACKEND block).
|
|
1352
|
+
|
|
1353
|
+
1a. URN-NAMESPACE FALLBACK. After navigation, read the page DOM/text (per the
|
|
1354
|
+
BROWSER BACKEND block: bh_run js("""return document.body.innerText""") or a
|
|
1355
|
+
screenshot). If it contains the markers 'Post not found' OR 'This post
|
|
1356
|
+
was deleted or removed' OR 'this content isn'\''t available', the
|
|
1357
|
+
URN namespace in $PA_URL may be wrong (activity/share/ugcPost are
|
|
1358
|
+
DIFFERENT namespaces with different numeric IDs — Phase A may have
|
|
1359
|
+
guessed wrong on a copy-link path). Before declaring the post
|
|
1360
|
+
unavailable, retry the other two namespaces:
|
|
1361
|
+
|
|
1362
|
+
* Extract the bare numeric '$PA_ACTIVITY_ID'.
|
|
1363
|
+
* Extract the current namespace from $PA_URL (one of activity, share, ugcPost).
|
|
1364
|
+
* Try each of the OTHER two namespaces in turn:
|
|
1365
|
+
- https://www.linkedin.com/feed/update/urn:li:share:$PA_ACTIVITY_ID/
|
|
1366
|
+
- https://www.linkedin.com/feed/update/urn:li:ugcPost:$PA_ACTIVITY_ID/
|
|
1367
|
+
- https://www.linkedin.com/feed/update/urn:li:activity:$PA_ACTIVITY_ID/
|
|
1368
|
+
(skip whichever you already tried). Navigate to each (per the
|
|
1369
|
+
BROWSER BACKEND block); after each, read the DOM/text the same way;
|
|
1370
|
+
if the post-not-found markers are absent AND a comment editor / post
|
|
1371
|
+
body renders, that URL is the correct one — adopt it and continue
|
|
1372
|
+
from step 2.
|
|
1373
|
+
* If ALL THREE namespaces hit post-not-found markers, the post
|
|
1374
|
+
genuinely no longer exists. Mark candidate skipped:
|
|
1375
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
|
|
1376
|
+
Update the run-level counter signal: print a line containing
|
|
1377
|
+
the literal token 'PHASE_B_SKIP_POST_UNAVAILABLE' so the wrapper
|
|
1378
|
+
can attribute it. Then STOP with '## Post unavailable, candidate skipped'.
|
|
1379
|
+
|
|
1380
|
+
1b. If you found a working namespace different from $PA_URL, persist it
|
|
1381
|
+
so future navigations / engaged-id checks use the right canonical:
|
|
1382
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'post_url': '<WORKING_URL>'}, ok_on_404=True)"
|
|
1383
|
+
|
|
1384
|
+
2. Defensive engaged-id re-check (Phase A may have missed a URN that only
|
|
1385
|
+
surfaces after the post page fully loads). Walk the rendered DOM for ALL
|
|
1386
|
+
URNs (activity, share, ugcPost forms), merge with '$PA_ALL_URNS', and run:
|
|
1387
|
+
python3 $REPO_DIR/scripts/linkedin_url.py --check-engaged-ids 'MERGED_URNS'
|
|
1388
|
+
If exit code 0 (already engaged), mark the candidate skipped:
|
|
1389
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
|
|
1390
|
+
then STOP with '## Already engaged (defensive catch in Phase B)'.
|
|
1391
|
+
|
|
1392
|
+
3. Draft the comment using the ASSIGNED engagement style (the style block above
|
|
1393
|
+
already assigns exactly one). This cycle: mode=$PICKED_MODE
|
|
1394
|
+
style='${PICKED_STYLE:-(invent)}'.
|
|
1395
|
+
- In USE mode ($PICKED_MODE=use) you MUST apply the assigned style
|
|
1396
|
+
'${PICKED_STYLE}' verbatim; do NOT pick a different style, do NOT invent a new
|
|
1397
|
+
name. (If your draft drifts, the orchestrator silently coerces it back to
|
|
1398
|
+
the assigned name at log time, so just use the assigned one.)
|
|
1399
|
+
- In INVENT mode ($PICKED_MODE=invent) you craft a NEW snake_case style name
|
|
1400
|
+
not in the curated block above, fitting the post + project. When you log
|
|
1401
|
+
(step 6 rejected / step 7 success), ALSO append this flag to the log_post.py
|
|
1402
|
+
command so the invention registers in engagement_styles_registry:
|
|
1403
|
+
--new-style '{\"description\":\"...\",\"example\":\"...\",\"why_existing_didnt_fit\":\"...\"}'
|
|
1404
|
+
(OMIT --new-style entirely in USE mode.)
|
|
1405
|
+
Apply the project's voice block (voice.tone, never violate voice.never,
|
|
1406
|
+
mirror voice.examples if present). Reply in $PA_LANG.
|
|
1407
|
+
NEVER use em dashes.
|
|
1408
|
+
|
|
1409
|
+
3a. LINK TAIL (A/B-gated, decided by the wrapper). The decision for THIS run is:
|
|
1410
|
+
TAIL_LINK_DECISION = '$TAIL_DECISION'
|
|
1411
|
+
LINK_URL = '$LINK_URL'
|
|
1412
|
+
If TAIL_LINK_DECISION is 'link' AND LINK_URL is non-empty, append ONE short
|
|
1413
|
+
CTA bridge sentence ending in LINK_URL to your draft. Run via Bash:
|
|
1414
|
+
TAIL_RESULT=\$(python3 $REPO_DIR/scripts/link_tail.py \\
|
|
1415
|
+
--reply-text "YOUR_COMMENT_TEXT" \\
|
|
1416
|
+
--link-url '$LINK_URL' \\
|
|
1417
|
+
--thread-text "$PA_EXCERPT" \\
|
|
1418
|
+
--project '$PA_PROJECT' \\
|
|
1419
|
+
--platform linkedin)
|
|
1420
|
+
echo "\$TAIL_RESULT"
|
|
1421
|
+
Parse {ok, text}. If ok is true, REPLACE your draft with tail_result.text
|
|
1422
|
+
(it now ends in the URL); that becomes YOUR_COMMENT_TEXT for every step
|
|
1423
|
+
below. If ok is false, keep your original draft (no link this run).
|
|
1424
|
+
If TAIL_LINK_DECISION is 'no_link' OR LINK_URL is empty, SKIP this step and
|
|
1425
|
+
do NOT add any URL yourself (this is the control arm).
|
|
1426
|
+
|
|
1427
|
+
3b. Wrap any URLs in your draft before typing. Run:
|
|
1428
|
+
WRAP_RESULT=\$(python3 $REPO_DIR/scripts/dm_short_links.py wrap-post-text \\
|
|
1429
|
+
--text "YOUR_COMMENT_TEXT" --platform linkedin --project '$PA_PROJECT')
|
|
1430
|
+
If wrap_result.ok is true: use wrap_result.text as the final comment text
|
|
1431
|
+
and save wrap_result.minted_session as MINTED_SESSION. Otherwise use the
|
|
1432
|
+
original draft and set MINTED_SESSION to empty.
|
|
1433
|
+
|
|
1434
|
+
3c. AUTO-LIKE the main post (mandatory, deterministic, FAIL-SOFT). Before you
|
|
1435
|
+
comment, react Like to the post itself, mirroring the Twitter pipeline
|
|
1436
|
+
(every successful engagement also likes the parent). This is fail-soft: a
|
|
1437
|
+
like failure must NEVER block or fail the comment. If it doesn't work in
|
|
1438
|
+
two tries, log 'auto-like skipped' and proceed straight to step 4.
|
|
1439
|
+
Primary (deterministic) path via the BROWSER BACKEND block:
|
|
1440
|
+
bh_run js("""return (() => { const btn = document.querySelector('button.react-button__trigger, .feed-shared-social-action-bar button[aria-label*=Like]'); if(!btn) return JSON.stringify({ok:false, reason:'no_button'}); const pressed = (btn.getAttribute('aria-pressed')||'').toLowerCase(); if(pressed==='true') return JSON.stringify({ok:true, already_liked:true}); btn.click(); return JSON.stringify({ok:true, clicked:true}); })()""")
|
|
1441
|
+
Parse the JSON:
|
|
1442
|
+
- ok:true, already_liked:true → post was already liked, do nothing.
|
|
1443
|
+
- ok:true, clicked:true → liked. Optionally screenshot to confirm
|
|
1444
|
+
the reaction bar shows the filled Like.
|
|
1445
|
+
- ok:false (no_button) → the deterministic selector missed.
|
|
1446
|
+
Fallback ONCE: capture a screenshot, Read
|
|
1447
|
+
it, locate the post's Like button (the
|
|
1448
|
+
leftmost action under the post body, NOT
|
|
1449
|
+
a Like on any comment), and click_at_xy
|
|
1450
|
+
it. If still not found, skip the like.
|
|
1451
|
+
NEVER click Like on a comment or on a different post; only the main
|
|
1452
|
+
pre-selected post. NEVER un-like (the aria-pressed guard prevents toggling
|
|
1453
|
+
an already-liked post off). Record AUTO_LIKE = liked | already | skipped
|
|
1454
|
+
for your final summary, then continue to step 4 regardless of outcome.
|
|
1455
|
+
|
|
1456
|
+
4. Post the comment via the BROWSER BACKEND block: scroll to the comment
|
|
1457
|
+
editor, click it (click_at_xy on the contenteditable box), type_text the
|
|
1458
|
+
(possibly wrapped) text from step 3b, then click the Post/Comment submit
|
|
1459
|
+
button (click_at_xy). The contenteditable box is the trickiest element —
|
|
1460
|
+
after clicking, capture a screenshot and Read it to confirm the caret is in
|
|
1461
|
+
the editor before typing.
|
|
1462
|
+
|
|
1463
|
+
5. POST-SUBMIT VERIFICATION (mandatory). The harness has NO network-capture
|
|
1464
|
+
tool, and reading /voyager or socialActions traffic is a flagged pattern —
|
|
1465
|
+
verify visually + via the rendered DOM only.
|
|
1466
|
+
5a. Harvest URNs from the rendered DOM (NOT from network). Read every
|
|
1467
|
+
16-19 digit URN present on the page:
|
|
1468
|
+
bh_run js("""return JSON.stringify(Array.from(document.querySelectorAll('[data-id],[data-urn],[href]')).map(e=>e.getAttribute('data-id')||e.getAttribute('data-urn')||e.getAttribute('href')).join(' ').match(/urn:li:(?:activity|share|ugcPost|comment):[0-9]{16,19}/g)||[])""")
|
|
1469
|
+
Dedupe the result with the seed URN list above into ALL_POST_URNS
|
|
1470
|
+
(comma-separated). Set NETWORK_RESPONSE to a short DOM/toast summary
|
|
1471
|
+
string (there is no real network payload to capture).
|
|
1472
|
+
5b. Capture a screenshot (bh_run print(capture_screenshot())) and Read the PNG
|
|
1473
|
+
to check for a toast.
|
|
1474
|
+
5c. Read the DOM (bh_run js("""...""")) and check:
|
|
1475
|
+
(a) comment count went up by at least 1
|
|
1476
|
+
(b) a fresh comment by 'Matthew Diakonov' / 'You' is rendered
|
|
1477
|
+
(c) NO 'could not be created' toast
|
|
1478
|
+
(d) editor textbox cleared
|
|
1479
|
+
5d. SUCCESS = all four pass. REJECTED = toast present OR count unchanged.
|
|
1480
|
+
|
|
1481
|
+
5e. ON SUCCESS ONLY — capture OUR comment's full comment URN so stats can
|
|
1482
|
+
later match it. The post-stats pipeline keys engagement on the numeric
|
|
1483
|
+
comment id embedded in our_url's commentUrn; without it our comment's
|
|
1484
|
+
impressions/reactions/replies can NEVER be matched (they stay frozen).
|
|
1485
|
+
Read every rendered comment node WITH its text:
|
|
1486
|
+
bh_run js("""return JSON.stringify(Array.from(document.querySelectorAll('[data-id^="urn:li:comment:"]')).map(n=>({id:n.getAttribute('data-id'),text:(n.innerText||'').replace(/\\s+/g,' ').slice(0,160)})))""")
|
|
1487
|
+
From that array pick the ONE entry whose text matches the comment YOU
|
|
1488
|
+
just posted (YOUR_COMMENT_TEXT, possibly link-wrapped). Take its 'id' —
|
|
1489
|
+
it is the full parenthesized comment URN, e.g.
|
|
1490
|
+
urn:li:comment:(activity:7468708028956016640,7468710512147460096)
|
|
1491
|
+
(the trailing number is OUR comment id). Store it verbatim as
|
|
1492
|
+
OUR_COMMENT_URN. If you cannot confidently identify our comment (no
|
|
1493
|
+
data-id match), set OUR_COMMENT_URN to empty and proceed — step 7 falls
|
|
1494
|
+
back to the bare thread URL.
|
|
1495
|
+
|
|
1496
|
+
6. If REJECTED, do NOT call the success log path. Mark candidate skipped:
|
|
1497
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'skipped'}, ok_on_404=True)"
|
|
1498
|
+
Then ledger the soft-block:
|
|
1499
|
+
python3 $REPO_DIR/scripts/log_post.py --rejected \\
|
|
1500
|
+
--platform linkedin \\
|
|
1501
|
+
--thread-url '$PA_URL' \\
|
|
1502
|
+
--our-content 'YOUR_COMMENT_TEXT' \\
|
|
1503
|
+
--project '$PA_PROJECT' \\
|
|
1504
|
+
--thread-author '$PA_AUTHOR_NAME' \\
|
|
1505
|
+
--thread-title '$PA_TITLE_HINT' \\
|
|
1506
|
+
--engagement-style STYLE_YOU_CHOSE \\
|
|
1507
|
+
--assigned-style '$PICKED_STYLE' \\
|
|
1508
|
+
--assigned-mode '$PICKED_MODE' \\
|
|
1509
|
+
--search-topic $PA_SEARCH_TOPIC_ARG \\
|
|
1510
|
+
--language '$PA_LANG' \\
|
|
1511
|
+
--rejection-reason 'TOAST: <verbatim toast text or quiet-fail>' \\
|
|
1512
|
+
--network-response 'NETWORK_RESPONSE'
|
|
1513
|
+
Then STOP with '## Comment soft-blocked, ledgered'.
|
|
1514
|
+
|
|
1515
|
+
7. If SUCCESS, log the post and mark candidate posted. First build OUR_URL so
|
|
1516
|
+
it carries our comment's commentUrn (REQUIRED for stats matching). Substitute
|
|
1517
|
+
the OUR_COMMENT_URN you captured in step 5e in place of the literal token
|
|
1518
|
+
below, then run:
|
|
1519
|
+
OUR_URL=\$(python3 -c "import urllib.parse,sys; cu=sys.argv[1].strip(); base=sys.argv[2]; print(base + '?commentUrn=' + urllib.parse.quote(cu, safe='') if cu.startswith('urn:li:comment:(') else base)" 'OUR_COMMENT_URN' '$PA_URL')
|
|
1520
|
+
If OUR_COMMENT_URN was empty, OUR_URL falls back to the bare thread URL.
|
|
1521
|
+
LOG_RESULT=\$(python3 $REPO_DIR/scripts/log_post.py \\
|
|
1522
|
+
--platform linkedin \\
|
|
1523
|
+
--thread-url '$PA_URL' \\
|
|
1524
|
+
--our-url "\$OUR_URL" \\
|
|
1525
|
+
--our-content 'YOUR_COMMENT_TEXT' \\
|
|
1526
|
+
--project '$PA_PROJECT' \\
|
|
1527
|
+
--thread-author '$PA_AUTHOR_NAME' \\
|
|
1528
|
+
--thread-title '$PA_TITLE_HINT' \\
|
|
1529
|
+
--engagement-style STYLE_YOU_CHOSE \\
|
|
1530
|
+
--assigned-style '$PICKED_STYLE' \\
|
|
1531
|
+
--assigned-mode '$PICKED_MODE' \\
|
|
1532
|
+
--search-topic $PA_SEARCH_TOPIC_ARG \\
|
|
1533
|
+
--language '$PA_LANG' \\
|
|
1534
|
+
--urns 'ALL_POST_URNS')
|
|
1535
|
+
echo "\$LOG_RESULT"
|
|
1536
|
+
python3 -c "import sys; sys.path.insert(0,'$REPO_DIR/scripts'); from http_api import api_patch; api_patch('/api/v1/linkedin-candidates', {'activity_id': '$PA_ACTIVITY_ID', 'status': 'posted'}, ok_on_404=True)"
|
|
1537
|
+
If MINTED_SESSION is non-empty: extract post_id from LOG_RESULT and backfill:
|
|
1538
|
+
LOG_POST_ID=\$(python3 -c "import json,sys; print(json.loads(sys.argv[1]).get('post_id',''))" "\$LOG_RESULT" 2>/dev/null || echo "")
|
|
1539
|
+
[ -n "\$LOG_POST_ID" ] && python3 $REPO_DIR/scripts/dm_short_links.py backfill-post \\
|
|
1540
|
+
--minted-session "\$MINTED_SESSION" --post-id "\$LOG_POST_ID"
|
|
1541
|
+
|
|
1542
|
+
CRITICAL: ONE post only. If anything fails, STOP — do NOT pick another candidate.
|
|
1543
|
+
CRITICAL: Use ONLY the browser tool described in the BROWSER BACKEND block
|
|
1544
|
+
(mcp__linkedin-harness__bh_run).
|
|
1545
|
+
CRITICAL: NEVER use em dashes.
|
|
1546
|
+
PROMPT_EOF
|
|
1547
|
+
fi
|
|
1548
|
+
|
|
1549
|
+
# --- DORMANT unipile branch: OFF by default (see header). Reached ONLY with an
|
|
1550
|
+
# --- explicit LINKEDIN_BACKEND=unipile override, which still 503s until the
|
|
1551
|
+
# --- UniPile account is manually reconnected. Presence here != in use.
|
|
1552
|
+
if [ "$LINKEDIN_BACKEND" = "unipile" ]; then
|
|
1553
|
+
# UniPile Phase B: comment via REST, no headed browser. No linkedin-browser
|
|
1554
|
+
# lock, no ensure_browser_healthy, no harness MCP, no hook lockfile.
|
|
1555
|
+
# --strict-mcp-config with NO --mcp-config = Bash-only tool surface; the
|
|
1556
|
+
# agent shells out to linkedin_unipile.py comment/comments.
|
|
1557
|
+
set +e
|
|
1558
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseB" --strict-mcp-config --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
|
|
1559
|
+
PB_RC=${PIPESTATUS[0]}
|
|
1560
|
+
set -e
|
|
1561
|
+
rm -f "$PHASE_B_PROMPT"
|
|
1562
|
+
rm -f "$PHASE_A_OUT"
|
|
1563
|
+
else
|
|
1564
|
+
# Re-acquire linkedin-browser for Phase B. The lock was released after
|
|
1565
|
+
# Phase A so peer pipelines could use the browser during our DB-ingest /
|
|
1566
|
+
# candidate-pick / styles-prep window (~1-3s). If a peer (or a parallel
|
|
1567
|
+
# linkedin cycle's Phase A) grabbed it in the meantime, this acquire blocks
|
|
1568
|
+
# until they release; the FIFO ticket queue in lock.sh guarantees fairness.
|
|
1569
|
+
acquire_lock "linkedin-browser" 3600
|
|
1570
|
+
ensure_linkedin_browser_for_backend 2>&1 | tee -a "$LOG_FILE"
|
|
1571
|
+
|
|
1572
|
+
set +e
|
|
1573
|
+
"$REPO_DIR/scripts/run_claude.sh" "run-linkedin-phaseB" --strict-mcp-config --mcp-config "$MCP_CONFIG_FILE" --output-format stream-json --verbose -p "$(cat "$PHASE_B_PROMPT")" 2>&1 | tee -a "$LOG_FILE"
|
|
1574
|
+
PB_RC=${PIPESTATUS[0]}
|
|
1575
|
+
set -e
|
|
1576
|
+
|
|
1577
|
+
release_lock "linkedin-browser"
|
|
1578
|
+
# Defense-in-depth: explicit hook-lockfile cleanup; see Phase A note.
|
|
1579
|
+
rm -f "$HOME/.claude/linkedin-agent-lock.json"
|
|
1580
|
+
rm -f "$PHASE_B_PROMPT"
|
|
1581
|
+
rm -f "$PHASE_A_OUT"
|
|
1582
|
+
fi
|
|
1583
|
+
|
|
1584
|
+
# ===== Persist run-level summary =====
|
|
1585
|
+
# Same logic that used to live inline now lives in
|
|
1586
|
+
# _sa_emit_run_summary_oneshot (defined near the top after sourcing lock.sh).
|
|
1587
|
+
# Calling it directly here on the happy path; the EXIT trap will short-
|
|
1588
|
+
# circuit afterwards via _SA_RUN_SUMMARY_EMITTED. Under SIGTERM mid-script,
|
|
1589
|
+
# the trap fires this same function so the dashboard still gets a row.
|
|
1590
|
+
_sa_emit_run_summary_oneshot
|
|
1591
|
+
|
|
1592
|
+
echo "=== Run complete: $(date) ===" | tee -a "$LOG_FILE"
|
|
1593
|
+
find "$LOG_DIR" -name "run-linkedin-*.log" -mtime +7 -delete 2>/dev/null || true
|