@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,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LinkedIn cooldown state management.
|
|
3
|
+
|
|
4
|
+
Shared cooldown file prevents cron runs from hammering LinkedIn after
|
|
5
|
+
rate limits, checkpoint challenges, or account restrictions.
|
|
6
|
+
|
|
7
|
+
Cooldown file: /tmp/linkedin_cooldown.json
|
|
8
|
+
Format: {"reason": "...", "resume_after": "ISO8601", "created_at": "ISO8601"}
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
# Check if we're in cooldown (exit 0 = clear, exit 1 = in cooldown)
|
|
12
|
+
python3 linkedin_cooldown.py check
|
|
13
|
+
|
|
14
|
+
# Set cooldown (duration in minutes)
|
|
15
|
+
python3 linkedin_cooldown.py set --reason "429 rate limit" --minutes 120
|
|
16
|
+
|
|
17
|
+
# Set cooldown until a specific time
|
|
18
|
+
python3 linkedin_cooldown.py set --reason "account restricted" --until "2026-04-15T21:43:00"
|
|
19
|
+
|
|
20
|
+
# Clear cooldown
|
|
21
|
+
python3 linkedin_cooldown.py clear
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
|
|
31
|
+
COOLDOWN_FILE = "/tmp/linkedin_cooldown.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def log(msg: str) -> None:
|
|
35
|
+
print(f"[linkedin-cooldown] {msg}", file=sys.stderr)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_cooldown() -> dict | None:
|
|
39
|
+
"""Read cooldown state. Returns None if no active cooldown."""
|
|
40
|
+
if not os.path.exists(COOLDOWN_FILE):
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
with open(COOLDOWN_FILE) as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
resume_after = datetime.fromisoformat(data["resume_after"])
|
|
46
|
+
if resume_after.tzinfo is None:
|
|
47
|
+
resume_after = resume_after.replace(tzinfo=timezone.utc)
|
|
48
|
+
now = datetime.now(timezone.utc)
|
|
49
|
+
if now >= resume_after:
|
|
50
|
+
os.remove(COOLDOWN_FILE)
|
|
51
|
+
return None
|
|
52
|
+
return data
|
|
53
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
54
|
+
os.remove(COOLDOWN_FILE)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_cooldown(reason: str, resume_after: datetime) -> None:
|
|
59
|
+
"""Write cooldown state."""
|
|
60
|
+
if resume_after.tzinfo is None:
|
|
61
|
+
resume_after = resume_after.replace(tzinfo=timezone.utc)
|
|
62
|
+
data = {
|
|
63
|
+
"reason": reason,
|
|
64
|
+
"resume_after": resume_after.isoformat(),
|
|
65
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
66
|
+
}
|
|
67
|
+
with open(COOLDOWN_FILE, "w") as f:
|
|
68
|
+
json.dump(data, f, indent=2)
|
|
69
|
+
log(f"Cooldown set: {reason} (until {resume_after.isoformat()})")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clear_cooldown() -> None:
|
|
73
|
+
"""Remove cooldown file."""
|
|
74
|
+
if os.path.exists(COOLDOWN_FILE):
|
|
75
|
+
os.remove(COOLDOWN_FILE)
|
|
76
|
+
log("Cooldown cleared")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
if len(sys.argv) < 2:
|
|
81
|
+
print(__doc__)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
cmd = sys.argv[1]
|
|
85
|
+
|
|
86
|
+
if cmd == "check":
|
|
87
|
+
state = read_cooldown()
|
|
88
|
+
if state:
|
|
89
|
+
resume = state["resume_after"]
|
|
90
|
+
log(f"In cooldown: {state['reason']} (until {resume})")
|
|
91
|
+
print(json.dumps(state))
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
else:
|
|
94
|
+
log("No active cooldown")
|
|
95
|
+
sys.exit(0)
|
|
96
|
+
|
|
97
|
+
elif cmd == "set":
|
|
98
|
+
import argparse
|
|
99
|
+
parser = argparse.ArgumentParser()
|
|
100
|
+
parser.add_argument("cmd_")
|
|
101
|
+
parser.add_argument("--reason", required=True)
|
|
102
|
+
parser.add_argument("--minutes", type=int)
|
|
103
|
+
parser.add_argument("--until")
|
|
104
|
+
args = parser.parse_args(sys.argv[1:])
|
|
105
|
+
|
|
106
|
+
if args.until:
|
|
107
|
+
resume = datetime.fromisoformat(args.until)
|
|
108
|
+
if resume.tzinfo is None:
|
|
109
|
+
resume = resume.replace(tzinfo=timezone.utc)
|
|
110
|
+
elif args.minutes:
|
|
111
|
+
from datetime import timedelta
|
|
112
|
+
resume = datetime.now(timezone.utc) + timedelta(minutes=args.minutes)
|
|
113
|
+
else:
|
|
114
|
+
print("ERROR: --minutes or --until required", file=sys.stderr)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
set_cooldown(args.reason, resume)
|
|
118
|
+
|
|
119
|
+
elif cmd == "clear":
|
|
120
|
+
clear_cooldown()
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single source of truth for "is this LinkedIn author excluded?".
|
|
3
|
+
|
|
4
|
+
Every LinkedIn rail can call this instead of re-implementing its own matcher:
|
|
5
|
+
- POST/comment rail (discover_linkedin_candidates.py -> drop before the picker)
|
|
6
|
+
- scoring (score_linkedin_candidates.py -> drop before upsert)
|
|
7
|
+
- engage / mentions (engage-linkedin.sh prompt -> inject `slugs`)
|
|
8
|
+
- DM candidate scan (scan_dm_candidates.py)
|
|
9
|
+
|
|
10
|
+
WHY SLUG, NOT NAME (learned 2026-06-03 in the harness):
|
|
11
|
+
A LinkedIn vanity slug (the `/in/<slug>/` segment, e.g. `louis030195`) is a
|
|
12
|
+
unique, stable key. A display name is NOT: a people-search for "Louis
|
|
13
|
+
Beaumont" returns a dozen unrelated real people. So:
|
|
14
|
+
* slug match -> HARD (drop deterministically; this is the reliable path)
|
|
15
|
+
* name match -> SOFT (flag for review only; never an automatic drop,
|
|
16
|
+
because it would hit innocent namesakes)
|
|
17
|
+
In practice discover always extracts author_profile_url, so the slug path
|
|
18
|
+
covers the normal case; the name path is a backstop for reshares/quotes that
|
|
19
|
+
somehow carry only a name.
|
|
20
|
+
|
|
21
|
+
SOURCES (unioned, both optional, fail-open):
|
|
22
|
+
1. config.json `exclusions.linkedin_profiles` + `exclusions.authors`
|
|
23
|
+
- entries WITHOUT a space -> hard slug
|
|
24
|
+
- entries WITH a space -> soft name
|
|
25
|
+
2. author_blocklist via GET /api/v1/blocklist?platform=linkedin
|
|
26
|
+
- severity=hard handle -> hard slug
|
|
27
|
+
- severity=soft handle -> soft slug
|
|
28
|
+
|
|
29
|
+
This module does NO direct SQL: the blocklist is read over the website HTTP API
|
|
30
|
+
(per the project DB-access rule), and the read fails open (ok_on_404) so a
|
|
31
|
+
website hiccup can never wedge a posting cycle.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
|
|
40
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
41
|
+
if _HERE not in sys.path:
|
|
42
|
+
sys.path.insert(0, _HERE)
|
|
43
|
+
|
|
44
|
+
REPO = os.path.dirname(_HERE)
|
|
45
|
+
CONFIG_PATH = os.path.join(REPO, "config.json")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------- normalizers
|
|
49
|
+
def slug_from_url(url):
|
|
50
|
+
"""Extract the lowercased /in/<slug> segment from a LinkedIn profile URL."""
|
|
51
|
+
m = re.search(r"/in/([^/?#]+)", url or "")
|
|
52
|
+
return m.group(1).lower() if m else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def norm_name(name):
|
|
56
|
+
return re.sub(r"\s+", " ", (name or "").strip()).lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------- sources
|
|
60
|
+
def _config_terms():
|
|
61
|
+
"""(hard_slugs, soft_names) from config.json exclusions."""
|
|
62
|
+
hard_slugs, soft_names = set(), set()
|
|
63
|
+
try:
|
|
64
|
+
with open(CONFIG_PATH) as f:
|
|
65
|
+
c = json.load(f)
|
|
66
|
+
except Exception:
|
|
67
|
+
return hard_slugs, soft_names
|
|
68
|
+
ex = c.get("exclusions", {}) or {}
|
|
69
|
+
for term in (ex.get("linkedin_profiles") or []) + (ex.get("authors") or []):
|
|
70
|
+
t = (term or "").strip().lower()
|
|
71
|
+
if not t:
|
|
72
|
+
continue
|
|
73
|
+
if " " in t:
|
|
74
|
+
soft_names.add(re.sub(r"\s+", " ", t))
|
|
75
|
+
else:
|
|
76
|
+
hard_slugs.add(t.lstrip("@"))
|
|
77
|
+
return hard_slugs, soft_names
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _blocklist_terms(platform="linkedin"):
|
|
81
|
+
"""(hard_handles, soft_handles) from the author_blocklist HTTP API.
|
|
82
|
+
|
|
83
|
+
Fails open: any error -> empty sets, never raises into a posting cycle.
|
|
84
|
+
"""
|
|
85
|
+
hard, soft = set(), set()
|
|
86
|
+
try:
|
|
87
|
+
from http_api import api_get
|
|
88
|
+
resp = api_get("/api/v1/blocklist", query={"platform": platform},
|
|
89
|
+
ok_on_404=True)
|
|
90
|
+
rows = ((resp or {}).get("data") or {}).get("rows") or []
|
|
91
|
+
for r in rows:
|
|
92
|
+
h = (r.get("handle") or "").strip().lstrip("@").lower()
|
|
93
|
+
if not h:
|
|
94
|
+
continue
|
|
95
|
+
if r.get("severity") == "hard":
|
|
96
|
+
hard.add(h)
|
|
97
|
+
elif r.get("severity") == "soft":
|
|
98
|
+
soft.add(h)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
return hard, soft
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_exclusions(platform="linkedin"):
|
|
105
|
+
"""Build the unioned exclusion sets once; reuse across many candidates."""
|
|
106
|
+
cfg_hard, cfg_soft_names = _config_terms()
|
|
107
|
+
bl_hard, bl_soft = _blocklist_terms(platform)
|
|
108
|
+
return {
|
|
109
|
+
"hard_slugs": cfg_hard | bl_hard, # slug match -> drop
|
|
110
|
+
"soft_slugs": bl_soft, # slug match -> flag
|
|
111
|
+
"soft_names": cfg_soft_names, # name match -> flag
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------- matcher
|
|
116
|
+
def classify_author(author_name, author_profile_url, excl=None):
|
|
117
|
+
"""Return (severity, reason).
|
|
118
|
+
|
|
119
|
+
severity: "hard" -> caller should DROP the candidate
|
|
120
|
+
"soft" -> caller should KEEP but flag for review
|
|
121
|
+
None -> not excluded
|
|
122
|
+
"""
|
|
123
|
+
if excl is None:
|
|
124
|
+
excl = load_exclusions()
|
|
125
|
+
slug = slug_from_url(author_profile_url)
|
|
126
|
+
if slug:
|
|
127
|
+
if slug in excl["hard_slugs"]:
|
|
128
|
+
return "hard", f"slug:{slug}"
|
|
129
|
+
if slug in excl["soft_slugs"]:
|
|
130
|
+
return "soft", f"blocklist_soft_slug:{slug}"
|
|
131
|
+
name = norm_name(author_name)
|
|
132
|
+
if name and name in excl["soft_names"]:
|
|
133
|
+
return "soft", f"name:{name}"
|
|
134
|
+
return None, ""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def filter_candidates(candidates, excl=None):
|
|
138
|
+
"""Split a discover candidate list into (kept, dropped).
|
|
139
|
+
|
|
140
|
+
`kept` keeps soft matches but tags them with `_exclusion_flag`. `dropped`
|
|
141
|
+
are the hard matches. Each item is whatever shape discover emitted; we only
|
|
142
|
+
read author_name + author_profile_url.
|
|
143
|
+
"""
|
|
144
|
+
if excl is None:
|
|
145
|
+
excl = load_exclusions()
|
|
146
|
+
kept, dropped = [], []
|
|
147
|
+
for cand in candidates or []:
|
|
148
|
+
sev, reason = classify_author(
|
|
149
|
+
cand.get("author_name"), cand.get("author_profile_url"), excl)
|
|
150
|
+
if sev == "hard":
|
|
151
|
+
cand = dict(cand)
|
|
152
|
+
cand["_exclusion_reason"] = reason
|
|
153
|
+
dropped.append(cand)
|
|
154
|
+
else:
|
|
155
|
+
if sev == "soft":
|
|
156
|
+
cand = dict(cand)
|
|
157
|
+
cand["_exclusion_flag"] = reason
|
|
158
|
+
kept.append(cand)
|
|
159
|
+
return kept, dropped
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ----------------------------------------------------------------------- CLI
|
|
163
|
+
def _extract_list(blob):
|
|
164
|
+
"""Find the candidate list inside a discover JSON payload."""
|
|
165
|
+
if isinstance(blob, list):
|
|
166
|
+
return blob, None
|
|
167
|
+
for key in ("candidates", "results", "items"):
|
|
168
|
+
if isinstance(blob.get(key), list):
|
|
169
|
+
return blob[key], key
|
|
170
|
+
return [], None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv):
|
|
174
|
+
cmd = argv[1] if len(argv) > 1 else ""
|
|
175
|
+
excl = load_exclusions()
|
|
176
|
+
|
|
177
|
+
if cmd == "slugs":
|
|
178
|
+
# Hard slugs only, comma-separated. For injecting into engage prompts.
|
|
179
|
+
print(", ".join(sorted(excl["hard_slugs"])))
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
if cmd == "show":
|
|
183
|
+
print(json.dumps({k: sorted(v) for k, v in excl.items()}, indent=2))
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
if cmd == "classify":
|
|
187
|
+
name = argv[2] if len(argv) > 2 else ""
|
|
188
|
+
url = argv[3] if len(argv) > 3 else ""
|
|
189
|
+
sev, reason = classify_author(name, url, excl)
|
|
190
|
+
print(json.dumps({"severity": sev, "reason": reason,
|
|
191
|
+
"excluded": sev == "hard"}))
|
|
192
|
+
return 0 if sev == "hard" else 1
|
|
193
|
+
|
|
194
|
+
if cmd == "filter":
|
|
195
|
+
# Read discover JSON on stdin, drop hard matches, flag soft, re-emit.
|
|
196
|
+
raw = sys.stdin.read()
|
|
197
|
+
try:
|
|
198
|
+
blob = json.loads(raw)
|
|
199
|
+
except Exception:
|
|
200
|
+
sys.stdout.write(raw) # pass through unparseable input untouched
|
|
201
|
+
return 0
|
|
202
|
+
items, key = _extract_list(blob)
|
|
203
|
+
kept, dropped = filter_candidates(items, excl)
|
|
204
|
+
if key:
|
|
205
|
+
blob[key] = kept
|
|
206
|
+
out = blob
|
|
207
|
+
else:
|
|
208
|
+
out = kept
|
|
209
|
+
sys.stderr.write(
|
|
210
|
+
f"[li_exclusions] dropped_hard={len(dropped)} "
|
|
211
|
+
f"flagged_soft={sum(1 for k in kept if k.get('_exclusion_flag'))} "
|
|
212
|
+
f"kept={len(kept)} "
|
|
213
|
+
f"slugs={sorted(excl['hard_slugs'])}\n"
|
|
214
|
+
)
|
|
215
|
+
for d in dropped:
|
|
216
|
+
sys.stderr.write(
|
|
217
|
+
f"[li_exclusions] DROP {d.get('author_name')!r} "
|
|
218
|
+
f"({d.get('author_profile_url')}) -> {d.get('_exclusion_reason')}\n"
|
|
219
|
+
)
|
|
220
|
+
print(json.dumps(out))
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
sys.stderr.write(
|
|
224
|
+
"usage: linkedin_exclusions.py {slugs|show|classify <name> <url>|filter}\n"
|
|
225
|
+
" slugs - comma-separated hard slug list (for prompt injection)\n"
|
|
226
|
+
" show - dump the unioned exclusion sets as JSON\n"
|
|
227
|
+
" classify - exit 0 (+json) if <name>/<url> is a HARD exclusion\n"
|
|
228
|
+
" filter - stdin discover JSON -> stdout with hard matches dropped\n"
|
|
229
|
+
)
|
|
230
|
+
return 2
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
sys.exit(main(sys.argv))
|