@m13v/s4l 1.6.197-rc.7
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 +1314 -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 +497 -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,730 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn browser automation: read-only sidebar pre-check.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 linkedin_browser.py unread-dms
|
|
6
|
+
|
|
7
|
+
Read-only DOM scrape: NO Voyager API, NO scroll-and-expand loops, NO
|
|
8
|
+
permalink fan-out, NO clicks/typing, NO programmatic login. Each
|
|
9
|
+
invocation does ONE navigation + ONE page.evaluate() then closes the context.
|
|
10
|
+
|
|
11
|
+
Per CLAUDE.md "LinkedIn: flagged patterns" carve-out (2026-04-29): a
|
|
12
|
+
read-only DOM read is permitted because its fingerprint is indistinguishable
|
|
13
|
+
from the existing mcp__linkedin-agent__ sessions (same profile, same cookies,
|
|
14
|
+
same headed Chrome binary). The 2026-04-17 restriction was caused by Voyager
|
|
15
|
+
calls + permalink scroll loops, neither of which appear here.
|
|
16
|
+
|
|
17
|
+
Connects to the running linkedin-agent's persistent profile at
|
|
18
|
+
~/.claude/browser-profiles/linkedin. Launches HEADED Chromium (per the
|
|
19
|
+
CLAUDE.md note that LinkedIn fingerprints headless aggressively). Holds
|
|
20
|
+
the linkedin-browser lock for the entire run; expects the caller (shell)
|
|
21
|
+
to have already done lock acquisition + ensure_browser_healthy so the MCP
|
|
22
|
+
Chrome is gone and the profile is free.
|
|
23
|
+
|
|
24
|
+
Sister script for SERP discovery: scripts/discover_linkedin_candidates.py
|
|
25
|
+
(replaces the Claude-driven SERP nav inside skill/run-linkedin.sh Phase A).
|
|
26
|
+
That script imports PROFILE_DIR / VIEWPORT / SYSTEM_CHROME / LOCK_*
|
|
27
|
+
constants + _acquire_browser_lock + _is_login_or_checkpoint from this
|
|
28
|
+
module so both tools cooperate on the same Chrome profile and lock file.
|
|
29
|
+
|
|
30
|
+
Output (stdout, JSON):
|
|
31
|
+
{
|
|
32
|
+
"ok": true,
|
|
33
|
+
"url": "https://www.linkedin.com/messaging/",
|
|
34
|
+
"total_threads": 13,
|
|
35
|
+
"unread_count": 0,
|
|
36
|
+
"threads": [...],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Failure shapes:
|
|
40
|
+
{"ok": false, "error": "session_invalid", "url": "..."}
|
|
41
|
+
{"ok": false, "error": "profile_locked", "detail": "..."}
|
|
42
|
+
{"ok": false, "error": "navigation_failed", "detail": "..."}
|
|
43
|
+
|
|
44
|
+
Exits 0 on success, 1 on failure.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import atexit
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import time
|
|
54
|
+
from typing import Optional
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bh_activity_log(action: str, cdp_url: str) -> None:
|
|
58
|
+
"""Append to the universal browser-activity.log (Python-CDP path coverage).
|
|
59
|
+
|
|
60
|
+
The harness MCP server.py logs its own bh_run calls, but these CDP scripts
|
|
61
|
+
attach via connect_over_cdp and bypass it, so they log here directly.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
import time as _t
|
|
65
|
+
import os as _o
|
|
66
|
+
from pathlib import Path as _P
|
|
67
|
+
_p = _P(_o.environ.get(
|
|
68
|
+
"BH_ACTIVITY_LOG",
|
|
69
|
+
str(_P.home() / ".claude" / "browser-profiles" / "browser-activity.log"),
|
|
70
|
+
))
|
|
71
|
+
_port = (cdp_url or "").rsplit(":", 1)[-1].split("/")[0] or "-"
|
|
72
|
+
_p.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with _p.open("a") as _f:
|
|
74
|
+
_f.write(
|
|
75
|
+
f"[{_t.strftime('%Y-%m-%d %H:%M:%S')}] pycdp "
|
|
76
|
+
f"script={_o.path.basename(__file__)} action={action} "
|
|
77
|
+
f"pid={_o.getpid()} ppid={_o.getppid()} cdp={cdp_url or '-'} "
|
|
78
|
+
f"port={_port}\n"
|
|
79
|
+
)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_holder_alive(holder: str) -> bool:
|
|
85
|
+
"""Mirror ~/.claude/hooks/linkedin-agent-lock.sh is_holder_alive().
|
|
86
|
+
|
|
87
|
+
A live Claude session puts its UUID on the cmdline as
|
|
88
|
+
`claude --session-id <UUID>`. pgrep matches it; absence means the
|
|
89
|
+
holder is dead and the lock is stale, even if its JSONL transcript
|
|
90
|
+
is still tail-flushing. This is the canonical liveness signal.
|
|
91
|
+
"""
|
|
92
|
+
if not holder:
|
|
93
|
+
return False
|
|
94
|
+
try:
|
|
95
|
+
return (
|
|
96
|
+
subprocess.run(
|
|
97
|
+
["pgrep", "-f", f"claude.*--session-id {holder}"],
|
|
98
|
+
stdout=subprocess.DEVNULL,
|
|
99
|
+
stderr=subprocess.DEVNULL,
|
|
100
|
+
timeout=2,
|
|
101
|
+
).returncode
|
|
102
|
+
== 0
|
|
103
|
+
)
|
|
104
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
105
|
+
# On error, assume alive to err on the side of NOT stealing the lock.
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
# Profile dir is overridable so the harness migration (2026-05-26) can point
|
|
109
|
+
# the cold-launch fallback at ~/.claude/browser-profiles/browser-harness-linkedin
|
|
110
|
+
# while leaving legacy linkedin-agent callers unchanged. The Twitter harness
|
|
111
|
+
# uses the same pattern (TWITTER_CDP_URL + harness profile dir).
|
|
112
|
+
PROFILE_DIR = os.path.expanduser(
|
|
113
|
+
os.environ.get(
|
|
114
|
+
"LINKEDIN_PROFILE_DIR",
|
|
115
|
+
"~/.claude/browser-profiles/linkedin",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
LOCK_FILE = os.path.expanduser("~/.claude/linkedin-agent-lock.json")
|
|
119
|
+
LOCK_EXPIRY = 300 # Must match ~/.claude/hooks/linkedin-agent-lock.sh
|
|
120
|
+
LOCK_WAIT_MAX = 30 # seconds; pre-check should not block long
|
|
121
|
+
LOCK_POLL_INTERVAL = 2
|
|
122
|
+
VIEWPORT = {"width": 911, "height": 1016}
|
|
123
|
+
# linkedin-agent uses the system Google Chrome binary, not Playwright's
|
|
124
|
+
# bundled "Chrome for Testing". Profile was created/migrated by system
|
|
125
|
+
# Chrome and "Chrome for Testing" fails to open it (SIGTRAP / kill EPERM
|
|
126
|
+
# observed 2026-04-29). Match the agent's binary so the profile stays
|
|
127
|
+
# compatible.
|
|
128
|
+
SYSTEM_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
129
|
+
|
|
130
|
+
_LOCK_SESSION_ID = f"python:{os.getpid()}"
|
|
131
|
+
_LOCK_INHERITED = False
|
|
132
|
+
_UUID_RE = re.compile(
|
|
133
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _release_browser_lock():
|
|
138
|
+
if _LOCK_INHERITED:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
if os.path.exists(LOCK_FILE):
|
|
142
|
+
with open(LOCK_FILE) as f:
|
|
143
|
+
lock = json.load(f)
|
|
144
|
+
if lock.get("session_id") == _LOCK_SESSION_ID:
|
|
145
|
+
os.remove(LOCK_FILE)
|
|
146
|
+
except (json.JSONDecodeError, OSError):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
atexit.register(_release_browser_lock)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_python_holder_alive(holder: str) -> bool:
|
|
154
|
+
"""Liveness probe for a `python:PID` lock holder (defect a fix, 2026-06-16).
|
|
155
|
+
|
|
156
|
+
Mirrors twitter_browser._is_python_holder_alive (see docs/twitter_browser_lock.md).
|
|
157
|
+
Holders written by the python scripts are `python:<pid>`; the linkedin-agent
|
|
158
|
+
PreToolUse hook writes UUID holders (handled separately by _is_holder_alive).
|
|
159
|
+
A python holder whose process died without running its atexit release used to
|
|
160
|
+
starve every peer until LOCK_EXPIRY (300s); os.kill(pid, 0) lets us reclaim it
|
|
161
|
+
at once. Returns True for anything we cannot prove dead, so the worst case
|
|
162
|
+
degrades to the LOCK_EXPIRY failsafe rather than stealing a live peer's lock.
|
|
163
|
+
"""
|
|
164
|
+
if not holder.startswith("python:"):
|
|
165
|
+
return True # not a python holder; this probe makes no claim
|
|
166
|
+
try:
|
|
167
|
+
pid = int(holder.split(":", 1)[1])
|
|
168
|
+
except (ValueError, IndexError):
|
|
169
|
+
return True # unparseable holder -> don't steal on this basis
|
|
170
|
+
try:
|
|
171
|
+
os.kill(pid, 0)
|
|
172
|
+
return True # process exists -> alive
|
|
173
|
+
except ProcessLookupError:
|
|
174
|
+
return False # no such process -> dead, reclaimable
|
|
175
|
+
except PermissionError:
|
|
176
|
+
return True # exists but another owner -> alive
|
|
177
|
+
except OSError:
|
|
178
|
+
return True # ambiguous -> err toward NOT stealing
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _try_take_lock() -> bool:
|
|
182
|
+
"""Atomically claim LOCK_FILE for this process (defect c fix, 2026-06-16).
|
|
183
|
+
O_CREAT|O_EXCL makes "is it free? then take it" a single syscall, so two
|
|
184
|
+
python acquirers can't both win the old os.path.exists + open(w) race. A
|
|
185
|
+
False return means a peer beat us; the caller re-loops. Coexists with the
|
|
186
|
+
linkedin-agent hook (which registers UUID holders via its own write path):
|
|
187
|
+
python only takes when it has decided the lock is free or reclaimable.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
191
|
+
except FileExistsError:
|
|
192
|
+
return False
|
|
193
|
+
except OSError:
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
os.write(fd, json.dumps(
|
|
197
|
+
{"session_id": _LOCK_SESSION_ID, "timestamp": int(time.time())}
|
|
198
|
+
).encode())
|
|
199
|
+
finally:
|
|
200
|
+
os.close(fd)
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _acquire_browser_lock():
|
|
205
|
+
"""Acquire the LinkedIn browser session mutex (~/.claude/linkedin-agent-lock.json).
|
|
206
|
+
|
|
207
|
+
Mirrors twitter_browser._acquire_browser_lock (full writeup:
|
|
208
|
+
docs/twitter_browser_lock.md). Co-managed with the linkedin-agent PreToolUse
|
|
209
|
+
hook, which registers live Claude sessions as UUID holders; we INHERIT those
|
|
210
|
+
rather than fight them. Reclaim priority (a holder PROVEN dead is taken at
|
|
211
|
+
once, so a crashed peer cannot starve the fleet for LOCK_WAIT_MAX/LOCK_EXPIRY):
|
|
212
|
+
1. holder == us -> re-entrant; already ours.
|
|
213
|
+
2. UUID holder, pid gone -> stale Claude session, reclaim.
|
|
214
|
+
3. python:PID, pid gone -> dead peer (defect a fix), reclaim.
|
|
215
|
+
4. age >= LOCK_EXPIRY -> failsafe for holders we cannot probe.
|
|
216
|
+
5. live UUID holder -> inherit (parent Claude session / hook).
|
|
217
|
+
6. live python:PID holder -> real peer; wait, then give up (profile_locked).
|
|
218
|
+
|
|
219
|
+
Acquisition is atomic (_try_take_lock / O_EXCL). The lockfile JSON shape
|
|
220
|
+
{"session_id","timestamp"} is preserved so the hook keeps interoperating.
|
|
221
|
+
"""
|
|
222
|
+
global _LOCK_SESSION_ID, _LOCK_INHERITED
|
|
223
|
+
deadline = time.time() + LOCK_WAIT_MAX
|
|
224
|
+
try:
|
|
225
|
+
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
228
|
+
while True:
|
|
229
|
+
if not os.path.exists(LOCK_FILE):
|
|
230
|
+
if _try_take_lock():
|
|
231
|
+
break
|
|
232
|
+
if time.time() >= deadline:
|
|
233
|
+
print(json.dumps({
|
|
234
|
+
"ok": False, "error": "profile_locked",
|
|
235
|
+
"detail": f"create-contended waited={LOCK_WAIT_MAX}s",
|
|
236
|
+
}))
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
|
239
|
+
continue
|
|
240
|
+
try:
|
|
241
|
+
with open(LOCK_FILE) as f:
|
|
242
|
+
lock = json.load(f)
|
|
243
|
+
except (json.JSONDecodeError, OSError):
|
|
244
|
+
# Corrupt / half-written / vanished between exists() and open().
|
|
245
|
+
if _try_take_lock():
|
|
246
|
+
break
|
|
247
|
+
if time.time() >= deadline:
|
|
248
|
+
print(json.dumps({
|
|
249
|
+
"ok": False, "error": "profile_locked",
|
|
250
|
+
"detail": f"unreadable waited={LOCK_WAIT_MAX}s",
|
|
251
|
+
}))
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
|
254
|
+
continue
|
|
255
|
+
age = time.time() - lock.get("timestamp", 0)
|
|
256
|
+
holder = lock.get("session_id", "")
|
|
257
|
+
|
|
258
|
+
# 1. Re-entrant: the lock is already ours (or a stale lock left by a
|
|
259
|
+
# previous process whose PID we reused). Refresh timestamp + proceed.
|
|
260
|
+
if holder == _LOCK_SESSION_ID and not _LOCK_INHERITED:
|
|
261
|
+
try:
|
|
262
|
+
with open(LOCK_FILE, "w") as f:
|
|
263
|
+
json.dump(
|
|
264
|
+
{"session_id": _LOCK_SESSION_ID,
|
|
265
|
+
"timestamp": int(time.time())}, f)
|
|
266
|
+
except OSError:
|
|
267
|
+
pass
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
# 2-4. Reclaim a holder we can prove dead/expired (remove + atomic take).
|
|
271
|
+
reclaim_reason = ""
|
|
272
|
+
if _UUID_RE.match(holder or "") and not _is_holder_alive(holder):
|
|
273
|
+
reclaim_reason = "dead_uuid"
|
|
274
|
+
elif holder.startswith("python:") and not _is_python_holder_alive(holder):
|
|
275
|
+
reclaim_reason = "dead_python"
|
|
276
|
+
elif age >= LOCK_EXPIRY:
|
|
277
|
+
reclaim_reason = "expired"
|
|
278
|
+
if reclaim_reason:
|
|
279
|
+
try:
|
|
280
|
+
os.remove(LOCK_FILE)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
283
|
+
if _try_take_lock():
|
|
284
|
+
print(f"[browser_lock] reclaimed holder={holder or '<none>'} "
|
|
285
|
+
f"reason={reclaim_reason} age={int(age)}s -> pid={os.getpid()} "
|
|
286
|
+
f"platform=linkedin", file=sys.stderr)
|
|
287
|
+
break
|
|
288
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# 5. Live UUID holder = parent Claude session / hook -> inherit.
|
|
292
|
+
if _UUID_RE.match(holder or ""):
|
|
293
|
+
_LOCK_SESSION_ID = holder
|
|
294
|
+
_LOCK_INHERITED = True
|
|
295
|
+
break
|
|
296
|
+
|
|
297
|
+
# 6. Live python:PID peer -> real contention. Wait, then give up. Reaching
|
|
298
|
+
# the deadline now means the holder is genuinely alive (dead ones were
|
|
299
|
+
# reclaimed above), NOT the defect-a starvation. peer_alive=1 is the tell.
|
|
300
|
+
if time.time() >= deadline:
|
|
301
|
+
print(json.dumps({
|
|
302
|
+
"ok": False,
|
|
303
|
+
"error": "profile_locked",
|
|
304
|
+
"detail": (
|
|
305
|
+
f"holder={holder} age={int(age)}s "
|
|
306
|
+
f"waited={LOCK_WAIT_MAX}s peer_alive=1"
|
|
307
|
+
),
|
|
308
|
+
}))
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
time.sleep(LOCK_POLL_INTERVAL)
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _is_login_or_checkpoint(url: str) -> bool:
|
|
315
|
+
if not url:
|
|
316
|
+
return True
|
|
317
|
+
return any(
|
|
318
|
+
marker in url
|
|
319
|
+
for marker in (
|
|
320
|
+
"/login",
|
|
321
|
+
"/checkpoint",
|
|
322
|
+
"/uas/login",
|
|
323
|
+
"linkedin.com/authwall",
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _read_devtools_active_port() -> Optional[int]:
|
|
329
|
+
"""Return the CDP port the linkedin-agent MCP Chrome is listening on.
|
|
330
|
+
|
|
331
|
+
The persistent profile dir holds a `DevToolsActivePort` file written
|
|
332
|
+
by Chrome on startup whenever `--remote-debugging-port=0` is passed
|
|
333
|
+
(the linkedin-agent MCP launches Chrome that way). First line is the
|
|
334
|
+
port, second line is the browser uuid path. Missing file -> None
|
|
335
|
+
(MCP is cold; caller raises RuntimeError — cold-launch removed
|
|
336
|
+
2026-05-27, never attach to the wrong profile).
|
|
337
|
+
"""
|
|
338
|
+
port_file = os.path.join(PROFILE_DIR, "DevToolsActivePort")
|
|
339
|
+
try:
|
|
340
|
+
with open(port_file) as f:
|
|
341
|
+
first = f.readline().strip()
|
|
342
|
+
port = int(first)
|
|
343
|
+
if port <= 0 or port >= 65536:
|
|
344
|
+
return None
|
|
345
|
+
return port
|
|
346
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _pid_listening_on(port: int) -> Optional[int]:
|
|
351
|
+
"""Return the PID listening on a TCP port, via lsof. Best-effort.
|
|
352
|
+
|
|
353
|
+
Used purely for diagnostic logging in `_connect_to_running_or_launch`
|
|
354
|
+
so failure logs can answer "did we attach to an existing Chrome or
|
|
355
|
+
cold-launch one ourselves?" without guesswork. Never raises.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
out = subprocess.run(
|
|
359
|
+
["lsof", "-ti", f":{port}", "-sTCP:LISTEN"],
|
|
360
|
+
stdout=subprocess.PIPE,
|
|
361
|
+
stderr=subprocess.DEVNULL,
|
|
362
|
+
timeout=3,
|
|
363
|
+
).stdout.decode("utf-8", "replace").strip()
|
|
364
|
+
if out:
|
|
365
|
+
return int(out.splitlines()[0])
|
|
366
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, OSError):
|
|
367
|
+
pass
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _connect_to_running_or_launch(p, *, prefer_cdp: bool = True):
|
|
372
|
+
"""Get a BrowserContext for LinkedIn via CDP attach to a running harness.
|
|
373
|
+
|
|
374
|
+
Cold-launch fallback REMOVED 2026-05-27. Per explicit user instruction:
|
|
375
|
+
the pipeline must NEVER spawn its own Chrome on the `linkedin` profile.
|
|
376
|
+
That fallback would attach to a *different* profile from the one the
|
|
377
|
+
harness Chrome owns (`browser-harness-linkedin`), and the two have
|
|
378
|
+
drifted in practice: the harness profile holds the active `li_at`
|
|
379
|
+
session cookie, the `linkedin` profile does not. Cold-launching it
|
|
380
|
+
sent every scrape straight to /authwall and silently masked the real
|
|
381
|
+
failure (the harness was unreachable).
|
|
382
|
+
|
|
383
|
+
Strategy now (harness-only, 2026-05-31):
|
|
384
|
+
1. If `LINKEDIN_CDP_URL` env var is set (skill/lib/linkedin-backend.sh
|
|
385
|
+
sets it to http://127.0.0.1:9556), attach to the linkedin-harness
|
|
386
|
+
Chrome via connect_over_cdp. Returns owns_context=False; caller
|
|
387
|
+
closes only the page they opened, never the context.
|
|
388
|
+
2. If that attach fails, RAISE. The legacy DevToolsActivePort fallback
|
|
389
|
+
to the linkedin-agent profile (~/.claude/browser-profiles/linkedin)
|
|
390
|
+
was REMOVED 2026-05-31: it silently attached to a SECOND Chrome
|
|
391
|
+
whenever the harness was momentarily unreachable, which is the
|
|
392
|
+
"two LinkedIn browsers in parallel" bug. The harness Chrome on :9556
|
|
393
|
+
is now the ONLY allowed LinkedIn browser. Never attach to a sibling
|
|
394
|
+
profile, never cold-launch.
|
|
395
|
+
|
|
396
|
+
`prefer_cdp` kept as a kwarg for caller-API stability but no longer
|
|
397
|
+
has a meaningful False branch (cold-launch and legacy attach are gone).
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
(context, owns_context) # owns_context is always False on success
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
RuntimeError if no warm harness/MCP Chrome is reachable.
|
|
404
|
+
"""
|
|
405
|
+
from playwright.sync_api import sync_playwright # noqa: F401
|
|
406
|
+
|
|
407
|
+
last_err: Optional[Exception] = None
|
|
408
|
+
|
|
409
|
+
# Lane 1: explicit harness CDP URL (preferred — set by linkedin-backend.sh
|
|
410
|
+
# when the browser-harness Chrome is up on port 9556).
|
|
411
|
+
harness_cdp_url = os.environ.get("LINKEDIN_CDP_URL", "").strip()
|
|
412
|
+
if prefer_cdp and harness_cdp_url:
|
|
413
|
+
try:
|
|
414
|
+
browser = p.chromium.connect_over_cdp(
|
|
415
|
+
harness_cdp_url,
|
|
416
|
+
timeout=5000,
|
|
417
|
+
)
|
|
418
|
+
contexts = browser.contexts
|
|
419
|
+
if contexts:
|
|
420
|
+
print(
|
|
421
|
+
f"[linkedin_browser] mode=harness_cdp_attach "
|
|
422
|
+
f"url={harness_cdp_url} profile=browser-harness-linkedin",
|
|
423
|
+
file=sys.stderr,
|
|
424
|
+
flush=True,
|
|
425
|
+
)
|
|
426
|
+
_bh_activity_log("attach_harness", harness_cdp_url)
|
|
427
|
+
return contexts[0], False
|
|
428
|
+
last_err = RuntimeError("harness CDP attach: zero contexts")
|
|
429
|
+
except Exception as e:
|
|
430
|
+
last_err = e
|
|
431
|
+
print(
|
|
432
|
+
f"[linkedin_browser] harness_cdp_attach failed: {e}",
|
|
433
|
+
file=sys.stderr,
|
|
434
|
+
flush=True,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Lane 2 (legacy DevToolsActivePort attach to the linkedin-agent profile
|
|
438
|
+
# ~/.claude/browser-profiles/linkedin) was REMOVED 2026-05-31. It let the
|
|
439
|
+
# pipeline silently attach to a SECOND Chrome (the retired linkedin-agent
|
|
440
|
+
# MCP browser) whenever the harness attach above failed — the root cause
|
|
441
|
+
# of the "two LinkedIn browsers in parallel" bug. The harness Chrome on
|
|
442
|
+
# :9556 is now the ONLY allowed LinkedIn browser.
|
|
443
|
+
|
|
444
|
+
# No warm harness Chrome reachable. Fail loudly — never attach to the
|
|
445
|
+
# legacy linkedin-agent profile, never cold-launch.
|
|
446
|
+
raise RuntimeError(
|
|
447
|
+
"linkedin_browser: harness Chrome (port 9556) not reachable via "
|
|
448
|
+
"LINKEDIN_CDP_URL. Legacy DevToolsActivePort + cold-launch fallbacks "
|
|
449
|
+
"were removed (they attached to the wrong profile and spawned a "
|
|
450
|
+
"second browser). Restart the linkedin-harness Chrome and retry. "
|
|
451
|
+
f"Last error: {last_err}"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def unread_dms() -> dict:
|
|
456
|
+
"""Scan LinkedIn /messaging/ sidebar in headed mode, read-only.
|
|
457
|
+
|
|
458
|
+
Cold-launch removed 2026-05-27 — this now requires the linkedin-harness
|
|
459
|
+
Chrome to be reachable via CDP; otherwise it returns
|
|
460
|
+
error='no_warm_browser' so the caller can surface the real cause
|
|
461
|
+
instead of silently attaching to a logged-out sibling profile.
|
|
462
|
+
"""
|
|
463
|
+
from playwright.sync_api import sync_playwright
|
|
464
|
+
|
|
465
|
+
_acquire_browser_lock()
|
|
466
|
+
|
|
467
|
+
with sync_playwright() as p:
|
|
468
|
+
try:
|
|
469
|
+
context, _owns_context = _connect_to_running_or_launch(p)
|
|
470
|
+
except RuntimeError as e:
|
|
471
|
+
return {
|
|
472
|
+
"ok": False,
|
|
473
|
+
"error": "no_warm_browser",
|
|
474
|
+
"detail": str(e),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
page = None
|
|
478
|
+
_reused_page = False
|
|
479
|
+
try:
|
|
480
|
+
# Reuse an existing harness tab instead of spawning a throwaway one
|
|
481
|
+
# (mirrors reddit_browser). new_page() also steals OS focus every
|
|
482
|
+
# call. Prefer a tab already on linkedin.com (not login/checkpoint),
|
|
483
|
+
# else the first open page; only new_page() when the context has no
|
|
484
|
+
# usable tab. A reused tab is left open in the finally below so the
|
|
485
|
+
# next consumer can reuse it too.
|
|
486
|
+
for pg in context.pages:
|
|
487
|
+
u = pg.url or ""
|
|
488
|
+
if "linkedin.com" in u and "login" not in u and "checkpoint" not in u:
|
|
489
|
+
page, _reused_page = pg, True
|
|
490
|
+
break
|
|
491
|
+
if page is None and context.pages:
|
|
492
|
+
page, _reused_page = context.pages[0], True
|
|
493
|
+
if page is None:
|
|
494
|
+
page = context.new_page()
|
|
495
|
+
try:
|
|
496
|
+
page.goto(
|
|
497
|
+
"https://www.linkedin.com/messaging/",
|
|
498
|
+
wait_until="domcontentloaded",
|
|
499
|
+
timeout=30000,
|
|
500
|
+
)
|
|
501
|
+
except Exception as e:
|
|
502
|
+
return {
|
|
503
|
+
"ok": False,
|
|
504
|
+
"error": "navigation_failed",
|
|
505
|
+
"detail": str(e),
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# Settle: wait for the conversation list to render. LinkedIn's
|
|
509
|
+
# messaging UI lazy-loads after DOMContentLoaded.
|
|
510
|
+
try:
|
|
511
|
+
page.wait_for_selector(
|
|
512
|
+
"ul.msg-conversations-container__conversations-list, "
|
|
513
|
+
"ul[class*='conversations-list'], "
|
|
514
|
+
"main [role='list']",
|
|
515
|
+
timeout=10000,
|
|
516
|
+
)
|
|
517
|
+
except Exception:
|
|
518
|
+
pass # we'll still try to read whatever's there
|
|
519
|
+
page.wait_for_timeout(1500)
|
|
520
|
+
|
|
521
|
+
cur_url = page.url
|
|
522
|
+
if _is_login_or_checkpoint(cur_url):
|
|
523
|
+
return {
|
|
524
|
+
"ok": False,
|
|
525
|
+
"error": "session_invalid",
|
|
526
|
+
"url": cur_url,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
# Read sidebar. Strategy:
|
|
530
|
+
# - For each conversation list item, derive partner name
|
|
531
|
+
# (bolded participant), preview text, time, and unread state.
|
|
532
|
+
# - Unread signal: visual blue dot (.notification-badge--show)
|
|
533
|
+
# OR data-test-unread, NOT generic [aria-label*=unread].
|
|
534
|
+
# LinkedIn renders hover "Mark as unread" buttons that
|
|
535
|
+
# contain the substring 'unread' on every thread.
|
|
536
|
+
# - thread_url: try the <a href> if rendered; otherwise null.
|
|
537
|
+
threads = page.evaluate(
|
|
538
|
+
"""
|
|
539
|
+
() => {
|
|
540
|
+
const out = [];
|
|
541
|
+
// Find conversation list items. LinkedIn renders these as
|
|
542
|
+
// <li> inside the conversations list; fall back to any
|
|
543
|
+
// [role=listitem] anchored under the messaging main.
|
|
544
|
+
const candidates = document.querySelectorAll(
|
|
545
|
+
"ul.msg-conversations-container__conversations-list > li, "
|
|
546
|
+
+ "ul[class*='conversations-list'] > li, "
|
|
547
|
+
+ "main [role='listitem']"
|
|
548
|
+
);
|
|
549
|
+
for (const item of candidates) {
|
|
550
|
+
// Skip ad slots / non-conversation rows.
|
|
551
|
+
const link = item.querySelector(
|
|
552
|
+
"a.msg-conversation-listitem__link, a[href*='/messaging/thread/']"
|
|
553
|
+
);
|
|
554
|
+
const innerText = (item.innerText || "").trim();
|
|
555
|
+
if (!innerText) continue;
|
|
556
|
+
|
|
557
|
+
// Unread badge: blue dot. Avoid the broad
|
|
558
|
+
// [aria-label*=unread] selector which matches the
|
|
559
|
+
// hover "Mark as unread" affordance.
|
|
560
|
+
const blueDot = item.querySelector(
|
|
561
|
+
".notification-badge--show, "
|
|
562
|
+
+ "[data-test-unread='true'], "
|
|
563
|
+
+ ".msg-conversation-card__unread-count, "
|
|
564
|
+
+ ".notification-badge.notification-badge--show"
|
|
565
|
+
);
|
|
566
|
+
const unread = !!blueDot;
|
|
567
|
+
|
|
568
|
+
// Partner name: prefer h3 / participant-names node.
|
|
569
|
+
const nameEl = item.querySelector(
|
|
570
|
+
"h3, .msg-conversation-listitem__participant-names, "
|
|
571
|
+
+ ".msg-conversation-card__participant-names"
|
|
572
|
+
);
|
|
573
|
+
const partner = nameEl
|
|
574
|
+
? (nameEl.textContent || "").trim()
|
|
575
|
+
: "";
|
|
576
|
+
|
|
577
|
+
// Time element: usually a small time/timestamp span.
|
|
578
|
+
const timeEl = item.querySelector(
|
|
579
|
+
"time, .msg-conversation-listitem__time-stamp, "
|
|
580
|
+
+ ".msg-conversation-card__time-stamp"
|
|
581
|
+
);
|
|
582
|
+
const time = timeEl
|
|
583
|
+
? (timeEl.textContent || "").trim()
|
|
584
|
+
: "";
|
|
585
|
+
|
|
586
|
+
// Preview (snippet of last message). Take first text
|
|
587
|
+
// node after the participant name that isn't the time.
|
|
588
|
+
const previewEl = item.querySelector(
|
|
589
|
+
".msg-conversation-card__message-snippet, "
|
|
590
|
+
+ ".msg-conversation-listitem__message-snippet, "
|
|
591
|
+
+ "p.msg-conversation-card__message-snippet"
|
|
592
|
+
);
|
|
593
|
+
let preview = previewEl
|
|
594
|
+
? (previewEl.textContent || "").trim()
|
|
595
|
+
: "";
|
|
596
|
+
if (!preview) {
|
|
597
|
+
// Fallback: trim partner+time off the innerText.
|
|
598
|
+
preview = innerText
|
|
599
|
+
.replace(partner, "")
|
|
600
|
+
.replace(time, "")
|
|
601
|
+
.trim();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
let threadUrl = null;
|
|
605
|
+
if (link) {
|
|
606
|
+
const href = link.getAttribute("href") || "";
|
|
607
|
+
if (href && /\\/messaging\\/thread\\//.test(href)) {
|
|
608
|
+
threadUrl = href.startsWith("http")
|
|
609
|
+
? href
|
|
610
|
+
: ("https://www.linkedin.com" + href);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
out.push({
|
|
615
|
+
partner,
|
|
616
|
+
preview: preview,
|
|
617
|
+
time,
|
|
618
|
+
thread_url: threadUrl,
|
|
619
|
+
unread,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return JSON.stringify(out);
|
|
623
|
+
}
|
|
624
|
+
"""
|
|
625
|
+
)
|
|
626
|
+
try:
|
|
627
|
+
threads_list = json.loads(threads or "[]")
|
|
628
|
+
except json.JSONDecodeError:
|
|
629
|
+
threads_list = []
|
|
630
|
+
|
|
631
|
+
unread_count = sum(1 for t in threads_list if t.get("unread"))
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
"ok": True,
|
|
635
|
+
"url": cur_url,
|
|
636
|
+
"total_threads": len(threads_list),
|
|
637
|
+
"unread_count": unread_count,
|
|
638
|
+
"threads": threads_list,
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
finally:
|
|
642
|
+
# CDP-attach branch: NEVER close the context — that would
|
|
643
|
+
# terminate the harness Chrome we just attached to. Only close a
|
|
644
|
+
# page WE created; if we reused an existing tab, leave it open so
|
|
645
|
+
# the next consumer can reuse it (tab-reuse convention).
|
|
646
|
+
if page is not None and not _reused_page:
|
|
647
|
+
try:
|
|
648
|
+
page.close()
|
|
649
|
+
except Exception:
|
|
650
|
+
pass
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def unread_dms_with_retry(max_attempts: int = 2) -> dict:
|
|
654
|
+
"""Wrap unread_dms with one retry on TargetClosedError-style transient
|
|
655
|
+
failures. The headed Chrome launch races against atexit lock release on
|
|
656
|
+
the previous run; a single retry after a short delay clears most cases.
|
|
657
|
+
"""
|
|
658
|
+
last_result: dict = {"ok": False, "error": "no_attempts"}
|
|
659
|
+
for attempt in range(1, max_attempts + 1):
|
|
660
|
+
try:
|
|
661
|
+
result = unread_dms()
|
|
662
|
+
except Exception as e:
|
|
663
|
+
result = {
|
|
664
|
+
"ok": False,
|
|
665
|
+
"error": "exception",
|
|
666
|
+
"detail": f"{type(e).__name__}: {e}",
|
|
667
|
+
"attempt": attempt,
|
|
668
|
+
}
|
|
669
|
+
last_result = result
|
|
670
|
+
# Only retry on transient browser-target failures, not on
|
|
671
|
+
# session_invalid / profile_locked which won't self-heal.
|
|
672
|
+
err = (result.get("error") or "").lower()
|
|
673
|
+
detail = (result.get("detail") or "").lower()
|
|
674
|
+
transient = (
|
|
675
|
+
"targetclosed" in detail
|
|
676
|
+
or "target page" in detail
|
|
677
|
+
or "browser has been closed" in detail
|
|
678
|
+
or err == "navigation_failed"
|
|
679
|
+
)
|
|
680
|
+
if result.get("ok") or not transient or attempt >= max_attempts:
|
|
681
|
+
if attempt > 1:
|
|
682
|
+
result["retry_attempt"] = attempt
|
|
683
|
+
return result
|
|
684
|
+
print(
|
|
685
|
+
f"[linkedin_browser] transient failure attempt {attempt}: "
|
|
686
|
+
f"{result.get('detail') or result.get('error')}; retrying...",
|
|
687
|
+
file=sys.stderr,
|
|
688
|
+
)
|
|
689
|
+
time.sleep(2)
|
|
690
|
+
return last_result
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def main():
|
|
694
|
+
# Guard: only authorized pipelines may invoke this helper. Other Claude
|
|
695
|
+
# subprocess planners (post_reddit, post_twitter, etc.) auto-load
|
|
696
|
+
# CLAUDE.md as system context, see this helper documented there, and
|
|
697
|
+
# have wandered off-task to "smoke test" it — racing the linkedin
|
|
698
|
+
# profile's SingletonLock and triggering server-side session
|
|
699
|
+
# invalidation. The legitimate caller sets the matching env var
|
|
700
|
+
# immediately before invoking; nothing else does.
|
|
701
|
+
if os.environ.get("SOCIAL_AUTOPOSTER_LINKEDIN_PRECHECK") != "1":
|
|
702
|
+
print(
|
|
703
|
+
json.dumps({
|
|
704
|
+
"ok": False,
|
|
705
|
+
"error": "unauthorized_caller",
|
|
706
|
+
"detail": (
|
|
707
|
+
"linkedin_browser.py is invoked only by the "
|
|
708
|
+
"engage-dm-replies pre-check. Set "
|
|
709
|
+
"SOCIAL_AUTOPOSTER_LINKEDIN_PRECHECK=1 from the caller "
|
|
710
|
+
"if this invocation is legitimate. (For SERP discovery, "
|
|
711
|
+
"use scripts/discover_linkedin_candidates.py instead.)"
|
|
712
|
+
),
|
|
713
|
+
}),
|
|
714
|
+
file=sys.stderr,
|
|
715
|
+
)
|
|
716
|
+
sys.exit(2)
|
|
717
|
+
if len(sys.argv) < 2:
|
|
718
|
+
print(__doc__)
|
|
719
|
+
sys.exit(2)
|
|
720
|
+
cmd = sys.argv[1]
|
|
721
|
+
if cmd == "unread-dms":
|
|
722
|
+
result = unread_dms_with_retry()
|
|
723
|
+
print(json.dumps(result, indent=2))
|
|
724
|
+
sys.exit(0 if result.get("ok") else 1)
|
|
725
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
726
|
+
sys.exit(2)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
if __name__ == "__main__":
|
|
730
|
+
main()
|